From 76e3f74df75de0098943fd703cfc2d0b7353705b Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Fri, 1 May 2026 12:38:22 -0300 Subject: [PATCH 01/44] feat(webui): improve beta turn completion and streaming UX --- nanobot/agent/loop.py | 7 + nanobot/channels/websocket.py | 14 + webui/package-lock.json | 711 ++++++++++++++++++ webui/src/components/ChatPane.tsx | 3 +- .../src/components/thread/ThreadComposer.tsx | 13 +- webui/src/components/thread/ThreadShell.tsx | 6 +- webui/src/hooks/useNanobotStream.ts | 92 ++- webui/src/hooks/useSessions.ts | 22 +- webui/src/i18n/locales/en/common.json | 1 + webui/src/i18n/locales/es/common.json | 1 + webui/src/i18n/locales/fr/common.json | 1 + webui/src/i18n/locales/id/common.json | 1 + webui/src/i18n/locales/ja/common.json | 1 + webui/src/i18n/locales/ko/common.json | 1 + webui/src/i18n/locales/vi/common.json | 1 + webui/src/i18n/locales/zh-CN/common.json | 1 + webui/src/i18n/locales/zh-TW/common.json | 1 + webui/src/lib/types.ts | 1 + 18 files changed, 850 insertions(+), 28 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 1a8042fed..598c66b59 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -796,6 +796,13 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content="", metadata=msg.metadata or {}, )) + # Signal that the turn is fully complete (all tools executed, + # final text streamed). This lets WS clients know when to + # definitively stop the loading indicator. + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content="", metadata={**msg.metadata, "_turn_end": True}, + )) except asyncio.CancelledError: logger.info("Task cancelled for session {}", session_key) # Preserve partial context from the interrupted turn so diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index eba9ed79a..f5477684b 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -1229,6 +1229,10 @@ class WebSocketChannel(BaseChannel): if not conns: logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id) return + # Signal that the agent has fully finished processing the current turn. + if msg.metadata.get("_turn_end"): + await self.send_turn_end(msg.chat_id) + return text = msg.content if msg.buttons: text = _append_buttons_as_text(text, msg.buttons) @@ -1285,3 +1289,13 @@ class WebSocketChannel(BaseChannel): raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" stream ") + + async def send_turn_end(self, chat_id: str) -> None: + """Signal that the agent has fully finished processing the current turn.""" + conns = list(self._subs.get(chat_id, ())) + if not conns: + return + body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id} + raw = json.dumps(body, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" turn_end ") diff --git a/webui/package-lock.json b/webui/package-lock.json index 2ee7152a9..fb97473e6 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -318,6 +318,278 @@ "node": ">=6.9.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/linux-x64": { "version": "0.21.5", "cpu": [ @@ -333,6 +605,108 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "license": "MIT", @@ -1280,6 +1654,244 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.1", "cpu": [ @@ -1304,6 +1916,90 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@tailwindcss/typography": { "version": "0.5.19", "dev": true, @@ -2309,6 +3005,21 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "dev": true, diff --git a/webui/src/components/ChatPane.tsx b/webui/src/components/ChatPane.tsx index 29d0df49f..779d3695a 100644 --- a/webui/src/components/ChatPane.tsx +++ b/webui/src/components/ChatPane.tsx @@ -22,7 +22,7 @@ interface ChatPaneProps { export function ChatPane({ session, onNewChat }: ChatPaneProps) { const chatId = session?.chatId ?? null; const historyKey = session?.key ?? null; - const { messages: historical, loading } = useSessionHistory(historyKey); + const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); const { client } = useClient(); const [booting, setBooting] = useState(false); const pendingFirstRef = useRef(null); @@ -31,6 +31,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) { const { messages, isStreaming, send, setMessages } = useNanobotStream( chatId, initial, + hasPendingToolCalls, ); useEffect(() => { diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 105bb6c77..d5e5dd65a 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -40,6 +40,7 @@ interface ThreadComposerProps { onSend: (content: string, images?: SendImage[]) => void; disabled?: boolean; placeholder?: string; + isStreaming?: boolean; modelLabel?: string | null; variant?: "thread" | "hero"; } @@ -48,6 +49,7 @@ export function ThreadComposer({ onSend, disabled, placeholder, + isStreaming = false, modelLabel = null, variant = "thread", }: ThreadComposerProps) { @@ -58,8 +60,9 @@ export function ThreadComposer({ const fileInputRef = useRef(null); const chipRefs = useRef(new Map()); const isHero = variant === "hero"; - const resolvedPlaceholder = - placeholder ?? t("thread.composer.placeholderThread"); + const resolvedPlaceholder = isStreaming + ? t("thread.composer.placeholderStreaming") + : placeholder ?? t("thread.composer.placeholderThread"); const { images, enqueue, remove, clear, encoding, full } = useAttachedImages(); @@ -344,7 +347,11 @@ export function ThreadComposer({ canSend && "hover:scale-[1.03] active:scale-95", )} > - + {isStreaming ? ( + + ) : ( + + )} diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 801080bbf..65ffd1e0d 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -39,7 +39,7 @@ export function ThreadShell({ const { t } = useTranslation(); const chatId = session?.chatId ?? null; const historyKey = session?.key ?? null; - const { messages: historical, loading } = useSessionHistory(historyKey); + const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); const { client, modelName } = useClient(); const [booting, setBooting] = useState(false); const pendingFirstRef = useRef(null); @@ -56,7 +56,7 @@ export function ThreadShell({ setMessages, streamError, dismissStreamError, - } = useNanobotStream(chatId, initial); + } = useNanobotStream(chatId, initial, hasPendingToolCalls); const showHeroComposer = messages.length === 0 && !loading; const pendingAsk = useMemo(() => { for (let index = messages.length - 1; index >= 0; index -= 1) { @@ -179,6 +179,7 @@ export function ThreadShell({ (initialMessages); - const [isStreaming, setIsStreaming] = useState(false); + /** If the last loaded message is a trace row (e.g. "Using 2 tools"), + * the model was still processing when the page loaded — keep the + * loading spinner alive so the user sees the model is active. */ + const initialStreaming = initialMessages.length > 0 + ? initialMessages[initialMessages.length - 1].kind === "trace" + : false; + const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls); const [streamError, setStreamError] = useState(null); const buffer = useRef(null); + /** Timer that defers ``isStreaming = false`` after ``stream_end``. + * + * When the model finishes a text segment and calls a tool, the server + * sends ``stream_end`` but the agent is still "thinking" while the tool + * executes. By deferring the flag reset by a short window (1 s) we keep + * the loading spinner alive across tool-call boundaries without needing + * backend changes. */ + const streamEndTimerRef = useRef | null>(null); useEffect(() => { return client.onError((err) => setStreamError(err)); @@ -62,21 +77,43 @@ export function useNanobotStream( const dismissStreamError = useCallback(() => setStreamError(null), []); // Reset local state when switching chats. ``streamError`` is scoped to the - // send that triggered it, so a chat swap should wipe it out: a stale - // "Message too large" banner on a freshly-opened chat-B would confuse the - // user about which send actually failed (and in which chat). - useEffect(() => { - setMessages(initialMessages); - setIsStreaming(false); - setStreamError(null); - buffer.current = null; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatId]); + // send that triggered it, so a chat swap should wipe it out: a stale + // "Message too large" banner on a freshly-opened chat-B would confuse the + // user about which send actually failed (and in which chat). + useEffect(() => { + setMessages(initialMessages); + // Check if the new chat's last message is a trace row — if so, the + // model may still be processing. + setIsStreaming( + initialMessages.length > 0 + ? initialMessages[initialMessages.length - 1].kind === "trace" + : false, + ); + // Also consider hasPendingToolCalls from session history. + if (hasPendingToolCalls) { + setIsStreaming(true); + } + setStreamError(null); + buffer.current = null; + if (streamEndTimerRef.current !== null) { + clearTimeout(streamEndTimerRef.current); + streamEndTimerRef.current = null; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatId, initialMessages, hasPendingToolCalls]); useEffect(() => { if (!chatId) return; const handle = (ev: InboundEvent) => { + // Any incoming event while the debounce timer is alive means the model + // is still working (e.g. tool result arrived, more text to stream). + // Cancel the pending "stream ended" timer so we don't hide the spinner. + if (streamEndTimerRef.current !== null) { + clearTimeout(streamEndTimerRef.current); + streamEndTimerRef.current = null; + } + if (ev.event === "delta") { const id = buffer.current?.messageId ?? crypto.randomUUID(); if (!buffer.current) { @@ -103,17 +140,24 @@ export function useNanobotStream( } if (ev.event === "stream_end") { - if (!buffer.current) { - setIsStreaming(false); - return; - } - const finalId = buffer.current.messageId; + // stream_end only means the text segment finished — the model may + // still be executing tools. Do NOT reset isStreaming here; the + // definitive "turn is complete" signal is ``turn_end``. + if (!buffer.current) return; buffer.current = null; + return; + } + + if (ev.event === "turn_end") { + // Definitive signal that the turn is fully complete. Cancel any + // pending debounce timer and stop the loading indicator immediately. + if (streamEndTimerRef.current !== null) { + clearTimeout(streamEndTimerRef.current); + streamEndTimerRef.current = null; + } setIsStreaming(false); setMessages((prev) => - prev.map((m) => - m.id === finalId ? { ...m, isStreaming: false } : m, - ), + prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)), ); return; } @@ -157,7 +201,8 @@ export function useNanobotStream( // flight, drop the placeholder so we don't render the text twice. const activeId = buffer.current?.messageId; buffer.current = null; - setIsStreaming(false); + // Do NOT reset isStreaming here — only ``turn_end`` signals that + // the full turn (all tool calls + final text) is complete. setMessages((prev) => { const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev; const content = ev.buttons?.length ? (ev.button_prompt ?? ev.text) : ev.text; @@ -183,6 +228,10 @@ export function useNanobotStream( return () => { unsub(); buffer.current = null; + if (streamEndTimerRef.current !== null) { + clearTimeout(streamEndTimerRef.current); + streamEndTimerRef.current = null; + } }; }, [chatId, client]); @@ -205,6 +254,9 @@ export function useNanobotStream( ...(previews ? { images: previews } : {}), }, ]); + // Mark streaming immediately so the UI shows the loading indicator + // right away, before the first delta arrives from the server. + setIsStreaming(true); const wireMedia = hasImages ? images!.map((i) => i.media) : undefined; client.sendMessage(chatId, content, wireMedia); }, diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 719d4ce16..1623a1ef4 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -84,6 +84,9 @@ export function useSessionHistory(key: string | null): { messages: UIMessage[]; loading: boolean; error: string | null; + /** ``true`` when the last persisted message has ``tool_calls`` but no + * final text yet — the model was still processing when the page loaded. */ + hasPendingToolCalls: boolean; } { const { token } = useClient(); const [state, setState] = useState<{ @@ -91,11 +94,13 @@ export function useSessionHistory(key: string | null): { messages: UIMessage[]; loading: boolean; error: string | null; + hasPendingToolCalls: boolean; }>({ key: null, messages: [], loading: false, error: null, + hasPendingToolCalls: false, }); useEffect(() => { @@ -105,6 +110,7 @@ export function useSessionHistory(key: string | null): { messages: [], loading: false, error: null, + hasPendingToolCalls: false, }); return; } @@ -116,6 +122,7 @@ export function useSessionHistory(key: string | null): { messages: [], loading: true, error: null, + hasPendingToolCalls: false, }); (async () => { try { @@ -146,11 +153,19 @@ export function useSessionHistory(key: string | null): { }, ]; }); + // Check if the last persisted message has tool_calls but no final + // text yet — the model was still processing when the page loaded. + const lastRaw = body.messages[body.messages.length - 1]; + const hasPending = + lastRaw?.role === "assistant" && + Array.isArray(lastRaw.tool_calls) && + lastRaw.tool_calls.length > 0; setState({ key, messages: ui, loading: false, error: null, + hasPendingToolCalls: hasPending, }); } catch (e) { if (cancelled) return; @@ -162,6 +177,7 @@ export function useSessionHistory(key: string | null): { messages: [], loading: false, error: null, + hasPendingToolCalls: false, }); } else { setState({ @@ -169,6 +185,7 @@ export function useSessionHistory(key: string | null): { messages: [], loading: false, error: (e as Error).message, + hasPendingToolCalls: false, }); } } @@ -179,19 +196,20 @@ export function useSessionHistory(key: string | null): { }, [key, token]); if (!key) { - return { messages: EMPTY_MESSAGES, loading: false, error: null }; + return { messages: EMPTY_MESSAGES, loading: false, error: null, hasPendingToolCalls: false }; } // Even before the effect above commits its loading state, never surface the // previous session's payload for a brand-new key. if (state.key !== key) { - return { messages: EMPTY_MESSAGES, loading: true, error: null }; + return { messages: EMPTY_MESSAGES, loading: true, error: null, hasPendingToolCalls: false }; } return { messages: state.messages, loading: state.loading, error: state.error, + hasPendingToolCalls: state.hasPendingToolCalls, }; } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index aa6b3165b..4ae832827 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -62,6 +62,7 @@ "placeholderThread": "Type your message…", "placeholderHero": "What's on your mind?", "placeholderOpening": "Opening a new chat…", + "placeholderStreaming": "Model is responding…", "inputAria": "Message input", "sendHint": "Enter to send · Shift+Enter for newline", "send": "Send message", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 93bef843e..aa9891660 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -62,6 +62,7 @@ "placeholderThread": "Escribe tu mensaje…", "placeholderHero": "¿Qué tienes en mente?", "placeholderOpening": "Abriendo un nuevo chat…", + "placeholderStreaming": "El modelo está respondiendo…", "inputAria": "Entrada de mensaje", "sendHint": "Enter para enviar · Shift+Enter para nueva línea", "send": "Enviar mensaje", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index ba9e759b3..a49c39849 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -62,6 +62,7 @@ "placeholderThread": "Saisissez votre message…", "placeholderHero": "Qu’avez-vous en tête ?", "placeholderOpening": "Ouverture d’une nouvelle discussion…", + "placeholderStreaming": "Le modèle est en train de répondre…", "inputAria": "Champ de message", "sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne", "send": "Envoyer le message", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 9775372cc..83d69d039 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -62,6 +62,7 @@ "placeholderThread": "Ketik pesan Anda…", "placeholderHero": "Apa yang sedang Anda pikirkan?", "placeholderOpening": "Membuka obrolan baru…", + "placeholderStreaming": "Model sedang merespons…", "inputAria": "Input pesan", "sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru", "send": "Kirim pesan", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 6868dec5c..a631ae1e7 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -62,6 +62,7 @@ "placeholderThread": "メッセージを入力…", "placeholderHero": "何を考えていますか?", "placeholderOpening": "新しいチャットを開いています…", + "placeholderStreaming": "モデルが応答しています…", "inputAria": "メッセージ入力欄", "sendHint": "Enter で送信 · Shift+Enter で改行", "send": "メッセージを送信", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index bb89af259..72563f3ac 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -62,6 +62,7 @@ "placeholderThread": "메시지를 입력하세요…", "placeholderHero": "무슨 생각을 하고 있나요?", "placeholderOpening": "새 채팅을 여는 중…", + "placeholderStreaming": "모델이 응답 중입니다…", "inputAria": "메시지 입력", "sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈", "send": "메시지 보내기", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index f2b64e33b..0259fb448 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -62,6 +62,7 @@ "placeholderThread": "Nhập tin nhắn…", "placeholderHero": "Bạn đang nghĩ gì?", "placeholderOpening": "Đang mở cuộc trò chuyện mới…", + "placeholderStreaming": "Mô hình đang trả lời…", "inputAria": "Ô nhập tin nhắn", "sendHint": "Enter để gửi · Shift+Enter để xuống dòng", "send": "Gửi tin nhắn", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 349e2625c..347fec179 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -62,6 +62,7 @@ "placeholderThread": "输入消息…", "placeholderHero": "你在想什么?", "placeholderOpening": "正在打开新对话…", + "placeholderStreaming": "模型正在回复…", "inputAria": "消息输入框", "sendHint": "Enter 发送 · Shift+Enter 换行", "send": "发送消息", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index b8a1e83da..83de364a0 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -62,6 +62,7 @@ "placeholderThread": "輸入訊息…", "placeholderHero": "你在想什麼?", "placeholderOpening": "正在開啟新對話…", + "placeholderStreaming": "模型正在回覆…", "inputAria": "訊息輸入框", "sendHint": "Enter 送出 · Shift+Enter 換行", "send": "送出訊息", diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 1b857a171..e4c09ba16 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -124,6 +124,7 @@ export type InboundEvent = chat_id: string; stream_id?: string; } + | { event: "turn_end"; chat_id: string } | { event: "error"; chat_id?: string; detail?: string }; /** Base64-encoded image attached to an outbound ``message`` envelope. From 08744ce4084c07c68b642f2099a3910a9f64f64e Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Fri, 1 May 2026 13:16:33 -0300 Subject: [PATCH 02/44] fix(webui): isolate thread cache during chat switches --- webui/src/components/thread/ThreadShell.tsx | 8 ++ webui/src/tests/thread-shell.test.tsx | 87 +++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 65ffd1e0d..7dc2afaec 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -44,6 +44,7 @@ export function ThreadShell({ const [booting, setBooting] = useState(false); const pendingFirstRef = useRef(null); const messageCacheRef = useRef>(new Map()); + const lastCachedChatIdRef = useRef(null); const initial = useMemo(() => { if (!chatId) return historical; @@ -91,6 +92,13 @@ export function ThreadShell({ useEffect(() => { if (!chatId) return; + // Skip the first cache write after a chat switch. During that render, + // `messages` can still belong to the previous chat until the stream hook + // resets its local state for the new session. + if (lastCachedChatIdRef.current !== chatId) { + lastCachedChatIdRef.current = chatId; + return; + } messageCacheRef.current.set(chatId, messages); }, [chatId, messages]); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index d134fcce2..f5dea5960 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -267,6 +267,93 @@ describe("ThreadShell", () => { expect(screen.queryByText("old answer")).not.toBeInTheDocument(); }); + it("does not cache optimistic messages under the next chat during a session switch", async () => { + const client = makeClient(); + const onNewChat = vi.fn().mockResolvedValue("chat-b"); + + const { rerender } = render( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "only in chat a" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => + expect(client.sendMessage).toHaveBeenCalledWith( + "chat-a", + "only in chat a", + undefined, + ), + ); + expect(screen.getByText("only in chat a")).toBeInTheDocument(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + }); + + await waitFor(() => { + expect(screen.queryByText("only in chat a")).not.toBeInTheDocument(); + }); + + await act(async () => { + rerender( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + }); + + expect(screen.getByText("only in chat a")).toBeInTheDocument(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + }); + + await waitFor(() => { + expect(screen.queryByText("only in chat a")).not.toBeInTheDocument(); + }); + }); + it("surfaces a dismissible banner when the stream reports message_too_big", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-a"); From be83525f99499752d5ddfe255e3490a9f8233837 Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Fri, 1 May 2026 18:01:46 -0300 Subject: [PATCH 03/44] test(webui): cover turn-end streaming regressions --- tests/agent/test_loop_progress.py | 31 ++++++++- tests/channels/test_websocket_channel.py | 29 ++++++++ webui/src/tests/useNanobotStream.test.tsx | 82 ++++++++++++++++++++++- webui/src/tests/useSessions.test.tsx | 47 +++++++++++++ 4 files changed, 185 insertions(+), 4 deletions(-) diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 0c32a8f16..d42a5821f 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -149,6 +149,7 @@ class TestToolEventProgress: provider.chat_with_retry = AsyncMock() loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5") loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] await loop._dispatch(InboundMessage( channel="websocket", @@ -165,7 +166,8 @@ class TestToolEventProgress: final = [m for m in outbound if not m.metadata.get("_progress")] assert [m.content for m in progress] == ["Hel", "lo"] - assert final[-1].content == "Hello" + assert final[-2].content == "Hello" + assert (final[-1].metadata or {}).get("_turn_end") is True provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio @@ -214,3 +216,30 @@ class TestToolEventProgress: 'custom_tool("foo.txt")', ] assert all(item[0] != "I will inspect it." for item in progress) + + @pytest.mark.asyncio + async def test_websocket_dispatch_publishes_final_turn_end_marker(self, tmp_path: Path) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[])) + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + await loop._dispatch(InboundMessage( + channel="websocket", + sender_id="u1", + chat_id="chat1", + content="say hello", + )) + + outbound = [] + while bus.outbound_size > 0: + outbound.append(await bus.consume_outbound()) + + assert outbound[-2].content == "Done" + assert (outbound[-2].metadata or {}).get("_turn_end") is not True + assert outbound[-1].content == "" + assert (outbound[-1].metadata or {}).get("_turn_end") is True + assert outbound[-1].chat_id == "chat1" diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index b5dc830b4..db61fc285 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -287,6 +287,25 @@ async def test_send_delta_emits_delta_and_stream_end() -> None: assert second["stream_id"] == "sid" +@pytest.mark.asyncio +async def test_send_turn_end_emits_turn_end_event() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={"_turn_end": True}, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == {"event": "turn_end", "chat_id": "chat-1"} + + @pytest.mark.asyncio async def test_send_non_connection_closed_exception_is_raised() -> None: bus = MagicMock() @@ -545,6 +564,16 @@ async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMoc end = json.loads(await client.recv()) assert end["event"] == "stream_end" assert end["stream_id"] == "s1" + + await channel.send(OutboundMessage( + channel="websocket", + chat_id=chat_id, + content="", + metadata={"_turn_end": True}, + )) + + turn_end = json.loads(await client.recv()) + assert turn_end == {"event": "turn_end", "chat_id": chat_id} finally: await channel.stop() await server_task diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index f5adcf176..2c7173174 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -6,6 +6,8 @@ import { useNanobotStream } from "@/hooks/useNanobotStream"; import type { InboundEvent } from "@/lib/types"; import { ClientProvider } from "@/providers/ClientProvider"; +const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = []; + function fakeClient() { const handlers = new Map void>>(); return { @@ -51,9 +53,27 @@ function wrap(client: ReturnType["client"]) { } describe("useNanobotStream", () => { + it("starts in streaming mode when history shows pending tool calls", () => { + const fake = fakeClient(); + const initialMessages = [{ + id: "m1", + role: "assistant" as const, + content: "Using tools", + createdAt: Date.now(), + }]; + const { result } = renderHook( + () => useNanobotStream("chat-p", initialMessages, true), + { + wrapper: wrap(fake.client), + }, + ); + + expect(result.current.isStreaming).toBe(true); + }); + it("collapses consecutive tool_hint frames into one trace row", () => { const fake = fakeClient(); - const { result } = renderHook(() => useNanobotStream("chat-t", []), { + const { result } = renderHook(() => useNanobotStream("chat-t", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); @@ -95,7 +115,7 @@ describe("useNanobotStream", () => { it("attaches assistant media_urls to complete messages", () => { const fake = fakeClient(); - const { result } = renderHook(() => useNanobotStream("chat-m", []), { + const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); @@ -116,7 +136,7 @@ describe("useNanobotStream", () => { it("keeps assistant buttons on complete messages", () => { const fake = fakeClient(); - const { result } = renderHook(() => useNanobotStream("chat-q", []), { + const { result } = renderHook(() => useNanobotStream("chat-q", EMPTY_MESSAGES), { wrapper: wrap(fake.client), }); @@ -136,4 +156,60 @@ describe("useNanobotStream", () => { ["Short answer", "Detailed answer"], ]); }); + + it("keeps streaming alive across stream_end and completes on turn_end", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-s", { + event: "delta", + chat_id: "chat-s", + text: "Hello", + }); + }); + + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + content: "Hello", + isStreaming: true, + }); + + act(() => { + fake.emit("chat-s", { + event: "stream_end", + chat_id: "chat-s", + }); + }); + + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages[0].isStreaming).toBe(true); + + act(() => { + fake.emit("chat-s", { + event: "message", + chat_id: "chat-s", + text: "Hello world", + }); + }); + + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages.at(-1)).toMatchObject({ + role: "assistant", + content: "Hello world", + }); + + act(() => { + fake.emit("chat-s", { + event: "turn_end", + chat_id: "chat-s", + }); + }); + + expect(result.current.isStreaming).toBe(false); + expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true); + }); }); diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index ad4f1c1af..f73e26cfe 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -170,6 +170,53 @@ describe("useSessions", () => { ]); }); + it("flags history with trailing assistant tool calls as still pending", async () => { + vi.mocked(api.fetchSessionMessages).mockResolvedValue({ + key: "websocket:chat-pending", + created_at: "2026-04-20T10:00:00Z", + updated_at: "2026-04-20T10:05:00Z", + messages: [ + { + role: "assistant", + content: "Using 2 tools", + timestamp: "2026-04-20T10:00:01Z", + tool_calls: [{ id: "call-1" }], + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-pending"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.hasPendingToolCalls).toBe(true); + }); + + it("does not flag history as pending once the assistant turn has no tool calls", async () => { + vi.mocked(api.fetchSessionMessages).mockResolvedValue({ + key: "websocket:chat-done", + created_at: "2026-04-20T10:00:00Z", + updated_at: "2026-04-20T10:05:00Z", + messages: [ + { + role: "assistant", + content: "All done", + timestamp: "2026-04-20T10:00:01Z", + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-done"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.hasPendingToolCalls).toBe(false); + }); + it("keeps the session in the list when delete fails", async () => { vi.mocked(api.listSessions).mockResolvedValue([ { From 96da6d819070668e0509144f8b63678b16685553 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 3 May 2026 09:38:58 +0000 Subject: [PATCH 04/44] fix(webui): tighten turn completion handling Keep the new turn-end signal scoped to WebSocket clients, preserve pending tool-call state across trailing tool result rows, and drop the accidental npm lockfile from the Bun-based WebUI. Co-authored-by: Cursor --- nanobot/agent/loop.py | 15 +- tests/agent/test_loop_progress.py | 25 + webui/package-lock.json | 6020 -------------------------- webui/src/hooks/useSessions.ts | 10 +- webui/src/tests/useSessions.test.tsx | 30 + 5 files changed, 69 insertions(+), 6031 deletions(-) delete mode 100644 webui/package-lock.json diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 598c66b59..c4da557ec 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -796,13 +796,14 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content="", metadata=msg.metadata or {}, )) - # Signal that the turn is fully complete (all tools executed, - # final text streamed). This lets WS clients know when to - # definitively stop the loading indicator. - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, - content="", metadata={**msg.metadata, "_turn_end": True}, - )) + if msg.channel == "websocket": + # Signal that the turn is fully complete (all tools executed, + # final text streamed). This lets WS clients know when to + # definitively stop the loading indicator. + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, + content="", metadata={**msg.metadata, "_turn_end": True}, + )) except asyncio.CancelledError: logger.info("Task cancelled for session {}", session_key) # Preserve partial context from the interrupted turn so diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index d42a5821f..d08448992 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -243,3 +243,28 @@ class TestToolEventProgress: assert outbound[-1].content == "" assert (outbound[-1].metadata or {}).get("_turn_end") is True assert outbound[-1].chat_id == "chat1" + + @pytest.mark.asyncio + async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[])) + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + await loop._dispatch(InboundMessage( + channel="slack", + sender_id="u1", + chat_id="chat1", + content="say hello", + )) + + outbound = [] + while bus.outbound_size > 0: + outbound.append(await bus.consume_outbound()) + + assert len(outbound) == 1 + assert outbound[0].content == "Done" + assert (outbound[0].metadata or {}).get("_turn_end") is not True diff --git a/webui/package-lock.json b/webui/package-lock.json deleted file mode 100644 index fb97473e6..000000000 --- a/webui/package-lock.json +++ /dev/null @@ -1,6020 +0,0 @@ -{ - "name": "nanobot-webui", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "nanobot-webui", - "version": "0.1.0", - "dependencies": { - "@radix-ui/react-alert-dialog": "^1.1.4", - "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-dialog": "^1.1.4", - "@radix-ui/react-dropdown-menu": "^2.1.4", - "@radix-ui/react-scroll-area": "^1.2.2", - "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-tooltip": "^1.1.6", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "i18next": "^26.0.6", - "lucide-react": "^0.469.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-i18next": "^17.0.4", - "react-markdown": "^9.0.1", - "react-syntax-highlighter": "^15.6.1", - "rehype-katex": "^7.0.1", - "remark-gfm": "^4.0.0", - "remark-math": "^6.0.0", - "tailwind-merge": "^2.6.0" - }, - "devDependencies": { - "@tailwindcss/typography": "^0.5.19", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.1.0", - "@testing-library/user-event": "^14.5.2", - "@types/node": "^22.10.5", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", - "@types/react-syntax-highlighter": "^15.5.13", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", - "happy-dom": "^16.3.0", - "katex": "^0.16.21", - "postcss": "^8.5.0", - "tailwindcss": "^3.4.17", - "tailwindcss-animate": "^1.0.7", - "typescript": "^5.7.2", - "vite": "^5.4.11", - "vitest": "^2.1.8" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "dev": true, - "license": "MIT" - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.6" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "license": "MIT" - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@radix-ui/number": { - "version": "1.1.1", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "license": "MIT" - }, - "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.15", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar": { - "version": "1.1.11", - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.3", - "@radix-ui/react-primitive": "2.1.4", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { - "version": "1.1.3", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.10", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.5.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "license": "MIT" - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.19", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { - "version": "0.5.16", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/debug": { - "version": "4.1.13", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/katex": { - "version": "0.16.8", - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.17", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.28", - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/react-syntax-highlighter": { - "version": "15.5.13", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "license": "ISC" - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aria-query": { - "version": "5.3.2", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/autoprefixer": { - "version": "10.5.0", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.19", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001788", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/ccount": { - "version": "2.0.1", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/character-entities": { - "version": "1.2.4", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "1.1.4", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "1.1.4", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "8.3.0", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/css.escape": { - "version": "1.5.1", - "dev": true, - "license": "MIT" - }, - "node_modules/cssesc": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/decode-named-character-reference/node_modules/character-entities": { - "version": "2.0.2", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "license": "MIT" - }, - "node_modules/devlop": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/dom-accessibility-api": { - "version": "0.6.3", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.340", - "dev": true, - "license": "ISC" - }, - "node_modules/entities": { - "version": "6.0.1", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fault": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "format": "^0.2.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/format": { - "version": "0.2.2", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-nonce": { - "version": "1.0.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/happy-dom": { - "version": "16.8.1", - "dev": true, - "license": "MIT", - "dependencies": { - "webidl-conversions": "^7.0.0", - "whatwg-mimetype": "^3.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-from-dom": { - "version": "5.0.1", - "license": "ISC", - "dependencies": { - "@types/hast": "^3.0.0", - "hastscript": "^9.0.0", - "web-namespaces": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-dom/node_modules/hastscript": { - "version": "9.0.1", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-dom/node_modules/hastscript/node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-html": { - "version": "2.0.3", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "devlop": "^1.1.0", - "hast-util-from-parse5": "^8.0.0", - "parse5": "^7.0.0", - "vfile": "^6.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-html-isomorphic": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-from-dom": "^5.0.0", - "hast-util-from-html": "^2.0.0", - "unist-util-remove-position": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5": { - "version": "8.0.3", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "hastscript": "^9.0.0", - "property-information": "^7.0.0", - "vfile": "^6.0.0", - "vfile-location": "^5.0.0", - "web-namespaces": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5/node_modules/hastscript": { - "version": "9.0.1", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5/node_modules/hastscript/node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-is-element": { - "version": "3.0.0", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-parse-selector": { - "version": "2.2.5", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-text": { - "version": "4.0.2", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unist-util-find-after": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "6.0.0", - "license": "MIT", - "dependencies": { - "@types/hast": "^2.0.0", - "comma-separated-tokens": "^1.0.0", - "hast-util-parse-selector": "^2.0.0", - "property-information": "^5.0.0", - "space-separated-tokens": "^1.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript/node_modules/@types/hast": { - "version": "2.3.10", - "license": "MIT", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/hastscript/node_modules/@types/hast/node_modules/@types/unist": { - "version": "2.0.11", - "license": "MIT" - }, - "node_modules/hastscript/node_modules/comma-separated-tokens": { - "version": "1.0.8", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/hastscript/node_modules/property-information": { - "version": "5.6.0", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/hastscript/node_modules/space-separated-tokens": { - "version": "1.1.5", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/highlight.js": { - "version": "10.7.3", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/highlightjs-vue": { - "version": "1.0.0", - "license": "CC0-1.0" - }, - "node_modules/html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", - "license": "MIT", - "dependencies": { - "void-elements": "3.1.0" - } - }, - "node_modules/html-url-attributes": { - "version": "3.0.1", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/i18next": { - "version": "26.0.6", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.6.tgz", - "integrity": "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==", - "funding": [ - { - "type": "individual", - "url": "https://www.locize.com/i18next" - }, - { - "type": "individual", - "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" - }, - { - "type": "individual", - "url": "https://www.locize.com" - } - ], - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.29.2" - }, - "peerDependencies": { - "typescript": "^5 || ^6" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "license": "MIT" - }, - "node_modules/is-alphabetical": { - "version": "1.0.4", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "1.0.4", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^1.0.0", - "is-decimal": "^1.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-decimal": { - "version": "1.0.4", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-hexadecimal": { - "version": "1.0.4", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/katex": { - "version": "0.16.45", - "funding": [ - "https://opencollective.com/katex", - "https://github.com/sponsors/katex" - ], - "license": "MIT", - "dependencies": { - "commander": "^8.3.0" - }, - "bin": { - "katex": "cli.js" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "dev": true, - "license": "MIT" - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "dev": true, - "license": "MIT" - }, - "node_modules/lowlight": { - "version": "1.20.0", - "license": "MIT", - "dependencies": { - "fault": "^1.0.0", - "highlight.js": "~10.7.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.469.0", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/markdown-table": { - "version": "3.0.4", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-math": { - "version": "3.0.0", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "longest-streak": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.1.0", - "unist-util-remove-position": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities": { - "version": "4.0.2", - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "license": "MIT" - }, - "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/character-entities-legacy": { - "version": "3.0.0", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/character-reference-invalid": { - "version": "2.0.1", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-alphanumerical": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-alphanumerical/node_modules/is-alphabetical": { - "version": "2.0.1", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-decimal": { - "version": "2.0.1", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-hexadecimal": { - "version": "2.0.1", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromark": { - "version": "4.0.2", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-math": { - "version": "3.1.0", - "license": "MIT", - "dependencies": { - "@types/katex": "^0.16.0", - "devlop": "^1.0.0", - "katex": "^0.16.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.37", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/parse-entities": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "character-entities": "^1.0.0", - "character-entities-legacy": "^1.0.0", - "character-reference-invalid": "^1.0.0", - "is-alphanumerical": "^1.0.0", - "is-decimal": "^1.0.0", - "is-hexadecimal": "^1.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "1.1.2", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/postcss": { - "version": "8.5.10", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "dev": true, - "license": "MIT" - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/prismjs": { - "version": "1.30.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react": { - "version": "18.3.1", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-i18next": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz", - "integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.29.2", - "html-parse-stringify": "^3.0.1", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "i18next": ">= 26.0.1", - "react": ">= 16.8.0", - "typescript": "^5 || ^6" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/react-markdown": { - "version": "9.1.0", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "html-url-attributes": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=18", - "react": ">=18" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-syntax-highlighter": { - "version": "15.6.6", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.3.1", - "highlight.js": "^10.4.1", - "highlightjs-vue": "^1.0.0", - "lowlight": "^1.17.0", - "prismjs": "^1.30.0", - "refractor": "^3.6.0" - }, - "peerDependencies": { - "react": ">= 0.14.0" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/refractor": { - "version": "3.6.0", - "license": "MIT", - "dependencies": { - "hastscript": "^6.0.0", - "parse-entities": "^2.0.0", - "prismjs": "~1.27.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/prismjs": { - "version": "1.27.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/rehype-katex": { - "version": "7.0.1", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/katex": "^0.16.0", - "hast-util-from-html-isomorphic": "^2.0.0", - "hast-util-to-text": "^4.0.0", - "katex": "^0.16.0", - "unist-util-visit-parents": "^6.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-math": { - "version": "6.0.0", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-math": "^3.0.0", - "micromark-extension-math": "^3.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/resolve": { - "version": "1.22.12", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.60.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "dev": true, - "license": "MIT" - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stringify-entities/node_modules/character-entities-legacy": { - "version": "3.0.0", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/style-to-js": { - "version": "1.1.21", - "license": "MIT", - "dependencies": { - "style-to-object": "1.0.14" - } - }, - "node_modules/style-to-object": { - "version": "1.0.14", - "license": "MIT", - "dependencies": { - "inline-style-parser": "0.2.7" - } - }, - "node_modules/sucrase": { - "version": "3.35.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwind-merge": { - "version": "2.6.1", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss-animate": { - "version": "1.0.7", - "dev": true, - "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.1", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "dev": true, - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-find-after": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-remove-position": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/vfile": { - "version": "6.0.3", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-location": { - "version": "5.0.3", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "2.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/zwitch": { - "version": "2.0.4", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 1623a1ef4..d16c2a118 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -84,7 +84,7 @@ export function useSessionHistory(key: string | null): { messages: UIMessage[]; loading: boolean; error: string | null; - /** ``true`` when the last persisted message has ``tool_calls`` but no + /** ``true`` when the last persisted assistant turn has ``tool_calls`` but no * final text yet — the model was still processing when the page loaded. */ hasPendingToolCalls: boolean; } { @@ -153,9 +153,11 @@ export function useSessionHistory(key: string | null): { }, ]; }); - // Check if the last persisted message has tool_calls but no final - // text yet — the model was still processing when the page loaded. - const lastRaw = body.messages[body.messages.length - 1]; + // Tool result rows can trail the assistant tool-call row while the turn + // is still running, so check the last conversational row. + const lastRaw = [...body.messages] + .reverse() + .find((m) => m.role === "user" || m.role === "assistant"); const hasPending = lastRaw?.role === "assistant" && Array.isArray(lastRaw.tool_calls) && diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index f73e26cfe..4805c6567 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -194,6 +194,36 @@ describe("useSessions", () => { expect(result.current.hasPendingToolCalls).toBe(true); }); + it("keeps pending when tool result rows trail assistant tool calls", async () => { + vi.mocked(api.fetchSessionMessages).mockResolvedValue({ + key: "websocket:chat-pending-tool-result", + created_at: "2026-04-20T10:00:00Z", + updated_at: "2026-04-20T10:05:00Z", + messages: [ + { + role: "assistant", + content: "Using 1 tool", + timestamp: "2026-04-20T10:00:01Z", + tool_calls: [{ id: "call-1" }], + }, + { + role: "tool", + content: "tool output", + timestamp: "2026-04-20T10:00:02Z", + tool_call_id: "call-1", + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-pending-tool-result"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.hasPendingToolCalls).toBe(true); + }); + it("does not flag history as pending once the assistant turn has no tool calls", async () => { vi.mocked(api.fetchSessionMessages).mockResolvedValue({ key: "websocket:chat-done", From 7faa3399026d2fbb114e258c1b6debc818e2871a Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 3 May 2026 09:40:29 +0000 Subject: [PATCH 05/44] fix(webui): keep existing package lockfile Restore the npm lockfile that is already present on main so this PR only carries the WebUI turn-completion changes. Co-authored-by: Cursor --- webui/package-lock.json | 5309 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 5309 insertions(+) create mode 100644 webui/package-lock.json diff --git a/webui/package-lock.json b/webui/package-lock.json new file mode 100644 index 000000000..2ee7152a9 --- /dev/null +++ b/webui/package-lock.json @@ -0,0 +1,5309 @@ +{ + "name": "nanobot-webui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nanobot-webui", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-alert-dialog": "^1.1.4", + "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "i18next": "^26.0.6", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.4", + "react-markdown": "^9.0.1", + "react-syntax-highlighter": "^15.6.1", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.19", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^22.10.5", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/react-syntax-highlighter": "^15.5.13", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "happy-dom": "^16.3.0", + "katex": "^0.16.21", + "postcss": "^8.5.0", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.2", + "vite": "^5.4.11", + "vitest": "^2.1.8" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/character-entities": { + "version": "1.2.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "1.1.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "1.1.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decode-named-character-reference/node_modules/character-entities": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fault": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/format": { + "version": "0.2.2", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/happy-dom": { + "version": "16.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-dom/node_modules/hastscript": { + "version": "9.0.1", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-dom/node_modules/hastscript/node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5/node_modules/hastscript": { + "version": "9.0.1", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5/node_modules/hastscript/node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "2.2.5", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript/node_modules/@types/hast": { + "version": "2.3.10", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/hastscript/node_modules/@types/hast/node_modules/@types/unist": { + "version": "2.0.11", + "license": "MIT" + }, + "node_modules/hastscript/node_modules/comma-separated-tokens": { + "version": "1.0.8", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hastscript/node_modules/property-information": { + "version": "5.6.0", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hastscript/node_modules/space-separated-tokens": { + "version": "1.1.5", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "license": "CC0-1.0" + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/i18next": { + "version": "26.0.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.6.tgz", + "integrity": "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "1.0.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "1.0.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "1.0.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/katex": { + "version": "0.16.45", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lowlight": { + "version": "1.20.0", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "license": "MIT" + }, + "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/character-entities-legacy": { + "version": "3.0.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/character-reference-invalid": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-alphanumerical": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-alphanumerical/node_modules/is-alphabetical": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-decimal": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/is-hexadecimal": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/parse-entities": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-i18next": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz", + "integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-syntax-highlighter": { + "version": "15.6.6", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^3.6.0" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/refractor": { + "version": "3.6.0", + "license": "MIT", + "dependencies": { + "hastscript": "^6.0.0", + "parse-entities": "^2.0.0", + "prismjs": "~1.27.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/prismjs": { + "version": "1.27.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities/node_modules/character-entities-legacy": { + "version": "3.0.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} From c15d816d9c318719056be28342f260b5bed90609 Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Sun, 3 May 2026 22:30:42 +0800 Subject: [PATCH 06/44] fix(cli): intercept _retry_wait so provider retry messages don't garble interactive output (#3600) --- nanobot/cli/commands.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 952742ea4..7386904d2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1127,6 +1127,10 @@ def agent( turn_done.set() continue + if msg.metadata.get("_retry_wait"): + await _print_interactive_progress_line(msg.content, _thinking) + continue + if msg.metadata.get("_progress"): is_tool_hint = msg.metadata.get("_tool_hint", False) ch = agent_loop.channels_config From 66682eb46f9aa2091588326e207d1dcca894a360 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 3 May 2026 14:56:28 +0000 Subject: [PATCH 07/44] test(cli): cover retry-wait interactive routing Keep provider retry wait messages on the interactive progress path so they do not fall through as assistant responses. Co-authored-by: Cursor --- nanobot/cli/commands.py | 41 ++++++++++++++++-------- tests/cli/test_interactive_retry_wait.py | 31 ++++++++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 tests/cli/test_interactive_retry_wait.py diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 7386904d2..33b33f541 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -216,6 +216,29 @@ async def _print_interactive_progress_line(text: str, thinking: ThinkingSpinner await _print_interactive_line(text) +async def _maybe_print_interactive_progress( + msg: Any, + thinking: ThinkingSpinner | None, + channels_config: Any, +) -> bool: + metadata = msg.metadata or {} + if metadata.get("_retry_wait"): + await _print_interactive_progress_line(msg.content, thinking) + return True + + if not metadata.get("_progress"): + return False + + is_tool_hint = metadata.get("_tool_hint", False) + if channels_config and is_tool_hint and not channels_config.send_tool_hints: + return True + if channels_config and not is_tool_hint and not channels_config.send_progress: + return True + + await _print_interactive_progress_line(msg.content, thinking) + return True + + def _is_exit_command(command: str) -> bool: """Return True when input should end interactive chat.""" return command.lower() in EXIT_COMMANDS @@ -1127,19 +1150,11 @@ def agent( turn_done.set() continue - if msg.metadata.get("_retry_wait"): - await _print_interactive_progress_line(msg.content, _thinking) - continue - - if msg.metadata.get("_progress"): - is_tool_hint = msg.metadata.get("_tool_hint", False) - ch = agent_loop.channels_config - if ch and is_tool_hint and not ch.send_tool_hints: - pass - elif ch and not is_tool_hint and not ch.send_progress: - pass - else: - await _print_interactive_progress_line(msg.content, _thinking) + if await _maybe_print_interactive_progress( + msg, + _thinking, + agent_loop.channels_config, + ): continue if not turn_done.is_set(): diff --git a/tests/cli/test_interactive_retry_wait.py b/tests/cli/test_interactive_retry_wait.py new file mode 100644 index 000000000..5cc217c56 --- /dev/null +++ b/tests/cli/test_interactive_retry_wait.py @@ -0,0 +1,31 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from nanobot.cli import commands + + +@pytest.mark.asyncio +async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress_disabled(): + """Provider retry waits should not fall through as assistant responses.""" + calls: list[tuple[str, object | None]] = [] + thinking = None + channels_config = SimpleNamespace(send_progress=False, send_tool_hints=False) + msg = SimpleNamespace( + content="Model request failed, retry in 2s (attempt 1).", + metadata={"_retry_wait": True}, + ) + + async def fake_print(text: str, active_thinking: object | None) -> None: + calls.append((text, active_thinking)) + + with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print): + handled = await commands._maybe_print_interactive_progress( + msg, + thinking, + channels_config, + ) + + assert handled is True + assert calls == [("Model request failed, retry in 2s (attempt 1).", thinking)] From 75c2506c075148864a3137cf8c2cbc245e0fe77b Mon Sep 17 00:00:00 2001 From: hussein1362 Date: Sun, 3 May 2026 16:19:13 +0300 Subject: [PATCH 08/44] fix(cron): atomic write for jobs.json + don't silently overwrite corrupt store Two related bugs that together caused scheduled jobs to disappear after a container restart: 1. `_save_store()` used `Path.write_text(...)`, which truncates the destination in place. A SIGKILL or shutdown mid-write left `jobs.json` either truncated or corrupt. 2. `_load_jobs()` caught any parse error, logged at WARNING, and returned an empty list. `start()` then called `_save_store()` immediately, overwriting the corrupt-but-recoverable file with an empty job array. Every scheduled job was silently lost with only a single warning line in the log. Reproduction in production: container restart at 18:08, after which a job that had fired correctly for two consecutive days never fired again. jobs.json on disk was missing the job entirely. Fix: - `_save_store()` now writes via temp file + `os.replace` + `fsync` (matches the session manager pattern from 512bf59, "fix(session): fsync sessions on graceful shutdown to prevent data loss"). An interrupted write cannot corrupt the live file. - `_load_jobs()` now moves a corrupt store aside as `jobs.json.corrupt-` and returns `None` instead of `[]`. - `start()` aborts with a `RuntimeError` when the on-disk store is corrupt, instead of starting empty and overwriting. - `_load_store()` falls back to the previous in-memory snapshot when a hot reload encounters a corrupt file, so a transient corruption after start does not drop live jobs. Tests cover the atomic-write path, the corrupt-file preservation, the start-time refusal, the in-memory fallback, and a basic save/load round trip across two service instances. Existing 79 cron tests and full suite (2553 tests) still pass. --- nanobot/cron/service.py | 106 ++++++++++++++++-- tests/cron/test_cron_persistence.py | 167 ++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 8 deletions(-) create mode 100644 tests/cron/test_cron_persistence.py diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 1cc858ce9..c3ccd08a6 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -2,8 +2,10 @@ import asyncio import json +import os import time import uuid +from contextlib import suppress from dataclasses import asdict from datetime import datetime from pathlib import Path @@ -83,8 +85,20 @@ class CronService: self._timer_active = False self.max_sleep_ms = max_sleep_ms - def _load_jobs(self) -> tuple[list[CronJob], int]: - jobs = [] + def _load_jobs(self) -> tuple[list[CronJob], int] | None: + """Load jobs from disk. + + Returns: + ``(jobs, version)`` tuple on success or when no store file exists + (in which case an empty list and version 1 are returned). + ``None`` when the store file exists but cannot be parsed; the + corrupt file is preserved with a ``.corrupt-`` suffix so the + caller can decide whether to overwrite or bail out. Returning a + sentinel here is important: silently treating a parse error as an + empty job list would cause the next ``_save_store`` to wipe every + job from disk. + """ + jobs: list[CronJob] = [] version = 1 if self.store_path.exists(): try: @@ -136,7 +150,22 @@ class CronService: delete_after_run=j.get("deleteAfterRun", False), )) except Exception as e: - logger.warning("Failed to load cron store: {}", e) + # Preserve the corrupt file for forensic recovery instead of + # letting the next save overwrite it with an empty job list. + backup = self.store_path.with_suffix( + self.store_path.suffix + f".corrupt-{int(time.time())}" + ) + with suppress(OSError): + self.store_path.rename(backup) + logger.error( + "Failed to load cron store at {}: {}. " + "Corrupt file preserved at {}. " + "Refusing to overwrite to avoid data loss.", + self.store_path, + e, + backup, + ) + return None return jobs, version def _merge_action(self): @@ -175,15 +204,28 @@ class CronService: self._save_store() return - def _load_store(self) -> CronStore: + def _load_store(self) -> CronStore | None: """Load jobs from disk. Reloads automatically if file was modified externally. - Reload every time because it needs to merge operations on the jobs object from other instances. - During _on_timer execution, return the existing store to prevent concurrent _load_store calls (e.g. from list_jobs polling) from replacing it mid-execution. + - When the on-disk store exists but is unreadable: keep using the + previous in-memory ``self._store`` if we already have one (so a + transient corruption does not drop live jobs); only the very first + load (during ``start``) can return ``None`` to signal an unrecoverable + state to the caller. """ if self._timer_active and self._store: return self._store - jobs, version = self._load_jobs() + loaded = self._load_jobs() + if loaded is None: + # Corrupt store on disk. Prefer the last good in-memory snapshot + # over wiping live jobs; ``_load_jobs`` has already moved the + # corrupt file aside with a ``.corrupt-`` suffix. + if self._store is not None: + return self._store + return None + jobs, version = loaded self._store = CronStore(version=version, jobs=jobs) self._merge_action() @@ -242,12 +284,56 @@ class CronService: ] } - self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + self._atomic_write(self.store_path, json.dumps(data, indent=2, ensure_ascii=False)) + + @staticmethod + def _atomic_write(path: Path, content: str) -> None: + """Write *content* to *path* atomically with fsync. + + Uses a temp-file + ``os.replace`` + ``fsync`` pattern so a crash or + SIGKILL mid-write cannot leave the destination truncated or invalid. + Mirrors ``nanobot.session.manager.SessionManager.save`` (see + commit 512bf59, ``fix(session): fsync sessions on graceful shutdown + to prevent data loss``). Without this, ``jobs.json`` could be + corrupted on container shutdown and silently re-created empty on + next start, wiping every scheduled job. + """ + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + try: + with open(tmp_path, "w", encoding="utf-8") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + # fsync the parent directory so the rename itself is durable. + # Skip on Windows where opening a directory raises PermissionError; + # NTFS journals metadata synchronously so this is a no-op there. + with suppress(PermissionError): + fd = os.open(str(path.parent), os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise async def start(self) -> None: """Start the cron service.""" self._running = True - self._load_store() + loaded = self._load_store() + if loaded is None: + # Store file existed but was corrupt and has been preserved with + # a ``.corrupt-`` suffix. Bail out instead of starting with + # an empty store; that would call ``_save_store`` and overwrite + # the now-renamed (but still recoverable) data with []. + self._running = False + raise RuntimeError( + f"cron store at {self.store_path} is corrupt and was preserved; " + "refusing to start with an empty job list. " + "Inspect the .corrupt- backup and restore manually." + ) self._recompute_next_runs() self._save_store() self._arm_timer() @@ -301,7 +387,11 @@ class CronService: async def _on_timer(self) -> None: """Handle timer tick - run due jobs.""" - self._load_store() + loaded = self._load_store() + # If a hot reload found a corrupt store on disk, ``loaded`` is + # ``None`` but ``self._store`` may still hold the previous, + # known-good in-memory snapshot. Keep using it rather than + # crashing the timer or wiping live jobs. if not self._store: self._arm_timer() return diff --git a/tests/cron/test_cron_persistence.py b/tests/cron/test_cron_persistence.py new file mode 100644 index 000000000..fb218687e --- /dev/null +++ b/tests/cron/test_cron_persistence.py @@ -0,0 +1,167 @@ +"""Persistence tests for ``nanobot.cron.service.CronService``. + +These tests target the specific failure mode where a corrupt or partially +written ``jobs.json`` would silently turn into an empty job list on the next +start, deleting every scheduled job. See ``fix(cron): atomic write for +jobs.json + don't silently overwrite corrupt store``. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +import pytest + +from nanobot.cron.service import CronService +from nanobot.cron.types import CronSchedule + + +def _seeded_store(tmp_path: Path) -> tuple[CronService, Path]: + """Build a service with one persisted job on disk and return both the + service and the resolved store path. Adds the job via the action log + (the path used when the service is not running) and then triggers a + merge so ``jobs.json`` is written, mirroring the persisted on-disk + state seen in production.""" + store_path = tmp_path / "cron" / "jobs.json" + service = CronService(store_path) + service.add_job( + name="Daily Loving Message", + schedule=CronSchedule(kind="cron", expr="0 10 * * *", tz="Asia/Kuwait"), + message="hello", + ) + # add_job appended to action.jsonl; flush to jobs.json by toggling + # ``_running`` long enough for ``_merge_action`` to do its rewrite. + service._running = True + try: + service._load_store() + finally: + service._running = False + assert store_path.exists() + return service, store_path + + +def test_save_store_is_atomic(tmp_path: Path) -> None: + """``_save_store`` must use temp-file + rename so an interrupted write + cannot leave the destination truncated or invalid.""" + service, store_path = _seeded_store(tmp_path) + + # Simulate an arbitrary save and confirm the result parses cleanly and + # no orphan ``.tmp`` is left behind. + service._save_store() + data = json.loads(store_path.read_text(encoding="utf-8")) + assert len(data["jobs"]) == 1 + + tmp_files = list(store_path.parent.glob("*.tmp")) + assert tmp_files == [], f"unexpected temp files left behind: {tmp_files}" + + +def test_save_store_failure_does_not_corrupt_existing_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """If writing the temp file blows up partway through, the previous + ``jobs.json`` must remain readable. This is the regression we are + actually fixing: pre-fix, ``write_text`` would truncate the destination + in place and leave it corrupt.""" + service, store_path = _seeded_store(tmp_path) + original = store_path.read_bytes() + + # Inject a failure inside the temp-file write. ``os.replace`` should + # never run; the destination must keep its previous content. + real_open = open + + def boom(path, *args, **kwargs): # type: ignore[no-untyped-def] + if str(path).endswith(".tmp"): + raise OSError("simulated disk full") + return real_open(path, *args, **kwargs) + + monkeypatch.setattr("builtins.open", boom) + + with pytest.raises(OSError, match="simulated disk full"): + service._save_store() + + assert store_path.read_bytes() == original + + +def test_load_jobs_preserves_corrupt_store_and_returns_none( + tmp_path: Path, +) -> None: + """A corrupt ``jobs.json`` must not be silently treated as an empty + list. The loader returns ``None`` and the corrupt file is moved aside + with a ``.corrupt-`` suffix so an operator can recover it.""" + store_path = tmp_path / "cron" / "jobs.json" + store_path.parent.mkdir(parents=True) + store_path.write_text("{not valid json", encoding="utf-8") + + service = CronService(store_path) + assert service._load_jobs() is None + + # Original path is gone; a ``.corrupt-`` backup exists alongside it. + assert not store_path.exists() + backups = list(store_path.parent.glob("jobs.json.corrupt-*")) + assert len(backups) == 1 + assert backups[0].read_text(encoding="utf-8") == "{not valid json" + + +def test_start_refuses_to_overwrite_corrupt_store(tmp_path: Path) -> None: + """``start`` must abort instead of running ``_save_store`` against an + empty in-memory state when the on-disk store is corrupt. Otherwise the + next save would overwrite the (recoverable) corrupt file with an empty + job list and the user's jobs would be unrecoverable.""" + store_path = tmp_path / "cron" / "jobs.json" + store_path.parent.mkdir(parents=True) + store_path.write_text("{still not json", encoding="utf-8") + + service = CronService(store_path) + import asyncio + + with pytest.raises(RuntimeError, match="corrupt"): + asyncio.run(service.start()) + + # Service is left in a stopped state so the operator notices. + assert service._running is False + + # And the corrupt file is still recoverable from the .corrupt- copy. + backups = list(store_path.parent.glob("jobs.json.corrupt-*")) + assert len(backups) == 1 + + +def test_load_store_falls_back_to_in_memory_on_corruption_after_start( + tmp_path: Path, +) -> None: + """If the store file becomes corrupt *after* a successful start (e.g. a + rclone-mounted Drive returns a partial read), the service must keep + using its existing in-memory snapshot instead of dropping every job.""" + service, store_path = _seeded_store(tmp_path) + # Force load so ``self._store`` is populated. + service._load_store() + snapshot = service._store + assert snapshot is not None and len(snapshot.jobs) == 1 + + # Now corrupt the file on disk. + store_path.write_text("\x00garbage\x00", encoding="utf-8") + + # Subsequent reload returns the in-memory snapshot, not None or empty. + result = service._load_store() + assert result is snapshot + assert len(result.jobs) == 1 + assert result.jobs[0].name == "Daily Loving Message" + + +def test_full_round_trip_survives_repeated_save_load(tmp_path: Path) -> None: + """Sanity check: jobs survive add → save → reload across fresh + ``CronService`` instances pointing at the same store.""" + store_path = tmp_path / "cron" / "jobs.json" + + s1 = CronService(store_path) + s1.add_job( + name="Daily Loving Message", + schedule=CronSchedule(kind="cron", expr="0 10 * * *", tz="Asia/Kuwait"), + message="hello", + ) + + s2 = CronService(store_path) + s2._load_store() + assert s2._store is not None + assert [j.name for j in s2._store.jobs] == ["Daily Loving Message"] From 9a9e446f3fb237f165327413405757162cba4852 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 3 May 2026 15:19:32 +0000 Subject: [PATCH 09/44] fix(cron): clean persistence lint issues Keep the cron persistence hardening clean under ruff without changing behavior. Co-authored-by: Cursor --- nanobot/cron/service.py | 18 ++++++++++++------ tests/cron/test_cron_persistence.py | 1 - 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index c3ccd08a6..e5428c114 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -14,7 +14,14 @@ from typing import Any, Callable, Coroutine, Literal from filelock import FileLock from loguru import logger -from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronRunRecord, CronSchedule, CronStore +from nanobot.cron.types import ( + CronJob, + CronJobState, + CronPayload, + CronRunRecord, + CronSchedule, + CronStore, +) def _now_ms() -> int: @@ -387,11 +394,10 @@ class CronService: async def _on_timer(self) -> None: """Handle timer tick - run due jobs.""" - loaded = self._load_store() - # If a hot reload found a corrupt store on disk, ``loaded`` is - # ``None`` but ``self._store`` may still hold the previous, - # known-good in-memory snapshot. Keep using it rather than - # crashing the timer or wiping live jobs. + self._load_store() + # If a hot reload found a corrupt store on disk, ``self._store`` may + # still hold the previous, known-good in-memory snapshot. Keep using + # it rather than crashing the timer or wiping live jobs. if not self._store: self._arm_timer() return diff --git a/tests/cron/test_cron_persistence.py b/tests/cron/test_cron_persistence.py index fb218687e..4732f61e0 100644 --- a/tests/cron/test_cron_persistence.py +++ b/tests/cron/test_cron_persistence.py @@ -9,7 +9,6 @@ jobs.json + don't silently overwrite corrupt store``. from __future__ import annotations import json -import time from pathlib import Path import pytest From 7742f8fbdcba82a84d3162d687051e8a997c3731 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 3 May 2026 16:34:31 +0000 Subject: [PATCH 10/44] fix(runner): narrow workspace_violation fatal classification (#3599, helps #3605 #3597) PR #3493 promoted every shell `_guard_command` rejection to a turn-fatal RuntimeError. The two heuristic outputs in that list -- `path outside working dir` and `path traversal detected` -- routinely false-positive on benign constructs (e.g. `2>/dev/null`, quoted `..` arguments to sed/find, absolute paths inside inline scripts), so legitimate workspace commands silently kill the user's turn (#3599) and the agent never gets a chance to retry with a different approach (#3605). Two changes, both narrowly scoped: - `ExecTool._guard_command` now skips a small allow-list of kernel device files (`/dev/null`, the standard streams, `/dev/random`, `/dev/fd/N`, ...) before the workspace path check, matched against the pre-resolve string so symlinks like `/dev/stderr -> /proc/self/fd/2` still hit the allow-list. Real outside writes such as `> /etc/issue` remain blocked. - `AgentRunner._WORKSPACE_BLOCK_MARKERS` keeps only the four hard path-resolution errors from filesystem.py / shell.py and the SSRF marker. The two heuristic substrings move out of the fatal list, so the LLM sees them as ordinary tool errors and can self-correct in the next iteration. SSRF stays fatal because retrying an internal URL with a different phrasing would defeat the safety boundary. Tests: - `tests/tools/test_exec_security.py`: parametrized regression for the exact #3599 command sample plus other stdio redirects and device reads; explicit negative case asserts `> /etc/issue` is still blocked. - `tests/agent/test_runner.py`: `_is_workspace_violation` no longer fatals on the two heuristic markers, plus an end-to-end case proving the runner hands the guard error back to the LLM and finalizes the next turn cleanly. --- nanobot/agent/runner.py | 22 ++++++++-- nanobot/agent/tools/shell.py | 39 +++++++++++++++++ tests/agent/test_runner.py | 73 +++++++++++++++++++++++++++++++ tests/tools/test_exec_security.py | 59 +++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 3d941f382..50b86de43 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -831,14 +831,30 @@ class AgentRunner: detail = detail[:120] + "..." return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None - # Markers identifying tool results that represent a workspace / safety boundary rejection. + # Markers identifying tool results that represent a *hard* workspace / + # safety boundary rejection -- only these abort the agent loop. + # + # We deliberately keep this list narrow (#3599 / #3605): + # - The first four come from explicit path-resolution checks in + # ``filesystem.py`` and ``shell.py`` that cannot false-positive on user + # payloads -- if you see them, the LLM truly tried to escape the + # workspace. + # - "internal/private url detected" stays fatal because SSRF is a real + # security boundary; allowing the LLM to "retry" would just let it + # poke internal infra with a different URL phrasing. + # - "path traversal detected" and "path outside working dir" are + # intentionally *not* listed: both come from the heuristic + # ``_guard_command`` checks in ``shell.py`` which scan the raw command + # string and routinely false-positive on legitimate constructs (e.g. + # ``2>/dev/null`` redirects, quoted ``..`` arguments to ``sed`` / + # ``find``, paths inside inline scripts). Treating them as fatal + # silently kills user turns (#3599) and prevents the agent from + # self-correcting by trying a different approach (#3605). _WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = ( "outside the configured workspace", "outside allowed directory", "working_dir is outside", "working_dir could not be resolved", - "path traversal detected", - "path outside working dir", "internal/private url detected", ) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index b7f841a5c..177176d25 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -83,6 +83,22 @@ class ExecTool(Tool): _MAX_TIMEOUT = 600 _MAX_OUTPUT = 10_000 + # Kernel device files that are universally safe as stdio redirect targets + # (e.g. ``cmd 2>/dev/null``). Without this allow-list the workspace guard + # treats them as ``path outside working dir`` and the LLM ends up unable + # to silence stderr inside the workspace (#3599). + _BENIGN_DEVICE_PATHS: frozenset[str] = frozenset({ + "/dev/null", + "/dev/zero", + "/dev/full", + "/dev/random", + "/dev/urandom", + "/dev/stdin", + "/dev/stdout", + "/dev/stderr", + "/dev/tty", + }) + @property def description(self) -> str: return ( @@ -300,10 +316,18 @@ class ExecTool(Tool): for raw in self._extract_absolute_paths(cmd): try: expanded = os.path.expandvars(raw.strip()) + # Match against the un-resolved path first. On Linux, + # /dev/stderr is a symlink to /proc/self/fd/2 and + # ``Path.resolve()`` would mask the device-file intent. + if self._is_benign_device_path(expanded): + continue p = Path(expanded).expanduser().resolve() except Exception: continue + if self._is_benign_device_path(str(p)): + continue + media_path = get_media_dir().resolve() if (p.is_absolute() and cwd_path not in p.parents @@ -315,6 +339,21 @@ class ExecTool(Tool): return None + @classmethod + def _is_benign_device_path(cls, path: str) -> bool: + """Return True when *path* is a kernel device file we should never block. + + Treats ``/dev/null``, the standard streams, ``/dev/random``, etc. as + always-safe targets so that idiomatic stdio plumbing such as + ``cmd 2>/dev/null`` or ``echo done >/dev/stderr`` is not flagged as a + workspace violation regardless of the configured working directory. + Also accepts ``/dev/fd/N`` because those are per-process aliases for + already-open file descriptors and never escape the workspace. + """ + if path in cls._BENIGN_DEVICE_PATHS: + return True + return path.startswith("/dev/fd/") + @staticmethod def _extract_absolute_paths(command: str) -> list[str]: # Windows: match drive-root paths like `C:\` as well as `C:\path\to\file` diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index aa558b4ff..86bb8f1bf 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -373,6 +373,79 @@ def test_is_workspace_violation_recognizes_ssrf_block(): ) is False +def test_is_workspace_violation_does_not_fatal_on_shell_guard_heuristics(): + """#3599 / #3605 regression: shell guard heuristics must NOT be fatal. + + ``path outside working dir`` and ``path traversal detected`` are produced + by best-effort string scans inside ``ExecTool._guard_command`` -- they + routinely false-positive on idiomatic constructs (``2>/dev/null``, + ``sed 's|x|../y|g'``) and should be surfaced to the LLM as recoverable + tool errors so it can switch tactics, not abort the whole turn. + """ + from nanobot.agent.runner import AgentRunner + + assert AgentRunner._is_workspace_violation( + "Error: Command blocked by safety guard (path outside working dir)" + ) is False + assert AgentRunner._is_workspace_violation( + "Error: Command blocked by safety guard (path traversal detected)" + ) is False + + +@pytest.mark.asyncio +async def test_runner_lets_llm_recover_from_shell_guard_path_outside(): + """End-to-end: a guard-blocked exec is a soft tool error, not a turn-fatal. + + Reporter scenario: a previous PR turned ``path outside working dir`` into + a turn-fatal RuntimeError, so when the false-positive guard fired the + user got no further iterations and (depending on channel) a silent hang. + After narrowing the marker list, the runner must hand the error back to + the LLM and let the next iteration succeed normally. + """ + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + provider = MagicMock() + captured_second_call: list[dict] = [] + + async def chat_with_retry(*, messages, **kwargs): + if provider.chat_with_retry.await_count == 1: + return LLMResponse( + content="trying noisy cleanup", + tool_calls=[ToolCallRequest( + id="call_blocked", + name="exec", + arguments={"command": "rm scratch.txt 2>/dev/null"}, + )], + ) + captured_second_call[:] = list(messages) + return LLMResponse(content="recovered final answer", tool_calls=[]) + + provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry) + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock( + return_value="Error: Command blocked by safety guard (path outside working dir)" + ) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=3, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert provider.chat_with_retry.await_count == 2, ( + "guard hit must NOT short-circuit the loop -- LLM should get a second turn" + ) + assert result.stop_reason != "tool_error" + assert result.error is None + assert result.final_content == "recovered final answer" + assert result.tool_events and result.tool_events[0]["status"] == "error" + assert "workspace_violation" not in result.tool_events[0]["detail"] + + @pytest.mark.asyncio async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path): from nanobot.agent.runner import AgentRunSpec, AgentRunner diff --git a/tests/tools/test_exec_security.py b/tests/tools/test_exec_security.py index 64dc49563..b7ccf6a2b 100644 --- a/tests/tools/test_exec_security.py +++ b/tests/tools/test_exec_security.py @@ -182,3 +182,62 @@ async def test_exec_ignores_workspace_check_when_not_restricted(tmp_path): result = await tool.execute(command="echo ok", working_dir=str(other)) assert "ok" in result assert "outside the configured workspace" not in result + + +# --- #3599: stdio redirects to /dev/null must not trip the workspace guard ---- + + +@pytest.mark.parametrize( + "command", + [ + # The exact command from the #3599 reporter. + 'rm test_print.txt 2>/dev/null; echo "done"', + # Plain redirect of stdout / stderr. + "find . -type f >/dev/null", + "noisy_cmd 2>/dev/null", + "noisy_cmd >/dev/null 2>&1", + # Read from /dev/urandom is also a benign device read. + "head -c 16 /dev/urandom | xxd", + "echo done >/dev/stderr", + "echo line 2>/dev/null`` must succeed against the workspace guard.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + target = workspace / "test_print.txt" + target.write_text("scratch") + tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5) + result = await tool.execute( + command=f'rm {target} 2>/dev/null; echo "done"', + working_dir=str(workspace), + ) + assert "done" in result + assert "path outside working dir" not in result + assert not target.exists() + + +def test_exec_still_blocks_real_outside_path_via_redirect(tmp_path): + """Redirect *targets* outside the workspace (not /dev/...) must still be blocked. + + We only whitelist kernel device files; arbitrary outside redirects such as + ``> /etc/issue`` should remain caught by the workspace guard so a buggy + LLM cannot exfiltrate data outside the workspace via stderr redirection. + """ + workspace = tmp_path / "workspace" + workspace.mkdir() + tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True) + blocked = tool._guard_command("echo pwn > /etc/issue", str(workspace)) + assert blocked is not None + assert "path outside working dir" in blocked From b8406be2156f458048a28e0e7612ad4881f4e717 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 3 May 2026 17:04:08 +0000 Subject: [PATCH 11/44] fix(runner): soft workspace boundary + per-target throttle (#3493 #3599 #3605) Replaces PR #3493's blanket fatal abort with a "tell the model + throttle the bypass loop" policy. Workspace-bound rejections are now ordinary recoverable tool errors enriched with a structured "this is a hard policy boundary" instruction; SSRF stays the only marker that aborts the turn. Why the fatal-abort approach broke ---------------------------------- PR #3493 promoted every shell `_guard_command` and filesystem path-resolution rejection to a turn-fatal RuntimeError. Two of those messages (`path outside working dir` and `path traversal detected`) are heuristic substring scans on the raw command, so legitimate commands like `rm /x.txt 2>/dev/null` or `find . -type f` killed the user's turn (#3599). On channels with outbound dedupe (Telegram) the user just saw silence (#3605), and the noise polluted the LLM's context until it started hallucinating guard rejections on plain relative paths (#3597). Why we still need *some* throttle --------------------------------- The original #3493 pain point was real: the LLM, refused once, would swap tools and try again -- read_file -> exec cat -> exec cp -> bash -c -> ln -sf -> python -c open(...). Just removing the fatal escape lets that loop run wild until max_iterations. What this commit does --------------------- - `nanobot/utils/runtime.py`: add `workspace_violation_signature` and `repeated_workspace_violation_error`. The signature normalizes filesystem `path` arguments and the first absolute path inside an exec command, so swapping tools against the same outside target hits the same throttle bucket. Two soft attempts are allowed; the third attempt's tool result is replaced with a hard "stop trying to bypass" message that quotes the target path and tells the model to ask the user for help. - `nanobot/agent/runner.py`: split classification into `_is_ssrf_violation` (still fatal) and `_is_workspace_violation` (now soft). All three failure branches in `_run_tool` (prep_error / exception / Error result) route through a shared `_classify_violation` that bumps the per-turn workspace_violation_counts dict and either keeps the tool's own message or substitutes the throttle escalation. `_execute_tools` now threads that dict alongside the existing external_lookup_counts. - `nanobot/agent/tools/shell.py`: append a structured boundary note to every workspace-bound guard rejection (`working_dir could not be resolved`, `working_dir is outside`, `path outside working dir`, `path traversal detected`). SSRF errors stay short and direct so the model doesn't try to "phrase around" them. Existing `2>/dev/null` allow-list and benign device passthrough from the previous commit remain. - `nanobot/agent/tools/filesystem.py`: append the same boundary note to the `outside allowed directory` PermissionError so read_file / write_file / list_dir errors give the LLM the same explicit hint. Tests ----- - `tests/utils/test_workspace_violation_throttle.py` (new): signature collapses across read_file/exec/python -c against the same path, different paths get independent budgets, escalation only fires after the third attempt. - `tests/agent/test_runner.py`: - `test_runner_does_not_abort_on_workspace_violation_anymore` -- v2 contract: filesystem PermissionError is now soft, runner moves to the next iteration and finalizes cleanly. - `test_is_ssrf_violation_remains_fatal` + the existing `test_runner_aborts_on_ssrf_violation` -- SSRF still aborts on the first attempt. - `test_runner_lets_llm_recover_from_shell_guard_path_outside` -- end to end recovery from `path outside working dir`. - `test_runner_throttles_repeated_workspace_bypass_attempts` -- four bypass attempts against the same outside target produce at least one `workspace_violation_escalated` event and the run completes naturally without aborting the turn. - The two `_execute_tools` direct-call tests now pass the new workspace_violation_counts dict. - `tests/tools/test_tool_validation.py`: relax three `==` assertions to `startswith` + "hard policy boundary" substring check to match the new structured error messages. - `tests/tools/test_exec_security.py` keeps the prior `2>/dev/null` regression and the `> /etc/issue` negative case from the previous commit on this branch -- they still pass under the new policy. Coverage status: full pytest 2648 passed / 2 skipped (was 2638 / 2 on origin/main). Ruff is clean for every file touched in this commit. Co-authored-by: Cursor --- nanobot/agent/runner.py | 199 ++++++++++++----- nanobot/agent/tools/filesystem.py | 12 +- nanobot/agent/tools/shell.py | 38 +++- nanobot/utils/runtime.py | 115 ++++++++++ tests/agent/test_runner.py | 200 +++++++++++++----- tests/tools/test_tool_validation.py | 18 +- .../test_workspace_violation_throttle.py | 120 +++++++++++ 7 files changed, 585 insertions(+), 117 deletions(-) create mode 100644 tests/utils/test_workspace_violation_throttle.py diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 50b86de43..7a34cfbb7 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -33,6 +33,7 @@ from nanobot.utils.runtime import ( ensure_nonempty_tool_result, is_blank_text, repeated_external_lookup_error, + repeated_workspace_violation_error, ) _DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model." @@ -239,6 +240,10 @@ class AgentRunner: stop_reason = "completed" tool_events: list[dict[str, str]] = [] external_lookup_counts: dict[str, int] = {} + # Tracks repeated bypass attempts against the same outside-workspace + # target within this turn. See ``repeated_workspace_violation_error`` + # in ``nanobot.utils.runtime`` for the throttle policy. + workspace_violation_counts: dict[str, int] = {} empty_content_retries = 0 length_recovery_count = 0 had_injections = False @@ -314,6 +319,7 @@ class AgentRunner: spec, tool_calls, external_lookup_counts, + workspace_violation_counts, ) tool_events.extend(new_events) context.tool_results = list(results) @@ -698,20 +704,25 @@ class AgentRunner: spec: AgentRunSpec, tool_calls: list[ToolCallRequest], external_lookup_counts: dict[str, int], + workspace_violation_counts: dict[str, int], ) -> tuple[list[Any], list[dict[str, str]], BaseException | None]: batches = self._partition_tool_batches(spec, tool_calls) tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = [] for batch in batches: if spec.concurrent_tools and len(batch) > 1: batch_results = await asyncio.gather(*( - self._run_tool(spec, tool_call, external_lookup_counts) + self._run_tool( + spec, tool_call, external_lookup_counts, workspace_violation_counts, + ) for tool_call in batch )) tool_results.extend(batch_results) else: batch_results = [] for tool_call in batch: - result = await self._run_tool(spec, tool_call, external_lookup_counts) + result = await self._run_tool( + spec, tool_call, external_lookup_counts, workspace_violation_counts, + ) tool_results.append(result) batch_results.append(result) if isinstance(result[2], AskUserInterrupt): @@ -734,6 +745,7 @@ class AgentRunner: spec: AgentRunSpec, tool_call: ToolCallRequest, external_lookup_counts: dict[str, int], + workspace_violation_counts: dict[str, int], ) -> tuple[Any, dict[str, str], BaseException | None]: hint = "\n\n[Analyze the error above and try a different approach.]" lookup_error = repeated_external_lookup_error( @@ -763,16 +775,20 @@ class AgentRunner: "status": "error", "detail": prep_error.split(": ", 1)[-1][:120], } - if self._is_workspace_violation(prep_error): - logger.warning( - "Tool {} blocked by workspace/safety guard during preparation; aborting turn: {}", - tool_call.name, - prep_error.replace("\n", " ").strip()[:200], - ) - event["detail"] = ("workspace_violation: " - + prep_error.replace("\n", " ").strip())[:160] - return prep_error, event, RuntimeError(prep_error) - return prep_error + hint, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None + handled = self._classify_violation( + raw_text=prep_error, + soft_payload=prep_error + hint, + ssrf_payload=prep_error, + ssrf_error=RuntimeError(prep_error), + event=event, + tool_call=tool_call, + workspace_violation_counts=workspace_violation_counts, + ) + if handled is not None: + return handled + return prep_error + hint, event, ( + RuntimeError(prep_error) if spec.fail_on_tool_error else None + ) try: if tool is not None: result = await tool.execute(**params) @@ -789,18 +805,25 @@ class AgentRunner: if isinstance(exc, AskUserInterrupt): event["status"] = "waiting" return "", event, exc - if self._is_workspace_violation(str(exc)): - logger.warning( - "Tool {} blocked by workspace/safety guard; aborting turn: {}", - tool_call.name, - str(exc).replace("\n", " ").strip()[:200], - ) - event["detail"] = ("workspace_violation: " - + str(exc).replace("\n", " ").strip())[:160] - return f"Error: {type(exc).__name__}: {exc}", event, exc + payload = f"Error: {type(exc).__name__}: {exc}" + handled = self._classify_violation( + raw_text=str(exc), + # Match the legacy behavior here: the exception branch never + # appended the "try a different approach" hint, even on the + # workspace-violation path -- preserve that for callers that + # eyeball the exact tool message. + soft_payload=payload, + ssrf_payload=payload, + ssrf_error=exc, + event=event, + tool_call=tool_call, + workspace_violation_counts=workspace_violation_counts, + ) + if handled is not None: + return handled if spec.fail_on_tool_error: - return f"Error: {type(exc).__name__}: {exc}", event, exc - return f"Error: {type(exc).__name__}: {exc}", event, None + return payload, event, exc + return payload, event, None if isinstance(result, str) and result.startswith("Error"): event = { @@ -808,17 +831,17 @@ class AgentRunner: "status": "error", "detail": result.replace("\n", " ").strip()[:120], } - - # check the outside workspace error and break loop - if self._is_workspace_violation(result): - logger.warning( - "Tool {} blocked by workspace/safety guard; aborting turn: {}", - tool_call.name, - result.replace("\n", " ").strip()[:200], - ) - event["detail"] = ("workspace_violation: " - + result.replace("\n", " ").strip())[:160] - return result, event, RuntimeError(result) + handled = self._classify_violation( + raw_text=result, + soft_payload=result + hint, + ssrf_payload=result, + ssrf_error=RuntimeError(result), + event=event, + tool_call=tool_call, + workspace_violation_counts=workspace_violation_counts, + ) + if handled is not None: + return handled if spec.fail_on_tool_error: return result + hint, event, RuntimeError(result) return result + hint, event, None @@ -831,39 +854,103 @@ class AgentRunner: detail = detail[:120] + "..." return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None - # Markers identifying tool results that represent a *hard* workspace / - # safety boundary rejection -- only these abort the agent loop. - # - # We deliberately keep this list narrow (#3599 / #3605): - # - The first four come from explicit path-resolution checks in - # ``filesystem.py`` and ``shell.py`` that cannot false-positive on user - # payloads -- if you see them, the LLM truly tried to escape the - # workspace. - # - "internal/private url detected" stays fatal because SSRF is a real - # security boundary; allowing the LLM to "retry" would just let it - # poke internal infra with a different URL phrasing. - # - "path traversal detected" and "path outside working dir" are - # intentionally *not* listed: both come from the heuristic - # ``_guard_command`` checks in ``shell.py`` which scan the raw command - # string and routinely false-positive on legitimate constructs (e.g. - # ``2>/dev/null`` redirects, quoted ``..`` arguments to ``sed`` / - # ``find``, paths inside inline scripts). Treating them as fatal - # silently kills user turns (#3599) and prevents the agent from - # self-correcting by trying a different approach (#3605). - _WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = ( + # SSRF rejections remain a hard, non-recoverable safety boundary: a single + # successful internal-URL fetch can leak cloud metadata, so we never let + # the LLM "retry" with a different phrasing of the same target. + _SSRF_MARKER: str = "internal/private url detected" + + # Markers that identify "tried to access something outside the workspace". + # Unlike SSRF these are intentionally *non-fatal* (#3599 / #3605): + # - The structured error message itself tells the model not to bypass. + # - ``repeated_workspace_violation_error`` throttles the loop reported + # in #3493 by escalating after two attempts against the same target. + # - max_iterations is the ultimate ceiling, so we never need to abort. + _WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = ( "outside the configured workspace", "outside allowed directory", "working_dir is outside", "working_dir could not be resolved", - "internal/private url detected", + "path outside working dir", + "path traversal detected", ) + @classmethod + def _is_ssrf_violation(cls, text: str) -> bool: + return bool(text) and cls._SSRF_MARKER in text.lower() + @classmethod def _is_workspace_violation(cls, text: str) -> bool: + """True when *text* looks like *any* policy boundary rejection. + + Kept as a public-ish hook for callers that need a yes/no signal + (logging, telemetry). The runner itself uses the more specific + ``_is_ssrf_violation`` to decide what is fatal. + """ if not text: return False lowered = text.lower() - return any(marker in lowered for marker in cls._WORKSPACE_BLOCK_MARKERS) + if cls._SSRF_MARKER in lowered: + return True + return any(marker in lowered for marker in cls._WORKSPACE_VIOLATION_MARKERS) + + def _classify_violation( + self, + *, + raw_text: str, + soft_payload: str, + ssrf_payload: str, + ssrf_error: BaseException, + event: dict[str, str], + tool_call: ToolCallRequest, + workspace_violation_counts: dict[str, int], + ) -> tuple[Any, dict[str, str], BaseException | None] | None: + """Apply violation policy to a tool failure, or pass through. + + Returns a fully-formed (payload, event, error) triple when *raw_text* + looks like a policy boundary rejection. Returns ``None`` when the + caller should fall through to its generic per-branch handling. + + - SSRF stays fatal -- a single successful internal fetch can leak + cloud metadata, so retrying with a different URL phrasing is + never acceptable. We mutate ``event`` in place so the caller's + telemetry stays consistent. + - All other workspace-bound rejections become soft tool errors. + Each repeated attempt against the same outside target bumps a + per-turn counter; after the soft retry budget is exhausted we + replace the message body with an explicit "stop trying to bypass + the policy" instruction (see #3493 for the original bypass-loop + that motivated PR #3493's hard-abort, and #3599 / #3605 for why + the hard-abort backfired). + """ + if self._is_ssrf_violation(raw_text): + logger.warning( + "Tool {} blocked by SSRF guard; aborting turn: {}", + tool_call.name, + raw_text.replace("\n", " ").strip()[:200], + ) + event["detail"] = ("workspace_violation: " + + raw_text.replace("\n", " ").strip())[:160] + return ssrf_payload, event, ssrf_error + + if self._is_workspace_violation(raw_text): + escalation = repeated_workspace_violation_error( + tool_call.name, + tool_call.arguments, + workspace_violation_counts, + ) + event["detail"] = ("workspace_violation: " + + raw_text.replace("\n", " ").strip())[:160] + if escalation is not None: + logger.warning( + "Tool {} hit workspace boundary repeatedly; escalating hint", + tool_call.name, + ) + event["detail"] = ("workspace_violation_escalated: " + + raw_text.replace("\n", " ").strip())[:160] + return escalation, event, None + return soft_payload, event, None + + return None async def _emit_checkpoint( self, diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 587a149f2..8091e7670 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -14,6 +14,13 @@ from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime from nanobot.config.paths import get_media_dir +_FS_WORKSPACE_BOUNDARY_NOTE = ( + " (this is a hard policy boundary, not a transient failure; " + "do not retry with shell tricks or alternative tools, and ask " + "the user how to proceed if the resource is genuinely required)" +) + + def _resolve_path( path: str, workspace: Path | None = None, @@ -29,7 +36,10 @@ def _resolve_path( media_path = get_media_dir().resolve() all_dirs = [allowed_dir] + [media_path] + (extra_allowed_dirs or []) if not any(_is_under(resolved, d) for d in all_dirs): - raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") + raise PermissionError( + f"Path {path} is outside allowed directory {allowed_dir}" + + _FS_WORKSPACE_BOUNDARY_NOTE + ) return resolved diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 177176d25..2ed6b981e 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -19,6 +19,20 @@ from nanobot.config.paths import get_media_dir _IS_WINDOWS = sys.platform == "win32" +# Appended to every workspace / safety guard rejection so the LLM is told +# explicitly that this is a policy boundary (not a transient failure) and +# that bypass loops will not change the answer. The throttle in +# ``nanobot.utils.runtime.repeated_workspace_violation_error`` upgrades +# this further once the model keeps targeting the same path. +_WORKSPACE_BOUNDARY_NOTE = ( + "\n\nNote: this is a hard policy boundary, not a transient failure. " + "Do NOT retry with shell tricks (symlinks, base64 piping, alternative " + "tools, working_dir overrides). If the user genuinely needs this " + "resource, tell them you cannot reach it under the current " + "restrict_to_workspace policy and ask how to proceed." +) + + @tool_parameters( tool_parameters_schema( command=StringSchema("The shell command to execute"), @@ -129,9 +143,15 @@ class ExecTool(Tool): requested = Path(cwd).expanduser().resolve() workspace_root = Path(self.working_dir).expanduser().resolve() except Exception: - return "Error: working_dir could not be resolved" + return ( + "Error: working_dir could not be resolved" + + _WORKSPACE_BOUNDARY_NOTE + ) if requested != workspace_root and workspace_root not in requested.parents: - return "Error: working_dir is outside the configured workspace" + return ( + "Error: working_dir is outside the configured workspace" + + _WORKSPACE_BOUNDARY_NOTE + ) guard_error = self._guard_command(command, cwd) if guard_error: @@ -305,11 +325,18 @@ class ExecTool(Tool): from nanobot.security.network import contains_internal_url if contains_internal_url(cmd): + # SSRF: stay short and direct. The runner classifies this + # marker as a hard, non-recoverable boundary, so the + # _WORKSPACE_BOUNDARY_NOTE policy text doesn't apply here -- + # we don't want the model to retry at all. return "Error: Command blocked by safety guard (internal/private URL detected)" if self.restrict_to_workspace: if "..\\" in cmd or "../" in cmd: - return "Error: Command blocked by safety guard (path traversal detected)" + return ( + "Error: Command blocked by safety guard (path traversal detected)" + + _WORKSPACE_BOUNDARY_NOTE + ) cwd_path = Path(cwd).resolve() @@ -335,7 +362,10 @@ class ExecTool(Tool): and media_path not in p.parents and p != media_path ): - return "Error: Command blocked by safety guard (path outside working dir)" + return ( + "Error: Command blocked by safety guard (path outside working dir)" + + _WORKSPACE_BOUNDARY_NOTE + ) return None diff --git a/nanobot/utils/runtime.py b/nanobot/utils/runtime.py index 39822fd48..f5c5f994a 100644 --- a/nanobot/utils/runtime.py +++ b/nanobot/utils/runtime.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re +from pathlib import Path from typing import Any from loguru import logger @@ -10,6 +12,14 @@ from nanobot.utils.helpers import stringify_text_blocks _MAX_REPEAT_EXTERNAL_LOOKUPS = 2 +# Workspace-violation throttle: how many times the LLM is allowed to bump +# against the same outside-workspace target *within a single turn* before the +# tool result is escalated with a hard "stop trying to bypass the policy" +# instruction. Two free attempts give the model room to e.g. read_file then +# fall back to exec without immediately escalating; the third attempt at the +# same target is treated as a clear bypass loop. +_MAX_REPEAT_WORKSPACE_VIOLATIONS = 2 + EMPTY_FINAL_RESPONSE_MESSAGE = ( "I completed the tool steps but couldn't produce a final answer. " "Please try again or narrow the task." @@ -95,3 +105,108 @@ def repeated_external_lookup_error( "Error: repeated external lookup blocked. " "Use the results you already have to answer, or try a meaningfully different source." ) + + +# --- Workspace-violation throttle -------------------------------------------- +# +# When ``restrict_to_workspace`` is on and the LLM tries to read or exec +# something outside of the workspace, we want to *tell* the model that it +# hit a hard policy boundary -- not silently abort the whole turn and not +# allow it to spin forever swapping ``read_file`` for ``exec cat`` for +# ``python -c open(...)`` (the actual loop reported in #3493). The strategy +# is two-fold: +# +# 1. Each individual guard error already includes structured instructions +# that tell the model "don't try to bypass this; ask the user for help". +# 2. We additionally count how many times the *same outside target* has +# been refused within the current turn. After two free attempts the +# third refusal swaps in a much more forceful message that quotes the +# target path and explicitly orders the model to stop and surface the +# boundary back to the user. The model is still free to do something +# else (different target, different question) -- only the bypass loop +# is interrupted. +# +# This intentionally does *not* fatal-abort the turn: max_iterations and +# the empty-final-response retries already provide the ultimate ceiling +# for runaway loops, and aborting is what produced the silent-hang bug +# in #3605 in the first place. + +_OUTSIDE_PATH_PATTERN = re.compile(r"(?:^|[\s|>'\"])((?:/[^\s\"'>;|<]+)|(?:~[^\s\"'>;|<]+))") + + +def workspace_violation_signature( + tool_name: str, + arguments: dict[str, Any], +) -> str | None: + """Return a stable signature for the outside-workspace target a tool tried. + + The signature is shared across tool names so that the LLM cannot bypass + the throttle by switching from ``read_file`` to ``exec cat`` to + ``python -c open(...)`` against the same path. Returns ``None`` when + the call has no obvious outside target (e.g. SSRF rejections, deny + pattern hits, or tools whose argument shape we don't understand). + """ + for key in ("path", "file_path", "target", "source", "destination"): + val = arguments.get(key) + if isinstance(val, str) and val.strip(): + return _normalize_violation_target(val.strip()) + + if tool_name in {"exec", "shell"}: + cmd = str(arguments.get("command") or "").strip() + if cmd: + match = _OUTSIDE_PATH_PATTERN.search(cmd) + if match: + return _normalize_violation_target(match.group(1)) + cwd = str(arguments.get("working_dir") or "").strip() + if cwd: + return _normalize_violation_target(cwd) + + return None + + +def _normalize_violation_target(raw: str) -> str: + """Normalize *raw* path so that equivalent spellings collide on the same key.""" + try: + normalized = str(Path(raw).expanduser().resolve()) + except Exception: + normalized = raw + return f"violation:{normalized}".lower() + + +def repeated_workspace_violation_error( + tool_name: str, + arguments: dict[str, Any], + seen_counts: dict[str, int], +) -> str | None: + """Return an escalated error string after repeated bypass attempts. + + Returns ``None`` while the LLM is still within the soft retry budget -- + callers should fall back to the tool's own error message in that case. + Once the budget is exceeded, returns a hard "stop trying" instruction + that quotes the offending target. Throttle state lives in + *seen_counts* (a per-turn dict), so the budget naturally resets across + turns without persisting LLM-controlled keys. + """ + signature = workspace_violation_signature(tool_name, arguments) + if signature is None: + return None + count = seen_counts.get(signature, 0) + 1 + seen_counts[signature] = count + if count <= _MAX_REPEAT_WORKSPACE_VIOLATIONS: + return None + logger.warning( + "Escalating repeated workspace bypass attempt {} (attempt {})", + signature[:160], + count, + ) + target = signature.split("violation:", 1)[1] if "violation:" in signature else signature + return ( + "Error: refusing repeated workspace-bypass attempts.\n" + f"You have tried to access '{target}' (or an equivalent path) " + f"{count} times in this turn. This is a hard policy boundary -- " + "switching tools, shell tricks, working_dir overrides, symlinks, " + "or base64 piping will NOT change the answer. Stop retrying. " + "If the user genuinely needs this resource, tell them you cannot " + "access it and ask how they want to proceed (e.g. copy the file " + "into the workspace, or disable restrict_to_workspace for this run)." + ) diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index 86bb8f1bf..09d1dbfd5 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -313,21 +313,33 @@ async def test_runner_returns_structured_tool_error(): @pytest.mark.asyncio -async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error(): +async def test_runner_does_not_abort_on_workspace_violation_anymore(): + """v2 behavior: workspace-bound rejections are *soft* tool errors. + + Previously (PR #3493) any workspace boundary error became a fatal + RuntimeError that aborted the turn. That silently killed legitimate + workspace commands once the heuristic guard misfired (#3599 #3605), so + we now hand the error back to the LLM as a recoverable tool result and + rely on ``repeated_workspace_violation_error`` to throttle bypass loops. + """ from nanobot.agent.runner import AgentRunSpec, AgentRunner provider = MagicMock() provider.chat_with_retry = AsyncMock(side_effect=[ LLMResponse( - content="working", - tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"})], + content="trying outside", + tool_calls=[ToolCallRequest( + id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"}, + )], ), - LLMResponse(content="should not continue", tool_calls=[]), + LLMResponse(content="ok, telling the user instead", tool_calls=[]), ]) tools = MagicMock() tools.get_definitions.return_value = [] tools.execute = AsyncMock( - side_effect=PermissionError("Path /tmp/outside.md is outside allowed directory /workspace") + side_effect=PermissionError( + "Path /tmp/outside.md is outside allowed directory /workspace" + ) ) runner = AgentRunner(provider) @@ -336,71 +348,92 @@ async def test_runner_stops_on_workspace_violation_without_fail_on_tool_error(): initial_messages=[], tools=tools, model="test-model", - max_iterations=2, + max_iterations=3, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, )) - assert provider.chat_with_retry.await_count == 1 - assert result.stop_reason == "tool_error" - assert "outside allowed directory" in (result.error or "") - assert result.tool_events == [ - { - "name": "read_file", - "status": "error", - "detail": "workspace_violation: Path /tmp/outside.md is outside allowed directory /workspace", - } - ] + assert provider.chat_with_retry.await_count == 2, ( + "workspace violation must NOT short-circuit the loop" + ) + assert result.stop_reason != "tool_error" + assert result.error is None + assert result.final_content == "ok, telling the user instead" + assert result.tool_events and result.tool_events[0]["status"] == "error" + # Detail still carries the workspace_violation breadcrumb for telemetry, + # but the runner did not raise. + assert "workspace_violation" in result.tool_events[0]["detail"] -def test_is_workspace_violation_recognizes_ssrf_block(): - """Internal/private URL block must be classified as a fatal workspace violation. +def test_is_ssrf_violation_remains_fatal(): + """SSRF rejections are the only marker that stays turn-fatal. - Regression guard: the deny/allowlist filter messages were intentionally split - out of `_WORKSPACE_BLOCK_MARKERS` so the LLM can retry, but SSRF rejections - are a hard security boundary and must remain fatal. + A single successful internal-URL fetch can leak cloud metadata, so we + never let the LLM "retry" with a different URL phrasing -- contrast + this with workspace-bound rejections which are soft + throttled in v2. """ from nanobot.agent.runner import AgentRunner ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)" - assert AgentRunner._is_workspace_violation(ssrf_msg) is True + assert AgentRunner._is_ssrf_violation(ssrf_msg) is True - # Sanity: deny/allowlist filter messages are deliberately *not* fatal. - assert AgentRunner._is_workspace_violation( - "Error: Command blocked by deny pattern filter" - ) is False - assert AgentRunner._is_workspace_violation( - "Error: Command blocked by allowlist filter (not in allowlist)" - ) is False - - -def test_is_workspace_violation_does_not_fatal_on_shell_guard_heuristics(): - """#3599 / #3605 regression: shell guard heuristics must NOT be fatal. - - ``path outside working dir`` and ``path traversal detected`` are produced - by best-effort string scans inside ``ExecTool._guard_command`` -- they - routinely false-positive on idiomatic constructs (``2>/dev/null``, - ``sed 's|x|../y|g'``) and should be surfaced to the LLM as recoverable - tool errors so it can switch tactics, not abort the whole turn. - """ - from nanobot.agent.runner import AgentRunner - - assert AgentRunner._is_workspace_violation( + # Workspace-bound markers are NOT classified as SSRF. + assert AgentRunner._is_ssrf_violation( "Error: Command blocked by safety guard (path outside working dir)" ) is False - assert AgentRunner._is_workspace_violation( - "Error: Command blocked by safety guard (path traversal detected)" + assert AgentRunner._is_ssrf_violation( + "Path /tmp/x is outside allowed directory /ws" + ) is False + # Deny / allowlist filter messages stay non-fatal too. + assert AgentRunner._is_ssrf_violation( + "Error: Command blocked by deny pattern filter" ) is False @pytest.mark.asyncio -async def test_runner_lets_llm_recover_from_shell_guard_path_outside(): - """End-to-end: a guard-blocked exec is a soft tool error, not a turn-fatal. +async def test_runner_aborts_on_ssrf_violation(): + """SSRF still fatal-aborts the turn even though workspace ones are soft.""" + from nanobot.agent.runner import AgentRunSpec, AgentRunner - Reporter scenario: a previous PR turned ``path outside working dir`` into - a turn-fatal RuntimeError, so when the false-positive guard fired the - user got no further iterations and (depending on channel) a silent hang. - After narrowing the marker list, the runner must hand the error back to - the LLM and let the next iteration succeed normally. + provider = MagicMock() + provider.chat_with_retry = AsyncMock(side_effect=[ + LLMResponse( + content="curl-ing metadata", + tool_calls=[ToolCallRequest( + id="call_ssrf", + name="exec", + arguments={"command": "curl http://169.254.169.254"}, + )], + ), + LLMResponse(content="should NOT be reached", tool_calls=[]), + ]) + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock(return_value=( + "Error: Command blocked by safety guard (internal/private URL detected)" + )) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=3, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + assert provider.chat_with_retry.await_count == 1, "SSRF must abort immediately" + assert result.stop_reason == "tool_error" + assert "internal/private url detected" in (result.error or "").lower() + + +@pytest.mark.asyncio +async def test_runner_lets_llm_recover_from_shell_guard_path_outside(): + """Reporter scenario for #3599 / #3605 -- guard hit, agent recovers. + + The shell `_guard_command` heuristic fires on `2>/dev/null`-style + redirects and other shell idioms. Before v2 that abort'd the whole + turn (silent hang on Telegram per #3605); now the LLM gets the soft + error back and can finalize on the next iteration. """ from nanobot.agent.runner import AgentRunSpec, AgentRunner @@ -443,7 +476,66 @@ async def test_runner_lets_llm_recover_from_shell_guard_path_outside(): assert result.error is None assert result.final_content == "recovered final answer" assert result.tool_events and result.tool_events[0]["status"] == "error" - assert "workspace_violation" not in result.tool_events[0]["detail"] + # v2: detail keeps the breadcrumb but the runner did not raise. + assert "workspace_violation" in result.tool_events[0]["detail"] + + +@pytest.mark.asyncio +async def test_runner_throttles_repeated_workspace_bypass_attempts(): + """#3493 motivation: stop the LLM bypass loop without aborting the turn. + + LLM keeps switching tools (read_file -> exec cat -> python -c open(...)) + against the same outside path. After the soft retry budget is exhausted + the runner replaces the tool result with a hard "stop trying" message + so the model finally gives up and surfaces the boundary to the user. + """ + from nanobot.agent.runner import AgentRunSpec, AgentRunner + + bypass_attempts = [ + ToolCallRequest( + id=f"a{i}", name="exec", + arguments={"command": f"cat /Users/x/Downloads/01.md # try {i}"}, + ) + for i in range(4) + ] + responses: list[LLMResponse] = [ + LLMResponse(content=f"try {i}", tool_calls=[bypass_attempts[i]]) + for i in range(4) + ] + responses.append(LLMResponse(content="ok telling user", tool_calls=[])) + + provider = MagicMock() + provider.chat_with_retry = AsyncMock(side_effect=responses) + tools = MagicMock() + tools.get_definitions.return_value = [] + tools.execute = AsyncMock( + return_value="Error: Command blocked by safety guard (path outside working dir)" + ) + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=10, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + )) + + # All 4 bypass attempts surface to the LLM (no fatal abort), and the + # runner finally completes once the LLM stops asking. + assert result.stop_reason != "tool_error" + assert result.error is None + assert result.final_content == "ok telling user" + # The third+ attempts must have been escalated -- look at the events. + escalated = [ + ev for ev in result.tool_events + if ev["status"] == "error" + and ev["detail"].startswith("workspace_violation_escalated:") + ] + assert escalated, ( + "expected at least one escalated workspace_violation event, got: " + f"{result.tool_events}" + ) @pytest.mark.asyncio @@ -924,6 +1016,7 @@ async def test_runner_batches_read_only_tools_before_exclusive_work(): ToolCallRequest(id="rw1", name="write_a", arguments={}), ], {}, + {}, ) assert shared_events[0:2] == ["start:read_a", "start:read_b"] @@ -968,6 +1061,7 @@ async def test_runner_does_not_batch_exclusive_read_only_tools(): ToolCallRequest(id="ro2", name="read_b", arguments={}), ], {}, + {}, ) assert shared_events[0] == "start:read_a" diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index 73e3b4f2a..92a3f8f50 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -242,13 +242,21 @@ def test_exec_extract_absolute_paths_captures_quoted_paths() -> None: def test_exec_guard_blocks_home_path_outside_workspace(tmp_path) -> None: tool = ExecTool(restrict_to_workspace=True) error = tool._guard_command("cat ~/.nanobot/config.json", str(tmp_path)) - assert error == "Error: Command blocked by safety guard (path outside working dir)" + assert error is not None + assert error.startswith( + "Error: Command blocked by safety guard (path outside working dir)" + ) + assert "hard policy boundary" in error def test_exec_guard_blocks_quoted_home_path_outside_workspace(tmp_path) -> None: tool = ExecTool(restrict_to_workspace=True) error = tool._guard_command('cat "~/.nanobot/config.json"', str(tmp_path)) - assert error == "Error: Command blocked by safety guard (path outside working dir)" + assert error is not None + assert error.startswith( + "Error: Command blocked by safety guard (path outside working dir)" + ) + assert "hard policy boundary" in error def test_exec_guard_allows_media_path_outside_workspace(tmp_path, monkeypatch) -> None: @@ -300,7 +308,11 @@ def test_exec_guard_blocks_windows_drive_root_outside_workspace(monkeypatch) -> tool = ExecTool(restrict_to_workspace=True) error = tool._guard_command("dir E:\\", "E:\\workspace") - assert error == "Error: Command blocked by safety guard (path outside working dir)" + assert error is not None + assert error.startswith( + "Error: Command blocked by safety guard (path outside working dir)" + ) + assert "hard policy boundary" in error # --- cast_params tests --- diff --git a/tests/utils/test_workspace_violation_throttle.py b/tests/utils/test_workspace_violation_throttle.py new file mode 100644 index 000000000..a0fb059e1 --- /dev/null +++ b/tests/utils/test_workspace_violation_throttle.py @@ -0,0 +1,120 @@ +"""Tests for repeated_workspace_violation throttle and signature.""" + +from __future__ import annotations + +from nanobot.utils.runtime import ( + repeated_workspace_violation_error, + workspace_violation_signature, +) + + +def test_signature_for_filesystem_tools_uses_path_argument(): + sig_a = workspace_violation_signature( + "read_file", {"path": "/Users/x/Downloads/01.md"} + ) + sig_b = workspace_violation_signature( + "write_file", {"path": "/Users/x/Downloads/01.md"} + ) + sig_c = workspace_violation_signature( + "edit_file", {"file_path": "/Users/x/Downloads/01.md"} + ) + + assert sig_a is not None + assert sig_a == sig_b == sig_c, ( + "the throttle must collapse equivalent paths across different tools " + "so the LLM cannot bypass it by switching tool" + ) + assert "/users/x/downloads/01.md" in sig_a + + +def test_signature_for_exec_extracts_first_absolute_path_in_command(): + sig = workspace_violation_signature( + "exec", + {"command": "cat /Users/x/Downloads/01.md && echo done"}, + ) + assert sig is not None + assert "/users/x/downloads/01.md" in sig + + +def test_signature_collides_across_filesystem_and_exec_for_same_target(): + """LLM bypass loops jump tools (read_file -> exec cat). Throttle must + treat both attempts as targeting the same outside resource.""" + fs_sig = workspace_violation_signature( + "read_file", {"path": "/Users/x/Downloads/01.md"} + ) + exec_sig = workspace_violation_signature( + "exec", {"command": "cat /Users/x/Downloads/01.md"} + ) + assert fs_sig == exec_sig + + +def test_signature_falls_back_to_working_dir_when_no_absolute_in_command(): + sig = workspace_violation_signature( + "exec", + {"command": "ls -la", "working_dir": "/etc"}, + ) + assert sig is not None + assert "/etc" in sig + + +def test_signature_is_none_for_unknown_tool_with_no_path(): + assert workspace_violation_signature("web_search", {"query": "anything"}) is None + assert workspace_violation_signature("exec", {"command": "echo hello"}) is None + + +def test_repeated_workspace_violation_returns_none_within_budget(): + counts: dict[str, int] = {} + arguments = {"path": "/Users/x/Downloads/01.md"} + + assert repeated_workspace_violation_error("read_file", arguments, counts) is None + assert repeated_workspace_violation_error("read_file", arguments, counts) is None + + +def test_repeated_workspace_violation_escalates_after_third_attempt(): + counts: dict[str, int] = {} + arguments = {"path": "/Users/x/Downloads/01.md"} + + repeated_workspace_violation_error("read_file", arguments, counts) + repeated_workspace_violation_error("read_file", arguments, counts) + third = repeated_workspace_violation_error("read_file", arguments, counts) + + assert third is not None + assert "refusing repeated workspace-bypass" in third + assert "/users/x/downloads/01.md" in third + assert "ask how they want to proceed" in third + + +def test_repeated_workspace_violation_independent_per_target(): + """Different outside paths must each get their own retry budget.""" + counts: dict[str, int] = {} + + repeated_workspace_violation_error( + "read_file", {"path": "/Users/x/Downloads/01.md"}, counts, + ) + repeated_workspace_violation_error( + "read_file", {"path": "/Users/x/Downloads/01.md"}, counts, + ) + # Different target, fresh budget. + assert repeated_workspace_violation_error( + "read_file", {"path": "/Users/x/Documents/notes.md"}, counts, + ) is None + + +def test_repeated_workspace_violation_collapses_tool_switching(): + """LLM switches from read_file to exec cat then to python -c open(...) + against the same path; the throttle must escalate on the third attempt.""" + counts: dict[str, int] = {} + + repeated_workspace_violation_error( + "read_file", {"path": "/Users/x/Downloads/01.md"}, counts, + ) + repeated_workspace_violation_error( + "exec", {"command": "cat /Users/x/Downloads/01.md"}, counts, + ) + third = repeated_workspace_violation_error( + "exec", + {"command": "python3 -c \"open('/Users/x/Downloads/01.md').read()\""}, + counts, + ) + assert third is not None + assert "refusing repeated workspace-bypass" in third From 2a7433b7ecf92963091ab80fbfb83c0da5dc4be0 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 3 May 2026 17:16:42 +0000 Subject: [PATCH 12/44] chore(runner): tighten workspace guard comments and Windows tests Keep the workspace-boundary changes easier to review by trimming long explanatory comments down to short local notes. Also make the #3599 POSIX command regression skip on Windows and normalize workspace violation signatures to POSIX separators so the throttle tests are platform-stable. Tests: - uv run pytest tests/tools/test_exec_security.py tests/utils/test_workspace_violation_throttle.py -q - uv run pytest -q Co-authored-by: Cursor --- nanobot/agent/runner.py | 62 ++++++++----------------------- nanobot/agent/tools/shell.py | 26 ++----------- nanobot/utils/runtime.py | 54 +++------------------------ tests/tools/test_exec_security.py | 2 + 4 files changed, 28 insertions(+), 116 deletions(-) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 7a34cfbb7..c2e15bf8a 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -240,9 +240,7 @@ class AgentRunner: stop_reason = "completed" tool_events: list[dict[str, str]] = [] external_lookup_counts: dict[str, int] = {} - # Tracks repeated bypass attempts against the same outside-workspace - # target within this turn. See ``repeated_workspace_violation_error`` - # in ``nanobot.utils.runtime`` for the throttle policy. + # Per-turn throttle for repeated attempts against the same outside target. workspace_violation_counts: dict[str, int] = {} empty_content_retries = 0 length_recovery_count = 0 @@ -808,10 +806,7 @@ class AgentRunner: payload = f"Error: {type(exc).__name__}: {exc}" handled = self._classify_violation( raw_text=str(exc), - # Match the legacy behavior here: the exception branch never - # appended the "try a different approach" hint, even on the - # workspace-violation path -- preserve that for callers that - # eyeball the exact tool message. + # Preserve legacy exception payloads without the retry hint. soft_payload=payload, ssrf_payload=payload, ssrf_error=exc, @@ -854,17 +849,10 @@ class AgentRunner: detail = detail[:120] + "..." return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None - # SSRF rejections remain a hard, non-recoverable safety boundary: a single - # successful internal-URL fetch can leak cloud metadata, so we never let - # the LLM "retry" with a different phrasing of the same target. + # SSRF remains fatal; workspace path boundaries are soft + throttled. _SSRF_MARKER: str = "internal/private url detected" - # Markers that identify "tried to access something outside the workspace". - # Unlike SSRF these are intentionally *non-fatal* (#3599 / #3605): - # - The structured error message itself tells the model not to bypass. - # - ``repeated_workspace_violation_error`` throttles the loop reported - # in #3493 by escalating after two attempts against the same target. - # - max_iterations is the ultimate ceiling, so we never need to abort. + # Non-SSRF boundary markers returned to the LLM as recoverable tool errors. _WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = ( "outside the configured workspace", "outside allowed directory", @@ -880,12 +868,7 @@ class AgentRunner: @classmethod def _is_workspace_violation(cls, text: str) -> bool: - """True when *text* looks like *any* policy boundary rejection. - - Kept as a public-ish hook for callers that need a yes/no signal - (logging, telemetry). The runner itself uses the more specific - ``_is_ssrf_violation`` to decide what is fatal. - """ + """True when *text* looks like any policy boundary rejection.""" if not text: return False lowered = text.lower() @@ -904,32 +887,14 @@ class AgentRunner: tool_call: ToolCallRequest, workspace_violation_counts: dict[str, int], ) -> tuple[Any, dict[str, str], BaseException | None] | None: - """Apply violation policy to a tool failure, or pass through. - - Returns a fully-formed (payload, event, error) triple when *raw_text* - looks like a policy boundary rejection. Returns ``None`` when the - caller should fall through to its generic per-branch handling. - - - SSRF stays fatal -- a single successful internal fetch can leak - cloud metadata, so retrying with a different URL phrasing is - never acceptable. We mutate ``event`` in place so the caller's - telemetry stays consistent. - - All other workspace-bound rejections become soft tool errors. - Each repeated attempt against the same outside target bumps a - per-turn counter; after the soft retry budget is exhausted we - replace the message body with an explicit "stop trying to bypass - the policy" instruction (see #3493 for the original bypass-loop - that motivated PR #3493's hard-abort, and #3599 / #3605 for why - the hard-abort backfired). - """ + """Classify safety-boundary failures, or return ``None`` to pass through.""" if self._is_ssrf_violation(raw_text): logger.warning( "Tool {} blocked by SSRF guard; aborting turn: {}", tool_call.name, raw_text.replace("\n", " ").strip()[:200], ) - event["detail"] = ("workspace_violation: " - + raw_text.replace("\n", " ").strip())[:160] + event["detail"] = self._event_detail("workspace_violation: ", raw_text) return ssrf_payload, event, ssrf_error if self._is_workspace_violation(raw_text): @@ -938,20 +903,25 @@ class AgentRunner: tool_call.arguments, workspace_violation_counts, ) - event["detail"] = ("workspace_violation: " - + raw_text.replace("\n", " ").strip())[:160] + event["detail"] = self._event_detail("workspace_violation: ", raw_text) if escalation is not None: logger.warning( "Tool {} hit workspace boundary repeatedly; escalating hint", tool_call.name, ) - event["detail"] = ("workspace_violation_escalated: " - + raw_text.replace("\n", " ").strip())[:160] + event["detail"] = self._event_detail( + "workspace_violation_escalated: ", + raw_text, + ) return escalation, event, None return soft_payload, event, None return None + @staticmethod + def _event_detail(prefix: str, text: str, limit: int = 160) -> str: + return (prefix + text.replace("\n", " ").strip())[:limit] + async def _emit_checkpoint( self, spec: AgentRunSpec, diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 2ed6b981e..a05293aae 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -19,11 +19,7 @@ from nanobot.config.paths import get_media_dir _IS_WINDOWS = sys.platform == "win32" -# Appended to every workspace / safety guard rejection so the LLM is told -# explicitly that this is a policy boundary (not a transient failure) and -# that bypass loops will not change the answer. The throttle in -# ``nanobot.utils.runtime.repeated_workspace_violation_error`` upgrades -# this further once the model keeps targeting the same path. +# Policy note appended to recoverable workspace-boundary guard errors. _WORKSPACE_BOUNDARY_NOTE = ( "\n\nNote: this is a hard policy boundary, not a transient failure. " "Do NOT retry with shell tricks (symlinks, base64 piping, alternative " @@ -97,10 +93,7 @@ class ExecTool(Tool): _MAX_TIMEOUT = 600 _MAX_OUTPUT = 10_000 - # Kernel device files that are universally safe as stdio redirect targets - # (e.g. ``cmd 2>/dev/null``). Without this allow-list the workspace guard - # treats them as ``path outside working dir`` and the LLM ends up unable - # to silence stderr inside the workspace (#3599). + # Kernel device files safe as stdio redirect targets (#3599). _BENIGN_DEVICE_PATHS: frozenset[str] = frozenset({ "/dev/null", "/dev/zero", @@ -325,10 +318,7 @@ class ExecTool(Tool): from nanobot.security.network import contains_internal_url if contains_internal_url(cmd): - # SSRF: stay short and direct. The runner classifies this - # marker as a hard, non-recoverable boundary, so the - # _WORKSPACE_BOUNDARY_NOTE policy text doesn't apply here -- - # we don't want the model to retry at all. + # SSRF stays fatal in the runner, so keep this marker direct. return "Error: Command blocked by safety guard (internal/private URL detected)" if self.restrict_to_workspace: @@ -371,15 +361,7 @@ class ExecTool(Tool): @classmethod def _is_benign_device_path(cls, path: str) -> bool: - """Return True when *path* is a kernel device file we should never block. - - Treats ``/dev/null``, the standard streams, ``/dev/random``, etc. as - always-safe targets so that idiomatic stdio plumbing such as - ``cmd 2>/dev/null`` or ``echo done >/dev/stderr`` is not flagged as a - workspace violation regardless of the configured working directory. - Also accepts ``/dev/fd/N`` because those are per-process aliases for - already-open file descriptors and never escape the workspace. - """ + """Return True for kernel device files that should never be workspace-blocked.""" if path in cls._BENIGN_DEVICE_PATHS: return True return path.startswith("/dev/fd/") diff --git a/nanobot/utils/runtime.py b/nanobot/utils/runtime.py index f5c5f994a..4157b396f 100644 --- a/nanobot/utils/runtime.py +++ b/nanobot/utils/runtime.py @@ -12,12 +12,7 @@ from nanobot.utils.helpers import stringify_text_blocks _MAX_REPEAT_EXTERNAL_LOOKUPS = 2 -# Workspace-violation throttle: how many times the LLM is allowed to bump -# against the same outside-workspace target *within a single turn* before the -# tool result is escalated with a hard "stop trying to bypass the policy" -# instruction. Two free attempts give the model room to e.g. read_file then -# fall back to exec without immediately escalating; the third attempt at the -# same target is treated as a clear bypass loop. +# Third same-target workspace violation in a turn escalates to "stop retrying". _MAX_REPEAT_WORKSPACE_VIOLATIONS = 2 EMPTY_FINAL_RESPONSE_MESSAGE = ( @@ -107,29 +102,7 @@ def repeated_external_lookup_error( ) -# --- Workspace-violation throttle -------------------------------------------- -# -# When ``restrict_to_workspace`` is on and the LLM tries to read or exec -# something outside of the workspace, we want to *tell* the model that it -# hit a hard policy boundary -- not silently abort the whole turn and not -# allow it to spin forever swapping ``read_file`` for ``exec cat`` for -# ``python -c open(...)`` (the actual loop reported in #3493). The strategy -# is two-fold: -# -# 1. Each individual guard error already includes structured instructions -# that tell the model "don't try to bypass this; ask the user for help". -# 2. We additionally count how many times the *same outside target* has -# been refused within the current turn. After two free attempts the -# third refusal swaps in a much more forceful message that quotes the -# target path and explicitly orders the model to stop and surface the -# boundary back to the user. The model is still free to do something -# else (different target, different question) -- only the bypass loop -# is interrupted. -# -# This intentionally does *not* fatal-abort the turn: max_iterations and -# the empty-final-response retries already provide the ultimate ceiling -# for runaway loops, and aborting is what produced the silent-hang bug -# in #3605 in the first place. +# Workspace-boundary violations are soft errors, with per-target throttling. _OUTSIDE_PATH_PATTERN = re.compile(r"(?:^|[\s|>'\"])((?:/[^\s\"'>;|<]+)|(?:~[^\s\"'>;|<]+))") @@ -138,14 +111,7 @@ def workspace_violation_signature( tool_name: str, arguments: dict[str, Any], ) -> str | None: - """Return a stable signature for the outside-workspace target a tool tried. - - The signature is shared across tool names so that the LLM cannot bypass - the throttle by switching from ``read_file`` to ``exec cat`` to - ``python -c open(...)`` against the same path. Returns ``None`` when - the call has no obvious outside target (e.g. SSRF rejections, deny - pattern hits, or tools whose argument shape we don't understand). - """ + """Return a stable cross-tool signature for the outside-workspace target.""" for key in ("path", "file_path", "target", "source", "destination"): val = arguments.get(key) if isinstance(val, str) and val.strip(): @@ -167,9 +133,9 @@ def workspace_violation_signature( def _normalize_violation_target(raw: str) -> str: """Normalize *raw* path so that equivalent spellings collide on the same key.""" try: - normalized = str(Path(raw).expanduser().resolve()) + normalized = Path(raw).expanduser().resolve().as_posix() except Exception: - normalized = raw + normalized = raw.replace("\\", "/") return f"violation:{normalized}".lower() @@ -178,15 +144,7 @@ def repeated_workspace_violation_error( arguments: dict[str, Any], seen_counts: dict[str, int], ) -> str | None: - """Return an escalated error string after repeated bypass attempts. - - Returns ``None`` while the LLM is still within the soft retry budget -- - callers should fall back to the tool's own error message in that case. - Once the budget is exceeded, returns a hard "stop trying" instruction - that quotes the offending target. Throttle state lives in - *seen_counts* (a per-turn dict), so the budget naturally resets across - turns without persisting LLM-controlled keys. - """ + """Return an escalated error after repeated bypass attempts.""" signature = workspace_violation_signature(tool_name, arguments) if signature is None: return None diff --git a/tests/tools/test_exec_security.py b/tests/tools/test_exec_security.py index b7ccf6a2b..844d535c0 100644 --- a/tests/tools/test_exec_security.py +++ b/tests/tools/test_exec_security.py @@ -3,6 +3,7 @@ from __future__ import annotations import socket +import sys from unittest.mock import patch import pytest @@ -212,6 +213,7 @@ def test_exec_allows_benign_device_targets_inside_workspace(tmp_path, command): @pytest.mark.asyncio +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX rm and /dev/null syntax") async def test_exec_3599_regression_rm_with_dev_null_redirect(tmp_path): """#3599: ``rm 2>/dev/null`` must succeed against the workspace guard.""" workspace = tmp_path / "workspace" From d3689d143c56355f5d9aaa1fef9e7b4cc6523a3d Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 4 May 2026 00:59:47 +0800 Subject: [PATCH 13/44] fix(agent): prevent safety guard false positives and streamed message drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent fixes for issues exposed by PR #3493: 1. shell.py: allow /dev/* paths in workspace guard Commands like `rm file.txt 2>/dev/null` were blocked because _extract_absolute_paths captured /dev/null as a path outside the workspace. Allow /dev like media_path is already allowed. 2. shell.py: remove | from home_paths regex prefix Loki query operator `|~` was misinterpreted as pipe + home directory, causing false workspace violation errors. 3. loop.py: change _streamed from blacklist to whitelist stop_reason "tool_error" was not in the exclusion set {"ask_user", "error"}, so _streamed=True was set on fatal errors. channel manager then skipped channel.send() because it assumed the content was already streamed — but it never was. Whitelist to only {"stop", "end_turn", "max_tokens"}. Also fixes a pre-existing Windows bug in _spawn where create_subprocess_exec + list2cmdline breaks commands with paths containing spaces (e.g. D:\Program Files\python.exe). Closes: #3599, #3605 --- nanobot/agent/loop.py | 2 +- nanobot/agent/tools/shell.py | 14 ++++++++++---- tests/tools/test_exec_platform.py | 28 +++++++++++++--------------- tests/tools/test_tool_validation.py | 21 +++++++++++++++++++++ 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c4da557ec..46d4bc1ae 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -1135,7 +1135,7 @@ class AgentLoop: ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [], msg.channel, ) - if on_stream is not None and stop_reason not in {"ask_user", "error"}: + if on_stream is not None and stop_reason not in {"ask_user", "error", "tool_error"}: meta["_streamed"] = True return OutboundMessage( channel=msg.channel, diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index a05293aae..0bbc4d69d 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -220,9 +220,12 @@ class ExecTool(Tool): ) -> asyncio.subprocess.Process: """Launch *command* in a platform-appropriate shell.""" if _IS_WINDOWS: - comspec = env.get("COMSPEC", os.environ.get("COMSPEC", "cmd.exe")) - return await asyncio.create_subprocess_exec( - comspec, "/c", command, + # create_subprocess_exec re-quotes args via list2cmdline, which + # breaks commands containing paths with spaces (e.g. "D:\Program + # Files\python.exe" "script.py"). create_subprocess_shell passes + # the raw command string to COMSPEC without re-quoting. + return await asyncio.create_subprocess_shell( + command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, @@ -346,11 +349,14 @@ class ExecTool(Tool): continue media_path = get_media_dir().resolve() + dev_path = Path("/dev").resolve() if (p.is_absolute() and cwd_path not in p.parents and p != cwd_path and media_path not in p.parents and p != media_path + and dev_path not in p.parents + and p != dev_path ): return ( "Error: Command blocked by safety guard (path outside working dir)" @@ -372,5 +378,5 @@ class ExecTool(Tool): # NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted. win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command) posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only - home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~ + home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~ return win_paths + posix_paths + home_paths diff --git a/tests/tools/test_exec_platform.py b/tests/tools/test_exec_platform.py index b3d7f4c18..6e5292e7f 100644 --- a/tests/tools/test_exec_platform.py +++ b/tests/tools/test_exec_platform.py @@ -112,33 +112,31 @@ class TestSpawnUnix: class TestSpawnWindows: @pytest.mark.asyncio - async def test_uses_comspec_from_env(self): + async def test_uses_create_subprocess_shell(self): env = {"COMSPEC": r"C:\Windows\system32\cmd.exe", "PATH": ""} with ( patch("nanobot.agent.tools.shell._IS_WINDOWS", True), - patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec, + patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell, ): - mock_exec.return_value = AsyncMock() - await ExecTool._spawn("dir", r"C:\Users", env) + mock_shell.return_value = AsyncMock() + await ExecTool._spawn("dir", r"C:\work", env) - args = mock_exec.call_args[0] - assert "cmd.exe" in args[0] - assert "/c" in args + args = mock_shell.call_args[0] assert "dir" in args @pytest.mark.asyncio - async def test_falls_back_to_default_comspec(self): - env = {"PATH": ""} + async def test_passes_cwd_and_env(self): + env = {"PATH": "/usr/bin"} with ( patch("nanobot.agent.tools.shell._IS_WINDOWS", True), - patch.dict("os.environ", {}, clear=True), - patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec, + patch("asyncio.create_subprocess_shell", new_callable=AsyncMock) as mock_shell, ): - mock_exec.return_value = AsyncMock() - await ExecTool._spawn("dir", r"C:\Users", env) + mock_shell.return_value = AsyncMock() + await ExecTool._spawn("echo hi", r"C:\work", env) - args = mock_exec.call_args[0] - assert args[0] == "cmd.exe" + kwargs = mock_shell.call_args[1] + assert kwargs["cwd"] == r"C:\work" + assert kwargs["env"] == env # --------------------------------------------------------------------------- diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index 92a3f8f50..12a478272 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -315,6 +315,27 @@ def test_exec_guard_blocks_windows_drive_root_outside_workspace(monkeypatch) -> assert "hard policy boundary" in error +def test_exec_guard_allows_dev_null_redirect(tmp_path) -> None: + tool = ExecTool(restrict_to_workspace=True) + ws = tmp_path / "workspace" + ws.mkdir() + (ws / "file.txt").write_text("ok", encoding="utf-8") + error = tool._guard_command(f'rm "{ws / "file.txt"}" 2>/dev/null', str(ws)) + assert error is None + + +def test_exec_guard_allows_dev_urandom(tmp_path) -> None: + tool = ExecTool(restrict_to_workspace=True) + error = tool._guard_command("cat /dev/urandom | head -c 16 > random.bin", str(tmp_path)) + assert error is None + + +def test_exec_extract_absolute_paths_ignores_pipe_tilde() -> None: + cmd = "python query.py --query '{job=\"app\"} |~ \"error\"'" + paths = ExecTool._extract_absolute_paths(cmd) + assert not any(p.startswith("~") for p in paths) + + # --- cast_params tests --- From 614b21368f21aa90572b3f82045b2eb996af36b9 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 3 May 2026 17:23:23 +0000 Subject: [PATCH 14/44] fix(agent): tighten safety guard edge cases Keep the /dev workspace guard exception scoped to the known benign device paths already handled by ExecTool, and add coverage that non-benign /dev targets still get blocked. Also add a streaming regression for tool_error responses so fatal tool failures are delivered by channels instead of being marked as already streamed. Co-authored-by: Cursor --- nanobot/agent/tools/shell.py | 3 --- tests/agent/test_runner.py | 39 +++++++++++++++++++++++++++++ tests/tools/test_tool_validation.py | 7 ++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 0bbc4d69d..17451432a 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -349,14 +349,11 @@ class ExecTool(Tool): continue media_path = get_media_dir().resolve() - dev_path = Path("/dev").resolve() if (p.is_absolute() and cwd_path not in p.parents and p != cwd_path and media_path not in p.parents and p != media_path - and dev_path not in p.parents - and p != dev_path ): return ( "Error: Command blocked by safety guard (path outside working dir)" diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index 09d1dbfd5..27ee2b065 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -1289,6 +1289,45 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path): "_streamed must not be set when stop_reason is error" +@pytest.mark.asyncio +async def test_streamed_flag_not_set_on_tool_error(tmp_path): + from nanobot.agent.loop import AgentLoop + from nanobot.bus.events import InboundMessage + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + tool_call_resp = LLMResponse( + content="checking metadata", + tool_calls=[ToolCallRequest( + id="call_ssrf", + name="exec", + arguments={"command": "curl http://169.254.169.254/latest/meta-data/"}, + )], + usage={}, + ) + provider.chat_stream_with_retry = AsyncMock(return_value=tool_call_resp) + + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.tools.prepare_call = MagicMock(return_value=(None, {}, None)) + loop.tools.execute = AsyncMock(return_value=( + "Error: Command blocked by safety guard (internal/private URL detected)" + )) + + result = await loop._process_message( + InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="hi"), + on_stream=AsyncMock(), + on_stream_end=AsyncMock(), + ) + + assert result is not None + assert "internal/private URL detected" in result.content + assert not result.metadata.get("_streamed"), \ + "_streamed must not be set when stop_reason is tool_error" + + @pytest.mark.asyncio async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path): from nanobot.agent.loop import AgentLoop diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index 12a478272..42620dcc6 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -330,6 +330,13 @@ def test_exec_guard_allows_dev_urandom(tmp_path) -> None: assert error is None +def test_exec_guard_blocks_non_benign_dev_path(tmp_path) -> None: + tool = ExecTool(restrict_to_workspace=True) + error = tool._guard_command("cat /dev/sda", str(tmp_path)) + assert error is not None + assert "path outside working dir" in error + + def test_exec_extract_absolute_paths_ignores_pipe_tilde() -> None: cmd = "python query.py --query '{job=\"app\"} |~ \"error\"'" paths = ExecTool._extract_absolute_paths(cmd) From 0f32c0451e3d0a1fbaeff14c7745c96af8b1de8e Mon Sep 17 00:00:00 2001 From: yorkhellen Date: Sun, 3 May 2026 21:44:09 +0800 Subject: [PATCH 15/44] fix: support WhatsApp voice message download --- bridge/src/whatsapp.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index 55d3a85b6..0d2f40b2e 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -165,6 +165,10 @@ export class WhatsAppClient { fallbackContent = '[Video]'; const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined); if (path) mediaPaths.push(path); + } else if (unwrapped.audioMessage) { + fallbackContent = '[Voice Message]'; + const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined); + if (path) mediaPaths.push(path); } const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || ''; From 387988b8e95cc4e56962c880d581d038c05f3ecc Mon Sep 17 00:00:00 2001 From: mikaku9944 <975414875@qq.com> Date: Thu, 2 Apr 2026 01:27:33 +0800 Subject: [PATCH 16/44] feat(cli): add provider logout command - Implement \ anobot provider logout \ to clear OAuth credentials. - Add \_LOGOUT_HANDLERS\ registration mechanism mirroring login. - Implement logout for \openai-codex\ by deleting local \oauth-cli-kit\ token and lock files. - Fallback gracefully when attempting to logout from providers lacking local credentials or implementations. - Fixes #2665 --- nanobot/cli/commands.py | 71 ++++++++++++++++++++++++++++++++++---- tests/cli/test_commands.py | 26 ++++++++++++++ 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 33b33f541..3b03869e2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1487,10 +1487,12 @@ provider_app = typer.Typer(help="Manage providers") app.add_typer(provider_app, name="provider") -_LOGIN_HANDLERS: dict[str, callable] = {} +_LOGIN_HANDLERS: dict[str, Any] = {} +_LOGOUT_HANDLERS: dict[str, Any] = {} def _register_login(name: str): + """注册 OAuth 登录处理器。""" def decorator(fn): _LOGIN_HANDLERS[name] = fn return fn @@ -1498,11 +1500,16 @@ def _register_login(name: str): return decorator -@provider_app.command("login") -def provider_login( - provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), -): - """Authenticate with an OAuth provider.""" +def _register_logout(name: str): + """注册 OAuth 登出处理器。""" + def decorator(fn): + _LOGOUT_HANDLERS[name] = fn + return fn + return decorator + + +def _resolve_oauth_provider(provider: str): + """解析并校验 OAuth provider 配置。""" from nanobot.providers.registry import PROVIDERS key = provider.replace("-", "_") @@ -1511,6 +1518,15 @@ def provider_login( names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth) console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}") raise typer.Exit(1) + return spec + + +@provider_app.command("login") +def provider_login( + provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), +): + """Authenticate with an OAuth provider.""" + spec = _resolve_oauth_provider(provider) handler = _LOGIN_HANDLERS.get(spec.name) if not handler: @@ -1521,6 +1537,22 @@ def provider_login( handler() +@provider_app.command("logout") +def provider_logout( + provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), +): + """Log out from an OAuth provider.""" + spec = _resolve_oauth_provider(provider) + + handler = _LOGOUT_HANDLERS.get(spec.name) + if not handler: + console.print(f"[red]Logout not implemented for {spec.label}[/red]") + raise typer.Exit(1) + + console.print(f"{__logo__} OAuth Logout - {spec.label}\n") + handler() + + @_register_login("openai_codex") def _login_openai_codex() -> None: try: @@ -1544,6 +1576,33 @@ def _login_openai_codex() -> None: raise typer.Exit(1) +@_register_logout("openai_codex") +def _logout_openai_codex() -> None: + """清理 OpenAI Codex 的本地 OAuth 凭证。""" + try: + from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER + from oauth_cli_kit.storage import FileTokenStorage + except ImportError: + console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + raise typer.Exit(1) + + storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename) + removed_paths: list[Path] = [] + + for path in (storage.get_token_path(), storage.get_token_path().with_suffix(".lock")): + if path.exists(): + path.unlink() + removed_paths.append(path) + + if not removed_paths: + console.print("[yellow]! No local OAuth credentials found for OpenAI Codex[/yellow]") + return + + console.print("[green]✓ Logged out from OpenAI Codex[/green]") + for path in removed_paths: + console.print(f"[dim]Removed: {path}[/dim]") + + @_register_login("github_copilot") def _login_github_copilot() -> None: try: diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 50ede9095..ce2fe6b0e 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -220,6 +220,32 @@ def test_config_dump_excludes_oauth_provider_blocks(): assert "githubCopilot" not in providers +def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkeypatch): + token_path = tmp_path / "auth" / "oauth.json" + lock_path = token_path.with_suffix(".lock") + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_text("{}", encoding="utf-8") + lock_path.write_text("", encoding="utf-8") + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) + + result = runner.invoke(app, ["provider", "logout", "openai-codex"]) + + assert result.exit_code == 0 + assert not token_path.exists() + assert not lock_path.exists() + assert "Logged out from OpenAI Codex" in result.stdout + + +def test_provider_logout_openai_codex_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path): + token_path = tmp_path / "auth" / "oauth.json" + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) + + result = runner.invoke(app, ["provider", "logout", "openai-codex"]) + + assert result.exit_code == 0 + assert "No local OAuth credentials found for OpenAI Codex" in result.stdout + + def test_config_matches_explicit_ollama_prefix_without_api_key(): config = Config() config.agents.defaults.model = "ollama/llama3.2" From 807b8188e3853fd7d89a3858082c0b76b2ad3872 Mon Sep 17 00:00:00 2001 From: mikaku9944 <975414875@qq.com> Date: Thu, 2 Apr 2026 01:51:09 +0800 Subject: [PATCH 17/44] style(cli): use English for docstrings in oauth commands --- nanobot/cli/commands.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 3b03869e2..abf4d5c9b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1492,7 +1492,7 @@ _LOGOUT_HANDLERS: dict[str, Any] = {} def _register_login(name: str): - """注册 OAuth 登录处理器。""" + """Register an OAuth login handler.""" def decorator(fn): _LOGIN_HANDLERS[name] = fn return fn @@ -1501,7 +1501,7 @@ def _register_login(name: str): def _register_logout(name: str): - """注册 OAuth 登出处理器。""" + """Register an OAuth logout handler.""" def decorator(fn): _LOGOUT_HANDLERS[name] = fn return fn @@ -1509,7 +1509,7 @@ def _register_logout(name: str): def _resolve_oauth_provider(provider: str): - """解析并校验 OAuth provider 配置。""" + """Resolve and validate an OAuth provider configuration.""" from nanobot.providers.registry import PROVIDERS key = provider.replace("-", "_") @@ -1578,7 +1578,7 @@ def _login_openai_codex() -> None: @_register_logout("openai_codex") def _logout_openai_codex() -> None: - """清理 OpenAI Codex 的本地 OAuth 凭证。""" + """Clear local OAuth credentials for OpenAI Codex.""" try: from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER from oauth_cli_kit.storage import FileTokenStorage From 3ceabdecd5f2da029e148ee65132654552d345be Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 4 May 2026 00:26:22 +0800 Subject: [PATCH 18/44] feat(cli): support github-copilot in provider logout Logout previously claimed to support github-copilot in --help text but had no registered handler, so `provider logout github-copilot` failed with "Logout not implemented". Add the handler, sharing token deletion with the codex flow via `_delete_oauth_files`. Tighten handler-table types, fix the codex test fixture filename, and cover github-copilot plus the unknown provider path. --- nanobot/cli/commands.py | 54 +++++++++++++---- nanobot/providers/github_copilot_provider.py | 6 +- tests/cli/test_commands.py | 61 +++++++++++++++++++- 3 files changed, 105 insertions(+), 16 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index abf4d5c9b..a062802a9 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -5,6 +5,7 @@ import os import select import signal import sys +from collections.abc import Callable from contextlib import nullcontext, suppress from pathlib import Path from typing import Any @@ -1487,8 +1488,13 @@ provider_app = typer.Typer(help="Manage providers") app.add_typer(provider_app, name="provider") -_LOGIN_HANDLERS: dict[str, Any] = {} -_LOGOUT_HANDLERS: dict[str, Any] = {} +_LOGIN_HANDLERS: dict[str, Callable[[], None]] = {} +_LOGOUT_HANDLERS: dict[str, Callable[[], None]] = {} + +_PROVIDER_DISPLAY: dict[str, str] = { + "openai_codex": "OpenAI Codex", + "github_copilot": "GitHub Copilot", +} def _register_login(name: str): @@ -1587,20 +1593,46 @@ def _logout_openai_codex() -> None: raise typer.Exit(1) storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename) + _delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["openai_codex"]) + + +@_register_logout("github_copilot") +def _logout_github_copilot() -> None: + """Clear local OAuth credentials for GitHub Copilot.""" + try: + from nanobot.providers.github_copilot_provider import get_storage + except ImportError: + console.print("[red]GitHub Copilot provider unavailable. Ensure oauth-cli-kit is installed.[/red]") + raise typer.Exit(1) + + storage = get_storage() + _delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["github_copilot"]) + + +def _delete_oauth_files(token_path: Path, provider_label: str) -> None: + """Delete OAuth token and lock files, reporting the result.""" removed_paths: list[Path] = [] - - for path in (storage.get_token_path(), storage.get_token_path().with_suffix(".lock")): - if path.exists(): + skipped: list[tuple[Path, OSError]] = [] + for path in (token_path, token_path.with_suffix(".lock")): + try: path.unlink() - removed_paths.append(path) + except FileNotFoundError: + continue + except OSError as exc: + skipped.append((path, exc)) + continue + removed_paths.append(path) - if not removed_paths: - console.print("[yellow]! No local OAuth credentials found for OpenAI Codex[/yellow]") + if not removed_paths and not skipped: + console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]") return - console.print("[green]✓ Logged out from OpenAI Codex[/green]") - for path in removed_paths: - console.print(f"[dim]Removed: {path}[/dim]") + if removed_paths: + console.print(f"[green]✓ Logged out from {provider_label}[/green]") + for path in removed_paths: + console.print(f"[dim]Removed: {path}[/dim]") + for path, exc in skipped: + console.print(f"[yellow]! Could not remove {path}: {exc}[/yellow]") @_register_login("github_copilot") diff --git a/nanobot/providers/github_copilot_provider.py b/nanobot/providers/github_copilot_provider.py index dbc49e73e..acd5d0574 100644 --- a/nanobot/providers/github_copilot_provider.py +++ b/nanobot/providers/github_copilot_provider.py @@ -29,7 +29,7 @@ _EXPIRY_SKEW_SECONDS = 60 _LONG_LIVED_TOKEN_SECONDS = 315360000 -def _storage() -> FileTokenStorage: +def get_storage() -> FileTokenStorage: return FileTokenStorage( token_filename=TOKEN_FILENAME, app_name=TOKEN_APP_NAME, @@ -48,7 +48,7 @@ def _copilot_headers(token: str) -> dict[str, str]: def _load_github_token() -> OAuthToken | None: - token = _storage().load() + token = get_storage().load() if not token or not token.access: return None return token @@ -150,7 +150,7 @@ def login_github_copilot( expires=expires_ms, account_id=str(account_id) if account_id else None, ) - _storage().save(token) + get_storage().save(token) return token diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index ce2fe6b0e..d217c5f03 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -221,7 +221,7 @@ def test_config_dump_excludes_oauth_provider_blocks(): def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkeypatch): - token_path = tmp_path / "auth" / "oauth.json" + token_path = tmp_path / "auth" / "codex.json" lock_path = token_path.with_suffix(".lock") token_path.parent.mkdir(parents=True, exist_ok=True) token_path.write_text("{}", encoding="utf-8") @@ -237,7 +237,7 @@ def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkey def test_provider_logout_openai_codex_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path): - token_path = tmp_path / "auth" / "oauth.json" + token_path = tmp_path / "auth" / "codex.json" monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) result = runner.invoke(app, ["provider", "logout", "openai-codex"]) @@ -246,6 +246,63 @@ def test_provider_logout_openai_codex_succeeds_when_no_local_oauth_file(monkeypa assert "No local OAuth credentials found for OpenAI Codex" in result.stdout +def test_provider_logout_github_copilot_removes_local_oauth_files(tmp_path, monkeypatch): + token_path = tmp_path / "auth" / "github-copilot.json" + lock_path = token_path.with_suffix(".lock") + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_text("{}", encoding="utf-8") + lock_path.write_text("", encoding="utf-8") + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) + + result = runner.invoke(app, ["provider", "logout", "github-copilot"]) + + assert result.exit_code == 0 + assert not token_path.exists() + assert not lock_path.exists() + assert "Logged out from GitHub Copilot" in result.stdout + + +def test_provider_logout_github_copilot_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path): + token_path = tmp_path / "auth" / "github-copilot.json" + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) + + result = runner.invoke(app, ["provider", "logout", "github-copilot"]) + + assert result.exit_code == 0 + assert "No local OAuth credentials found for GitHub Copilot" in result.stdout + + +def test_provider_logout_rejects_unknown_provider(): + result = runner.invoke(app, ["provider", "logout", "not-a-real-provider"]) + + assert result.exit_code == 1 + assert "Unknown OAuth provider" in result.stdout + + +def test_provider_logout_paths_resolve_to_expected_files(): + from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER + from oauth_cli_kit.storage import FileTokenStorage + + from nanobot.providers.github_copilot_provider import get_storage + + codex_storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename) + codex_path = codex_storage.get_token_path() + assert codex_path.name == "codex.json" + assert codex_path.parent.name == "auth" + + gh_storage = get_storage() + gh_path = gh_storage.get_token_path() + assert gh_path.name == "github-copilot.json" + assert gh_path.parent.name == "auth" + + +def test_provider_login_rejects_unknown_provider(): + result = runner.invoke(app, ["provider", "login", "not-a-real-provider"]) + + assert result.exit_code == 1 + assert "Unknown OAuth provider" in result.stdout + + def test_config_matches_explicit_ollama_prefix_without_api_key(): config = Config() config.agents.defaults.model = "ollama/llama3.2" From 9d6afd86b58f46437e796f586cd9be834b1db2ea Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Mon, 4 May 2026 09:55:04 +0800 Subject: [PATCH 19/44] fix(provider): backfill DeepSeek reasoning_content instead of dropping history (#3554, #3584) --- nanobot/providers/openai_compat_provider.py | 88 ++++----------------- tests/providers/test_litellm_kwargs.py | 38 ++++----- 2 files changed, 31 insertions(+), 95 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index decdccb3a..555d0e5e0 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -449,59 +449,6 @@ class OpenAICompatProvider(LLMProvider): clean["content"] = self._coerce_content_to_string(clean.get("content")) return self._enforce_role_alternation(sanitized) - def _drop_deepseek_incomplete_reasoning_history( - self, - messages: list[dict[str, Any]], - model_name: str, - reasoning_effort: str | None, - ) -> list[dict[str, Any]]: - if ( - not self._spec - or self._spec.name != "deepseek" - ): - return messages - - semantic_effort = reasoning_effort.lower() if isinstance(reasoning_effort, str) else None - if semantic_effort in {"none", "minimal", "minimum"}: - return messages - - # DeepSeek-V4 can require reasoning_content even when the config did - # not explicitly request reasoning_effort. Keep that implicit-thinking - # cleanup scoped to known thinking-capable DeepSeek models so normal - # deepseek-chat history is not trimmed. - if semantic_effort is None: - model_lower = model_name.lower() - if not any(token in model_lower for token in ("deepseek-v4", "deepseek-reasoner")): - return messages - - bad_idx = None - for idx, msg in enumerate(messages): - if ( - msg.get("role") == "assistant" - and msg.get("tool_calls") - and not msg.get("reasoning_content") - ): - bad_idx = idx - if bad_idx is None: - return messages - - keep_from = None - for idx in range(bad_idx + 1, len(messages)): - if messages[idx].get("role") == "user": - keep_from = idx - break - - if keep_from is None: - trimmed = messages[:bad_idx] - else: - prefix = [msg for msg in messages[:keep_from] if msg.get("role") == "system"] - trimmed = prefix + messages[keep_from:] - logger.warning( - "Dropped {} DeepSeek thinking history message(s) with incomplete reasoning_content", - len(messages) - len(trimmed), - ) - return trimmed - # ------------------------------------------------------------------ # Build kwargs # ------------------------------------------------------------------ @@ -542,11 +489,6 @@ class OpenAICompatProvider(LLMProvider): if spec and spec.strip_model_prefix: model_name = model_name.split("/")[-1] - messages = self._drop_deepseek_incomplete_reasoning_history( - messages, - model_name, - reasoning_effort, - ) kwargs: dict[str, Any] = { "model": model_name, "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), @@ -611,22 +553,22 @@ class OpenAICompatProvider(LLMProvider): kwargs["tools"] = tools kwargs["tool_choice"] = tool_choice or "auto" - # Backfill reasoning_content on legacy assistant messages. - # DeepSeek V4 (and potentially others) rejects thinking-mode - # requests that contain assistant messages without reasoning_content - # — even on turns that had no tool calls. This happens when a - # session was started with a non-thinking model or without - # reasoning_effort, then the user switches thinking mode on - # mid-session. Injecting an empty string satisfies the API - # without altering semantics (the model treats it as "no - # thinking happened on that turn"). - thinking_active = ( - (spec and spec.thinking_style and reasoning_effort is not None - and semantic_effort not in ("none", "minimal")) - or (reasoning_effort is not None and _is_kimi_thinking_model(model_name) - and semantic_effort not in ("none", "minimal")) + # Backfill reasoning_content="" on assistants missing it: DeepSeek + # thinking mode rejects history otherwise (#3554, #3584); "" reads + # as "no thinking that turn". DeepSeek-V4/reasoner reason natively, + # so backfill even without explicit reasoning_effort. + explicit_thinking = ( + reasoning_effort is not None + and semantic_effort not in ("none", "minimal") + and ((spec and spec.thinking_style) or _is_kimi_thinking_model(model_name)) ) - if thinking_active: + implicit_deepseek_thinking = ( + spec is not None + and spec.name == "deepseek" + and semantic_effort not in ("none", "minimal", "minimum") + and any(t in model_name.lower() for t in ("deepseek-v4", "deepseek-reasoner")) + ) + if explicit_thinking or implicit_deepseek_thinking: for msg in kwargs["messages"]: if msg.get("role") == "assistant" and "reasoning_content" not in msg: msg["reasoning_content"] = "" diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index a3b624171..94455fd40 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -620,7 +620,8 @@ def _tool_call(call_id: str) -> dict: } -def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> None: +def test_deepseek_thinking_backfills_missing_reasoning_content_on_tool_history() -> None: + """Backfill reasoning_content="" instead of dropping the turn (#3554, #3584).""" kwargs = _deepseek_kwargs([ {"role": "system", "content": "system"}, {"role": "user", "content": "can we use wechat?"}, @@ -629,10 +630,12 @@ def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> Non {"role": "user", "content": "continue"}, ]) - assert kwargs["messages"] == [ - {"role": "system", "content": "system"}, - {"role": "user", "content": "continue"}, + assert [m["role"] for m in kwargs["messages"]] == [ + "system", "user", "assistant", "tool", "user", ] + assistant = kwargs["messages"][2] + assert assistant["reasoning_content"] == "" + assert assistant["tool_calls"][0]["function"]["name"] == "my" def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None: @@ -654,20 +657,6 @@ def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None: assert kwargs["messages"][2]["role"] == "tool" -def test_deepseek_thinking_drops_current_bad_tool_turn_without_followup_user() -> None: - kwargs = _deepseek_kwargs([ - {"role": "system", "content": "system"}, - {"role": "user", "content": "can we use wechat?"}, - {"role": "assistant", "content": "", "tool_calls": [_tool_call("call_bad")]}, - {"role": "tool", "tool_call_id": "call_bad", "name": "my", "content": "channels"}, - ]) - - assert kwargs["messages"] == [ - {"role": "system", "content": "system"}, - {"role": "user", "content": "can we use wechat?"}, - ] - - def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): provider = OpenAICompatProvider() @@ -937,8 +926,8 @@ def test_backfill_does_not_touch_messages_when_thinking_explicitly_off() -> None assert "reasoning_content" not in msg -def test_deepseek_v4_drops_incomplete_reasoning_history_when_effort_implicit() -> None: - """DeepSeek-V4 may default to thinking, so incomplete legacy history is trimmed.""" +def test_deepseek_v4_backfills_incomplete_reasoning_history_when_effort_implicit() -> None: + """DeepSeek-V4 reasons natively: backfill even without explicit reasoning_effort.""" spec = find_by_name("deepseek") with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec) @@ -958,12 +947,16 @@ def test_deepseek_v4_drops_incomplete_reasoning_history_when_effort_implicit() - reasoning_effort=None, tool_choice=None, ) - assert [msg["role"] for msg in kw["messages"]] == ["system", "user"] + assert [msg["role"] for msg in kw["messages"]] == [ + "system", "user", "assistant", "tool", "user", + ] + assert kw["messages"][2]["reasoning_content"] == "" assert kw["messages"][-1]["content"] == "thanks" def test_deepseek_chat_keeps_tool_history_when_effort_implicit() -> None: - """Implicit cleanup must not trim non-thinking DeepSeek chat models.""" + """Non-thinking deepseek-chat must keep history untouched and must NOT + receive backfilled reasoning_content (#3554, #3584).""" spec = find_by_name("deepseek") with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): p = OpenAICompatProvider(api_key="k", default_model="deepseek-chat", spec=spec) @@ -985,6 +978,7 @@ def test_deepseek_chat_keeps_tool_history_when_effort_implicit() -> None: roles = [msg["role"] for msg in kw["messages"]] assert roles == ["user", "assistant", "tool", "user"] assert kw["messages"][1]["tool_calls"] + assert "reasoning_content" not in kw["messages"][1] def test_deepseek_coerces_list_content_to_string() -> None: From c30e4d86f37b11f7193c9e5cc39a750706b6d0f1 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Tue, 5 May 2026 21:11:27 +0800 Subject: [PATCH 20/44] refactor(agent): simplify subagent concurrency with rejection over semaphore Replace the asyncio.Semaphore queueing approach with a simple count check in SpawnTool.execute(). When the concurrency limit is reached, the tool returns an error string so the agent can perceive the reason and adjust its behavior instead of silently queueing. - Remove max_concurrent_subagents parameter threading through AgentLoop, commands.py, and nanobot.py - SubagentManager reads the limit directly from AgentDefaults - SpawnTool checks get_running_count() before calling spawn() - Simplify tests to verify rejection behavior --- docs/configuration.md | 22 ++++++++ nanobot/agent/subagent.py | 4 +- nanobot/agent/tools/spawn.py | 8 +++ nanobot/config/schema.py | 1 + tests/agent/tools/test_subagent_tools.py | 69 ++++++++++++++++++++++++ tests/test_tool_contextvars.py | 15 ++++++ 6 files changed, 118 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index ec889c758..d0a7fe940 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1009,6 +1009,28 @@ MCP tools are automatically discovered and registered on startup. The LLM can us **Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation). +## Subagent Concurrency + +By default, nanobot only allows one spawned subagent at a time. When the limit is +reached, the `spawn` tool returns an error so the agent can decide to wait or +rearrange its work. This protects local LLM servers from loading multiple KV caches +at once. If your provider can handle more parallel work, raise the limit: + +```json +{ + "agents": { + "defaults": { + "maxConcurrentSubagents": 2 + } + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `agents.defaults.maxConcurrentSubagents` | `1` | Maximum number of spawned subagents that may run at the same time. Attempts to spawn beyond this limit return an error. | + + ## Auto Compact When a user is idle for longer than a configured threshold, nanobot **proactively** compresses the older part of the session context into a summary while keeping a recent legal suffix of live messages. This reduces token cost and first-token latency when the user returns — instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary, the most recent live context, and fresh input. diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 18f0bd53b..6d64698a7 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -83,6 +83,7 @@ class SubagentManager: disabled_skills: list[str] | None = None, max_iterations: int | None = None, ): + defaults = AgentDefaults() self.provider = provider self.workspace = workspace self.bus = bus @@ -95,8 +96,9 @@ class SubagentManager: self.max_iterations = ( max_iterations if max_iterations is not None - else AgentDefaults().max_tool_iterations + else defaults.max_tool_iterations ) + self.max_concurrent_subagents = defaults.max_concurrent_subagents self.runner = AgentRunner(provider) self._running_tasks: dict[str, asyncio.Task[None]] = {} self._task_statuses: dict[str, SubagentStatus] = {} diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index a1acf0aae..17ad48d12 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -56,6 +56,14 @@ class SpawnTool(Tool): async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str: """Spawn a subagent to execute the given task.""" + running = self._manager.get_running_count() + limit = self._manager.max_concurrent_subagents + if running >= limit: + return ( + f"Cannot spawn subagent: concurrency limit reached " + f"({running}/{limit} running). Wait for a running subagent " + f"to complete before spawning a new one." + ) return await self._manager.spawn( task=task, label=label, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index be4eb7202..2f20eb99e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -78,6 +78,7 @@ class AgentDefaults(Base): context_block_limit: int | None = None temperature: float = 0.1 max_tool_iterations: int = 200 + max_concurrent_subagents: int = Field(default=1, ge=1) max_tool_result_chars: int = 16_000 provider_retry_mode: Literal["standard", "persistent"] = "standard" reasoning_effort: str | None = None # low / medium / high / adaptive - enables LLM thinking mode diff --git a/tests/agent/tools/test_subagent_tools.py b/tests/agent/tools/test_subagent_tools.py index a050a4271..f43f98f24 100644 --- a/tests/agent/tools/test_subagent_tools.py +++ b/tests/agent/tools/test_subagent_tools.py @@ -93,6 +93,75 @@ async def test_subagent_uses_configured_max_iterations(tmp_path): mgr.runner.run.assert_awaited_once() +@pytest.mark.asyncio +async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path): + """SpawnTool should return an error string when the concurrency limit is reached.""" + from nanobot.agent.subagent import SubagentManager + from nanobot.agent.tools.spawn import SpawnTool + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) + mgr._announce_result = AsyncMock() + + # Block the first subagent so it stays "running" + release = asyncio.Event() + + async def fake_run(spec): + await release.wait() + return SimpleNamespace( + stop_reason="done", + final_content="done", + error=None, + tool_events=[], + ) + + mgr.runner.run = AsyncMock(side_effect=fake_run) + + tool = SpawnTool(mgr) + tool.set_context("test", "c1", "test:c1") + + # First spawn succeeds + result = await tool.execute(task="first task") + assert "started" in result + + # Second spawn should be rejected (default limit is 1) + result = await tool.execute(task="second task") + assert "Cannot spawn subagent" in result + assert "concurrency limit reached" in result + + # Release the first subagent + release.set() + # Allow cleanup + await asyncio.gather(*mgr._running_tasks.values(), return_exceptions=True) + + +def test_subagent_default_max_concurrent_matches_agent_defaults(tmp_path): + """Direct SubagentManager construction should use the agent default concurrency limit.""" + from nanobot.agent.subagent import SubagentManager + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + + mgr = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) + + assert mgr.max_concurrent_subagents == AgentDefaults().max_concurrent_subagents + + def test_subagent_default_max_iterations_matches_agent_defaults(tmp_path): """Direct SubagentManager construction should use the agent default limit.""" from nanobot.agent.subagent import SubagentManager diff --git a/tests/test_tool_contextvars.py b/tests/test_tool_contextvars.py index a1e7bd8c0..3763ba980 100644 --- a/tests/test_tool_contextvars.py +++ b/tests/test_tool_contextvars.py @@ -49,6 +49,11 @@ async def test_spawn_tool_keeps_task_local_context() -> None: release = asyncio.Event() class _Manager: + max_concurrent_subagents = 1 + + def get_running_count(self) -> int: + return 0 + async def spawn( self, *, @@ -156,6 +161,11 @@ async def test_spawn_tool_basic_set_context_and_execute() -> None: seen: list[tuple[str, str, str]] = [] class _Manager: + max_concurrent_subagents = 1 + + def get_running_count(self) -> int: + return 0 + async def spawn( self, *, @@ -183,6 +193,11 @@ async def test_spawn_tool_default_values_without_set_context() -> None: seen: list[tuple[str, str, str]] = [] class _Manager: + max_concurrent_subagents = 1 + + def get_running_count(self) -> int: + return 0 + async def spawn( self, *, From 9fa90b1034555afb7c754034674c35148689be30 Mon Sep 17 00:00:00 2001 From: Jiajun Xie Date: Tue, 5 May 2026 09:24:02 +0000 Subject: [PATCH 21/44] fix: only advance dream_cursor on completed batches to prevent silent loss --- nanobot/agent/memory.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 85cc5ab4a..7794af5c2 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -974,12 +974,10 @@ class Dream: if event["status"] == "ok": changelog.append(f"{event['name']}: {event['detail']}") - # Advance cursor — always, to avoid re-processing Phase 1 - new_cursor = batch[-1]["cursor"] - self.store.set_last_dream_cursor(new_cursor) - self.store.compact_history() - + # Only advance cursor on successful completion to prevent silent loss if result and result.stop_reason == "completed": + new_cursor = batch[-1]["cursor"] + self.store.set_last_dream_cursor(new_cursor) logger.info( "Dream done: {} change(s), cursor advanced to {}", len(changelog), new_cursor, @@ -987,10 +985,12 @@ class Dream: else: reason = result.stop_reason if result else "exception" logger.warning( - "Dream incomplete ({}): cursor advanced to {}", - reason, new_cursor, + "Dream incomplete ({}): cursor NOT advanced, will retry next cron cycle", + reason, ) + self.store.compact_history() + # Git auto-commit (only when there are actual changes) if changelog and self.store.git.is_initialized(): ts = batch[-1]["timestamp"] From 358997554c5444f0977cfe6c117c24887f626782 Mon Sep 17 00:00:00 2001 From: futurist <26634873@qq.com> Date: Tue, 5 May 2026 13:46:08 +0800 Subject: [PATCH 22/44] fix-feishu-media-path --- nanobot/channels/feishu.py | 5 +-- tests/channels/test_feishu_reply.py | 52 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index f617b93db..7e65b6210 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1081,8 +1081,9 @@ class FeishuChannel(BaseChannel): if data and filename: file_path = media_dir / filename file_path.write_bytes(data) - logger.debug("Downloaded {} to {}", msg_type, file_path) - return str(file_path), f"[{msg_type}: {filename}]" + path_str = str(file_path) + logger.debug("Downloaded {} to {}", msg_type, path_str) + return path_str, f"[{msg_type}: {path_str}]" return None, f"[{msg_type}: download failed]" diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index 430e5abea..31d3a1d71 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -445,6 +445,58 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None: assert len(captured) == 1 +# --------------------------------------------------------------------------- +# Inbound media tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_on_message_audio_publishes_downloaded_path_and_transcription() -> None: + channel = _make_feishu_channel() + channel._processed_message_ids.clear() + captured = [] + + async def capture(msg): + captured.append(msg) + + channel.bus.publish_inbound = capture + channel._download_and_save_media = AsyncMock( + return_value=(r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg", "[audio: voice.ogg]") + ) + channel.transcribe_audio = AsyncMock(return_value="hello from voice") + channel._add_reaction = AsyncMock(return_value=None) + + event = _make_feishu_event( + msg_type="audio", + content='{"file_key": "audio_key", "duration": 1000}', + message_id="om_audio", + ) + await channel._on_message(event) + + channel._download_and_save_media.assert_awaited_once_with( + "audio", {"file_key": "audio_key", "duration": 1000}, "om_audio" + ) + channel.transcribe_audio.assert_awaited_once_with(r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg") + assert len(captured) == 1 + assert captured[0].media == [r"C:\\Users\\dodre\\.nanobot\\media\\feishu\\voice.ogg"] + assert captured[0].content == "[transcription: hello from voice]" + + +@pytest.mark.asyncio +async def test_download_and_save_media_returns_absolute_path_in_content(monkeypatch, tmp_path) -> None: + channel = _make_feishu_channel() + monkeypatch.setattr(feishu, "get_media_dir", lambda _channel: tmp_path) + channel._download_file_sync = MagicMock(return_value=(b"voice-bytes", None)) + + file_path, content_text = await channel._download_and_save_media( + "audio", {"file_key": "voice_key"}, "om_audio" + ) + + assert file_path == str(tmp_path / "voice_key.ogg") + assert (tmp_path / "voice_key.ogg").read_bytes() == b"voice-bytes" + assert content_text == f"[audio: {file_path}]" + + # --------------------------------------------------------------------------- # Session key derivation tests # --------------------------------------------------------------------------- From 5aa61e08d3518d3d1edf1e6b43d5afe0d8a7a99a Mon Sep 17 00:00:00 2001 From: DG Multica Date: Tue, 5 May 2026 13:03:38 +0700 Subject: [PATCH 23/44] fix(telegram): ignore unauthorized users silently --- nanobot/channels/telegram.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 793419917..eecb73225 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -790,6 +790,8 @@ class TelegramChannel(BaseChannel): return user = update.effective_user + if not self.is_allowed(self._sender_id(user)): + return await update.message.reply_text( f"👋 Hi {user.first_name}! I'm nanobot.\n\n" "Send me a message and I'll respond!\n" @@ -797,8 +799,10 @@ class TelegramChannel(BaseChannel): ) async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /help command, bypassing ACL so all users can access it.""" - if not update.message: + """Handle /help command for allowed users only.""" + if not update.message or not update.effective_user: + return + if not self.is_allowed(self._sender_id(update.effective_user)): return await update.message.reply_text(build_help_text()) @@ -1016,6 +1020,8 @@ class TelegramChannel(BaseChannel): user = update.effective_user chat_id = message.chat_id sender_id = self._sender_id(user) + if not self.is_allowed(sender_id): + return self._remember_thread_context(message) # Store chat_id for replies From 1813fc5021eca22023fe88b2bd3921c9cf3db48a Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 5 May 2026 14:32:11 +0000 Subject: [PATCH 24/44] test(telegram): cover silent allowlist rejection Co-authored-by: Cursor --- tests/channels/test_telegram_channel.py | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 803415dfd..591df84f4 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -1309,6 +1309,58 @@ async def test_on_help_includes_restart_command() -> None: assert "/dream-restore" in help_text +@pytest.mark.asyncio +async def test_on_start_ignores_unauthorized_user_silently() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"), + MessageBus(), + ) + update = _make_telegram_update(text="/start", chat_type="private") + update.message.reply_text = AsyncMock() + + await channel._on_start(update, None) + + update.message.reply_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_help_ignores_unauthorized_user_silently() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"), + MessageBus(), + ) + update = _make_telegram_update(text="/help", chat_type="private") + update.message.reply_text = AsyncMock() + + await channel._on_help(update, None) + + update.message.reply_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_message_ignores_unauthorized_user_before_side_effects() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], group_policy="open"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + started_typing: list[str] = [] + handled: list[dict] = [] + channel._start_typing = lambda chat_id: started_typing.append(chat_id) + channel._add_reaction = AsyncMock(return_value=None) + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + + await channel._on_message(_make_telegram_update(text="hello", chat_type="private"), None) + + assert started_typing == [] + channel._add_reaction.assert_not_awaited() + assert handled == [] + + @pytest.mark.asyncio async def test_on_message_location_content() -> None: """Location messages are forwarded as [location: lat, lon] content.""" From 4db50f2e32c0f0189307cbb3f631f34f19edab32 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 5 May 2026 15:14:40 +0000 Subject: [PATCH 25/44] fix(channels): reject unauthorized inbound before side effects Co-authored-by: Cursor --- nanobot/channels/email.py | 6 ++++ nanobot/channels/feishu.py | 20 +++++++------ nanobot/channels/qq.py | 16 +++++++---- nanobot/channels/telegram.py | 7 ++++- nanobot/channels/wecom.py | 19 ++++++++----- nanobot/channels/weixin.py | 14 +++++---- nanobot/channels/whatsapp.py | 21 ++++++++------ tests/channels/test_email_channel.py | 38 +++++++++++++++++++++---- tests/channels/test_feishu_reply.py | 23 +++++++++++++++ tests/channels/test_qq_media.py | 31 +++++++++++++++++++- tests/channels/test_telegram_channel.py | 29 +++++++++++++++++++ tests/channels/test_wecom_channel.py | 34 +++++++++++++++++++++- tests/channels/test_weixin_channel.py | 34 ++++++++++++++++++++-- tests/channels/test_whatsapp_channel.py | 34 ++++++++++++++++++---- 14 files changed, 273 insertions(+), 53 deletions(-) diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 36cafc995..401da7bb6 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -407,6 +407,12 @@ class EmailChannel(BaseChannel): self._remember_processed_uid(uid, dedupe, cycle_uids) continue + if not self.is_allowed(sender): + self._remember_processed_uid(uid, dedupe, cycle_uids) + if mark_seen: + client.store(imap_id, "+FLAGS", "\\Seen") + continue + subject = self._decode_header_value(parsed.get("Subject", "")) date_value = parsed.get("Date", "") message_id = parsed.get("Message-ID", "").strip() diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 7e65b6210..6fe8b9d5f 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1644,15 +1644,7 @@ class FeishuChannel(BaseChannel): logger.debug("Feishu raw message: {}", message.content) logger.debug("Feishu mentions: {}", getattr(message, "mentions", None)) - # Deduplication check message_id = message.message_id - if message_id in self._processed_message_ids: - return - self._processed_message_ids[message_id] = None - - # Trim cache - while len(self._processed_message_ids) > 1000: - self._processed_message_ids.popitem(last=False) # Skip bot messages if sender.sender_type == "bot": @@ -1663,10 +1655,22 @@ class FeishuChannel(BaseChannel): chat_type = message.chat_type msg_type = message.message_type + if not self.is_allowed(sender_id): + return + if chat_type == "group" and not self._is_group_message_for_bot(message): logger.debug("Feishu: skipping group message (not mentioned)") return + # Deduplication check + if message_id in self._processed_message_ids: + return + self._processed_message_ids[message_id] = None + + # Trim cache + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) + # Add reaction (non-blocking — tracked background task) task = asyncio.create_task( self._add_reaction(message_id, self.config.react_emoji) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 00338229a..ef70cc943 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -474,24 +474,28 @@ class QQChannel(BaseChannel): async def _on_message(self, data: C2CMessage | GroupMessage, is_group: bool = False) -> None: """Parse inbound message, download attachments, and publish to the bus.""" try: - if data.id in self._processed_ids: - return - self._processed_ids.append(data.id) - if is_group: chat_id = data.group_openid user_id = data.author.member_openid - self._chat_type_cache[chat_id] = "group" + chat_type = "group" else: chat_id = str( getattr(data.author, "id", None) or getattr(data.author, "user_openid", "unknown") ) user_id = chat_id - self._chat_type_cache[chat_id] = "c2c" + chat_type = "c2c" content = (data.content or "").strip() + if not self.is_allowed(user_id): + return + + if data.id in self._processed_ids: + return + self._processed_ids.append(data.id) + self._chat_type_cache[chat_id] = chat_type + # the data used by tests don't contain attachments property # so we use getattr with a default of [] to avoid AttributeError in tests attachments = getattr(data, "attachments", None) or [] diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index eecb73225..492b3ef50 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -993,6 +993,9 @@ class TelegramChannel(BaseChannel): return message = update.message user = update.effective_user + sender_id = self._sender_id(user) + if not self.is_allowed(sender_id): + return self._remember_thread_context(message) # Strip @bot_username suffix if present @@ -1004,7 +1007,7 @@ class TelegramChannel(BaseChannel): content = self._normalize_telegram_command(content) await self._handle_message( - sender_id=self._sender_id(user), + sender_id=sender_id, chat_id=str(message.chat_id), content=content, metadata=self._build_message_metadata(message, user), @@ -1264,6 +1267,8 @@ class TelegramChannel(BaseChannel): if not chat_id: logger.warning("Callback query without chat_id") return + if not self.is_allowed(sender_id): + return button_label = query.data or "" await query.answer() if query.message: diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py index 69bdf3f08..ce3e7ed51 100644 --- a/nanobot/channels/wecom.py +++ b/nanobot/channels/wecom.py @@ -11,13 +11,13 @@ from pathlib import Path from typing import Any from loguru import logger +from pydantic import Field from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base -from pydantic import Field WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None @@ -204,6 +204,9 @@ class WecomChannel(BaseChannel): chat_id = body.get("chatid", "") if isinstance(body, dict) else "" + if chat_id and not self.is_allowed(chat_id): + return + if chat_id and self.config.welcome_message: await self._client.reply_welcome(frame, { "msgtype": "text", @@ -233,6 +236,12 @@ class WecomChannel(BaseChannel): if not msg_id: msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}" + # Extract sender info from "from" field (SDK format) + from_info = body.get("from", {}) + sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown" + if not self.is_allowed(sender_id): + return + # Deduplication check if msg_id in self._processed_message_ids: return @@ -242,10 +251,6 @@ class WecomChannel(BaseChannel): while len(self._processed_message_ids) > 1000: self._processed_message_ids.popitem(last=False) - # Extract sender info from "from" field (SDK format) - from_info = body.get("from", {}) - sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown" - # For single chat, chatid is the sender's userid # For group chat, chatid is provided in body chat_type = body.get("chattype", "single") @@ -424,9 +429,9 @@ class WecomChannel(BaseChannel): # MD5 is used for file integrity only, not cryptographic security md5_hash = hashlib.md5(data).hexdigest() - CHUNK_SIZE = 512 * 1024 # 512 KB raw (before base64) + chunk_size = 512 * 1024 # 512 KB raw (before base64) mv = memoryview(data) - chunk_list = [bytes(mv[i : i + CHUNK_SIZE]) for i in range(0, file_size, CHUNK_SIZE)] + chunk_list = [bytes(mv[i : i + chunk_size]) for i in range(0, file_size, chunk_size)] n_chunks = len(chunk_list) del mv, data diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 68fbed85d..af82984b2 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -588,20 +588,24 @@ class WeixinChannel(BaseChannel): if msg.get("message_type") == MESSAGE_TYPE_BOT: return - # Deduplication by message_id msg_id = str(msg.get("message_id", "") or msg.get("seq", "")) if not msg_id: msg_id = f"{msg.get('from_user_id', '')}_{msg.get('create_time_ms', '')}" + + from_user_id = msg.get("from_user_id", "") or "" + if not from_user_id: + return + + if not self.is_allowed(from_user_id): + return + + # Deduplication by message_id if msg_id in self._processed_ids: return self._processed_ids[msg_id] = None while len(self._processed_ids) > 1000: self._processed_ids.popitem(last=False) - from_user_id = msg.get("from_user_id", "") or "" - if not from_user_id: - return - # Cache context_token (required for all replies — inbound.ts:23-27) ctx_token = msg.get("context_token", "") if ctx_token: diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 74d53203f..26869de18 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -8,8 +8,8 @@ import os import secrets import shutil import subprocess -from contextlib import suppress from collections import OrderedDict +from contextlib import suppress from pathlib import Path from typing import Any, Literal @@ -214,13 +214,6 @@ class WhatsAppChannel(BaseChannel): content = data.get("content", "") message_id = data.get("id", "") - if message_id: - if message_id in self._processed_message_ids: - return - self._processed_message_ids[message_id] = None - while len(self._processed_message_ids) > 1000: - self._processed_message_ids.popitem(last=False) - # Extract just the phone number or lid as chat_id is_group = data.get("isGroup", False) was_mentioned = data.get("wasMentioned", False) @@ -246,9 +239,19 @@ class WhatsAppChannel(BaseChannel): elif extracted and not phone_id: phone_id = extracted # best guess for bare values + sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b + if not self.is_allowed(sender_id): + return + + if message_id: + if message_id in self._processed_message_ids: + return + self._processed_message_ids[message_id] = None + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) + if phone_id and lid_id: self._lid_to_phone[lid_id] = phone_id - sender_id = phone_id or self._lid_to_phone.get(lid_id, "") or lid_id or id_a or id_b logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id) diff --git a/tests/channels/test_email_channel.py b/tests/channels/test_email_channel.py index 98343522c..cb5aed45e 100644 --- a/tests/channels/test_email_channel.py +++ b/tests/channels/test_email_channel.py @@ -1,14 +1,13 @@ -from email.message import EmailMessage -from datetime import date -from pathlib import Path import imaplib +from datetime import date +from email.message import EmailMessage +from pathlib import Path import pytest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.email import EmailChannel -from nanobot.channels.email import EmailConfig +from nanobot.channels.email import EmailChannel, EmailConfig def _make_config(**overrides) -> EmailConfig: @@ -24,6 +23,7 @@ def _make_config(**overrides) -> EmailConfig: smtp_username="bot@example.com", smtp_password="secret", mark_seen=True, + allow_from=["*"], # Disable auth verification by default so existing tests are unaffected verify_dkim=False, verify_spf=False, @@ -707,8 +707,8 @@ def test_email_content_tagged_with_email_context(monkeypatch) -> None: def test_check_authentication_results_method() -> None: """Unit test for the _check_authentication_results static method.""" - from email.parser import BytesParser from email import policy + from email.parser import BytesParser # No Authentication-Results header msg_no_auth = EmailMessage() @@ -788,6 +788,32 @@ def _make_raw_email_with_attachment( return msg.as_bytes() +def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monkeypatch) -> None: + raw = _make_raw_email_with_attachment(from_addr="blocked@example.com") + fake = _make_fake_imap(raw) + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + called = {"attachments": False} + + def _extract_attachments(*_args, **_kwargs): + called["attachments"] = True + return [] + + monkeypatch.setattr(EmailChannel, "_extract_attachments", _extract_attachments) + + cfg = _make_config( + allow_from=["allowed@example.com"], + allowed_attachment_types=["application/pdf"], + verify_dkim=False, + verify_spf=False, + ) + channel = EmailChannel(cfg, MessageBus()) + + assert channel._fetch_new_messages() == [] + assert called["attachments"] is False + assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")] + + def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None: """PDF attachment is saved to media dir and path returned in media list.""" monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path) diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index 31d3a1d71..cc7e21e5f 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -806,3 +806,26 @@ def test_on_background_task_done_removes_from_set() -> None: loop.close() assert task not in channel._background_tasks + + +@pytest.mark.asyncio +async def test_on_message_ignores_unauthorized_sender_before_side_effects() -> None: + channel = _make_feishu_channel(group_policy="open") + channel.config.allow_from = ["ou_allowed"] + channel._add_reaction = AsyncMock() + channel._download_and_save_media = AsyncMock(return_value=("/tmp/audio.ogg", "[audio]")) + channel.transcribe_audio = AsyncMock(return_value="transcript") + channel._handle_message = AsyncMock() + + event = _make_feishu_event( + msg_type="audio", + content='{"file_key": "file_1"}', + sender_open_id="ou_blocked", + ) + + await channel._on_message(event) + + channel._add_reaction.assert_not_awaited() + channel._download_and_save_media.assert_not_awaited() + channel.transcribe_audio.assert_not_awaited() + channel._handle_message.assert_not_awaited() diff --git a/tests/channels/test_qq_media.py b/tests/channels/test_qq_media.py index 80a5ad20e..e2de72f28 100644 --- a/tests/channels/test_qq_media.py +++ b/tests/channels/test_qq_media.py @@ -1,7 +1,7 @@ """Tests for QQ channel media support: helpers, send, inbound, and upload.""" from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -182,6 +182,35 @@ async def test_send_media_failure_falls_back_to_text() -> None: assert "bad.png" in failure_calls[0]["content"] +@pytest.mark.asyncio +async def test_on_message_ignores_unauthorized_sender_before_attachments_and_ack() -> None: + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["allowed-user"], + ack_message="Processing...", + ), + MessageBus(), + ) + channel._client = _FakeClient() + channel._handle_attachments = AsyncMock(return_value=(["/tmp/a.png"], ["file"], [])) + channel._handle_message = AsyncMock() + + data = SimpleNamespace( + id="msg-blocked", + content="hello", + author=SimpleNamespace(user_openid="blocked-user"), + attachments=[SimpleNamespace(filename="a.png")], + ) + + await channel._on_message(data, is_group=False) + + channel._handle_attachments.assert_not_awaited() + channel._handle_message.assert_not_awaited() + assert channel._client.api.c2c_calls == [] + + # ── _on_message() exception handling ──────────────────────────────── diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 591df84f4..2ae5cce9f 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -1802,3 +1802,32 @@ async def test_send_uses_native_keyboard_when_flag_on() -> None: sent = channel._app.bot.sent_messages[0] assert isinstance(sent.get("reply_markup"), InlineKeyboardMarkup) assert "[Yes]" not in sent["text"] # native keyboard owns the rendering + + +@pytest.mark.asyncio +async def test_callback_query_ignores_unauthorized_user_before_side_effects() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["999"], inline_keyboards=True), + MessageBus(), + ) + channel._handle_message = AsyncMock() + + query = SimpleNamespace( + id="cb_1", + data="Yes", + answer=AsyncMock(), + message=SimpleNamespace( + chat_id=123, + edit_reply_markup=AsyncMock(), + ), + ) + update = SimpleNamespace( + callback_query=query, + effective_user=SimpleNamespace(id=12345, username="alice", first_name="Alice"), + ) + + await channel._on_callback_query(update, None) + + query.answer.assert_not_awaited() + query.message.edit_reply_markup.assert_not_awaited() + channel._handle_message.assert_not_awaited() diff --git a/tests/channels/test_wecom_channel.py b/tests/channels/test_wecom_channel.py index a8ed3c0e9..7cb61ab82 100644 --- a/tests/channels/test_wecom_channel.py +++ b/tests/channels/test_wecom_channel.py @@ -3,7 +3,6 @@ import os import tempfile from pathlib import Path -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -451,6 +450,39 @@ async def test_process_text_message() -> None: assert msg.metadata["msg_type"] == "text" +@pytest.mark.asyncio +async def test_enter_chat_ignores_unauthorized_user_before_welcome() -> None: + channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus()) + client = _FakeWeComClient() + channel._client = client + channel.config.welcome_message = "hello" + + await channel._on_enter_chat(_FakeFrame(body={"chatid": "blocked"})) + + client.reply_welcome.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_process_message_ignores_unauthorized_sender_before_download() -> None: + channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["allowed"]), MessageBus()) + client = _FakeWeComClient() + channel._client = client + channel._handle_message = AsyncMock() + + frame = _FakeFrame(body={ + "msgid": "msg_blocked", + "chatid": "chat1", + "from": {"userid": "blocked"}, + "image": {"url": "https://example.com/img.png", "aeskey": "key123"}, + }) + + await channel._process_message(frame, "image") + + client.download_file.assert_not_awaited() + channel._handle_message.assert_not_awaited() + assert channel.bus.inbound_size == 0 + + @pytest.mark.asyncio async def test_process_image_message() -> None: """Image message: download success → media_paths non-empty.""" diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 2b455fca6..4b9b294a9 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -5,8 +5,8 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock -import pytest import httpx +import pytest import nanobot.channels.weixin as weixin_mod from nanobot.bus.queue import MessageBus @@ -15,10 +15,10 @@ from nanobot.channels.weixin import ( ITEM_TEXT, MESSAGE_TYPE_BOT, WEIXIN_CHANNEL_VERSION, - _decrypt_aes_ecb, - _encrypt_aes_ecb, WeixinChannel, WeixinConfig, + _decrypt_aes_ecb, + _encrypt_aes_ecb, ) @@ -128,6 +128,34 @@ async def test_process_message_caches_context_token_and_send_uses_it() -> None: channel._send_text.assert_awaited_once_with("wx-user", "pong", "ctx-2") +@pytest.mark.asyncio +async def test_process_message_ignores_unauthorized_sender_before_side_effects(tmp_path) -> None: + bus = MessageBus() + channel = WeixinChannel( + WeixinConfig(enabled=True, allow_from=["allowed-user"], state_dir=str(tmp_path)), + bus, + ) + channel._download_media_item = AsyncMock(return_value="/tmp/test.jpg") + channel._start_typing = AsyncMock() + + await channel._process_message( + { + "message_type": 1, + "message_id": "m-unauthorized", + "from_user_id": "blocked-user", + "context_token": "ctx-blocked", + "item_list": [ + {"type": ITEM_IMAGE, "image_item": {"media": {"encrypt_query_param": "x"}}}, + ], + } + ) + + assert channel._context_tokens == {} + channel._download_media_item.assert_not_awaited() + channel._start_typing.assert_not_awaited() + assert bus.inbound_size == 0 + + @pytest.mark.asyncio async def test_process_message_persists_context_token_to_state_file(tmp_path) -> None: bus = MessageBus() diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index b61033677..6229723a5 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -116,7 +116,7 @@ async def test_send_when_disconnected_is_noop(): @pytest.mark.asyncio async def test_group_policy_mention_skips_unmentioned_group_message(): - ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock()) ch._handle_message = AsyncMock() await ch._handle_bridge_message( @@ -139,7 +139,7 @@ async def test_group_policy_mention_skips_unmentioned_group_message(): @pytest.mark.asyncio async def test_group_policy_mention_accepts_mentioned_group_message(): - ch = WhatsAppChannel({"enabled": True, "groupPolicy": "mention"}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"], "groupPolicy": "mention"}, MagicMock()) ch._handle_message = AsyncMock() await ch._handle_bridge_message( @@ -166,7 +166,7 @@ async def test_group_policy_mention_accepts_mentioned_group_message(): @pytest.mark.asyncio async def test_sender_id_prefers_phone_jid_over_lid(): """sender_id should resolve to phone number when @s.whatsapp.net JID is present.""" - ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock()) ch._handle_message = AsyncMock() await ch._handle_bridge_message( @@ -187,7 +187,7 @@ async def test_sender_id_prefers_phone_jid_over_lid(): @pytest.mark.asyncio async def test_lid_to_phone_cache_resolves_lid_only_messages(): """When only LID is present, a cached LID→phone mapping should be used.""" - ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock()) ch._handle_message = AsyncMock() # First message: both phone and LID → builds cache @@ -220,7 +220,7 @@ async def test_lid_to_phone_cache_resolves_lid_only_messages(): @pytest.mark.asyncio async def test_voice_message_transcription_uses_media_path(): """Voice messages are transcribed when media path is available.""" - ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock()) ch.transcription_provider = "openai" ch.transcription_api_key = "sk-test" ch._handle_message = AsyncMock() @@ -243,10 +243,32 @@ async def test_voice_message_transcription_uses_media_path(): assert kwargs["content"].startswith("Hello world") +@pytest.mark.asyncio +async def test_unauthorized_voice_message_does_not_transcribe() -> None: + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock()) + ch._handle_message = AsyncMock() + ch.transcribe_audio = AsyncMock(return_value="Hello world") + + await ch._handle_bridge_message( + json.dumps({ + "type": "message", + "id": "v-blocked", + "sender": "blocked@s.whatsapp.net", + "pn": "", + "content": "[Voice Message]", + "timestamp": 1, + "media": ["/tmp/voice.ogg"], + }) + ) + + ch.transcribe_audio.assert_not_awaited() + ch._handle_message.assert_not_awaited() + + @pytest.mark.asyncio async def test_voice_message_no_media_shows_not_available(): """Voice messages without media produce a fallback placeholder.""" - ch = WhatsAppChannel({"enabled": True}, MagicMock()) + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock()) ch._handle_message = AsyncMock() await ch._handle_bridge_message( From ca7877f27226de58932e17b7e1c473c2728bcd97 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Fri, 17 Apr 2026 09:51:59 -0400 Subject: [PATCH 26/44] fix(sdk): populate RunResult.tools_used and RunResult.messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``Nanobot.run()`` has always documented ``RunResult.tools_used`` and ``RunResult.messages`` but actually returned ``[]`` for both, so SDK consumers could never inspect which tools fired or what the final message list looked like — the only useful field was ``content``. This threads the data out via a tiny ``_SDKCaptureHook`` that installs alongside any user-supplied hooks. The capture hook accumulates tool names across iterations and snapshots the message list on each ``after_iteration`` call; the last snapshot reflects end-of-turn state. Only the SDK facade is touched: ``AgentLoop.process_direct`` and ``AgentRunner`` signatures are unchanged, so channels / CLI / API paths are unaffected. --- nanobot/nanobot.py | 32 +++++++- tests/test_nanobot_facade.py | 139 ++++++++++++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index d2bff97d7..f8ffd8fa7 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from nanobot.agent.hook import AgentHook +from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus @@ -104,9 +104,10 @@ class Nanobot: Different keys get independent history. hooks: Optional lifecycle hooks for this run. """ + capture = _SDKCaptureHook() prev = self._loop._extra_hooks - if hooks is not None: - self._loop._extra_hooks = list(hooks) + base_hooks = list(hooks) if hooks is not None else list(prev or []) + self._loop._extra_hooks = [capture, *base_hooks] try: response = await self._loop.process_direct( message, session_key=session_key, @@ -115,7 +116,30 @@ class Nanobot: self._loop._extra_hooks = prev content = (response.content if response else None) or "" - return RunResult(content=content, tools_used=[], messages=[]) + return RunResult( + content=content, + tools_used=capture.tools_used, + messages=capture.messages, + ) + + +class _SDKCaptureHook(AgentHook): + """Record tool names and the final message list for ``RunResult``. + + The runner mutates ``context.messages`` in place across iterations, so the + snapshot is refreshed on every ``after_iteration`` call; the last call + reflects the end-of-turn state the SDK caller cares about. + """ + + def __init__(self) -> None: + super().__init__() + self.tools_used: list[str] = [] + self.messages: list[dict[str, Any]] = [] + + async def after_iteration(self, context: AgentHookContext) -> None: + for call in context.tool_calls: + self.tools_used.append(call.name) + self.messages = list(context.messages) def _make_provider(config: Any) -> Any: diff --git a/tests/test_nanobot_facade.py b/tests/test_nanobot_facade.py index 9ad9c5db1..009c1c20d 100644 --- a/tests/test_nanobot_facade.py +++ b/tests/test_nanobot_facade.py @@ -163,6 +163,139 @@ async def test_run_custom_session_key(tmp_path): def test_import_from_top_level(): - from nanobot import Nanobot as N, RunResult as R - assert N is Nanobot - assert R is RunResult + import nanobot + + assert nanobot.Nanobot is Nanobot + assert nanobot.RunResult is RunResult + + +# --------------------------------------------------------------------------- +# RunResult.tools_used / messages — populated from the agent iterations +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_run_populates_tools_used_across_iterations(tmp_path): + """tools_used collects every tool name fired across all iterations, in order.""" + from nanobot.agent.hook import AgentHookContext + from nanobot.bus.events import OutboundMessage + from nanobot.providers.base import ToolCallRequest + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + async def fake_process_direct(message, *, session_key): + # Whatever hooks the SDK installed are now on the loop. + extras = bot._loop._extra_hooks + messages = [{"role": "user", "content": message}] + ctx1 = AgentHookContext(iteration=0, messages=messages) + ctx1.tool_calls = [ + ToolCallRequest(id="c1", name="read_file", arguments={}), + ToolCallRequest(id="c2", name="glob", arguments={}), + ] + for h in extras: + await h.after_iteration(ctx1) + messages.append({"role": "assistant", "content": "ok"}) + ctx2 = AgentHookContext(iteration=1, messages=messages) + ctx2.tool_calls = [ToolCallRequest(id="c3", name="web_fetch", arguments={})] + for h in extras: + await h.after_iteration(ctx2) + return OutboundMessage(channel="cli", chat_id="direct", content="final") + + bot._loop.process_direct = fake_process_direct + result = await bot.run("do stuff") + assert result.content == "final" + assert result.tools_used == ["read_file", "glob", "web_fetch"] + + +@pytest.mark.asyncio +async def test_run_populates_final_messages(tmp_path): + """messages reflects the agent's message list at the last iteration.""" + from nanobot.agent.hook import AgentHookContext + from nanobot.bus.events import OutboundMessage + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + async def fake_process_direct(message, *, session_key): + extras = bot._loop._extra_hooks + messages = [ + {"role": "user", "content": message}, + {"role": "assistant", "content": "hi there"}, + ] + ctx = AgentHookContext(iteration=0, messages=messages) + for h in extras: + await h.after_iteration(ctx) + return OutboundMessage(channel="cli", chat_id="direct", content="hi there") + + bot._loop.process_direct = fake_process_direct + result = await bot.run("hello") + assert result.messages == [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi there"}, + ] + + +@pytest.mark.asyncio +async def test_run_no_iterations_leaves_defaults_empty(tmp_path): + """If process_direct never triggers after_iteration, tools_used/messages stay [].""" + from nanobot.bus.events import OutboundMessage + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + bot._loop.process_direct = AsyncMock( + return_value=OutboundMessage(channel="cli", chat_id="direct", content="noop"), + ) + result = await bot.run("hi") + assert result.tools_used == [] + assert result.messages == [] + + +@pytest.mark.asyncio +async def test_run_user_hooks_still_fire_alongside_capture(tmp_path): + """Capture hook must not displace user-provided hooks.""" + from nanobot.agent.hook import AgentHook, AgentHookContext + from nanobot.bus.events import OutboundMessage + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + seen_iterations: list[int] = [] + + class UserHook(AgentHook): + async def after_iteration(self, context: AgentHookContext) -> None: + seen_iterations.append(context.iteration) + + async def fake_process_direct(message, *, session_key): + extras = bot._loop._extra_hooks + assert len(extras) == 2, f"expected capture + user hook, got {len(extras)}" + ctx = AgentHookContext(iteration=7, messages=[]) + for h in extras: + await h.after_iteration(ctx) + return OutboundMessage(channel="cli", chat_id="direct", content="ok") + + bot._loop.process_direct = fake_process_direct + await bot.run("x", hooks=[UserHook()]) + assert seen_iterations == [7] + + +@pytest.mark.asyncio +async def test_run_restores_extra_hooks_even_on_populated_iterations(tmp_path): + """Previously-installed _extra_hooks must be restored regardless of capture state.""" + from nanobot.agent.hook import AgentHook, AgentHookContext + from nanobot.bus.events import OutboundMessage + + config_path = _write_config(tmp_path) + bot = Nanobot.from_config(config_path, workspace=tmp_path) + + sentinel_hook = AgentHook() + bot._loop._extra_hooks = [sentinel_hook] + + async def fake_process_direct(message, *, session_key): + ctx = AgentHookContext(iteration=0, messages=[]) + for h in bot._loop._extra_hooks: + await h.after_iteration(ctx) + return OutboundMessage(channel="cli", chat_id="direct", content="done") + + bot._loop.process_direct = fake_process_direct + await bot.run("hello") + assert bot._loop._extra_hooks == [sentinel_hook] From d97e1779819750e14b7f99af1468233c7f25e0c5 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 4 May 2026 23:26:20 +0800 Subject: [PATCH 27/44] refactor(sdk): move SDKCaptureHook to agent/hook.py Colocate the capture hook with the rest of the hook infrastructure instead of inlining it in the top-level facade module. --- nanobot/agent/hook.py | 19 +++++++++++++++++++ nanobot/nanobot.py | 23 ++--------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/nanobot/agent/hook.py b/nanobot/agent/hook.py index 52daf6042..d0106cfb6 100644 --- a/nanobot/agent/hook.py +++ b/nanobot/agent/hook.py @@ -102,3 +102,22 @@ class CompositeHook(AgentHook): for h in self._hooks: content = h.finalize_content(context, content) return content + + +class SDKCaptureHook(AgentHook): + """Record tool names and the final message list for ``RunResult``. + + The runner mutates ``context.messages`` in place across iterations, so the + snapshot is refreshed on every ``after_iteration`` call; the last call + reflects the end-of-turn state the SDK caller cares about. + """ + + def __init__(self) -> None: + super().__init__() + self.tools_used: list[str] = [] + self.messages: list[dict[str, Any]] = [] + + async def after_iteration(self, context: AgentHookContext) -> None: + for call in context.tool_calls: + self.tools_used.append(call.name) + self.messages = list(context.messages) diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index f8ffd8fa7..5e5857595 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from nanobot.agent.hook import AgentHook, AgentHookContext +from nanobot.agent.hook import AgentHook, SDKCaptureHook from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus @@ -104,7 +104,7 @@ class Nanobot: Different keys get independent history. hooks: Optional lifecycle hooks for this run. """ - capture = _SDKCaptureHook() + capture = SDKCaptureHook() prev = self._loop._extra_hooks base_hooks = list(hooks) if hooks is not None else list(prev or []) self._loop._extra_hooks = [capture, *base_hooks] @@ -123,25 +123,6 @@ class Nanobot: ) -class _SDKCaptureHook(AgentHook): - """Record tool names and the final message list for ``RunResult``. - - The runner mutates ``context.messages`` in place across iterations, so the - snapshot is refreshed on every ``after_iteration`` call; the last call - reflects the end-of-turn state the SDK caller cares about. - """ - - def __init__(self) -> None: - super().__init__() - self.tools_used: list[str] = [] - self.messages: list[dict[str, Any]] = [] - - async def after_iteration(self, context: AgentHookContext) -> None: - for call in context.tool_calls: - self.tools_used.append(call.name) - self.messages = list(context.messages) - - def _make_provider(config: Any) -> Any: """Create the LLM provider from config (extracted from CLI).""" from nanobot.providers.factory import make_provider From db14685a69c6ce7218fe8cf98a2b8b78e9f695fe Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 5 May 2026 16:18:33 +0000 Subject: [PATCH 28/44] fix(agent): soften SSRF guard recovery Keep private URL access blocked at the tool boundary, but return a clear non-retryable hint so the agent can recover conversationally instead of aborting the turn. Co-authored-by: Cursor --- nanobot/agent/runner.py | 43 ++++++++++++++++++++----------- nanobot/agent/tools/shell.py | 2 +- tests/agent/test_runner.py | 50 +++++++++++++++++++++++------------- 3 files changed, 61 insertions(+), 34 deletions(-) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index c2e15bf8a..b9418045e 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -776,8 +776,6 @@ class AgentRunner: handled = self._classify_violation( raw_text=prep_error, soft_payload=prep_error + hint, - ssrf_payload=prep_error, - ssrf_error=RuntimeError(prep_error), event=event, tool_call=tool_call, workspace_violation_counts=workspace_violation_counts, @@ -808,8 +806,6 @@ class AgentRunner: raw_text=str(exc), # Preserve legacy exception payloads without the retry hint. soft_payload=payload, - ssrf_payload=payload, - ssrf_error=exc, event=event, tool_call=tool_call, workspace_violation_counts=workspace_violation_counts, @@ -829,8 +825,6 @@ class AgentRunner: handled = self._classify_violation( raw_text=result, soft_payload=result + hint, - ssrf_payload=result, - ssrf_error=RuntimeError(result), event=event, tool_call=tool_call, workspace_violation_counts=workspace_violation_counts, @@ -849,8 +843,21 @@ class AgentRunner: detail = detail[:120] + "..." return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None - # SSRF remains fatal; workspace path boundaries are soft + throttled. - _SSRF_MARKER: str = "internal/private url detected" + # SSRF is a hard security block at the tool boundary, but the agent turn + # should recover conversationally instead of aborting the runtime. + _SSRF_MARKERS: tuple[str, ...] = ( + "internal/private url detected", + "private/internal address", + "private address", + ) + _SSRF_BOUNDARY_NOTE: str = ( + "This is a non-bypassable security boundary. Stop trying to access " + "private/internal URLs. Do not retry with curl, wget, encoded IPs, " + "alternate DNS, redirects, proxies, or another tool. Ask the user for " + "local files, logs, screenshots, or an explicit safe public URL instead. " + "If the user explicitly trusts this private URL, ask them to whitelist " + "the exact IP/CIDR via tools.ssrfWhitelist." + ) # Non-SSRF boundary markers returned to the LLM as recoverable tool errors. _WORKSPACE_VIOLATION_MARKERS: tuple[str, ...] = ( @@ -864,7 +871,10 @@ class AgentRunner: @classmethod def _is_ssrf_violation(cls, text: str) -> bool: - return bool(text) and cls._SSRF_MARKER in text.lower() + if not text: + return False + lowered = text.lower() + return any(marker in lowered for marker in cls._SSRF_MARKERS) @classmethod def _is_workspace_violation(cls, text: str) -> bool: @@ -872,7 +882,7 @@ class AgentRunner: if not text: return False lowered = text.lower() - if cls._SSRF_MARKER in lowered: + if cls._is_ssrf_violation(lowered): return True return any(marker in lowered for marker in cls._WORKSPACE_VIOLATION_MARKERS) @@ -881,8 +891,6 @@ class AgentRunner: *, raw_text: str, soft_payload: str, - ssrf_payload: str, - ssrf_error: BaseException, event: dict[str, str], tool_call: ToolCallRequest, workspace_violation_counts: dict[str, int], @@ -890,12 +898,12 @@ class AgentRunner: """Classify safety-boundary failures, or return ``None`` to pass through.""" if self._is_ssrf_violation(raw_text): logger.warning( - "Tool {} blocked by SSRF guard; aborting turn: {}", + "Tool {} blocked by SSRF guard; returning non-retryable tool error: {}", tool_call.name, raw_text.replace("\n", " ").strip()[:200], ) - event["detail"] = self._event_detail("workspace_violation: ", raw_text) - return ssrf_payload, event, ssrf_error + event["detail"] = self._event_detail("ssrf_violation: ", raw_text) + return self._ssrf_soft_payload(raw_text), event, None if self._is_workspace_violation(raw_text): escalation = repeated_workspace_violation_error( @@ -918,6 +926,11 @@ class AgentRunner: return None + @classmethod + def _ssrf_soft_payload(cls, raw_text: str) -> str: + text = raw_text.strip() or "Error: request blocked by SSRF guard" + return f"{text}\n\n{cls._SSRF_BOUNDARY_NOTE}" + @staticmethod def _event_detail(prefix: str, text: str, limit: int = 160) -> str: return (prefix + text.replace("\n", " ").strip())[:limit] diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 17451432a..44767e97a 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -321,7 +321,7 @@ class ExecTool(Tool): from nanobot.security.network import contains_internal_url if contains_internal_url(cmd): - # SSRF stays fatal in the runner, so keep this marker direct. + # The runner turns this marker into a non-retryable security hint. return "Error: Command blocked by safety guard (internal/private URL detected)" if self.restrict_to_workspace: diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index 27ee2b065..0be615cb9 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -364,17 +364,15 @@ async def test_runner_does_not_abort_on_workspace_violation_anymore(): assert "workspace_violation" in result.tool_events[0]["detail"] -def test_is_ssrf_violation_remains_fatal(): - """SSRF rejections are the only marker that stays turn-fatal. - - A single successful internal-URL fetch can leak cloud metadata, so we - never let the LLM "retry" with a different URL phrasing -- contrast - this with workspace-bound rejections which are soft + throttled in v2. - """ +def test_is_ssrf_violation_recognizes_private_url_blocks(): + """SSRF rejections are classified separately from workspace boundaries.""" from nanobot.agent.runner import AgentRunner ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)" assert AgentRunner._is_ssrf_violation(ssrf_msg) is True + assert AgentRunner._is_ssrf_violation( + "URL validation failed: Blocked: host resolves to private/internal address 192.168.1.2" + ) is True # Workspace-bound markers are NOT classified as SSRF. assert AgentRunner._is_ssrf_violation( @@ -390,8 +388,8 @@ def test_is_ssrf_violation_remains_fatal(): @pytest.mark.asyncio -async def test_runner_aborts_on_ssrf_violation(): - """SSRF still fatal-aborts the turn even though workspace ones are soft.""" +async def test_runner_returns_non_retryable_hint_on_ssrf_violation(): + """SSRF stays blocked, but the runtime gives the LLM a final chance to recover.""" from nanobot.agent.runner import AgentRunSpec, AgentRunner provider = MagicMock() @@ -404,7 +402,10 @@ async def test_runner_aborts_on_ssrf_violation(): arguments={"command": "curl http://169.254.169.254"}, )], ), - LLMResponse(content="should NOT be reached", tool_calls=[]), + LLMResponse( + content="I cannot access that private URL. Please share local files.", + tool_calls=[], + ), ]) tools = MagicMock() tools.get_definitions.return_value = [] @@ -421,9 +422,16 @@ async def test_runner_aborts_on_ssrf_violation(): max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, )) - assert provider.chat_with_retry.await_count == 1, "SSRF must abort immediately" - assert result.stop_reason == "tool_error" - assert "internal/private url detected" in (result.error or "").lower() + assert provider.chat_with_retry.await_count == 2 + assert result.stop_reason == "completed" + assert result.error is None + assert result.final_content == "I cannot access that private URL. Please share local files." + assert result.tool_events and result.tool_events[0]["detail"].startswith("ssrf_violation:") + tool_messages = [m for m in result.messages if m.get("role") == "tool"] + assert tool_messages + assert "non-bypassable security boundary" in tool_messages[0]["content"] + assert "Do not retry" in tool_messages[0]["content"] + assert "tools.ssrfWhitelist" in tool_messages[0]["content"] @pytest.mark.asyncio @@ -1290,7 +1298,7 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path): @pytest.mark.asyncio -async def test_streamed_flag_not_set_on_tool_error(tmp_path): +async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path): from nanobot.agent.loop import AgentLoop from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus @@ -1307,7 +1315,14 @@ async def test_streamed_flag_not_set_on_tool_error(tmp_path): )], usage={}, ) - provider.chat_stream_with_retry = AsyncMock(return_value=tool_call_resp) + provider.chat_stream_with_retry = AsyncMock(side_effect=[ + tool_call_resp, + LLMResponse( + content="I cannot access private URLs. Please share the local file.", + tool_calls=[], + usage={}, + ), + ]) loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") loop.tools.get_definitions = MagicMock(return_value=[]) @@ -1323,9 +1338,8 @@ async def test_streamed_flag_not_set_on_tool_error(tmp_path): ) assert result is not None - assert "internal/private URL detected" in result.content - assert not result.metadata.get("_streamed"), \ - "_streamed must not be set when stop_reason is tool_error" + assert result.content == "I cannot access private URLs. Please share the local file." + assert result.metadata.get("_streamed") is True @pytest.mark.asyncio From e54fbfeb2a033394af3f5d896d0f04c01e83fe39 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Tue, 5 May 2026 16:28:20 +0000 Subject: [PATCH 29/44] test(cron): avoid Windows timer race Disable the externally updated cron job before yielding to the event loop so slow Windows CI cannot run the short-interval job before the test writes the update. --- tests/cron/test_cron_service.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py index 1f000dbd7..fa304e06e 100644 --- a/tests/cron/test_cron_service.py +++ b/tests/cron/test_cron_service.py @@ -228,8 +228,9 @@ async def test_running_service_honors_external_disable(tmp_path) -> None: ) await service.start() try: - # Wait slightly to ensure file mtime is definitively different - await asyncio.sleep(0.05) + # Disable before yielding back to the event loop. On slower Windows CI + # a short sleep here can overrun the 200ms schedule and let the job fire + # before the external update is written. external = CronService(store_path) updated = external.enable_job(job.id, enabled=False) assert updated is not None @@ -552,7 +553,7 @@ def test_update_job_offline_writes_action(tmp_path) -> None: action_path = tmp_path / "cron" / "action.jsonl" assert action_path.exists() - lines = [l for l in action_path.read_text().strip().split("\n") if l] + lines = [line for line in action_path.read_text().strip().split("\n") if line] last = json.loads(lines[-1]) assert last["action"] == "update" assert last["params"]["name"] == "updated-offline" From 7ebf611be817b6744bfb2aa2a81ffc9f556b1341 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sat, 25 Apr 2026 17:45:36 -0400 Subject: [PATCH 30/44] fix(transcription): retry Whisper calls and guard malformed responses A single transient failure between the agent and an OpenAI/Groq Whisper endpoint currently vanishes as `return ""` in transcribe(). The voice message arrives as the empty string and there is no way to tell real silence apart from a failed upload. A malformed but successful response body is even worse: the JSON-decode error escapes the helper unhandled. Add a shared `_post_transcription_with_retry` used by both providers. Retry behaviour: - exponential backoff 1s -> 2s -> 4s, up to 3 retries (4 attempts) - retryable HTTP statuses: 408, 429, 500, 502, 503, 504 - retryable exceptions: TimeoutException, ConnectError, ReadError, WriteError, RemoteProtocolError Non-transient failures short-circuit to "" on the first attempt -- retrying a misconfigured key or a broken upload only burns rate-limit quota. Branches that short-circuit: - missing API key, missing audio file - file-read errors (PermissionError, OSError) on the audio path, preserving the nightly contract for direct provider callers - HTTP auth/4xx body issues via raise_for_status() - response.json() parse failures - non-dict JSON payloads Sharing one helper means OpenAI and Groq cannot drift apart silently. Thread `language` through the helper. The multipart files dict is rebuilt inside the per-attempt loop, so when a caller sets self.language the `language` field is sent on every attempt -- not just the first. Tests cover: - every advertised retryable status and exception, parameterized - language present on attempts 1 and 2 of a 503->200 sequence - language absent when unset; present when set (both providers) - malformed JSON body and non-dict JSON body short-circuit to "" - PermissionError on file read short-circuits with no HTTP attempt - max-attempts give-up, exponential-backoff schedule, auth no-retry, missing-key / missing-file short-circuit Test stub fix: the _StubResponse in tests/channels/test_channel_plugins.py declared no status_code, which the new helper reads for retry classification. Set status_code = 200 so the stub advertises the successful response that those tests already simulate. Also moved the two transcription-provider imports to the top of that file (previously placed mid-file) so the file is ruff-clean (E402). --- nanobot/providers/transcription.py | 176 +++++++++++---- tests/channels/test_channel_plugins.py | 2 + tests/providers/test_transcription.py | 293 +++++++++++++++++++++++++ 3 files changed, 428 insertions(+), 43 deletions(-) create mode 100644 tests/providers/test_transcription.py diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index 10fcafd6d..25e09dab7 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -1,11 +1,123 @@ """Voice transcription providers (Groq and OpenAI Whisper).""" +import asyncio import os from pathlib import Path import httpx from loguru import logger +# Up to 3 retries (4 attempts total) with exponential backoff on transient +# failures. Whisper endpoints occasionally return 502/503 under load, and +# mobile-network transcription callers hit sporadic connect/read errors. +# Without this, a voice message silently becomes the empty string. +_MAX_RETRIES = 3 +_BACKOFF_S = (1.0, 2.0, 4.0) +_RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504} +_RETRYABLE_EXCEPTIONS = ( + httpx.TimeoutException, + httpx.ConnectError, + httpx.ReadError, + httpx.WriteError, + httpx.RemoteProtocolError, +) + + +async def _post_transcription_with_retry( + url: str, + *, + api_key: str, + path: Path, + model: str, + provider_label: str, + language: str | None = None, +) -> str: + """POST an audio file for transcription, retrying on transient errors. + + Retries on connect/read/timeout failures and on 408/429/5xx responses. + Other errors (including 4xx such as 401/403) return "" immediately — the + caller's config is wrong and retrying only wastes quota. + + When ``language`` is provided, it is forwarded as the ``language`` + multipart field on every attempt (the dict is rebuilt per attempt so the + same field is present on retries). + """ + try: + data = path.read_bytes() + except OSError as e: + logger.error("{} transcription error: cannot read audio file: {}", provider_label, e) + return "" + headers = {"Authorization": f"Bearer {api_key}"} + + async with httpx.AsyncClient() as client: + for attempt in range(_MAX_RETRIES + 1): + files = { + "file": (path.name, data), + "model": (None, model), + } + if language: + files["language"] = (None, language) + try: + response = await client.post(url, headers=headers, files=files, timeout=60.0) + except _RETRYABLE_EXCEPTIONS as e: + if attempt < _MAX_RETRIES: + logger.warning( + "{} transcription transient error (attempt {}/{}): {}", + provider_label, + attempt + 1, + _MAX_RETRIES + 1, + e, + ) + await asyncio.sleep(_BACKOFF_S[attempt]) + continue + logger.error( + "{} transcription error after {} attempts: {}", + provider_label, + _MAX_RETRIES + 1, + e, + ) + return "" + except Exception as e: + logger.error("{} transcription error: {}", provider_label, e) + return "" + + if response.status_code in _RETRYABLE_STATUS and attempt < _MAX_RETRIES: + logger.warning( + "{} transcription transient HTTP {} (attempt {}/{})", + provider_label, + response.status_code, + attempt + 1, + _MAX_RETRIES + 1, + ) + await asyncio.sleep(_BACKOFF_S[attempt]) + continue + + try: + response.raise_for_status() + except Exception as e: + logger.error("{} transcription error: {}", provider_label, e) + return "" + + try: + payload = response.json() + except Exception as e: + logger.error( + "{} transcription error: malformed response body: {}", + provider_label, + e, + ) + return "" + if not isinstance(payload, dict): + logger.error( + "{} transcription error: unexpected response shape: {!r}", + provider_label, + type(payload).__name__, + ) + return "" + return payload.get("text", "") + + return "" + class OpenAITranscriptionProvider: """Voice transcription provider using OpenAI's Whisper API.""" @@ -32,21 +144,14 @@ class OpenAITranscriptionProvider: if not path.exists(): logger.error("Audio file not found: {}", file_path) return "" - try: - async with httpx.AsyncClient() as client: - with open(path, "rb") as f: - files = {"file": (path.name, f), "model": (None, "whisper-1")} - if self.language: - files["language"] = (None, self.language) - headers = {"Authorization": f"Bearer {self.api_key}"} - response = await client.post( - self.api_url, headers=headers, files=files, timeout=60.0, - ) - response.raise_for_status() - return response.json().get("text", "") - except Exception as e: - logger.error("OpenAI transcription error: {}", e) - return "" + return await _post_transcription_with_retry( + self.api_url, + api_key=self.api_key, + path=path, + model="whisper-1", + provider_label="OpenAI", + language=self.language, + ) class GroqTranscriptionProvider: @@ -63,7 +168,11 @@ class GroqTranscriptionProvider: language: str | None = None, ): self.api_key = api_key or os.environ.get("GROQ_API_KEY") - self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions" + self.api_url = ( + api_base + or os.environ.get("GROQ_BASE_URL") + or "https://api.groq.com/openai/v1/audio/transcriptions" + ) self.language = language or None async def transcribe(self, file_path: str | Path) -> str: @@ -85,30 +194,11 @@ class GroqTranscriptionProvider: logger.error("Audio file not found: {}", file_path) return "" - try: - async with httpx.AsyncClient() as client: - with open(path, "rb") as f: - files = { - "file": (path.name, f), - "model": (None, "whisper-large-v3"), - } - if self.language: - files["language"] = (None, self.language) - headers = { - "Authorization": f"Bearer {self.api_key}", - } - - response = await client.post( - self.api_url, - headers=headers, - files=files, - timeout=60.0 - ) - - response.raise_for_status() - data = response.json() - return data.get("text", "") - - except Exception as e: - logger.error("Groq transcription error: {}", e) - return "" + return await _post_transcription_with_retry( + self.api_url, + api_key=self.api_key, + path=path, + model="whisper-large-v3", + provider_label="Groq", + language=self.language, + ) diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index 378cdd059..a32d96e1a 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -342,6 +342,8 @@ async def test_base_channel_passes_language_to_groq_transcription_provider(): class _StubResponse: + status_code = 200 + def raise_for_status(self): return None diff --git a/tests/providers/test_transcription.py b/tests/providers/test_transcription.py new file mode 100644 index 000000000..288290a92 --- /dev/null +++ b/tests/providers/test_transcription.py @@ -0,0 +1,293 @@ +"""Tests for transcription retry behavior on transient errors (B10).""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from nanobot.providers.transcription import GroqTranscriptionProvider, OpenAITranscriptionProvider + + +@pytest.fixture +def audio_file(tmp_path: Path) -> Path: + p = tmp_path / "voice.ogg" + p.write_bytes(b"OggS\x00fake-audio-bytes") + return p + + +def _response(status: int, payload: dict[str, object] | None = None) -> httpx.Response: + request = httpx.Request("POST", "https://example.test/audio/transcriptions") + return httpx.Response(status_code=status, json=payload or {}, request=request) + + +def _raw_response(status: int, content: bytes) -> httpx.Response: + """Build a Response with a raw, possibly-malformed body (bypasses json= encoding).""" + request = httpx.Request("POST", "https://example.test/audio/transcriptions") + return httpx.Response(status_code=status, content=content, request=request) + + +# --------------------------------------------------------------------------- +# OpenAI provider — retry on transient HTTP + network errors +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_openai_retries_on_5xx_then_succeeds(audio_file: Path) -> None: + """Transient 503 is retried; a subsequent 200 yields the text.""" + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock(side_effect=[_response(503), _response(200, {"text": "hello"})]) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "hello" + assert post.await_count == 2 + + +@pytest.mark.asyncio +async def test_openai_retries_on_429_then_succeeds(audio_file: Path) -> None: + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock(side_effect=[_response(429), _response(200, {"text": "rate ok"})]) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "rate ok" + assert post.await_count == 2 + + +@pytest.mark.asyncio +async def test_openai_retries_on_connect_error(audio_file: Path) -> None: + """Network-level transient errors are retried.""" + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock(side_effect=[httpx.ConnectError("boom"), _response(200, {"text": "ok"})]) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "ok" + assert post.await_count == 2 + + +@pytest.mark.asyncio +async def test_openai_does_not_retry_on_auth_error(audio_file: Path) -> None: + """401 is the user's misconfiguration — retrying wastes time and rate-limit quota.""" + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock(return_value=_response(401, {"error": {"message": "bad key"}})) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "" + assert post.await_count == 1 + + +@pytest.mark.asyncio +async def test_openai_gives_up_after_max_attempts(audio_file: Path) -> None: + """Persistent 503 returns "" after the final retry — never hangs.""" + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock(return_value=_response(503)) + sleep = AsyncMock() + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", sleep): + result = await provider.transcribe(audio_file) + assert result == "" + # 4 attempts total (initial + 3 retries) with 3 sleeps between them. + assert post.await_count == 4 + assert sleep.await_count == 3 + + +@pytest.mark.asyncio +async def test_openai_backoff_grows_exponentially(audio_file: Path) -> None: + """Verify the backoff schedule is exponential (1s, 2s, 4s).""" + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock(return_value=_response(503)) + sleep = AsyncMock() + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", sleep): + await provider.transcribe(audio_file) + delays = [call.args[0] for call in sleep.await_args_list] + assert delays == [1.0, 2.0, 4.0] + + +# --------------------------------------------------------------------------- +# Groq provider — same semantics (both go through the shared helper) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_groq_retries_on_5xx_then_succeeds(audio_file: Path) -> None: + provider = GroqTranscriptionProvider(api_key="gsk-test") + post = AsyncMock(side_effect=[_response(502), _response(200, {"text": "groq ok"})]) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "groq ok" + assert post.await_count == 2 + + +@pytest.mark.asyncio +async def test_groq_does_not_retry_on_auth_error(audio_file: Path) -> None: + provider = GroqTranscriptionProvider(api_key="gsk-test") + post = AsyncMock(return_value=_response(403)) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "" + assert post.await_count == 1 + + +# --------------------------------------------------------------------------- +# Regression: missing file / missing key must still short-circuit +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_openai_missing_api_key_short_circuits(tmp_path: Path) -> None: + provider = OpenAITranscriptionProvider(api_key=None) + # Ensure env var doesn't accidentally satisfy it. + with patch.dict("os.environ", {}, clear=True): + provider = OpenAITranscriptionProvider(api_key=None) + post = AsyncMock() + with patch("httpx.AsyncClient.post", post): + assert await provider.transcribe(tmp_path / "voice.ogg") == "" + assert post.await_count == 0 + + +@pytest.mark.asyncio +async def test_openai_missing_file_short_circuits() -> None: + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock() + with patch("httpx.AsyncClient.post", post): + assert await provider.transcribe("/nonexistent/path/voice.ogg") == "" + assert post.await_count == 0 + + +@pytest.mark.asyncio +async def test_returns_empty_when_file_unreadable(audio_file: Path) -> None: + """Existing file that cannot be read (PermissionError/OSError): "" with no HTTP attempt.""" + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock() + with patch.object(Path, "read_bytes", side_effect=PermissionError("denied")), patch( + "httpx.AsyncClient.post", post + ): + result = await provider.transcribe(audio_file) + assert result == "" + assert post.await_count == 0 + + +# --------------------------------------------------------------------------- +# language: forwarded through the helper to the multipart body, on every attempt +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "provider_cls,language", + [(OpenAITranscriptionProvider, "en"), (GroqTranscriptionProvider, "ko")], + ids=["openai", "groq"], +) +@pytest.mark.asyncio +async def test_provider_forwards_language_in_multipart( + audio_file: Path, provider_cls: type, language: str +) -> None: + """When ``language`` is set, the helper sends it as a multipart field.""" + provider = provider_cls(api_key="k", language=language) + post = AsyncMock(return_value=_response(200, {"text": "ok"})) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "ok" + assert post.await_count == 1 + files = post.await_args_list[0].kwargs["files"] + assert files["language"] == (None, language) + + +@pytest.mark.parametrize( + "provider_cls", + [OpenAITranscriptionProvider, GroqTranscriptionProvider], + ids=["openai", "groq"], +) +@pytest.mark.asyncio +async def test_provider_omits_language_when_unset( + audio_file: Path, provider_cls: type +) -> None: + """When ``language`` is None, no ``language`` field is sent.""" + provider = provider_cls(api_key="k") + post = AsyncMock(return_value=_response(200, {"text": "ok"})) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "ok" + assert post.await_count == 1 + files = post.await_args_list[0].kwargs["files"] + assert "language" not in files + + +@pytest.mark.asyncio +async def test_language_survives_retry(audio_file: Path) -> None: + """Regression: language must be present on every retry attempt, not just the first.""" + provider = OpenAITranscriptionProvider(api_key="sk-test", language="ja") + post = AsyncMock(side_effect=[_response(503), _response(200, {"text": "konnichiwa"})]) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "konnichiwa" + assert post.await_count == 2 + for call in post.await_args_list: + assert call.kwargs["files"]["language"] == (None, "ja") + + +# --------------------------------------------------------------------------- +# Malformed / unexpected response bodies must short-circuit, not escape +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_returns_empty_on_malformed_json_body(audio_file: Path) -> None: + """200 with invalid JSON: log and return "" immediately (no retry, no exception).""" + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock(return_value=_raw_response(200, b"not json")) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "" + assert post.await_count == 1 + + +@pytest.mark.asyncio +async def test_returns_empty_on_non_dict_json_body(audio_file: Path) -> None: + """200 with a JSON array (not dict): no AttributeError leak; return "" immediately.""" + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock(return_value=_raw_response(200, b"[]")) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "" + assert post.await_count == 1 + + +# --------------------------------------------------------------------------- +# Pin the full advertised retry contract: all retryable statuses + exceptions +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("status", [408, 429, 500, 502, 503, 504]) +@pytest.mark.asyncio +async def test_retries_on_every_advertised_transient_status( + audio_file: Path, status: int +) -> None: + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock(side_effect=[_response(status), _response(200, {"text": "ok"})]) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "ok" + assert post.await_count == 2 + + +@pytest.mark.parametrize( + "exc", + [ + httpx.TimeoutException("t"), + httpx.ConnectError("c"), + httpx.ReadError("r"), + httpx.WriteError("w"), + httpx.RemoteProtocolError("p"), + ], + ids=["timeout", "connect", "read", "write", "remote_protocol"], +) +@pytest.mark.asyncio +async def test_retries_on_every_advertised_transient_exception( + audio_file: Path, exc: Exception +) -> None: + provider = OpenAITranscriptionProvider(api_key="sk-test") + post = AsyncMock(side_effect=[exc, _response(200, {"text": "recovered"})]) + with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()): + result = await provider.transcribe(audio_file) + assert result == "recovered" + assert post.await_count == 2 From 3437ff273f5a41da4bfe24987d943c1512087ed6 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 6 May 2026 15:45:47 +0800 Subject: [PATCH 31/44] fix(transcription): address review nits on PR #3253 - Correct api_key type hint to str | None in _post_transcription_with_retry - Remove unreachable final return "" - Fix test_openai_missing_api_key_short_circuits to actually test missing-key path (use audio_file fixture so file exists) - Fix PermissionError patch for Windows (patch class method instead of instance attribute) --- nanobot/providers/transcription.py | 4 +--- tests/providers/test_transcription.py | 9 ++++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index 25e09dab7..b4c71929e 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -26,7 +26,7 @@ _RETRYABLE_EXCEPTIONS = ( async def _post_transcription_with_retry( url: str, *, - api_key: str, + api_key: str | None, path: Path, model: str, provider_label: str, @@ -116,8 +116,6 @@ async def _post_transcription_with_retry( return "" return payload.get("text", "") - return "" - class OpenAITranscriptionProvider: """Voice transcription provider using OpenAI's Whisper API.""" diff --git a/tests/providers/test_transcription.py b/tests/providers/test_transcription.py index 288290a92..5fd10d552 100644 --- a/tests/providers/test_transcription.py +++ b/tests/providers/test_transcription.py @@ -134,14 +134,13 @@ async def test_groq_does_not_retry_on_auth_error(audio_file: Path) -> None: @pytest.mark.asyncio -async def test_openai_missing_api_key_short_circuits(tmp_path: Path) -> None: - provider = OpenAITranscriptionProvider(api_key=None) - # Ensure env var doesn't accidentally satisfy it. +async def test_openai_missing_api_key_short_circuits(audio_file: Path) -> None: + """Missing API key short-circuits before any HTTP call, even when the file exists.""" with patch.dict("os.environ", {}, clear=True): provider = OpenAITranscriptionProvider(api_key=None) post = AsyncMock() with patch("httpx.AsyncClient.post", post): - assert await provider.transcribe(tmp_path / "voice.ogg") == "" + assert await provider.transcribe(audio_file) == "" assert post.await_count == 0 @@ -159,7 +158,7 @@ async def test_returns_empty_when_file_unreadable(audio_file: Path) -> None: """Existing file that cannot be read (PermissionError/OSError): "" with no HTTP attempt.""" provider = OpenAITranscriptionProvider(api_key="sk-test") post = AsyncMock() - with patch.object(Path, "read_bytes", side_effect=PermissionError("denied")), patch( + with patch("pathlib.Path.read_bytes", side_effect=PermissionError("denied")), patch( "httpx.AsyncClient.post", post ): result = await provider.transcribe(audio_file) From 05e01065925c6860da05f15984afad81d9481748 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 6 May 2026 21:11:26 +0800 Subject: [PATCH 32/44] refactor(logging): preserve tracebacks and add channel context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Preserve tracebacks: logger.error in except blocks → logger.exception - Channel context: BaseChannel injects self.logger = logger.bind(channel=name) - Third-party bridge: redirect_lib_logging() replaces ad-hoc stdlib-to-loguru bridges - Log levels: network timeouts downgraded from ERROR → WARNING - Fix --verbose flag to actually work with loguru (set handler to DEBUG) --- nanobot/agent/loop.py | 10 +- nanobot/agent/runner.py | 14 +-- nanobot/agent/subagent.py | 2 +- nanobot/agent/tools/mcp.py | 19 ++- nanobot/agent/tools/web.py | 4 +- nanobot/channels/base.py | 13 ++- nanobot/channels/dingtalk.py | 149 ++++++++++++------------ nanobot/channels/discord.py | 83 +++++++------ nanobot/channels/email.py | 50 ++++---- nanobot/channels/feishu.py | 134 ++++++++++----------- nanobot/channels/manager.py | 14 +-- nanobot/channels/matrix.py | 65 ++++------- nanobot/channels/mochat.py | 43 ++++--- nanobot/channels/msteams.py | 41 ++++--- nanobot/channels/qq.py | 61 +++++----- nanobot/channels/slack.py | 41 ++++--- nanobot/channels/telegram.py | 79 +++++++------ nanobot/channels/websocket.py | 42 +++---- nanobot/channels/wecom.py | 73 ++++++------ nanobot/channels/weixin.py | 68 ++++++----- nanobot/channels/whatsapp.py | 42 +++---- nanobot/cli/commands.py | 32 ++++- nanobot/cli/onboard.py | 2 +- nanobot/config/loader.py | 2 +- nanobot/cron/service.py | 13 +-- nanobot/heartbeat/service.py | 4 +- nanobot/providers/transcription.py | 8 +- nanobot/utils/document.py | 10 +- nanobot/utils/gitstore.py | 12 +- nanobot/utils/helpers.py | 6 +- nanobot/utils/logging_bridge.py | 47 ++++++++ tests/agent/test_runner.py | 2 +- tests/channels/test_telegram_channel.py | 21 ++-- tests/test_msteams.py | 2 +- tests/tools/test_mcp_tool.py | 2 +- 35 files changed, 631 insertions(+), 579 deletions(-) create mode 100644 nanobot/utils/logging_bridge.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 46d4bc1ae..d5e7681f1 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -112,6 +112,11 @@ class _LoopHook(AgentHook): async def before_iteration(self, context: AgentHookContext) -> None: self._loop._current_iteration = context.iteration + logger.debug( + "Starting agent loop iteration {} for session {}", + context.iteration, + self._session_key, + ) async def before_execute_tools(self, context: AgentHookContext) -> None: if self._on_progress: @@ -417,7 +422,7 @@ class AgentLoop: logger.warning("MCP connection cancelled (will retry next message)") self._mcp_stacks.clear() except BaseException as e: - logger.error("Failed to connect MCP servers (will retry next message): {}", e) + logger.warning("Failed to connect MCP servers (will retry next message): {}", e) self._mcp_stacks.clear() finally: self._mcp_connecting = False @@ -907,6 +912,8 @@ class AgentLoop: self.sessions.save(session) session, pending = self.auto_compact.prepare_session(session, key) + if pending: + logger.info("Memory compact triggered for session {}", key) await self.consolidator.maybe_consolidate_by_tokens( session, @@ -919,6 +926,7 @@ class AgentLoop: # LLM via the merged prompt. See _persist_subagent_followup. is_subagent = msg.sender_id == "subagent" if is_subagent and self._persist_subagent_followup(session, msg): + logger.debug("Subagent result persisted for session {}", key) self.sessions.save(session) self._set_tool_context( channel, chat_id, msg.metadata.get("message_id"), diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index b9418045e..b81df4168 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -261,12 +261,11 @@ class AgentRunner: # Snipping may have created new orphans; clean them up. messages_for_model = self._drop_orphan_tool_results(messages_for_model) messages_for_model = self._backfill_missing_tool_results(messages_for_model) - except Exception as exc: - logger.warning( - "Context governance failed on turn {} for {}: {}; applying minimal repair", + except Exception: + logger.exception( + "Context governance failed on turn {} for {}; applying minimal repair", iteration, spec.session_key or "default", - exc, ) try: messages_for_model = self._drop_orphan_tool_results(messages) @@ -981,12 +980,11 @@ class AgentRunner: result, max_chars=spec.max_tool_result_chars, ) - except Exception as exc: - logger.warning( - "Tool result persist failed for {} in {}: {}; using raw result", + except Exception: + logger.exception( + "Tool result persist failed for {} in {}; using raw result", tool_call_id, spec.session_key or "default", - exc, ) content = result if isinstance(content, str) and len(content) > spec.max_tool_result_chars: diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 6d64698a7..e418c2a7e 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -250,7 +250,7 @@ class SubagentManager: except Exception as e: status.phase = "error" status.error = str(e) - logger.error("Subagent [{}] failed: {}", task_id, e) + logger.exception("Subagent [{}] failed", task_id) await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error", origin_message_id) async def _announce_result( diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 580020a64..04b88386f 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -198,11 +198,10 @@ class MCPToolWrapper(Tool): await asyncio.sleep(1) # Brief backoff before retry continue # Second transient failure — give up with retry-specific message - logger.error( - "MCP tool '{}' failed after retry: {}: {}", + logger.exception( + "MCP tool '{}' failed after retry: {}", self._name, type(exc).__name__, - exc, ) return f"(MCP tool call failed after retry: {type(exc).__name__})" logger.exception( @@ -287,11 +286,10 @@ class MCPResourceWrapper(Tool): ) await asyncio.sleep(1) continue - logger.error( - "MCP resource '{}' failed after retry: {}: {}", + logger.exception( + "MCP resource '{}' failed after retry: {}", self._name, type(exc).__name__, - exc, ) return f"(MCP resource read failed after retry: {type(exc).__name__})" logger.exception( @@ -383,7 +381,7 @@ class MCPPromptWrapper(Tool): logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name) return "(MCP prompt call was cancelled)" except McpError as exc: - logger.error( + logger.exception( "MCP prompt '{}' failed: code={} message={}", self._name, exc.error.code, @@ -400,11 +398,10 @@ class MCPPromptWrapper(Tool): ) await asyncio.sleep(1) continue - logger.error( - "MCP prompt '{}' failed after retry: {}: {}", + logger.exception( + "MCP prompt '{}' failed after retry: {}", self._name, type(exc).__name__, - exc, ) return f"(MCP prompt call failed after retry: {type(exc).__name__})" logger.exception( @@ -608,7 +605,7 @@ async def connect_mcp_servers( " Hint: this looks like stdio protocol pollution. Make sure the MCP server writes " "only JSON-RPC to stdout and sends logs/debug output to stderr instead." ) - logger.error("MCP server '{}': failed to connect: {}{}", name, e, hint) + logger.exception("MCP server '{}': failed to connect: {}", name, hint) with suppress(Exception): await server_stack.aclose() return name, None diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 6378a7979..aae40ac9c 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -500,10 +500,10 @@ class WebFetchTool(Tool): "untrusted": True, "text": text, }, ensure_ascii=False) except httpx.ProxyError as e: - logger.error("WebFetch proxy error for {}: {}", url, e) + logger.exception("WebFetch proxy error for {}", url) return json.dumps({"error": f"Proxy error: {e}", "url": url}, ensure_ascii=False) except Exception as e: - logger.error("WebFetch error for {}: {}", url, e) + logger.exception("WebFetch error for {}", url) return json.dumps({"error": str(e), "url": url}, ensure_ascii=False) def _to_markdown(self, html_content: str) -> str: diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 6097b420f..087677494 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -38,6 +38,7 @@ class BaseChannel(ABC): bus: The message bus for communication. """ self.config = config + self.logger = logger.bind(channel=self.name) self.bus = bus self._running = False @@ -61,8 +62,8 @@ class BaseChannel(ABC): language=self.transcription_language or None, ) return await provider.transcribe(file_path) - except Exception as e: - logger.warning("{}: audio transcription failed: {}", self.name, e) + except Exception: + self.logger.exception("Audio transcription failed") return "" async def login(self, force: bool = False) -> bool: @@ -136,7 +137,7 @@ class BaseChannel(ABC): else: allow_list = getattr(self.config, "allow_from", []) if not allow_list: - logger.warning("{}: allow_from is empty — all access denied", self.name) + self.logger.warning("allow_from is empty — all access denied") return False if "*" in allow_list: return True @@ -165,10 +166,10 @@ class BaseChannel(ABC): session_key: Optional session key override (e.g. thread-scoped sessions). """ if not self.is_allowed(sender_id): - logger.warning( - "Access denied for sender {} on channel {}. " + self.logger.warning( + "Access denied for sender {}. " "Add them to allowFrom list in config to grant access.", - sender_id, self.name, + sender_id, ) return diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 609a7fa54..72199fdf9 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -12,7 +12,6 @@ from typing import Any from urllib.parse import unquote, urljoin, urlparse import httpx -from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -113,7 +112,7 @@ class NanobotDingTalkHandler(CallbackHandler): content = content + "\n\nReceived files:\n" + file_list if not content: - logger.warning( + self.channel.logger.warning( "Received empty or unsupported message type: {}", chatbot_msg.message_type, ) @@ -128,7 +127,7 @@ class NanobotDingTalkHandler(CallbackHandler): or message.data.get("openConversationId") ) - logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content) + self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content) # Forward to Nanobot via _on_message (non-blocking). # Store reference to prevent GC before task completes. @@ -146,8 +145,8 @@ class NanobotDingTalkHandler(CallbackHandler): return AckMessage.STATUS_OK, "OK" - except Exception as e: - logger.error("Error processing DingTalk message: {}", e) + except Exception: + self.channel.logger.exception("Error processing message") # Return OK to avoid retry loop from DingTalk server return AckMessage.STATUS_OK, "Error" @@ -204,20 +203,20 @@ class DingTalkChannel(BaseChannel): """Start the DingTalk bot with Stream Mode.""" try: if not DINGTALK_AVAILABLE: - logger.error( - "DingTalk Stream SDK not installed. Run: pip install dingtalk-stream" + self.logger.error( + "Stream SDK not installed. Run: pip install dingtalk-stream" ) return if not self.config.client_id or not self.config.client_secret: - logger.error("DingTalk client_id and client_secret not configured") + self.logger.error("client_id and client_secret not configured") return self._running = True self._http = httpx.AsyncClient() - logger.info( - "Initializing DingTalk Stream Client with Client ID: {}...", + self.logger.info( + "Initializing Stream Client with Client ID: {}...", self.config.client_id, ) credential = Credential(self.config.client_id, self.config.client_secret) @@ -227,20 +226,20 @@ class DingTalkChannel(BaseChannel): handler = NanobotDingTalkHandler(self) self._client.register_callback_handler(ChatbotMessage.TOPIC, handler) - logger.info("DingTalk bot started with Stream Mode") + self.logger.info("bot started with Stream Mode") # Reconnect loop: restart stream if SDK exits or crashes while self._running: try: await self._client.start() except Exception as e: - logger.warning("DingTalk stream error: {}", e) + self.logger.warning("stream error: {}", e) if self._running: - logger.info("Reconnecting DingTalk stream in 5 seconds...") + self.logger.info("Reconnecting stream in 5 seconds...") await asyncio.sleep(5) - except Exception as e: - logger.exception("Failed to start DingTalk channel: {}", e) + except Exception: + self.logger.exception("Failed to start channel") async def stop(self) -> None: """Stop the DingTalk bot.""" @@ -266,7 +265,7 @@ class DingTalkChannel(BaseChannel): } if not self._http: - logger.warning("DingTalk HTTP client not initialized, cannot refresh token") + self.logger.warning("HTTP client not initialized, cannot refresh token") return None try: @@ -277,8 +276,8 @@ class DingTalkChannel(BaseChannel): # Expire 60s early to be safe self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60 return self._access_token - except Exception as e: - logger.error("Failed to get DingTalk access token: {}", e) + except Exception: + self.logger.exception("Failed to get access token") return None @staticmethod @@ -317,8 +316,8 @@ class DingTalkChannel(BaseChannel): ) -> tuple[bytes, str, str | None]: ext = Path(filename).suffix.lower() if ext in self._ZIP_BEFORE_UPLOAD_EXTS or content_type == "text/html": - logger.info( - "DingTalk does not accept raw HTML attachments, zipping {} before upload", + self.logger.info( + "does not accept raw HTML attachments, zipping {} before upload", filename, ) return self._zip_bytes(filename, data) @@ -327,7 +326,7 @@ class DingTalkChannel(BaseChannel): def _validate_remote_media_url(self, media_ref: str) -> bool: ok, err = validate_url_target(media_ref) if not ok: - logger.warning("DingTalk remote media URL blocked ref={} reason={}", media_ref, err) + self.logger.warning("remote media URL blocked ref={} reason={}", media_ref, err) return False return True @@ -343,15 +342,15 @@ class DingTalkChannel(BaseChannel): def _next_remote_media_url(self, current_url: str, location: str | None) -> str | None: if not self.config.allow_remote_media_redirects: - logger.warning("DingTalk media download redirect refused ref={}", current_url) + self.logger.warning("media download redirect refused ref={}", current_url) return None if not location: - logger.warning("DingTalk media download redirect without Location ref={}", current_url) + self.logger.warning("media download redirect without Location ref={}", current_url) return None next_url = urljoin(current_url, location) if not self._redirect_host_allowed(current_url, next_url): - logger.warning( - "DingTalk media download cross-host redirect refused ref={} next={}", + self.logger.warning( + "media download cross-host redirect refused ref={} next={}", current_url, next_url, ) @@ -382,8 +381,8 @@ class DingTalkChannel(BaseChannel): async with stream("GET", current_url, follow_redirects=False) as resp: final_ok, final_err = validate_resolved_url(str(resp.url)) if not final_ok: - logger.warning( - "DingTalk remote media redirect blocked ref={} final={} reason={}", + self.logger.warning( + "remote media redirect blocked ref={} final={} reason={}", media_ref, resp.url, final_err, @@ -398,8 +397,8 @@ class DingTalkChannel(BaseChannel): current_url = next_url continue if resp.status_code >= 400: - logger.warning( - "DingTalk media download failed status={} ref={}", + self.logger.warning( + "media download failed status={} ref={}", resp.status_code, current_url, ) @@ -409,15 +408,15 @@ class DingTalkChannel(BaseChannel): async for chunk in resp.aiter_bytes(): total += len(chunk) if total > DINGTALK_MAX_REMOTE_MEDIA_BYTES: - logger.warning( - "DingTalk media download too large ref={} bytes>{}", + self.logger.warning( + "media download too large ref={} bytes>{}", current_url, DINGTALK_MAX_REMOTE_MEDIA_BYTES, ) return None, None chunks.append(chunk) return b"".join(chunks), (resp.headers.get("content-type") or "") - logger.warning("DingTalk media download exceeded redirect limit ref={}", media_ref) + self.logger.warning("media download exceeded redirect limit ref={}", media_ref) return None, None current_url = media_ref @@ -425,8 +424,8 @@ class DingTalkChannel(BaseChannel): resp = await self._http.get(current_url, follow_redirects=False) final_ok, final_err = validate_resolved_url(str(getattr(resp, "url", current_url))) if not final_ok: - logger.warning( - "DingTalk remote media redirect blocked ref={} final={} reason={}", + self.logger.warning( + "remote media redirect blocked ref={} final={} reason={}", media_ref, getattr(resp, "url", current_url), final_err, @@ -441,27 +440,27 @@ class DingTalkChannel(BaseChannel): current_url = next_url continue if resp.status_code >= 400: - logger.warning( - "DingTalk media download failed status={} ref={}", + self.logger.warning( + "media download failed status={} ref={}", resp.status_code, current_url, ) return None, None if len(resp.content) > DINGTALK_MAX_REMOTE_MEDIA_BYTES: - logger.warning( - "DingTalk media download too large ref={} bytes>{}", + self.logger.warning( + "media download too large ref={} bytes>{}", current_url, DINGTALK_MAX_REMOTE_MEDIA_BYTES, ) return None, None return resp.content, (resp.headers.get("content-type") or "") - logger.warning("DingTalk media download exceeded redirect limit ref={}", media_ref) + self.logger.warning("media download exceeded redirect limit ref={}", media_ref) return None, None - except httpx.TransportError as e: - logger.error("DingTalk media download network error ref={} err={}", media_ref, e) + except httpx.TransportError: + self.logger.exception("media download network error ref={}", media_ref) raise - except Exception as e: - logger.error("DingTalk media download error ref={} err={}", media_ref, e) + except Exception: + self.logger.exception("media download error ref={}", media_ref) return None, None async def _read_media_bytes( @@ -486,13 +485,13 @@ class DingTalkChannel(BaseChannel): else: local_path = Path(os.path.expanduser(media_ref)) if not local_path.is_file(): - logger.warning("DingTalk media file not found: {}", local_path) + self.logger.warning("media file not found: {}", local_path) return None, None, None data = await asyncio.to_thread(local_path.read_bytes) content_type = mimetypes.guess_type(local_path.name)[0] return data, local_path.name, content_type - except Exception as e: - logger.error("DingTalk media read error ref={} err={}", media_ref, e) + except Exception: + self.logger.exception("media read error ref={}", media_ref) return None, None, None async def _upload_media( @@ -514,23 +513,23 @@ class DingTalkChannel(BaseChannel): text = resp.text result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} if resp.status_code >= 400: - logger.error("DingTalk media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500]) + self.logger.error("media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500]) return None errcode = result.get("errcode", 0) if errcode != 0: - logger.error("DingTalk media upload api error type={} errcode={} body={}", media_type, errcode, text[:500]) + self.logger.error("media upload api error type={} errcode={} body={}", media_type, errcode, text[:500]) return None sub = result.get("result") or {} media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId") if not media_id: - logger.error("DingTalk media upload missing media_id body={}", text[:500]) + self.logger.error("media upload missing media_id body={}", text[:500]) return None return str(media_id) - except httpx.TransportError as e: - logger.error("DingTalk media upload network error type={} err={}", media_type, e) + except httpx.TransportError: + self.logger.exception("media upload network error type={}", media_type) raise - except Exception as e: - logger.error("DingTalk media upload error type={} err={}", media_type, e) + except Exception: + self.logger.exception("media upload error type={}", media_type) return None async def _send_batch_message( @@ -541,7 +540,7 @@ class DingTalkChannel(BaseChannel): msg_param: dict[str, Any], ) -> bool: if not self._http: - logger.warning("DingTalk HTTP client not initialized, cannot send") + self.logger.warning("HTTP client not initialized, cannot send") return False headers = {"x-acs-dingtalk-access-token": token} @@ -568,7 +567,7 @@ class DingTalkChannel(BaseChannel): resp = await self._http.post(url, json=payload, headers=headers) body = resp.text if resp.status_code != 200: - logger.error("DingTalk send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500]) + self.logger.error("send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500]) return False try: result = resp.json() @@ -576,15 +575,15 @@ class DingTalkChannel(BaseChannel): result = {} errcode = result.get("errcode") if errcode not in (None, 0): - logger.error("DingTalk send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500]) + self.logger.error("send api error msgKey={} errcode={} body={}", msg_key, errcode, body[:500]) return False - logger.debug("DingTalk message sent to {} with msgKey={}", chat_id, msg_key) + self.logger.debug("message sent to {} with msgKey={}", chat_id, msg_key) return True - except httpx.TransportError as e: - logger.error("DingTalk network error sending message msgKey={} err={}", msg_key, e) + except httpx.TransportError: + self.logger.exception("network error sending message msgKey={}", msg_key) raise - except Exception as e: - logger.error("Error sending DingTalk message msgKey={} err={}", msg_key, e) + except Exception: + self.logger.exception("Error sending message msgKey={}", msg_key) return False async def _send_markdown_text(self, token: str, chat_id: str, content: str) -> bool: @@ -610,11 +609,11 @@ class DingTalkChannel(BaseChannel): ) if ok: return True - logger.warning("DingTalk image url send failed, trying upload fallback: {}", media_ref) + self.logger.warning("image url send failed, trying upload fallback: {}", media_ref) data, filename, content_type = await self._read_media_bytes(media_ref) if not data: - logger.error("DingTalk media read failed: {}", media_ref) + self.logger.error("media read failed: {}", media_ref) return False filename = filename or self._guess_filename(media_ref, upload_type) @@ -646,7 +645,7 @@ class DingTalkChannel(BaseChannel): ) if ok: return True - logger.warning("DingTalk image media_id send failed, falling back to file: {}", media_ref) + self.logger.warning("image media_id send failed, falling back to file: {}", media_ref) return await self._send_batch_message( token, @@ -668,7 +667,7 @@ class DingTalkChannel(BaseChannel): ok = await self._send_media_ref(token, msg.chat_id, media_ref) if ok: continue - logger.error("DingTalk media send failed for {}", media_ref) + self.logger.error("media send failed for {}", media_ref) # Send visible fallback so failures are observable by the user. filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref)) await self._send_markdown_text( @@ -691,7 +690,7 @@ class DingTalkChannel(BaseChannel): permission checks before publishing to the bus. """ try: - logger.info("DingTalk inbound: {} from {}", content, sender_name) + self.logger.info("inbound: {} from {}", content, sender_name) is_group = conversation_type == "2" and conversation_id chat_id = f"group:{conversation_id}" if is_group else sender_id await self._handle_message( @@ -704,8 +703,8 @@ class DingTalkChannel(BaseChannel): "conversation_type": conversation_type, }, ) - except Exception as e: - logger.error("Error publishing DingTalk message: {}", e) + except Exception: + self.logger.exception("Error publishing message") async def _download_dingtalk_file( self, @@ -719,7 +718,7 @@ class DingTalkChannel(BaseChannel): try: token = await self._get_access_token() if not token or not self._http: - logger.error("DingTalk file download: no token or http client") + self.logger.error("file download: no token or http client") return None # Step 1: Exchange downloadCode for a temporary download URL @@ -728,19 +727,19 @@ class DingTalkChannel(BaseChannel): payload = {"downloadCode": download_code, "robotCode": self.config.client_id} resp = await self._http.post(api_url, json=payload, headers=headers) if resp.status_code != 200: - logger.error("DingTalk get download URL failed: status={}, body={}", resp.status_code, resp.text) + self.logger.error("get download URL failed: status={}, body={}", resp.status_code, resp.text) return None result = resp.json() download_url = result.get("downloadUrl") if not download_url: - logger.error("DingTalk download URL not found in response: {}", result) + self.logger.error("download URL not found in response: {}", result) return None # Step 2: Download the file content file_resp = await self._http.get(download_url, follow_redirects=True) if file_resp.status_code != 200: - logger.error("DingTalk file download failed: status={}", file_resp.status_code) + self.logger.error("file download failed: status={}", file_resp.status_code) return None # Save to media directory (accessible under workspace) @@ -748,8 +747,8 @@ class DingTalkChannel(BaseChannel): download_dir.mkdir(parents=True, exist_ok=True) file_path = download_dir / filename await asyncio.to_thread(file_path.write_bytes, file_resp.content) - logger.info("DingTalk file saved: {}", file_path) + self.logger.info("file saved: {}", file_path) return str(file_path) - except Exception as e: - logger.error("DingTalk file download error: {}", e) + except Exception: + self.logger.exception("file download error") return None diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index bb39b66b7..10d569692 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -10,7 +10,6 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Literal -from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -86,12 +85,12 @@ if DISCORD_AVAILABLE: async def on_ready(self) -> None: self._channel._bot_user_id = str(self.user.id) if self.user else None - logger.info("Discord bot connected as user {}", self._channel._bot_user_id) + self._channel.logger.info("bot connected as user {}", self._channel._bot_user_id) try: synced = await self.tree.sync() - logger.info("Discord app commands synced: {}", len(synced)) + self._channel.logger.info("app commands synced: {}", len(synced)) except Exception as e: - logger.warning("Discord app command sync failed: {}", e) + self._channel.logger.warning("app command sync failed: {}", e) async def on_message(self, message: discord.Message) -> None: await self._channel._handle_discord_message(message) @@ -111,7 +110,7 @@ if DISCORD_AVAILABLE: await interaction.response.send_message(text, ephemeral=True) return True except Exception as e: - logger.warning("Discord interaction response failed: {}", e) + self._channel.logger.warning("interaction response failed: {}", e) return False async def _resolve_interaction_channel( @@ -126,7 +125,7 @@ if DISCORD_AVAILABLE: try: channel = await self.fetch_channel(channel_id) except Exception as e: - logger.warning("Discord interaction channel {} unavailable: {}", channel_id, e) + self._channel.logger.warning("interaction channel {} unavailable: {}", channel_id, e) return None self._channel._remember_channel(channel) return channel @@ -154,7 +153,7 @@ if DISCORD_AVAILABLE: channel_id = interaction.channel_id if channel_id is None: - logger.warning("Discord slash command missing channel_id: {}", command_text) + self._channel.logger.warning("slash command missing channel_id: {}", command_text) return if not self._channel.is_allowed(sender_id): @@ -226,8 +225,8 @@ if DISCORD_AVAILABLE: error: app_commands.AppCommandError, ) -> None: command_name = interaction.command.qualified_name if interaction.command else "?" - logger.warning( - "Discord app command failed user={} channel={} cmd={} error={}", + self._channel.logger.warning( + "app command failed user={} channel={} cmd={} error={}", interaction.user.id, interaction.channel_id, command_name, @@ -243,7 +242,7 @@ if DISCORD_AVAILABLE: try: channel = await self.fetch_channel(channel_id) except Exception as e: - logger.warning("Discord channel {} unavailable: {}", msg.chat_id, e) + self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e) return reference, mention_settings = self._build_reply_context(channel, msg.reply_to) @@ -281,11 +280,11 @@ if DISCORD_AVAILABLE: """Send a file attachment via discord.py.""" path = Path(file_path) if not path.is_file(): - logger.warning("Discord file not found, skipping: {}", file_path) + self._channel.logger.warning("file not found, skipping: {}", file_path) return False if path.stat().st_size > MAX_ATTACHMENT_BYTES: - logger.warning("Discord file too large (>20MB), skipping: {}", path.name) + self._channel.logger.warning("file too large (>20MB), skipping: {}", path.name) return False try: @@ -294,10 +293,10 @@ if DISCORD_AVAILABLE: kwargs["reference"] = reference kwargs["allowed_mentions"] = mention_settings await channel.send(**kwargs) - logger.info("Discord file sent: {}", path.name) + self._channel.logger.info("file sent: {}", path.name) return True - except Exception as e: - logger.error("Error sending Discord file {}: {}", path.name, e) + except Exception: + self._channel.logger.exception("Error sending file {}", path.name) return False @staticmethod @@ -321,7 +320,7 @@ if DISCORD_AVAILABLE: try: message_id = int(reply_to) except ValueError: - logger.warning("Invalid Discord reply target: {}", reply_to) + self._channel.logger.warning("Invalid reply target: {}", reply_to) return None, mention_settings return channel.get_partial_message(message_id), mention_settings @@ -385,11 +384,11 @@ class DiscordChannel(BaseChannel): async def start(self) -> None: """Start the Discord client.""" if not DISCORD_AVAILABLE: - logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]") + self.logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]") return if not self.config.token: - logger.error("Discord bot token not configured") + self.logger.error("bot token not configured") return try: @@ -407,8 +406,8 @@ class DiscordChannel(BaseChannel): password=self.config.proxy_password, ) elif has_user != has_pass: - logger.warning( - "Discord proxy auth incomplete: both proxy_username and " + self.logger.warning( + "proxy auth incomplete: both proxy_username and " "proxy_password must be set; ignoring partial credentials", ) @@ -418,21 +417,21 @@ class DiscordChannel(BaseChannel): proxy=self.config.proxy, proxy_auth=proxy_auth, ) - except Exception as e: - logger.error("Failed to initialize Discord client: {}", e) + except Exception: + self.logger.exception("Failed to initialize client") self._client = None self._running = False return self._running = True - logger.info("Starting Discord client via discord.py...") + self.logger.info("Starting client via discord.py...") try: await self._client.start(self.config.token) except asyncio.CancelledError: raise - except Exception as e: - logger.error("Discord client startup failed: {}", e) + except Exception: + self.logger.exception("client startup failed") finally: self._running = False await self._reset_runtime_state(close_client=True) @@ -446,15 +445,15 @@ class DiscordChannel(BaseChannel): """Send a message through Discord using discord.py.""" client = self._client if client is None or not client.is_ready(): - logger.warning("Discord client not ready; dropping outbound message") + self.logger.warning("client not ready; dropping outbound message") return is_progress = bool((msg.metadata or {}).get("_progress")) try: await client.send_outbound(msg) - except Exception as e: - logger.error("Error sending Discord message: {}", e) + except Exception: + self.logger.exception("Error sending message") raise finally: if not is_progress: @@ -467,7 +466,7 @@ class DiscordChannel(BaseChannel): """Progressive Discord delivery: send once, then edit until the stream ends.""" client = self._client if client is None or not client.is_ready(): - logger.warning("Discord client not ready; dropping stream delta") + self.logger.warning("client not ready; dropping stream delta") return meta = metadata or {} @@ -497,7 +496,7 @@ class DiscordChannel(BaseChannel): target = await self._resolve_channel(chat_id) if target is None: - logger.warning("Discord stream target {} unavailable", chat_id) + self.logger.warning("stream target {} unavailable", chat_id) return now = time.monotonic() @@ -506,7 +505,7 @@ class DiscordChannel(BaseChannel): buf.message = await target.send(content=buf.text) buf.last_edit = now except Exception as e: - logger.warning("Discord stream initial send failed: {}", e) + self.logger.warning("stream initial send failed: {}", e) raise return @@ -517,7 +516,7 @@ class DiscordChannel(BaseChannel): await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0]) buf.last_edit = now except Exception as e: - logger.warning("Discord stream edit failed: {}", e) + self.logger.warning("stream edit failed: {}", e) raise async def _handle_discord_message(self, message: discord.Message) -> None: @@ -560,7 +559,7 @@ class DiscordChannel(BaseChannel): await message.add_reaction(self.config.read_receipt_emoji) self._pending_reactions[channel_id] = message except Exception as e: - logger.debug("Failed to add read receipt reaction: {}", e) + self.logger.debug("Failed to add read receipt reaction: {}", e) # Delayed working indicator (cosmetic — not tied to subagent lifecycle) async def _delayed_working_emoji() -> None: @@ -603,7 +602,7 @@ class DiscordChannel(BaseChannel): try: return await client.fetch_channel(channel_id) except Exception as e: - logger.warning("Discord channel {} unavailable: {}", chat_id, e) + self.logger.warning("channel {} unavailable: {}", chat_id, e) return None async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None: @@ -616,12 +615,12 @@ class DiscordChannel(BaseChannel): try: await buf.message.edit(content=chunks[0]) except Exception as e: - logger.warning("Discord final stream edit failed: {}", e) + self.logger.warning("final stream edit failed: {}", e) raise target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id) if target is None: - logger.warning("Discord stream follow-up target {} unavailable", chat_id) + self.logger.warning("stream follow-up target {} unavailable", chat_id) self._stream_bufs.pop(chat_id, None) return @@ -673,7 +672,7 @@ class DiscordChannel(BaseChannel): media_paths.append(str(file_path)) markers.append(f"[attachment: {file_path.name}]") except Exception as e: - logger.warning("Failed to download Discord attachment: {}", e) + self.logger.warning("Failed to download attachment: {}", e) markers.append(f"[attachment: {filename} - download failed]") return media_paths, markers @@ -715,8 +714,8 @@ class DiscordChannel(BaseChannel): if bot_user_id is None and self._client and self._client.user: bot_user_id = str(self._client.user.id) if bot_user_id is None: - logger.debug( - "Discord message in {} ignored (bot identity unavailable)", message.channel.id + self.logger.debug( + "message in {} ignored (bot identity unavailable)", message.channel.id ) return False @@ -729,7 +728,7 @@ class DiscordChannel(BaseChannel): if self._references_bot_message(message, bot_user_id): return True - logger.debug("Discord message in {} ignored (bot not mentioned)", message.channel.id) + self.logger.debug("message in {} ignored (bot not mentioned)", message.channel.id) return False return True @@ -759,7 +758,7 @@ class DiscordChannel(BaseChannel): except asyncio.CancelledError: return except Exception as e: - logger.debug("Discord typing indicator failed for {}: {}", channel_id, e) + self.logger.debug("typing indicator failed for {}: {}", channel_id, e) return self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) @@ -803,6 +802,6 @@ class DiscordChannel(BaseChannel): try: await self._client.close() except Exception as e: - logger.warning("Discord client close failed: {}", e) + self.logger.warning("client close failed: {}", e) self._client = None self._bot_user_id = None diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 401da7bb6..f729d18e4 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -128,7 +128,7 @@ class EmailChannel(BaseChannel): async def start(self) -> None: """Start polling IMAP for inbound emails.""" if not self.config.consent_granted: - logger.warning( + self.logger.warning( "Email channel disabled: consent_granted is false. " "Set channels.email.consentGranted=true after explicit user permission." ) @@ -139,12 +139,12 @@ class EmailChannel(BaseChannel): self._running = True if not self.config.verify_dkim and not self.config.verify_spf: - logger.warning( - "Email channel: DKIM and SPF verification are both DISABLED. " + self.logger.warning( + "DKIM and SPF verification are both DISABLED. " "Emails with spoofed From headers will be accepted. " "Set verify_dkim=true and verify_spf=true for anti-spoofing protection." ) - logger.info("Starting Email channel (IMAP polling mode)...") + self.logger.info("Starting Email channel (IMAP polling mode)...") poll_seconds = max(5, int(self.config.poll_interval_seconds)) while self._running: @@ -167,8 +167,8 @@ class EmailChannel(BaseChannel): media=item.get("media") or None, metadata=item.get("metadata", {}), ) - except Exception as e: - logger.error("Email polling error: {}", e) + except Exception: + self.logger.exception("Polling error") await asyncio.sleep(poll_seconds) @@ -179,16 +179,16 @@ class EmailChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send email via SMTP.""" if not self.config.consent_granted: - logger.warning("Skip email send: consent_granted is false") + self.logger.warning("Skip email send: consent_granted is false") return if not self.config.smtp_host: - logger.warning("Email channel SMTP host not configured") + self.logger.warning("SMTP host not configured") return to_addr = msg.chat_id.strip() if not to_addr: - logger.warning("Email channel missing recipient address") + self.logger.warning("Missing recipient address") return # Determine if this is a reply (recipient has sent us an email before) @@ -197,7 +197,7 @@ class EmailChannel(BaseChannel): # autoReplyEnabled only controls automatic replies, not proactive sends if is_reply and not self.config.auto_reply_enabled and not force_send: - logger.info("Skip automatic email reply to {}: auto_reply_enabled is false", to_addr) + self.logger.info("Skip automatic reply to {}: auto_reply_enabled is false", to_addr) return base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply") @@ -220,8 +220,8 @@ class EmailChannel(BaseChannel): try: await asyncio.to_thread(self._smtp_send, email_msg) - except Exception as e: - logger.error("Error sending email to {}: {}", to_addr, e) + except Exception: + self.logger.exception("Error sending to {}", to_addr) raise def _validate_config(self) -> bool: @@ -240,7 +240,7 @@ class EmailChannel(BaseChannel): missing.append("smtp_password") if missing: - logger.error("Email channel not configured, missing: {}", ', '.join(missing)) + self.logger.error("Channel not configured, missing: {}", ', '.join(missing)) return False return True @@ -321,7 +321,7 @@ class EmailChannel(BaseChannel): except Exception as exc: if attempt == 1 or not self._is_stale_imap_error(exc): raise - logger.warning("Email IMAP connection went stale, retrying once: {}", exc) + self.logger.warning("IMAP connection went stale, retrying once: {}", exc) return messages @@ -348,11 +348,11 @@ class EmailChannel(BaseChannel): status, _ = client.select(mailbox) except Exception as exc: if self._is_missing_mailbox_error(exc): - logger.warning("Email mailbox unavailable, skipping poll for {}: {}", mailbox, exc) + self.logger.warning("Mailbox unavailable, skipping poll for {}: {}", mailbox, exc) return messages raise if status != "OK": - logger.warning("Email mailbox select returned {}, skipping poll for {}", status, mailbox) + self.logger.warning("Mailbox select returned {}, skipping poll for {}", status, mailbox) return messages status, data = client.search(None, *search_criteria) @@ -382,7 +382,7 @@ class EmailChannel(BaseChannel): if not sender: continue if self._is_self_address(sender): - logger.info("Email from {} ignored: matches bot-owned address", sender) + self.logger.info("From {} ignored: matches bot-owned address", sender) self._remember_processed_uid(uid, dedupe, cycle_uids) if mark_seen: client.store(imap_id, "+FLAGS", "\\Seen") @@ -391,16 +391,16 @@ class EmailChannel(BaseChannel): # --- Anti-spoofing: verify Authentication-Results --- spf_pass, dkim_pass = self._check_authentication_results(parsed) if self.config.verify_spf and not spf_pass: - logger.warning( - "Email from {} rejected: SPF verification failed " + self.logger.warning( + "From {} rejected: SPF verification failed " "(no 'spf=pass' in Authentication-Results header)", sender, ) self._remember_processed_uid(uid, dedupe, cycle_uids) continue if self.config.verify_dkim and not dkim_pass: - logger.warning( - "Email from {} rejected: DKIM verification failed " + self.logger.warning( + "From {} rejected: DKIM verification failed " "(no 'dkim=pass' in Authentication-Results header)", sender, ) @@ -641,7 +641,7 @@ class EmailChannel(BaseChannel): content_type = part.get_content_type() if not any(fnmatch(content_type, pat) for pat in allowed_types): - logger.debug("Email attachment skipped (type {}): not in allowed list", content_type) + logger.debug("Attachment skipped (type {}): not in allowed list", content_type) continue payload = part.get_payload(decode=True) @@ -649,7 +649,7 @@ class EmailChannel(BaseChannel): continue if len(payload) > max_size: logger.warning( - "Email attachment skipped: size {} exceeds limit {}", + "Attachment skipped: size {} exceeds limit {}", len(payload), max_size, ) @@ -662,9 +662,9 @@ class EmailChannel(BaseChannel): try: dest.write_bytes(payload) saved.append(dest) - logger.info("Email attachment saved: {}", dest) + logger.info("Attachment saved: {}", dest) except Exception as exc: - logger.warning("Failed to save email attachment {}: {}", dest, exc) + logger.warning("Failed to save attachment {}: {}", dest, exc) return saved diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6fe8b9d5f..91022b9af 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -15,7 +15,6 @@ from typing import Any, Literal from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1 from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN -from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -23,6 +22,7 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base +from nanobot.utils.logging_bridge import redirect_lib_logging FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None @@ -320,15 +320,17 @@ class FeishuChannel(BaseChannel): async def start(self) -> None: """Start the Feishu bot with WebSocket long connection.""" if not FEISHU_AVAILABLE: - logger.error("Feishu SDK not installed. Run: pip install lark-oapi") + self.logger.error("SDK not installed. Run: pip install lark-oapi") return if not self.config.app_id or not self.config.app_secret: - logger.error("Feishu app_id and app_secret not configured") + self.logger.error("app_id and app_secret not configured") return import lark_oapi as lark + redirect_lib_logging("Lark") + self._running = True self._loop = asyncio.get_running_loop() @@ -390,7 +392,7 @@ class FeishuChannel(BaseChannel): try: self._ws_client.start() except Exception as e: - logger.warning("Feishu WebSocket error: {}", e) + self.logger.warning("WebSocket error: {}", e) if self._running: time.sleep(5) finally: @@ -404,12 +406,12 @@ class FeishuChannel(BaseChannel): None, self._fetch_bot_open_id ) if self._bot_open_id: - logger.info("Feishu bot open_id: {}", self._bot_open_id) + self.logger.info("bot open_id: {}", self._bot_open_id) else: - logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate") + self.logger.warning("Could not fetch bot open_id; @mention matching may be inaccurate") - logger.info("Feishu bot started with WebSocket long connection") - logger.info("No public IP required - using WebSocket to receive events") + self.logger.info("bot started with WebSocket long connection") + self.logger.info("No public IP required - using WebSocket to receive events") # Keep running until stopped while self._running: @@ -424,7 +426,7 @@ class FeishuChannel(BaseChannel): Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86 """ self._running = False - logger.info("Feishu bot stopped") + self.logger.info("bot stopped") def _fetch_bot_open_id(self) -> str | None: """Fetch the bot's own open_id via GET /open-apis/bot/v3/info.""" @@ -445,10 +447,10 @@ class FeishuChannel(BaseChannel): data = json.loads(response.raw.content) bot = (data.get("data") or data).get("bot") or data.get("bot") or {} return bot.get("open_id") - logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg) + self.logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg) return None except Exception as e: - logger.warning("Error fetching bot info: {}", e) + self.logger.warning("Error fetching bot info: {}", e) return None @staticmethod @@ -539,15 +541,15 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.message_reaction.create(request) if not response.success(): - logger.warning( + self.logger.warning( "Failed to add reaction: code={}, msg={}", response.code, response.msg ) return None else: - logger.debug("Added {} reaction to message {}", emoji_type, message_id) + self.logger.debug("Added {} reaction to message {}", emoji_type, message_id) return response.data.reaction_id if response.data else None except Exception as e: - logger.warning("Error adding reaction: {}", e) + self.logger.warning("Error adding reaction: {}", e) return None async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None: @@ -579,13 +581,13 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.message_reaction.delete(request) if response.success(): - logger.debug("Removed reaction {} from message {}", reaction_id, message_id) + self.logger.debug("Removed reaction {} from message {}", reaction_id, message_id) else: - logger.debug( + self.logger.debug( "Failed to remove reaction: code={}, msg={}", response.code, response.msg ) except Exception as e: - logger.debug("Error removing reaction: {}", e) + self.logger.debug("Error removing reaction: {}", e) async def _remove_reaction(self, message_id: str, reaction_id: str) -> None: """ @@ -607,7 +609,7 @@ class FeishuChannel(BaseChannel): try: task.result() except Exception as exc: - logger.warning("Background task failed: {}", exc) + self.logger.warning("Background task failed: {}", exc) def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None: """Callback: store reaction_id after background add-reaction completes.""" @@ -917,15 +919,15 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.image.create(request) if response.success(): image_key = response.data.image_key - logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key) + self.logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key) return image_key else: - logger.error( + self.logger.error( "Failed to upload image: code={}, msg={}", response.code, response.msg ) return None - except Exception as e: - logger.error("Error uploading image {}: {}", file_path, e) + except Exception: + self.logger.exception("Error uploading image {}", file_path) return None def _upload_file_sync(self, file_path: str) -> str | None: @@ -951,15 +953,15 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.file.create(request) if response.success(): file_key = response.data.file_key - logger.debug("Uploaded file {}: {}", file_name, file_key) + self.logger.debug("Uploaded file {}: {}", file_name, file_key) return file_key else: - logger.error( + self.logger.error( "Failed to upload file: code={}, msg={}", response.code, response.msg ) return None - except Exception as e: - logger.error("Error uploading file {}: {}", file_path, e) + except Exception: + self.logger.exception("Error uploading file {}", file_path) return None def _download_image_sync( @@ -984,12 +986,12 @@ class FeishuChannel(BaseChannel): file_data = file_data.read() return file_data, response.file_name else: - logger.error( + self.logger.error( "Failed to download image: code={}, msg={}", response.code, response.msg ) return None, None - except Exception as e: - logger.error("Error downloading image {}: {}", image_key, e) + except Exception: + self.logger.exception("Error downloading image {}", image_key) return None, None def _download_file_sync( @@ -1018,7 +1020,7 @@ class FeishuChannel(BaseChannel): file_data = file_data.read() return file_data, response.file_name else: - logger.error( + self.logger.error( "Failed to download {}: code={}, msg={}", resource_type, response.code, @@ -1026,7 +1028,7 @@ class FeishuChannel(BaseChannel): ) return None, None except Exception: - logger.exception("Error downloading {} {}", resource_type, file_key) + self.logger.exception("Error downloading {} {}", resource_type, file_key) return None, None async def _download_and_save_media( @@ -1055,10 +1057,10 @@ class FeishuChannel(BaseChannel): elif msg_type in ("audio", "file", "media"): file_key = content_json.get("file_key") if not file_key: - logger.warning("Feishu {} message missing file_key: {}", msg_type, content_json) + self.logger.warning("{} message missing file_key: {}", msg_type, content_json) return None, f"[{msg_type}: missing file_key]" if not message_id: - logger.warning("Feishu {} message missing message_id", msg_type) + self.logger.warning("{} message missing message_id", msg_type) return None, f"[{msg_type}: missing message_id]" data, filename = await loop.run_in_executor( @@ -1066,7 +1068,7 @@ class FeishuChannel(BaseChannel): ) if not data: - logger.warning("Feishu {} download failed: file_key={}", msg_type, file_key) + self.logger.warning("{} download failed: file_key={}", msg_type, file_key) return None, f"[{msg_type}: download failed]" if not filename: @@ -1082,7 +1084,7 @@ class FeishuChannel(BaseChannel): file_path = media_dir / filename file_path.write_bytes(data) path_str = str(file_path) - logger.debug("Downloaded {} to {}", msg_type, path_str) + self.logger.debug("Downloaded {} to {}", msg_type, path_str) return path_str, f"[{msg_type}: {path_str}]" return None, f"[{msg_type}: download failed]" @@ -1100,8 +1102,8 @@ class FeishuChannel(BaseChannel): request = GetMessageRequest.builder().message_id(message_id).build() response = self._client.im.v1.message.get(request) if not response.success(): - logger.debug( - "Feishu: could not fetch parent message {}: code={}, msg={}", + self.logger.debug( + "could not fetch parent message {}: code={}, msg={}", message_id, response.code, response.msg, @@ -1133,7 +1135,7 @@ class FeishuChannel(BaseChannel): text = text[: self._REPLY_CONTEXT_MAX_LEN] + "..." return f"[Reply to: {text}]" except Exception as e: - logger.debug("Feishu: error fetching parent message {}: {}", message_id, e) + self.logger.debug("error fetching parent message {}: {}", message_id, e) return None def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool: @@ -1157,18 +1159,18 @@ class FeishuChannel(BaseChannel): ) response = self._client.im.v1.message.reply(request) if not response.success(): - logger.error( - "Failed to reply to Feishu message {}: code={}, msg={}, log_id={}", + self.logger.error( + "Failed to reply to message {}: code={}, msg={}, log_id={}", parent_message_id, response.code, response.msg, response.get_log_id(), ) return False - logger.debug("Feishu reply sent to message {}", parent_message_id) + self.logger.debug("reply sent to message {}", parent_message_id) return True - except Exception as e: - logger.error("Error replying to Feishu message {}: {}", parent_message_id, e) + except Exception: + self.logger.exception("Error replying to message {}", parent_message_id) return False def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool: @@ -1207,8 +1209,8 @@ class FeishuChannel(BaseChannel): ) response = self._client.im.v1.message.create(request) if not response.success(): - logger.error( - "Failed to send Feishu {} message: code={}, msg={}, log_id={}", + self.logger.error( + "Failed to send {} message: code={}, msg={}, log_id={}", msg_type, response.code, response.msg, @@ -1216,10 +1218,10 @@ class FeishuChannel(BaseChannel): ) return None msg_id = getattr(response.data, "message_id", None) - logger.debug("Feishu {} message sent to {}: {}", msg_type, receive_id, msg_id) + self.logger.debug("{} message sent to {}: {}", msg_type, receive_id, msg_id) return msg_id - except Exception as e: - logger.error("Error sending Feishu {} message: {}", msg_type, e) + except Exception: + self.logger.exception("Error sending {} message", msg_type) return None def _create_streaming_card_sync( @@ -1259,7 +1261,7 @@ class FeishuChannel(BaseChannel): ) response = self._client.cardkit.v1.card.create(request) if not response.success(): - logger.warning( + self.logger.warning( "Failed to create streaming card: code={}, msg={}", response.code, response.msg ) return None @@ -1279,12 +1281,12 @@ class FeishuChannel(BaseChannel): ) is not None if sent: return card_id - logger.warning( + self.logger.warning( "Created streaming card {} but failed to send it to {}", card_id, chat_id ) return None except Exception as e: - logger.warning("Error creating streaming card: {}", e) + self.logger.warning("Error creating streaming card: {}", e) return None def _stream_update_text_sync(self, card_id: str, content: str, sequence: int) -> bool: @@ -1309,7 +1311,7 @@ class FeishuChannel(BaseChannel): ) response = self._client.cardkit.v1.card_element.content(request) if not response.success(): - logger.warning( + self.logger.warning( "Failed to stream-update card {}: code={}, msg={}", card_id, response.code, @@ -1318,7 +1320,7 @@ class FeishuChannel(BaseChannel): return False return True except Exception as e: - logger.warning("Error stream-updating card {}: {}", card_id, e) + self.logger.warning("Error stream-updating card {}: {}", card_id, e) return False def _close_streaming_mode_sync(self, card_id: str, sequence: int) -> bool: @@ -1346,7 +1348,7 @@ class FeishuChannel(BaseChannel): ) response = self._client.cardkit.v1.card.settings(request) if not response.success(): - logger.warning( + self.logger.warning( "Failed to close streaming on card {}: code={}, msg={}", card_id, response.code, @@ -1355,7 +1357,7 @@ class FeishuChannel(BaseChannel): return False return True except Exception as e: - logger.warning("Error closing streaming on card {}: {}", card_id, e) + self.logger.warning("Error closing streaming on card {}: {}", card_id, e) return False async def send_delta( @@ -1416,7 +1418,7 @@ class FeishuChannel(BaseChannel): buf.sequence, ) return - logger.warning( + self.logger.warning( "Streaming card {} final update failed, falling back to regular card", buf.card_id, ) @@ -1484,7 +1486,7 @@ class FeishuChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send a message through Feishu, including media (images/files) if present.""" if not self._client: - logger.warning("Feishu client not initialized") + self.logger.warning("client not initialized") return try: @@ -1566,7 +1568,7 @@ class FeishuChannel(BaseChannel): for file_path in msg.media: if not os.path.isfile(file_path): - logger.warning("Media file not found: {}", file_path) + self.logger.warning("Media file not found: {}", file_path) continue ext = os.path.splitext(file_path)[1].lower() if ext in self._IMAGE_EXTS: @@ -1622,8 +1624,8 @@ class FeishuChannel(BaseChannel): json.dumps(card, ensure_ascii=False), ) - except Exception as e: - logger.error("Error sending Feishu message: {}", e) + except Exception: + self.logger.exception("Error sending message") raise def _on_message_sync(self, data: Any) -> None: @@ -1641,8 +1643,8 @@ class FeishuChannel(BaseChannel): message = event.message sender = event.sender - logger.debug("Feishu raw message: {}", message.content) - logger.debug("Feishu mentions: {}", getattr(message, "mentions", None)) + self.logger.debug("raw message: {}", message.content) + self.logger.debug("mentions: {}", getattr(message, "mentions", None)) message_id = message.message_id @@ -1659,7 +1661,7 @@ class FeishuChannel(BaseChannel): return if chat_type == "group" and not self._is_group_message_for_bot(message): - logger.debug("Feishu: skipping group message (not mentioned)") + self.logger.debug("skipping group message (not mentioned)") return # Deduplication check @@ -1784,8 +1786,8 @@ class FeishuChannel(BaseChannel): session_key=session_key, ) - except Exception as e: - logger.error("Error processing Feishu message: {}", e) + except Exception: + self.logger.exception("Error processing message") def _on_reaction_created(self, data: Any) -> None: """Ignore reaction events so they do not generate SDK noise.""" @@ -1801,7 +1803,7 @@ class FeishuChannel(BaseChannel): def _on_bot_p2p_chat_entered(self, data: Any) -> None: """Ignore p2p-enter events when a user opens a bot chat.""" - logger.debug("Bot entered p2p chat (user opened chat window)") + self.logger.debug("Bot entered p2p chat (user opened chat window)") pass @staticmethod diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 95806008a..783aac966 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -174,8 +174,8 @@ class ChannelManager: """Start a channel and log any exceptions.""" try: await channel.start() - except Exception as e: - logger.error("Failed to start channel {}: {}", name, e) + except Exception: + logger.exception("Failed to start channel {}", name) async def start_all(self) -> None: """Start all channels and the outbound dispatcher.""" @@ -230,8 +230,8 @@ class ChannelManager: try: await channel.stop() logger.info("Stopped {} channel", name) - except Exception as e: - logger.error("Error stopping {}: {}", name, e) + except Exception: + logger.exception("Error stopping {}", name) @staticmethod def _fingerprint_content(content: str) -> str: @@ -392,9 +392,9 @@ class ChannelManager: raise # Propagate cancellation for graceful shutdown except Exception as e: if attempt == max_attempts - 1: - logger.error( - "Failed to send to {} after {} attempts: {} - {}", - msg.channel, max_attempts, type(e).__name__, e + logger.exception( + "Failed to send to {} after {} attempts", + msg.channel, max_attempts ) return delay = _SEND_RETRY_DELAYS[min(attempt, len(_SEND_RETRY_DELAYS) - 1)] diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 0d1989b03..6919be874 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -2,7 +2,6 @@ import asyncio import json -import logging import mimetypes import time from contextlib import suppress @@ -10,7 +9,6 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Literal, TypeAlias -from loguru import logger from pydantic import Field try: @@ -47,6 +45,7 @@ from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_data_dir, get_media_dir from nanobot.config.schema import Base from nanobot.utils.helpers import safe_filename +from nanobot.utils.logging_bridge import redirect_lib_logging TYPING_NOTICE_TIMEOUT_MS = 30_000 # Must stay below TYPING_NOTICE_TIMEOUT_MS so the indicator doesn't expire mid-processing. @@ -178,28 +177,6 @@ def _build_matrix_text_content( return content -class _NioLoguruHandler(logging.Handler): - """Route matrix-nio stdlib logs into Loguru.""" - - def emit(self, record: logging.LogRecord) -> None: - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - frame, depth = logging.currentframe(), 2 - while frame and frame.f_code.co_filename == logging.__file__: - frame, depth = frame.f_back, depth + 1 - logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) - - -def _configure_nio_logging_bridge() -> None: - """Bridge matrix-nio logs to Loguru (idempotent).""" - nio_logger = logging.getLogger("nio") - if not any(isinstance(h, _NioLoguruHandler) for h in nio_logger.handlers): - nio_logger.handlers = [_NioLoguruHandler()] - nio_logger.propagate = False - - class MatrixConfig(Base): """Matrix (Element) channel configuration.""" @@ -259,7 +236,7 @@ class MatrixChannel(BaseChannel): """Start Matrix client and begin sync loop.""" self._running = True self._started_at_ms = int(time.time() * 1000) - _configure_nio_logging_bridge() + redirect_lib_logging("nio", level="WARNING") self.store_path = get_data_dir() / "matrix-store" self.store_path.mkdir(parents=True, exist_ok=True) @@ -283,15 +260,15 @@ class MatrixChannel(BaseChannel): self._register_response_callbacks() if not self.config.e2ee_enabled: - logger.warning("Matrix E2EE disabled; encrypted rooms may be undecryptable.") + self.logger.warning("E2EE disabled; encrypted rooms may be undecryptable.") if self.config.password: if self.config.access_token or self.config.device_id: - logger.warning("Password-based Matrix login active; access_token and device_id fields will be ignored.") + self.logger.warning("Password-based login active; access_token and device_id fields will be ignored.") create_new_session = True if self.session_path.exists(): - logger.info("Found session.json at {}; attempting to use existing session...", self.session_path) + self.logger.info("Found session.json at {}; attempting to use existing session...", self.session_path) try: with open(self.session_path, "r", encoding="utf-8") as f: session = json.load(f) @@ -299,20 +276,20 @@ class MatrixChannel(BaseChannel): self.client.access_token = session["access_token"] self.client.device_id = session["device_id"] self.client.load_store() - logger.info("Successfully loaded from existing session") + self.logger.info("Successfully loaded from existing session") create_new_session = False except Exception as e: - logger.warning("Failed to load from existing session: {}", e) - logger.info("Falling back to password login...") + self.logger.warning("Failed to load from existing session: {}", e) + self.logger.info("Falling back to password login...") if create_new_session: - logger.info("Using password login...") + self.logger.info("Using password login...") resp = await self.client.login(self.config.password) if isinstance(resp, LoginResponse): - logger.info("Logged in using a password; saving details to disk") + self.logger.info("Logged in using a password; saving details to disk") self._write_session_to_disk(resp) else: - logger.error("Failed to log in: {}", resp) + self.logger.error("Failed to log in: {}", resp) return elif self.config.access_token and self.config.device_id: @@ -321,12 +298,12 @@ class MatrixChannel(BaseChannel): self.client.access_token = self.config.access_token self.client.device_id = self.config.device_id self.client.load_store() - logger.info("Successfully loaded from existing session") + self.logger.info("Successfully loaded from existing session") except Exception as e: - logger.warning("Failed to load from existing session: {}", e) + self.logger.warning("Failed to load from existing session: {}", e) else: - logger.warning("Unable to load a Matrix session due to missing password, access_token, or device_id; encryption may not work") + self.logger.warning("Unable to load a session due to missing password, access_token, or device_id; encryption may not work") return self._sync_task = asyncio.create_task(self._sync_loop()) @@ -358,9 +335,9 @@ class MatrixChannel(BaseChannel): try: with open(self.session_path, "w", encoding="utf-8") as f: json.dump(session, f, indent=2) - logger.info("Session saved to {}", self.session_path) + self.logger.info("Session saved to {}", self.session_path) except Exception as e: - logger.warning("Failed to save session: {}", e) + self.logger.warning("Failed to save session: {}", e) def _is_workspace_path_allowed(self, path: Path) -> bool: """Check path is inside workspace (when restriction enabled).""" @@ -598,14 +575,14 @@ class MatrixChannel(BaseChannel): def _log_response_error(self, label: str, response: Any) -> None: """Log Matrix response errors — auth errors at ERROR level, rest at WARNING.""" is_fatal = self._is_fatal_auth_response(response) - (logger.error if is_fatal else logger.warning)("Matrix {} failed: {}", label, response) + (self.logger.error if is_fatal else self.logger.warning)("{} failed: {}", label, response) async def _on_sync_error(self, response: SyncError) -> None: self._log_response_error("sync", response) if self._is_fatal_auth_response(response): # Auth errors won't recover by retry; stop the sync loop instead of # spamming the homeserver every 2s (#1851). - logger.error("Matrix authentication failed irrecoverably; stopping sync loop") + self.logger.error("Authentication failed irrecoverably; stopping sync loop") self._running = False if self.client: with suppress(Exception): @@ -625,7 +602,7 @@ class MatrixChannel(BaseChannel): response = await self.client.room_typing(room_id=room_id, typing_state=typing, timeout=TYPING_NOTICE_TIMEOUT_MS) if isinstance(response, RoomTypingError): - logger.debug("Matrix typing failed for {}: {}", room_id, response) + self.logger.debug("typing failed for {}: {}", room_id, response) async def _start_typing_keepalive(self, room_id: str) -> None: """Start periodic typing refresh (spec-recommended keepalive).""" @@ -796,7 +773,7 @@ class MatrixChannel(BaseChannel): return None response = await self.client.download(mxc=mxc_url) if isinstance(response, DownloadError): - logger.warning("Matrix download failed for {}: {}", mxc_url, response) + self.logger.warning("download failed for {}: {}", mxc_url, response) return None body = getattr(response, "body", None) if isinstance(body, (bytes, bytearray)): @@ -821,7 +798,7 @@ class MatrixChannel(BaseChannel): try: return decrypt_attachment(ciphertext, key, sha256, iv) except (EncryptionError, ValueError, TypeError): - logger.warning("Matrix decrypt failed for event {}", getattr(event, "event_id", "")) + self.logger.warning("decrypt failed for event {}", getattr(event, "event_id", "")) return None async def _fetch_media_attachment( diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py index 110b454cc..dfe225640 100644 --- a/nanobot/channels/mochat.py +++ b/nanobot/channels/mochat.py @@ -11,7 +11,6 @@ from datetime import datetime from typing import Any import httpx -from loguru import logger from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus @@ -303,7 +302,7 @@ class MochatChannel(BaseChannel): async def start(self) -> None: """Start Mochat channel workers and websocket connection.""" if not self.config.claw_token: - logger.error("Mochat claw_token not configured") + self.logger.error("claw_token not configured") return self._running = True @@ -348,7 +347,7 @@ class MochatChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send outbound message to session or panel.""" if not self.config.claw_token: - logger.warning("Mochat claw_token missing, skip send") + self.logger.warning("claw_token missing, skip send") return parts = ([msg.content.strip()] if msg.content and msg.content.strip() else []) @@ -360,7 +359,7 @@ class MochatChannel(BaseChannel): target = resolve_mochat_target(msg.chat_id) if not target.id: - logger.warning("Mochat outbound target is empty") + self.logger.warning("outbound target is empty") return is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_") @@ -371,8 +370,8 @@ class MochatChannel(BaseChannel): else: await self._api_send("/api/claw/sessions/send", "sessionId", target.id, content, msg.reply_to) - except Exception as e: - logger.error("Failed to send Mochat message: {}", e) + except Exception: + self.logger.exception("Failed to send message") raise # ---- config / init helpers --------------------------------------------- @@ -395,7 +394,7 @@ class MochatChannel(BaseChannel): async def _start_socket_client(self) -> bool: if not SOCKETIO_AVAILABLE: - logger.warning("python-socketio not installed, Mochat using polling fallback") + self.logger.warning("python-socketio not installed, using polling fallback") return False serializer = "default" @@ -403,7 +402,7 @@ class MochatChannel(BaseChannel): if MSGPACK_AVAILABLE: serializer = "msgpack" else: - logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON") + self.logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON") client = socketio.AsyncClient( reconnection=True, @@ -416,7 +415,7 @@ class MochatChannel(BaseChannel): @client.event async def connect() -> None: self._ws_connected, self._ws_ready = True, False - logger.info("Mochat websocket connected") + self.logger.info("websocket connected") subscribed = await self._subscribe_all() self._ws_ready = subscribed await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers()) @@ -426,12 +425,12 @@ class MochatChannel(BaseChannel): if not self._running: return self._ws_connected = self._ws_ready = False - logger.warning("Mochat websocket disconnected") + self.logger.warning("websocket disconnected") await self._ensure_fallback_workers() @client.event async def connect_error(data: Any) -> None: - logger.error("Mochat websocket connect error: {}", data) + self.logger.error("websocket connect error: {}", data) @client.on("claw.session.events") async def on_session_events(payload: dict[str, Any]) -> None: @@ -457,8 +456,8 @@ class MochatChannel(BaseChannel): wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0), ) return True - except Exception as e: - logger.error("Failed to connect Mochat websocket: {}", e) + except Exception: + self.logger.exception("Failed to connect websocket") with suppress(Exception): await client.disconnect() self._socket = None @@ -493,7 +492,7 @@ class MochatChannel(BaseChannel): "limit": self.config.watch_limit, }) if not ack.get("result"): - logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error')) + self.logger.error("subscribeSessions failed: {}", ack.get('message', 'unknown error')) return False data = ack.get("data") @@ -515,7 +514,7 @@ class MochatChannel(BaseChannel): return True ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids}) if not ack.get("result"): - logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error')) + self.logger.error("subscribePanels failed: {}", ack.get('message', 'unknown error')) return False return True @@ -537,7 +536,7 @@ class MochatChannel(BaseChannel): try: await self._refresh_targets(subscribe_new=self._ws_ready) except Exception as e: - logger.warning("Mochat refresh failed: {}", e) + self.logger.warning("refresh failed: {}", e) if self._fallback_mode: await self._ensure_fallback_workers() @@ -551,7 +550,7 @@ class MochatChannel(BaseChannel): try: response = await self._post_json("/api/claw/sessions/list", {}) except Exception as e: - logger.warning("Mochat listSessions failed: {}", e) + self.logger.warning("listSessions failed: {}", e) return sessions = response.get("sessions") @@ -585,7 +584,7 @@ class MochatChannel(BaseChannel): try: response = await self._post_json("/api/claw/groups/get", {}) except Exception as e: - logger.warning("Mochat getWorkspaceGroup failed: {}", e) + self.logger.warning("getWorkspaceGroup failed: {}", e) return raw_panels = response.get("panels") @@ -647,7 +646,7 @@ class MochatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning("Mochat watch fallback error ({}): {}", session_id, e) + self.logger.warning("watch fallback error ({}): {}", session_id, e) await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0)) async def _panel_poll_worker(self, panel_id: str) -> None: @@ -674,7 +673,7 @@ class MochatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning("Mochat panel polling error ({}): {}", panel_id, e) + self.logger.warning("panel polling error ({}): {}", panel_id, e) await asyncio.sleep(sleep_s) # ---- inbound event processing ------------------------------------------ @@ -885,7 +884,7 @@ class MochatChannel(BaseChannel): try: data = json.loads(self._cursor_path.read_text("utf-8")) except Exception as e: - logger.warning("Failed to read Mochat cursor file: {}", e) + self.logger.warning("Failed to read cursor file: {}", e) return cursors = data.get("cursors") if isinstance(data, dict) else None if isinstance(cursors, dict): @@ -901,7 +900,7 @@ class MochatChannel(BaseChannel): "cursors": self._session_cursor, }, ensure_ascii=False, indent=2) + "\n", "utf-8") except Exception as e: - logger.warning("Failed to save Mochat cursor file: {}", e) + self.logger.warning("Failed to save cursor file: {}", e) # ---- HTTP helpers ------------------------------------------------------ diff --git a/nanobot/channels/msteams.py b/nanobot/channels/msteams.py index f30b1af61..cdb0ae904 100644 --- a/nanobot/channels/msteams.py +++ b/nanobot/channels/msteams.py @@ -32,7 +32,6 @@ except ImportError: # pragma: no cover fcntl = None import httpx -from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -134,16 +133,16 @@ class MSTeamsChannel(BaseChannel): async def start(self) -> None: """Start the Teams webhook listener.""" if not MSTEAMS_AVAILABLE: - logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]") + self.logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]") return if not self.config.app_id or not self.config.app_password: - logger.error("MSTeams app_id/app_password not configured") + self.logger.error("app_id/app_password not configured") return if not self.config.validate_inbound_auth: - logger.warning( - "MSTeams inbound auth validation was explicitly DISABLED in config. " + self.logger.warning( + "Inbound auth validation was explicitly DISABLED in config. " "Anyone who knows the webhook URL can send messages as any user. " "Only disable this for local development or controlled testing." ) @@ -166,7 +165,7 @@ class MSTeamsChannel(BaseChannel): raw = self.rfile.read(length) if length > 0 else b"{}" payload = json.loads(raw.decode("utf-8")) except Exception as e: - logger.warning("MSTeams invalid request body: {}", e) + channel.logger.warning("Invalid request body: {}", e) self.send_response(400) self.end_headers() return @@ -180,7 +179,7 @@ class MSTeamsChannel(BaseChannel): ) fut.result(timeout=15) except Exception as e: - logger.warning("MSTeams inbound auth validation failed: {}", e) + channel.logger.warning("Inbound auth validation failed: {}", e) self.send_response(401) self.send_header("Content-Type", "application/json") self.end_headers() @@ -193,7 +192,7 @@ class MSTeamsChannel(BaseChannel): ) fut.result(timeout=15) except Exception as e: - logger.warning("MSTeams activity handling failed: {}", e) + channel.logger.warning("Activity handling failed: {}", e) self.send_response(200) self.send_header("Content-Type", "application/json") @@ -211,8 +210,8 @@ class MSTeamsChannel(BaseChannel): ) self._server_thread.start() - logger.info( - "MSTeams webhook listening on http://{}:{}{}", + self.logger.info( + "Webhook listening on http://{}:{}{}", self.config.host, self.config.port, self.config.path, @@ -261,10 +260,10 @@ class MSTeamsChannel(BaseChannel): try: resp = await self._http.post(base_url, headers=headers, json=payload) resp.raise_for_status() - logger.info("MSTeams message sent to {}", ref.conversation_id) + self.logger.info("Message sent to {}", ref.conversation_id) self._touch_conversation_ref(str(msg.chat_id), persist=True) - except Exception as e: - logger.error("MSTeams send failed: {}", e) + except Exception: + self.logger.exception("Send failed") raise async def _handle_activity(self, activity: dict[str, Any]) -> None: @@ -291,18 +290,18 @@ class MSTeamsChannel(BaseChannel): # DM-only MVP: ignore group/channel traffic for now if conversation_type and conversation_type not in ("personal", ""): - logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type) + self.logger.debug("Ignoring non-DM conversation {}", conversation_type) return text = self._sanitize_inbound_text(activity) if not text: text = self.config.mention_only_response.strip() if not text: - logger.debug("MSTeams ignoring empty message after Teams text sanitization") + self.logger.debug("Ignoring empty message after Teams text sanitization") return if not self.is_allowed(sender_id): - logger.warning( + self.logger.warning( "Access denied for sender {} on channel {}. " "Add them to allowFrom list in config to grant access.", sender_id, self.name, @@ -554,7 +553,7 @@ class MSTeamsChannel(BaseChannel): if isinstance(loaded, dict): main_data = loaded except Exception as e: - logger.warning("Failed to load MSTeams conversation refs: {}", e) + self.logger.warning("Failed to load conversation refs: {}", e) if meta_exists: try: @@ -562,7 +561,7 @@ class MSTeamsChannel(BaseChannel): if isinstance(loaded_meta, dict): meta_data = loaded_meta except Exception as e: - logger.warning("Failed to load MSTeams conversation refs metadata: {}", e) + self.logger.warning("Failed to load conversation refs metadata: {}", e) return main_data, meta_data, meta_exists @@ -660,8 +659,8 @@ class MSTeamsChannel(BaseChannel): for key in keys_to_drop: self._conversation_refs.pop(key, None) - logger.info( - "MSTeams pruned {} stale/unsupported conversation refs (ttl={} days)", + self.logger.info( + "Pruned {} stale/unsupported conversation refs (ttl={} days)", len(keys_to_drop), ttl_days, ) @@ -742,7 +741,7 @@ class MSTeamsChannel(BaseChannel): self._write_json_atomically(self._refs_path, refs_data) self._write_json_atomically(self._refs_meta_path, refs_meta) except Exception as e: - logger.warning("Failed to save MSTeams conversation refs: {}", e) + self.logger.warning("Failed to save conversation refs: {}", e) def _save_refs(self, *, prune: bool = True) -> None: """Persist conversation references.""" diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index ef70cc943..4ef63238c 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -38,7 +38,7 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Base -from nanobot.security.network import validate_url_target +from nanobot.utils.logging_bridge import redirect_lib_logging try: from nanobot.config.paths import get_media_dir @@ -187,24 +187,25 @@ class QQChannel(BaseChannel): root = Path.home() / ".nanobot" / "media" / "qq" root.mkdir(parents=True, exist_ok=True) - logger.info("QQ media directory: {}", str(root)) + self.logger.info("media directory: {}", str(root)) return root async def start(self) -> None: """Start the QQ bot with auto-reconnect loop.""" + redirect_lib_logging("botpy", level="WARNING") if not QQ_AVAILABLE: - logger.error("QQ SDK not installed. Run: pip install qq-botpy") + self.logger.error("SDK not installed. Run: pip install qq-botpy") return if not self.config.app_id or not self.config.secret: - logger.error("QQ app_id and secret not configured") + self.logger.error("app_id and secret not configured") return self._running = True self._http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=120)) self._client = _make_bot_class(self)() - logger.info("QQ bot started (C2C & Group supported)") + self.logger.info("bot started (C2C & Group supported)") await self._run_bot() async def _run_bot(self) -> None: @@ -213,9 +214,9 @@ class QQChannel(BaseChannel): try: await self._client.start(appid=self.config.app_id, secret=self.config.secret) except Exception as e: - logger.warning("QQ bot error: {}", e) + self.logger.warning("bot error: {}", e) if self._running: - logger.info("Reconnecting QQ bot in 5 seconds...") + self.logger.info("Reconnecting bot in 5 seconds...") await asyncio.sleep(5) async def stop(self) -> None: @@ -231,7 +232,7 @@ class QQChannel(BaseChannel): await self._http.close() self._http = None - logger.info("QQ bot stopped") + self.logger.info("bot stopped") # --------------------------- # Outbound (send) @@ -241,7 +242,7 @@ class QQChannel(BaseChannel): """Send attachments first, then text.""" try: if not self._client: - logger.warning("QQ client not initialized") + self.logger.warning("client not initialized") return msg_id = msg.metadata.get("message_id") @@ -281,7 +282,7 @@ class QQChannel(BaseChannel): # Network / transport errors — propagate so ChannelManager can retry raise except Exception: - logger.exception("Error sending QQ message to chat_id={}", msg.chat_id) + self.logger.exception("Error sending message to chat_id={}", msg.chat_id) async def _send_text_only( self, @@ -339,7 +340,7 @@ class QQChannel(BaseChannel): srv_send_msg=False, ) if not media_obj: - logger.error("QQ media upload failed: empty response") + self.logger.error("media upload failed: empty response") return False self._msg_seq += 1 @@ -360,15 +361,15 @@ class QQChannel(BaseChannel): media=media_obj, ) - logger.info("QQ media sent: {}", filename) + self.logger.info("media sent: {}", filename) return True except (aiohttp.ClientError, OSError) as e: # Network / transport errors — propagate for retry by caller - logger.warning("QQ send media network error filename={} err={}", filename, e) + self.logger.warning("send media network error filename={} err={}", filename, e) raise - except Exception as e: + except Exception: # API-level or other non-network errors — return False so send() can fallback - logger.error("QQ send media failed filename={} err={}", filename, e) + self.logger.exception("send media failed filename={}", filename) return False async def _read_media_bytes(self, media_ref: str) -> tuple[bytes | None, str | None]: @@ -389,19 +390,19 @@ class QQChannel(BaseChannel): local_path = Path(os.path.expanduser(media_ref)) if not local_path.is_file(): - logger.warning("QQ outbound media file not found: {}", str(local_path)) + self.logger.warning("outbound media file not found: {}", str(local_path)) return None, None data = await asyncio.to_thread(local_path.read_bytes) return data, local_path.name except Exception as e: - logger.warning("QQ outbound media read error ref={} err={}", media_ref, e) + self.logger.warning("outbound media read error ref={} err={}", media_ref, e) return None, None # Remote URL ok, err = validate_url_target(media_ref) if not ok: - logger.warning("QQ outbound media URL validation failed url={} err={}", media_ref, err) + self.logger.warning("outbound media URL validation failed url={} err={}", media_ref, err) return None, None if not self._http: @@ -409,8 +410,8 @@ class QQChannel(BaseChannel): try: async with self._http.get(media_ref, allow_redirects=True) as resp: if resp.status >= 400: - logger.warning( - "QQ outbound media download failed status={} url={}", + self.logger.warning( + "outbound media download failed status={} url={}", resp.status, media_ref, ) @@ -421,7 +422,7 @@ class QQChannel(BaseChannel): filename = os.path.basename(urlparse(media_ref).path) or "file.bin" return data, filename except Exception as e: - logger.warning("QQ outbound media download error url={} err={}", media_ref, e) + self.logger.warning("outbound media download error url={} err={}", media_ref, e) return None, None # https://github.com/tencent-connect/botpy/issues/198 @@ -525,7 +526,7 @@ class QQChannel(BaseChannel): content=self.config.ack_message, ) except Exception: - logger.debug("QQ ack message failed for chat_id={}", chat_id) + self.logger.debug("ack message failed for chat_id={}", chat_id) await self._handle_message( sender_id=user_id, @@ -538,7 +539,7 @@ class QQChannel(BaseChannel): }, ) except Exception: - logger.exception("Error handling QQ inbound message id={}", getattr(data, "id", "?")) + self.logger.exception("Error handling inbound message id={}", getattr(data, "id", "?")) async def _handle_attachments( self, @@ -557,7 +558,7 @@ class QQChannel(BaseChannel): filename = getattr(att, "filename", None) or "" ctype = getattr(att, "content_type", None) or "" - logger.info("Downloading file from QQ: {}", filename or url) + self.logger.info("Downloading file: {}", filename or url) local_path = await self._download_to_media_dir_chunked(url, filename_hint=filename) att_meta.append( @@ -608,7 +609,7 @@ class QQChannel(BaseChannel): allow_redirects=True, ) as resp: if resp.status != 200: - logger.warning("QQ download failed: status={} url={}", resp.status, url) + self.logger.warning("download failed: status={} url={}", resp.status, url) return None ctype = (resp.headers.get("Content-Type") or "").lower() @@ -662,8 +663,8 @@ class QQChannel(BaseChannel): continue downloaded += len(chunk) if downloaded > max_bytes: - logger.warning( - "QQ download exceeded max_bytes={} url={} -> abort", + self.logger.warning( + "download exceeded max_bytes={} url={} -> abort", max_bytes, url, ) @@ -675,11 +676,11 @@ class QQChannel(BaseChannel): # Atomic rename await asyncio.to_thread(os.replace, tmp_path, target) tmp_path = None # mark as moved - logger.info("QQ file saved: {}", str(target)) + self.logger.info("file saved: {}", str(target)) return str(target) - except Exception as e: - logger.error("QQ download error: {}", e) + except Exception: + self.logger.exception("download error") return None finally: # Cleanup partial file diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 0bdeedc78..dc8899861 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import Any import httpx -from loguru import logger from pydantic import Field from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse @@ -84,10 +83,10 @@ class SlackChannel(BaseChannel): async def start(self) -> None: """Start the Slack Socket Mode client.""" if not self.config.bot_token or not self.config.app_token: - logger.error("Slack bot/app token not configured") + self.logger.error("bot/app token not configured") return if self.config.mode != "socket": - logger.error("Unsupported Slack mode: {}", self.config.mode) + self.logger.error("Unsupported mode: {}", self.config.mode) return self._running = True @@ -104,11 +103,11 @@ class SlackChannel(BaseChannel): try: auth = await self._web_client.auth_test() self._bot_user_id = auth.get("user_id") - logger.info("Slack bot connected as {}", self._bot_user_id) + self.logger.info("bot connected as {}", self._bot_user_id) except Exception as e: - logger.warning("Slack auth_test failed: {}", e) + self.logger.warning("auth_test failed: {}", e) - logger.info("Starting Slack Socket Mode client...") + self.logger.info("Starting Socket Mode client...") await self._socket_client.connect() while self._running: @@ -121,13 +120,13 @@ class SlackChannel(BaseChannel): try: await self._socket_client.close() except Exception as e: - logger.warning("Slack socket close failed: {}", e) + self.logger.warning("socket close failed: {}", e) self._socket_client = None async def send(self, msg: OutboundMessage) -> None: """Send a message through Slack.""" if not self._web_client: - logger.warning("Slack client not running") + self.logger.warning("client not running") return try: target_chat_id = await self._resolve_target_chat_id(msg.chat_id) @@ -162,16 +161,16 @@ class SlackChannel(BaseChannel): file=media_path, thread_ts=thread_ts_param, ) - except Exception as e: - logger.error("Failed to upload file {}: {}", media_path, e) + except Exception: + self.logger.exception("Failed to upload file {}", media_path) # Update reaction emoji when the final (non-progress) response is sent if not (msg.metadata or {}).get("_progress"): event = slack_meta.get("event", {}) await self._update_react_emoji(origin_chat_id, event.get("ts")) - except Exception as e: - logger.error("Error sending Slack message: {}", e) + except Exception: + self.logger.exception("Error sending message") raise async def _resolve_target_chat_id(self, target: str) -> str: @@ -328,8 +327,8 @@ class SlackChannel(BaseChannel): return # Debug: log basic event shape - logger.debug( - "Slack event: type={} subtype={} user={} channel={} channel_type={} text={}", + self.logger.debug( + "event: type={} subtype={} user={} channel={} channel_type={} text={}", event_type, subtype, sender_id, @@ -371,7 +370,7 @@ class SlackChannel(BaseChannel): timestamp=event.get("ts"), ) except Exception as e: - logger.debug("Slack reactions_add failed: {}", e) + self.logger.debug("reactions_add failed: {}", e) # Thread-scoped session key whenever the user is in a real thread # (raw_thread_ts is set). DM threads get their own session, separate @@ -420,7 +419,7 @@ class SlackChannel(BaseChannel): session_key=session_key, ) except Exception: - logger.exception("Error handling Slack message from {}", sender_id) + self.logger.exception("Error handling message from {}", sender_id) async def _download_slack_file(self, file_info: dict[str, Any]) -> tuple[str | None, str]: """Download a Slack private file to the local media directory.""" @@ -453,7 +452,7 @@ class SlackChannel(BaseChannel): path.write_bytes(response.content) return str(path), marker except Exception as e: - logger.warning("Failed to download Slack file {}: {}", file_id, e) + self.logger.warning("Failed to download file {}: {}", file_id, e) return None, self._download_failure_marker(marker_type, name, "download failed") @staticmethod @@ -500,7 +499,7 @@ class SlackChannel(BaseChannel): session_key=session_key, ) except Exception: - logger.exception("Error handling Slack button click from {}", sender_id) + self.logger.exception("Error handling button click from {}", sender_id) async def _with_thread_context( self, @@ -537,7 +536,7 @@ class SlackChannel(BaseChannel): limit=max(1, self.config.thread_context_limit), ) except Exception as e: - logger.warning("Slack thread context unavailable for {}: {}", key, e) + self.logger.warning("thread context unavailable for {}: {}", key, e) return text lines = self._format_thread_context( @@ -597,7 +596,7 @@ class SlackChannel(BaseChannel): timestamp=ts, ) except Exception as e: - logger.debug("Slack reactions_remove failed: {}", e) + self.logger.debug("reactions_remove failed: {}", e) if self.config.done_emoji: try: await self._web_client.reactions_add( @@ -606,7 +605,7 @@ class SlackChannel(BaseChannel): timestamp=ts, ) except Exception as e: - logger.debug("Slack done reaction failed: {}", e) + self.logger.debug("done reaction failed: {}", e) def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: if channel_type == "im": diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 492b3ef50..5c97cddf9 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -11,7 +11,6 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Literal -from loguru import logger from pydantic import Field from telegram import ( BotCommand, @@ -320,7 +319,7 @@ class TelegramChannel(BaseChannel): async def start(self) -> None: """Start the Telegram bot with long polling.""" if not self.config.token: - logger.error("Telegram bot token not configured") + self.logger.error("bot token not configured") return self._running = True @@ -382,11 +381,11 @@ class TelegramChannel(BaseChannel): if self.config.inline_keyboards: self._app.add_handler(CallbackQueryHandler(self._on_callback_query)) allowed_updates = ["message", "callback_query"] - logger.debug("Telegram inline keyboards enabled") + self.logger.debug("inline keyboards enabled") else: allowed_updates = ["message"] - logger.info("Starting Telegram bot (polling mode)...") + self.logger.info("Starting bot (polling mode)...") # Initialize and start polling await self._app.initialize() @@ -396,13 +395,13 @@ class TelegramChannel(BaseChannel): bot_info = await self._app.bot.get_me() self._bot_user_id = getattr(bot_info, "id", None) self._bot_username = getattr(bot_info, "username", None) - logger.info("Telegram bot @{} connected", bot_info.username) + self.logger.info("bot @{} connected", bot_info.username) try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) - logger.debug("Telegram bot commands registered") + self.logger.debug("bot commands registered") except Exception as e: - logger.warning("Failed to register bot commands: {}", e) + self.logger.warning("Failed to register bot commands: {}", e) # Start polling (this runs until stopped) await self._app.updater.start_polling( @@ -429,7 +428,7 @@ class TelegramChannel(BaseChannel): self._media_group_buffers.clear() if self._app: - logger.info("Stopping Telegram bot...") + self.logger.info("Stopping bot...") await self._app.updater.stop() await self._app.stop() await self._app.shutdown() @@ -456,7 +455,7 @@ class TelegramChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send a message through Telegram.""" if not self._app: - logger.warning("Telegram bot not running") + self.logger.warning("bot not running") return # Only stop typing indicator and remove reaction for final responses @@ -469,7 +468,7 @@ class TelegramChannel(BaseChannel): try: chat_id = int(msg.chat_id) except ValueError: - logger.error("Invalid chat_id: {}", msg.chat_id) + self.logger.exception("Invalid chat_id: {}", msg.chat_id) return reply_to_message_id = msg.metadata.get("message_id") message_thread_id = msg.metadata.get("message_thread_id") @@ -533,9 +532,9 @@ class TelegramChannel(BaseChannel): **extra, **send_kwargs, ) - except Exception as e: + except Exception: filename = media_path.rsplit("/", 1)[-1] - logger.error("Failed to send media {}: {}", media_path, e) + self.logger.exception("Failed to send media {}", media_path) await self._app.bot.send_message( chat_id=chat_id, text=f"[Failed to send: {filename}]", @@ -572,8 +571,8 @@ class TelegramChannel(BaseChannel): if attempt == _SEND_MAX_RETRIES: raise delay = _SEND_RETRY_BASE_DELAY * (2 ** (attempt - 1)) - logger.warning( - "Telegram timeout (attempt {}/{}), retrying in {:.1f}s", + self.logger.warning( + "timeout (attempt {}/{}), retrying in {:.1f}s", attempt, _SEND_MAX_RETRIES, delay, ) await asyncio.sleep(delay) @@ -581,8 +580,8 @@ class TelegramChannel(BaseChannel): if attempt == _SEND_MAX_RETRIES: raise delay = float(e.retry_after) - logger.warning( - "Telegram Flood Control (attempt {}/{}), retrying in {:.1f}s", + self.logger.warning( + "Flood Control (attempt {}/{}), retrying in {:.1f}s", attempt, _SEND_MAX_RETRIES, delay, ) await asyncio.sleep(delay) @@ -607,7 +606,7 @@ class TelegramChannel(BaseChannel): **(thread_kwargs or {}), ) except BadRequest as e: - logger.warning("HTML parse failed, falling back to plain text: {}", e) + self.logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._call_with_retry( self._app.bot.send_message, @@ -617,8 +616,8 @@ class TelegramChannel(BaseChannel): reply_markup=reply_markup, **(thread_kwargs or {}), ) - except Exception as e2: - logger.error("Error sending Telegram message: {}", e2) + except Exception: + self.logger.exception("Error sending message") raise @staticmethod @@ -666,10 +665,10 @@ class TelegramChannel(BaseChannel): # Network errors (TimedOut, NetworkError) should propagate immediately # to avoid doubling connection demand during pool exhaustion. if self._is_not_modified_error(e): - logger.debug("Final stream edit already applied for {}", chat_id) + self.logger.debug("Final stream edit already applied for {}", chat_id) self._stream_bufs.pop(chat_id, None) return - logger.debug("Final stream edit failed (HTML), trying plain: {}", e) + self.logger.debug("Final stream edit failed (HTML), trying plain: {}", e) # Fall back to raw markdown (not HTML) so users don't see raw tags. primary_plain = split_message(raw_text, TELEGRAM_MAX_MESSAGE_LEN)[0] if len(raw_text) > TELEGRAM_MAX_MESSAGE_LEN else raw_text try: @@ -680,9 +679,9 @@ class TelegramChannel(BaseChannel): ) except Exception as e2: if self._is_not_modified_error(e2): - logger.debug("Final stream plain edit already applied for {}", chat_id) + self.logger.debug("Final stream plain edit already applied for {}", chat_id) else: - logger.warning("Final stream edit failed: {}", e2) + self.logger.warning("Final stream edit failed: {}", e2) raise # Let ChannelManager handle retry for extra_html_chunk in extra_html_chunks: try: @@ -724,7 +723,7 @@ class TelegramChannel(BaseChannel): buf.message_id = sent.message_id buf.last_edit = now except Exception as e: - logger.warning("Stream initial send failed: {}", e) + self.logger.warning("Stream initial send failed: {}", e) raise # Let ChannelManager handle retry elif (now - buf.last_edit) >= self.config.stream_edit_interval: if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN: @@ -743,7 +742,7 @@ class TelegramChannel(BaseChannel): if self._is_not_modified_error(e): buf.last_edit = now return - logger.warning("Stream edit failed: {}", e) + self.logger.warning("Stream edit failed: {}", e) raise # Let ChannelManager handle retry async def _flush_stream_overflow( @@ -769,7 +768,7 @@ class TelegramChannel(BaseChannel): ) except Exception as e: if not self._is_not_modified_error(e): - logger.warning("Stream overflow edit failed: {}", e) + self.logger.warning("Stream overflow edit failed: {}", e) raise for chunk in chunks[1:-1]: await self._call_with_retry( @@ -903,12 +902,12 @@ class TelegramChannel(BaseChannel): if media_type in ("voice", "audio"): transcription = await self.transcribe_audio(file_path) if transcription: - logger.info("Transcribed {}: {}...", media_type, transcription[:50]) + self.logger.info("Transcribed {}: {}...", media_type, transcription[:50]) return [path_str], [f"[transcription: {transcription}]"] return [path_str], [f"[{media_type}: {path_str}]"] return [path_str], [f"[{media_type}: {path_str}]"] except Exception as e: - logger.warning("Failed to download message media: {}", e) + self.logger.warning("Failed to download message media: {}", e) if add_failure_content: return [], [f"[{media_type}: download failed]"] return [], [] @@ -1056,7 +1055,7 @@ class TelegramChannel(BaseChannel): media_paths.extend(current_media_paths) content_parts.extend(current_media_parts) if current_media_paths: - logger.debug("Downloaded message media to {}", current_media_paths[0]) + self.logger.debug("Downloaded message media to {}", current_media_paths[0]) # Reply context: text and/or media from the replied-to message reply = getattr(message, "reply_to_message", None) @@ -1065,13 +1064,13 @@ class TelegramChannel(BaseChannel): reply_media, reply_media_parts = await self._download_message_media(reply) if reply_media: media_paths = reply_media + media_paths - logger.debug("Attached replied-to media: {}", reply_media[0]) + self.logger.debug("Attached replied-to media: {}", reply_media[0]) tag = reply_ctx or (f"[Reply to: {reply_media_parts[0]}]" if reply_media_parts else None) if tag: content_parts.insert(0, tag) content = "\n".join(content_parts) if content_parts else "[empty message]" - logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) + self.logger.debug("message from {}: {}...", sender_id, content[:50]) str_chat_id = str(chat_id) metadata = self._build_message_metadata(message, user) @@ -1150,7 +1149,7 @@ class TelegramChannel(BaseChannel): reaction=[ReactionTypeEmoji(emoji=emoji)], ) except Exception as e: - logger.debug("Telegram reaction failed: {}", e) + self.logger.debug("reaction failed: {}", e) async def _remove_reaction(self, chat_id: str, message_id: int) -> None: """Remove emoji reaction from a message (best-effort, non-blocking).""" @@ -1163,7 +1162,7 @@ class TelegramChannel(BaseChannel): reaction=[], ) except Exception as e: - logger.debug("Telegram reaction removal failed: {}", e) + self.logger.debug("reaction removal failed: {}", e) async def _typing_loop(self, chat_id: str) -> None: """Repeatedly send 'typing' action until cancelled.""" @@ -1173,7 +1172,7 @@ class TelegramChannel(BaseChannel): await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing") await asyncio.sleep(4) except Exception as e: - logger.debug("Typing indicator stopped for {}: {}", chat_id, e) + self.logger.debug("Typing indicator stopped for {}: {}", chat_id, e) @staticmethod def _format_telegram_error(exc: Exception) -> str: @@ -1193,18 +1192,18 @@ class TelegramChannel(BaseChannel): """Keep long-polling network failures to a single readable line.""" summary = self._format_telegram_error(exc) if isinstance(exc, (NetworkError, TimedOut)): - logger.warning("Telegram polling network issue: {}", summary) + self.logger.warning("polling network issue: {}", summary) else: - logger.error("Telegram polling error: {}", summary) + self.logger.error("polling error: {}", summary) async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Log polling / handler errors instead of silently swallowing them.""" summary = self._format_telegram_error(context.error) if isinstance(context.error, (NetworkError, TimedOut)): - logger.warning("Telegram network issue: {}", summary) + self.logger.warning("network issue: {}", summary) else: - logger.error("Telegram error: {}", summary) + self.logger.error("error: {}", summary) def _get_extension( self, @@ -1265,7 +1264,7 @@ class TelegramChannel(BaseChannel): chat_id = query.message.chat_id if query.message else None sender_id = self._sender_id(user) if not chat_id: - logger.warning("Callback query without chat_id") + self.logger.warning("Callback query without chat_id") return if not self.is_allowed(sender_id): return @@ -1274,7 +1273,7 @@ class TelegramChannel(BaseChannel): if query.message: with suppress(Exception): await query.message.edit_reply_markup(reply_markup=None) - logger.debug("Inline button tap from {}: {}", sender_id, button_label) + self.logger.debug("Inline button tap from {}: {}", sender_id, button_label) self._start_typing(str(chat_id)) await self._handle_message( sender_id=sender_id, diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index f5477684b..0f60c63a8 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -448,7 +448,7 @@ class WebSocketChannel(BaseChannel): except ConnectionClosed: self._cleanup_connection(connection) except Exception as e: - logger.warning("websocket: failed to send {} event: {}", event, e) + self.logger.warning("failed to send {} event: {}", event, e) @classmethod def default_config(cls) -> dict[str, Any]: @@ -464,7 +464,7 @@ class WebSocketChannel(BaseChannel): return None if not cert or not key: raise ValueError( - "websocket: ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty" + "ssl_certfile and ssl_keyfile must both be set for WSS, or both left empty" ) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.minimum_version = ssl.TLSVersion.TLSv1_2 @@ -501,14 +501,14 @@ class WebSocketChannel(BaseChannel): if not _issue_route_secret_matches(request.headers, secret): return connection.respond(401, "Unauthorized") else: - logger.warning( - "websocket: token_issue_path is set but token_issue_secret is empty; " + self.logger.warning( + "token_issue_path is set but token_issue_secret is empty; " "any client can obtain connection tokens — set token_issue_secret for production." ) self._purge_expired_issued_tokens() if len(self._issued_tokens) >= self._MAX_ISSUED_TOKENS: - logger.error( - "websocket: too many outstanding issued tokens ({}), rejecting issuance", + self.logger.error( + "too many outstanding issued tokens ({}), rejecting issuance", len(self._issued_tokens), ) return _http_json_response({"error": "too many outstanding tokens"}, status=429) @@ -821,7 +821,7 @@ class WebSocketChannel(BaseChannel): staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}" shutil.copyfile(path, staged) except OSError as exc: - logger.warning("websocket: failed to stage outbound media {}: {}", path, exc) + self.logger.warning("failed to stage outbound media {}: {}", path, exc) return None signed = self._sign_media_path(staged) if signed is None: @@ -917,7 +917,7 @@ class WebSocketChannel(BaseChannel): try: body = candidate.read_bytes() except OSError as e: - logger.warning("websocket static: failed to read {}: {}", candidate, e) + self.logger.warning("static: failed to read {}: {}", candidate, e) return _http_error(500, "Internal Server Error") ctype, _ = mimetypes.guess_type(candidate.name) if ctype is None: @@ -972,7 +972,7 @@ class WebSocketChannel(BaseChannel): async def handler(connection: ServerConnection) -> None: await self._connection_loop(connection) - logger.info( + self.logger.info( "WebSocket server listening on {}://{}:{}{}", scheme, self.config.host, @@ -980,7 +980,7 @@ class WebSocketChannel(BaseChannel): self.config.path, ) if self.config.token_issue_path: - logger.info( + self.logger.info( "WebSocket token issue route: {}://{}:{}{}", scheme, self.config.host, @@ -1014,7 +1014,7 @@ class WebSocketChannel(BaseChannel): if not client_id: client_id = f"anon-{uuid.uuid4().hex[:12]}" elif len(client_id) > 128: - logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id)) + self.logger.warning("client_id too long ({} chars), truncating", len(client_id)) client_id = client_id[:128] default_chat_id = str(uuid.uuid4()) @@ -1039,7 +1039,7 @@ class WebSocketChannel(BaseChannel): try: raw = raw.decode("utf-8") except UnicodeDecodeError: - logger.warning("websocket: ignoring non-utf8 binary frame") + self.logger.warning("ignoring non-utf8 binary frame") continue envelope = _parse_envelope(raw) @@ -1057,7 +1057,7 @@ class WebSocketChannel(BaseChannel): metadata={"remote": getattr(connection, "remote_address", None)}, ) except Exception as e: - logger.debug("websocket connection ended: {}", e) + self.logger.debug("connection ended: {}", e) finally: self._cleanup_connection(connection) @@ -1097,8 +1097,8 @@ class WebSocketChannel(BaseChannel): try: Path(p).unlink(missing_ok=True) except OSError as exc: - logger.warning( - "websocket: failed to unlink partial media {}: {}", p, exc + self.logger.warning( + "failed to unlink partial media {}: {}", p, exc ) return [], reason @@ -1122,7 +1122,7 @@ class WebSocketChannel(BaseChannel): except FileSizeExceeded: return _abort("size") except Exception as exc: - logger.warning("websocket: media decode failed: {}", exc) + self.logger.warning("media decode failed: {}", exc) return _abort("decode") if saved is None: return _abort("decode") @@ -1204,7 +1204,7 @@ class WebSocketChannel(BaseChannel): try: await self._server_task except Exception as e: - logger.warning("websocket: server task error during shutdown: {}", e) + self.logger.warning("server task error during shutdown: {}", e) self._server_task = None self._subs.clear() self._conn_chats.clear() @@ -1218,16 +1218,16 @@ class WebSocketChannel(BaseChannel): await connection.send(raw) except ConnectionClosed: self._cleanup_connection(connection) - logger.warning("websocket{}connection gone", label) - except Exception as e: - logger.error("websocket{}send failed: {}", label, e) + self.logger.warning("connection gone{}", label) + except Exception: + self.logger.exception("send failed{}", label) raise async def send(self, msg: OutboundMessage) -> None: # Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe. conns = list(self._subs.get(msg.chat_id, ())) if not conns: - logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id) + self.logger.warning("no active subscribers for chat_id={}", msg.chat_id) return # Signal that the agent has fully finished processing the current turn. if msg.metadata.get("_turn_end"): diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py index ce3e7ed51..2dd9f8856 100644 --- a/nanobot/channels/wecom.py +++ b/nanobot/channels/wecom.py @@ -10,7 +10,6 @@ from collections import OrderedDict from pathlib import Path from typing import Any -from loguru import logger from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -103,11 +102,11 @@ class WecomChannel(BaseChannel): async def start(self) -> None: """Start the WeCom bot with WebSocket long connection.""" if not WECOM_AVAILABLE: - logger.error("WeCom SDK not installed. Run: pip install nanobot-ai[wecom]") + self.logger.error("SDK not installed. Run: pip install nanobot-ai[wecom]") return if not self.config.bot_id or not self.config.secret: - logger.error("WeCom bot_id and secret not configured") + self.logger.error("bot_id and secret not configured") return from wecom_aibot_sdk import WSClient, generate_req_id @@ -137,8 +136,8 @@ class WecomChannel(BaseChannel): self._client.on("message.mixed", self._on_mixed_message) self._client.on("event.enter_chat", self._on_enter_chat) - logger.info("WeCom bot starting with WebSocket long connection") - logger.info("No public IP required - using WebSocket to receive events") + self.logger.info("bot starting with WebSocket long connection") + self.logger.info("No public IP required - using WebSocket to receive events") # Connect await self._client.connect_async() @@ -152,24 +151,24 @@ class WecomChannel(BaseChannel): self._running = False if self._client: await self._client.disconnect() - logger.info("WeCom bot stopped") + self.logger.info("bot stopped") async def _on_connected(self, frame: Any) -> None: """Handle WebSocket connected event.""" - logger.info("WeCom WebSocket connected") + self.logger.info("WebSocket connected") async def _on_authenticated(self, frame: Any) -> None: """Handle authentication success event.""" - logger.info("WeCom authenticated successfully") + self.logger.info("authenticated successfully") async def _on_disconnected(self, frame: Any) -> None: """Handle WebSocket disconnected event.""" reason = frame.body if hasattr(frame, 'body') else str(frame) - logger.warning("WeCom WebSocket disconnected: {}", reason) + self.logger.warning("WebSocket disconnected: {}", reason) async def _on_error(self, frame: Any) -> None: """Handle error event.""" - logger.error("WeCom error: {}", frame) + self.logger.error("error: {}", frame) async def _on_text_message(self, frame: Any) -> None: """Handle text message.""" @@ -212,8 +211,8 @@ class WecomChannel(BaseChannel): "msgtype": "text", "text": {"content": self.config.welcome_message}, }) - except Exception as e: - logger.error("Error handling enter_chat: {}", e) + except Exception: + self.logger.exception("Error handling enter_chat") async def _process_message(self, frame: Any, msg_type: str) -> None: """Process incoming message and forward to bus.""" @@ -228,7 +227,7 @@ class WecomChannel(BaseChannel): # Ensure body is a dict if not isinstance(body, dict): - logger.warning("Invalid body type: {}", type(body)) + self.logger.warning("Invalid body type: {}", type(body)) return # Extract message info @@ -350,8 +349,8 @@ class WecomChannel(BaseChannel): } ) - except Exception as e: - logger.error("Error processing WeCom message: {}", e) + except Exception: + self.logger.exception("Error processing message") async def _download_and_save_media( self, @@ -370,12 +369,12 @@ class WecomChannel(BaseChannel): data, fname = await self._client.download_file(file_url, aes_key) if not data: - logger.warning("Failed to download media from WeCom") + self.logger.warning("Failed to download media") return None if len(data) > WECOM_UPLOAD_MAX_BYTES: - logger.warning( - "WeCom inbound media too large: {} bytes (max {})", + self.logger.warning( + "inbound media too large: {} bytes (max {})", len(data), WECOM_UPLOAD_MAX_BYTES, ) @@ -388,11 +387,11 @@ class WecomChannel(BaseChannel): file_path = media_dir / filename await asyncio.to_thread(file_path.write_bytes, data) - logger.debug("Downloaded {} to {}", media_type, file_path) + self.logger.debug("Downloaded {} to {}", media_type, file_path) return str(file_path) - except Exception as e: - logger.error("Error downloading media: {}", e) + except Exception: + self.logger.exception("Error downloading media") return None async def _upload_media_ws( @@ -445,11 +444,11 @@ class WecomChannel(BaseChannel): "md5": md5_hash, }, "aibot_upload_media_init") if resp.errcode != 0: - logger.warning("WeCom upload init failed ({}): {}", resp.errcode, resp.errmsg) + self.logger.warning("upload init failed ({}): {}", resp.errcode, resp.errmsg) return None, None upload_id = resp.body.get("upload_id") if resp.body else None if not upload_id: - logger.warning("WeCom upload init: no upload_id in response") + self.logger.warning("upload init: no upload_id in response") return None, None # Step 2: send chunks @@ -461,7 +460,7 @@ class WecomChannel(BaseChannel): "base64_data": base64.b64encode(chunk).decode(), }, "aibot_upload_media_chunk") if resp.errcode != 0: - logger.warning("WeCom upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg) + self.logger.warning("upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg) return None, None # Step 3: finish @@ -470,29 +469,29 @@ class WecomChannel(BaseChannel): "upload_id": upload_id, }, "aibot_upload_media_finish") if resp.errcode != 0: - logger.warning("WeCom upload finish failed ({}): {}", resp.errcode, resp.errmsg) + self.logger.warning("upload finish failed ({}): {}", resp.errcode, resp.errmsg) return None, None media_id = resp.body.get("media_id") if resp.body else None if not media_id: - logger.warning("WeCom upload finish: no media_id in response body={}", resp.body) + self.logger.warning("upload finish: no media_id in response body={}", resp.body) return None, None suffix = "..." if len(media_id) > 16 else "" - logger.debug("WeCom uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix) + self.logger.debug("uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix) return media_id, media_type except ValueError as e: - logger.warning("WeCom upload skipped for {}: {}", file_path, e) + self.logger.warning("upload skipped for {}: {}", file_path, e) return None, None - except Exception as e: - logger.error("WeCom _upload_media_ws error for {}: {}", file_path, e) + except Exception: + self.logger.exception("_upload_media_ws error for {}", file_path) return None, None async def send(self, msg: OutboundMessage) -> None: """Send a message through WeCom.""" if not self._client: - logger.warning("WeCom client not initialized") + self.logger.warning("client not initialized") return try: @@ -505,7 +504,7 @@ class WecomChannel(BaseChannel): # Send media files via WebSocket upload for file_path in msg.media or []: if not os.path.isfile(file_path): - logger.warning("WeCom media file not found: {}", file_path) + self.logger.warning("media file not found: {}", file_path) continue media_id, media_type = await self._upload_media_ws(self._client, file_path) if media_id: @@ -519,7 +518,7 @@ class WecomChannel(BaseChannel): "msgtype": media_type, media_type: {"media_id": media_id}, }) - logger.debug("WeCom sent {} → {}", media_type, msg.chat_id) + self.logger.debug("sent {} → {}", media_type, msg.chat_id) else: content += f"\n[file upload failed: {os.path.basename(file_path)}]" @@ -537,8 +536,8 @@ class WecomChannel(BaseChannel): content, finish=not is_progress, ) - logger.debug( - "WeCom {} sent to {}", + self.logger.debug( + "{} sent to {}", "progress" if is_progress else "message", msg.chat_id, ) @@ -548,7 +547,7 @@ class WecomChannel(BaseChannel): "msgtype": "markdown", "markdown": {"content": content}, }) - logger.info("WeCom proactive send to {}", msg.chat_id) + self.logger.info("proactive send to {}", msg.chat_id) except Exception: - logger.exception("Error sending WeCom message to chat_id={}", msg.chat_id) + self.logger.exception("Error sending message to chat_id={}", msg.chat_id) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index af82984b2..698acc70e 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -366,14 +366,14 @@ class WeixinChannel(BaseChannel): if base_url: self.config.base_url = base_url self._save_state() - logger.info( - "WeChat login successful! bot_id={} user_id={}", + self.logger.info( + "login successful! bot_id={} user_id={}", bot_id, user_id, ) return True else: - logger.error("Login confirmed but no bot_token in response") + self.logger.error("Login confirmed but no bot_token in response") return False elif status == "scaned_but_redirect": redirect_host = str(status_data.get("redirect_host", "") or "").strip() @@ -387,7 +387,7 @@ class WeixinChannel(BaseChannel): elif status == "expired": refresh_count += 1 if refresh_count > MAX_QR_REFRESH_COUNT: - logger.warning( + self.logger.warning( "QR code expired too many times ({}/{}), giving up.", refresh_count - 1, MAX_QR_REFRESH_COUNT, @@ -401,8 +401,8 @@ class WeixinChannel(BaseChannel): await asyncio.sleep(1) - except Exception as e: - logger.error("WeChat QR login failed: {}", e) + except Exception: + self.logger.exception("QR login failed") return False @@ -469,11 +469,11 @@ class WeixinChannel(BaseChannel): self._token = self.config.token elif not self._load_state(): if not await self._qr_login(): - logger.error("WeChat login failed. Run 'nanobot channels login weixin' to authenticate.") + self.logger.error("login failed. Run 'nanobot channels login weixin' to authenticate.") self._running = False return - logger.info("WeChat channel starting with long-poll...") + self.logger.info("channel starting with long-poll...") consecutive_failures = 0 while self._running: @@ -551,8 +551,8 @@ class WeixinChannel(BaseChannel): if errcode == ERRCODE_SESSION_EXPIRED or ret == ERRCODE_SESSION_EXPIRED: self._pause_session() remaining = self._session_pause_remaining_s() - logger.warning( - "WeChat session expired (errcode {}). Pausing {} min.", + self.logger.warning( + "session expired (errcode {}). Pausing {} min.", errcode, max((remaining + 59) // 60, 1), ) @@ -759,8 +759,8 @@ class WeixinChannel(BaseChannel): if not content: return - logger.info( - "WeChat inbound: from={} items={} bodyLen={}", + self.logger.info( + "inbound: from={} items={} bodyLen={}", from_user_id, ",".join(str(i.get("type", 0)) for i in item_list), len(content), @@ -843,8 +843,8 @@ class WeixinChannel(BaseChannel): and self._is_retryable_media_download_error(e) ) if should_fallback: - logger.warning( - "WeChat media download failed via full_url, falling back to encrypt_query_param: type={} err={}", + self.logger.warning( + "media download failed via full_url, falling back to encrypt_query_param: type={} err={}", media_type, e, ) @@ -869,8 +869,8 @@ class WeixinChannel(BaseChannel): file_path.write_bytes(data) return str(file_path) - except Exception as e: - logger.error("Error downloading WeChat media: {}", e) + except Exception: + self.logger.exception("Error downloading media") return None # ------------------------------------------------------------------ @@ -940,7 +940,7 @@ class WeixinChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: if not self._client or not self._token: - logger.warning("WeChat client not initialized or not authenticated") + self.logger.warning("client not initialized or not authenticated") return try: self._assert_session_active() @@ -954,8 +954,8 @@ class WeixinChannel(BaseChannel): content = msg.content.strip() ctx_token = self._context_tokens.get(msg.chat_id, "") if not ctx_token: - logger.warning( - "WeChat: no context_token for chat_id={}, cannot send", + self.logger.warning( + "no context_token for chat_id={}, cannot send", msg.chat_id, ) return @@ -980,14 +980,13 @@ class WeixinChannel(BaseChannel): for media_path in (msg.media or []): try: await self._send_media_file(msg.chat_id, media_path, ctx_token) - except (httpx.TimeoutException, httpx.TransportError) as net_err: + except (httpx.TimeoutException, httpx.TransportError): # Network/transport errors: do NOT fall back to text — # the text send would also likely fail, and the outer # except will re-raise so ChannelManager retries properly. - logger.error( - "Network error sending WeChat media {}: {}", + self.logger.opt(exception=True).warning( + "Network error sending media {}", media_path, - net_err, ) raise except httpx.HTTPStatusError as http_err: @@ -998,27 +997,26 @@ class WeixinChannel(BaseChannel): ) if status_code >= 500: # Server-side / retryable HTTP error — same as network. - logger.error( - "Server error ({} {}) sending WeChat media {}: {}", + self.logger.exception( + "Server error ({} {}) sending media {}", status_code, http_err.response.reason_phrase if http_err.response is not None else "", media_path, - http_err, ) raise # 4xx client errors are NOT retryable — fall back to text. filename = Path(media_path).name - logger.error("Failed to send WeChat media {}: {}", media_path, http_err) + self.logger.exception("Failed to send media {}", media_path) await self._send_text( msg.chat_id, f"[Failed to send: {filename}]", ctx_token, ) - except Exception as e: + except Exception: # Non-network errors (format, file-not-found, etc.): # notify the user via text fallback. filename = Path(media_path).name - logger.error("Failed to send WeChat media {}: {}", media_path, e) + self.logger.exception("Failed to send media {}", media_path) # Notify user about failure via text await self._send_text( msg.chat_id, f"[Failed to send: {filename}]", ctx_token, @@ -1031,8 +1029,8 @@ class WeixinChannel(BaseChannel): chunks = split_message(content, WEIXIN_MAX_MESSAGE_LEN) for chunk in chunks: await self._send_text(msg.chat_id, chunk, ctx_token) - except Exception as e: - logger.error("Error sending WeChat message: {}", e) + except Exception: + self.logger.exception("Error sending message") raise finally: if typing_keepalive_task: @@ -1056,7 +1054,7 @@ class WeixinChannel(BaseChannel): return await self._send_typing(chat_id, ticket, TYPING_STATUS_TYPING) except Exception as e: - logger.debug("WeChat typing indicator start failed for {}: {}", chat_id, e) + self.logger.debug("typing indicator start failed for {}: {}", chat_id, e) return stop_event = asyncio.Event() @@ -1095,7 +1093,7 @@ class WeixinChannel(BaseChannel): try: await self._send_typing(chat_id, ticket, TYPING_STATUS_CANCEL) except Exception as e: - logger.debug("WeChat typing clear failed for {}: {}", chat_id, e) + self.logger.debug("typing clear failed for {}: {}", chat_id, e) async def _send_text( self, @@ -1130,8 +1128,8 @@ class WeixinChannel(BaseChannel): data = await self._api_post("ilink/bot/sendmessage", body) errcode = data.get("errcode", 0) if errcode and errcode != 0: - logger.warning( - "WeChat send error (code {}): {}", + self.logger.warning( + "send error (code {}): {}", errcode, data.get("errmsg", ""), ) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 26869de18..bd0620334 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -99,15 +99,15 @@ class WhatsAppChannel(BaseChannel): """ try: bridge_dir = _ensure_bridge_setup() - except RuntimeError as e: - logger.error("{}", e) + except RuntimeError: + self.logger.exception("bridge setup failed") return False env = {**os.environ} env["BRIDGE_TOKEN"] = self._effective_bridge_token() env["AUTH_DIR"] = str(_bridge_token_path().parent) - logger.info("Starting WhatsApp bridge for QR login...") + self.logger.info("Starting WhatsApp bridge for QR login...") try: subprocess.run( [shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env @@ -123,7 +123,7 @@ class WhatsAppChannel(BaseChannel): bridge_url = self.config.bridge_url - logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) + self.logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) self._running = True @@ -135,24 +135,24 @@ class WhatsAppChannel(BaseChannel): json.dumps({"type": "auth", "token": self._effective_bridge_token()}) ) self._connected = True - logger.info("Connected to WhatsApp bridge") + self.logger.info("Connected to WhatsApp bridge") # Listen for messages async for message in ws: try: await self._handle_bridge_message(message) - except Exception as e: - logger.error("Error handling bridge message: {}", e) + except Exception: + self.logger.exception("Error handling bridge message") except asyncio.CancelledError: break except Exception as e: self._connected = False self._ws = None - logger.warning("WhatsApp bridge connection error: {}", e) + self.logger.warning("WhatsApp bridge connection error: {}", e) if self._running: - logger.info("Reconnecting in 5 seconds...") + self.logger.info("Reconnecting in 5 seconds...") await asyncio.sleep(5) async def stop(self) -> None: @@ -167,7 +167,7 @@ class WhatsAppChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send a message through WhatsApp.""" if not self._ws or not self._connected: - logger.warning("WhatsApp bridge not connected") + self.logger.warning("WhatsApp bridge not connected") return chat_id = msg.chat_id @@ -176,8 +176,8 @@ class WhatsAppChannel(BaseChannel): try: payload = {"type": "send", "to": chat_id, "text": msg.content} await self._ws.send(json.dumps(payload, ensure_ascii=False)) - except Exception as e: - logger.error("Error sending WhatsApp message: {}", e) + except Exception: + self.logger.exception("Error sending message") raise for media_path in msg.media or []: @@ -191,8 +191,8 @@ class WhatsAppChannel(BaseChannel): "fileName": media_path.rsplit("/", 1)[-1], } await self._ws.send(json.dumps(payload, ensure_ascii=False)) - except Exception as e: - logger.error("Error sending WhatsApp media {}: {}", media_path, e) + except Exception: + self.logger.exception("Error sending media {}", media_path) raise async def _handle_bridge_message(self, raw: str) -> None: @@ -200,7 +200,7 @@ class WhatsAppChannel(BaseChannel): try: data = json.loads(raw) except json.JSONDecodeError: - logger.warning("Invalid JSON from bridge: {}", raw[:100]) + self.logger.warning("Invalid JSON from bridge: {}", raw[:100]) return msg_type = data.get("type") @@ -253,7 +253,7 @@ class WhatsAppChannel(BaseChannel): if phone_id and lid_id: self._lid_to_phone[lid_id] = phone_id - logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id) + self.logger.info("Sender phone={} lid={} → sender_id={}", phone_id or "(empty)", lid_id or "(empty)", sender_id) # Extract media paths (images/documents/videos downloaded by the bridge) media_paths = data.get("media") or [] @@ -261,11 +261,11 @@ class WhatsAppChannel(BaseChannel): # Handle voice transcription if it's a voice message if content == "[Voice Message]": if media_paths: - logger.info("Transcribing voice message from {}...", sender_id) + self.logger.info("Transcribing voice message from {}...", sender_id) transcription = await self.transcribe_audio(media_paths[0]) if transcription: content = transcription - logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50]) + self.logger.info("Transcribed voice from {}: {}...", sender_id, transcription[:50]) else: content = "[Voice Message: Transcription failed]" else: @@ -294,7 +294,7 @@ class WhatsAppChannel(BaseChannel): elif msg_type == "status": # Connection status update status = data.get("status") - logger.info("WhatsApp status: {}", status) + self.logger.info("Status: {}", status) if status == "connected": self._connected = True @@ -303,10 +303,10 @@ class WhatsAppChannel(BaseChannel): elif msg_type == "qr": # QR code for authentication - logger.info("Scan QR code in the bridge terminal to connect WhatsApp") + self.logger.info("Scan QR code in the bridge terminal to connect WhatsApp") elif msg_type == "error": - logger.error("WhatsApp bridge error: {}", data.get("error")) + self.logger.error("Bridge error: {}", data.get("error")) def _ensure_bridge_setup() -> Path: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index a062802a9..c54a2bc7c 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -21,6 +21,22 @@ if sys.platform == "win32": import typer from loguru import logger + +# Remove default handler and re-add with unified nanobot format +logger.remove() +_log_handler_id = logger.add( + sys.stderr, + format=( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <5} | " + "{extra[channel]} | " + "{message}" + ), + level="INFO", + colorize=None, + filter=lambda record: record["extra"].setdefault("channel", "-") or True, +) + from prompt_toolkit import PromptSession, print_formatted_text from prompt_toolkit.application import run_in_terminal from prompt_toolkit.formatted_text import ANSI, HTML @@ -597,9 +613,19 @@ def gateway( ): """Start the nanobot gateway.""" if verbose: - import logging - - logging.basicConfig(level=logging.DEBUG) + logger.remove(_log_handler_id) + logger.add( + sys.stderr, + format=( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <5} | " + "{extra[channel]} | " + "{message}" + ), + level="DEBUG", + colorize=None, + filter=lambda record: record["extra"].setdefault("channel", "-") or True, + ) cfg = _load_runtime_config(config, workspace) _run_gateway(cfg, port=port) diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 4c5700892..5eadb43d9 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -840,7 +840,7 @@ def _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]: display_name = getattr(channel_cls, "display_name", name.capitalize()) result[name] = (display_name, config_cls) except Exception: - logger.warning(f"Failed to load channel module: {name}") + logger.warning("Failed to load channel module: {}", name) return result diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index d663105f5..e0808e107 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -49,7 +49,7 @@ def load_config(config_path: Path | None = None) -> Config: data = _migrate_config(data) config = Config.model_validate(data) except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e: - logger.warning(f"Failed to load config from {path}: {e}") + logger.warning("Failed to load config from {}: {}", path, e) logger.warning("Using default configuration.") _apply_ssrf_whitelist(config) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index e5428c114..31c5b50a7 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -156,7 +156,7 @@ class CronService: updated_at_ms=j.get("updatedAtMs", 0), delete_after_run=j.get("deleteAfterRun", False), )) - except Exception as e: + except Exception: # Preserve the corrupt file for forensic recovery instead of # letting the next save overwrite it with an empty job list. backup = self.store_path.with_suffix( @@ -164,12 +164,11 @@ class CronService: ) with suppress(OSError): self.store_path.rename(backup) - logger.error( - "Failed to load cron store at {}: {}. " + logger.exception( + "Failed to load cron store at {}. " "Corrupt file preserved at {}. " "Refusing to overwrite to avoid data loss.", self.store_path, - e, backup, ) return None @@ -202,8 +201,8 @@ class CronService: else: _update(action.get("params", {})) changed = True - except Exception as exp: - logger.debug(f"load action line error: {exp}") + except Exception: + logger.exception("load action line error") continue self._store.jobs = list(jobs_map.values()) if self._running and changed: @@ -434,7 +433,7 @@ class CronService: except Exception as e: job.state.last_status = "error" job.state.last_error = str(e) - logger.error("Cron: job '{}' failed: {}", job.name, e) + logger.exception("Cron: job '{}' failed", job.name) end_ms = _now_ms() job.state.last_run_at_ms = start_ms diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index fea2c51b6..b41ee7a1e 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -144,8 +144,8 @@ class HeartbeatService: await self._tick() except asyncio.CancelledError: break - except Exception as e: - logger.error("Heartbeat error: {}", e) + except Exception: + logger.exception("Heartbeat error") @staticmethod def _is_deliverable(response: str) -> bool: diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index 10fcafd6d..456c09ea2 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -44,8 +44,8 @@ class OpenAITranscriptionProvider: ) response.raise_for_status() return response.json().get("text", "") - except Exception as e: - logger.error("OpenAI transcription error: {}", e) + except Exception: + logger.exception("OpenAI transcription error") return "" @@ -109,6 +109,6 @@ class GroqTranscriptionProvider: data = response.json() return data.get("text", "") - except Exception as e: - logger.error("Groq transcription error: {}", e) + except Exception: + logger.exception("Groq transcription error") return "" diff --git a/nanobot/utils/document.py b/nanobot/utils/document.py index 3a1ea9067..53039e97f 100644 --- a/nanobot/utils/document.py +++ b/nanobot/utils/document.py @@ -93,7 +93,7 @@ def _extract_pdf(path: Path) -> str: pages.append(f"--- Page {i} ---\n{text}") return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH) except Exception as e: - logger.error("Failed to extract PDF {}: {}", path, e) + logger.exception("Failed to extract PDF {}", path) return f"[error: failed to extract PDF: {e!s}]" @@ -108,7 +108,7 @@ def _extract_docx(path: Path) -> str: paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()] return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH) except Exception as e: - logger.error("Failed to extract DOCX {}: {}", path, e) + logger.exception("Failed to extract DOCX {}", path) return f"[error: failed to extract DOCX: {e!s}]" @@ -135,7 +135,7 @@ def _extract_xlsx(path: Path) -> str: finally: wb.close() except Exception as e: - logger.error("Failed to extract XLSX {}: {}", path, e) + logger.exception("Failed to extract XLSX {}", path) return f"[error: failed to extract XLSX: {e!s}]" @@ -156,7 +156,7 @@ def _extract_pptx(path: Path) -> str: slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text)) return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH) except Exception as e: - logger.error("Failed to extract PPTX {}: {}", path, e) + logger.exception("Failed to extract PPTX {}", path) return f"[error: failed to extract PPTX: {e!s}]" @@ -195,7 +195,7 @@ def _extract_text_file(path: Path) -> str: content = path.read_text(encoding="latin-1") return _truncate(content, _MAX_TEXT_LENGTH) except Exception as e: - logger.error("Failed to read text file {}: {}", path, e) + logger.exception("Failed to read text file {}", path) return f"[error: failed to read file: {e!s}]" diff --git a/nanobot/utils/gitstore.py b/nanobot/utils/gitstore.py index d9b528c97..6e05ca128 100644 --- a/nanobot/utils/gitstore.py +++ b/nanobot/utils/gitstore.py @@ -113,7 +113,7 @@ class GitStore: logger.info("Git store initialized at {}", self._workspace) return True except Exception: - logger.warning("Git store init failed for {}", self._workspace) + logger.exception("Git store init failed for {}", self._workspace) return False # -- daily operations ------------------------------------------------------ @@ -149,7 +149,7 @@ class GitStore: logger.debug("Git auto-commit: {} ({})", sha, message) return sha except Exception: - logger.warning("Git auto-commit failed: {}", message) + logger.exception("Git auto-commit failed: {}", message) return None # -- internal helpers ------------------------------------------------------ @@ -243,7 +243,7 @@ class GitStore: return entries except Exception: - logger.warning("Git log failed") + logger.exception("Git log failed") return [] def line_ages(self, file_path: str) -> list[LineAge]: @@ -266,7 +266,7 @@ class GitStore: annotated = porcelain.annotate(str(self._workspace), file_path) except Exception: - logger.warning("Git line_ages annotate failed for {}", file_path) + logger.exception("Git line_ages annotate failed for {}", file_path) return [] if not annotated: @@ -296,7 +296,7 @@ class GitStore: ) return out.getvalue().decode("utf-8", errors="replace") except Exception: - logger.warning("Git diff_commits failed") + logger.exception("Git diff_commits failed") return "" def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None: @@ -367,7 +367,7 @@ class GitStore: msg = f"revert: undo {commit}" return self.auto_commit(msg) except Exception: - logger.warning("Git revert failed for {}", commit) + logger.exception("Git revert failed for {}", commit) return None @staticmethod diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 0afe193cc..b047e24d2 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -268,8 +268,8 @@ def maybe_persist_tool_result( bucket = ensure_dir(root / safe_filename(session_key or "default")) try: _cleanup_tool_result_buckets(root, bucket) - except Exception as exc: - logger.warning("Failed to clean stale tool result buckets in {}: {}", root, exc) + except Exception: + logger.exception("Failed to clean stale tool result buckets in {}", root) path = bucket / f"{safe_filename(tool_call_id)}.{suffix}" if not path.exists(): if suffix == "json" and isinstance(content, list): @@ -540,6 +540,6 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str] ) gs.init() except Exception: - logger.warning("Failed to initialize git store for {}", workspace) + logger.exception("Failed to initialize git store for {}", workspace) return added diff --git a/nanobot/utils/logging_bridge.py b/nanobot/utils/logging_bridge.py new file mode 100644 index 000000000..a20e2e888 --- /dev/null +++ b/nanobot/utils/logging_bridge.py @@ -0,0 +1,47 @@ +"""Utilities for redirecting stdlib logging to loguru.""" +from __future__ import annotations + +import logging + +from loguru import logger + + +class _LoguruBridge(logging.Handler): + """Route stdlib log records into loguru with consistent formatting.""" + + _LEVEL_MAP: dict[int, str] = { + logging.DEBUG: "DEBUG", + logging.INFO: "INFO", + logging.WARNING: "WARNING", + logging.ERROR: "ERROR", + logging.CRITICAL: "CRITICAL", + } + + def __init__(self, lib_name: str) -> None: + super().__init__() + self.lib_name = lib_name + + def emit(self, record: logging.LogRecord) -> None: + level = self._LEVEL_MAP.get(record.levelno, "INFO") + frame, depth = logging.currentframe(), 2 + while frame and frame.f_code.co_filename == logging.__file__: + frame, depth = frame.f_back, depth + 1 + logger.opt(depth=depth, exception=record.exc_info).log( + level, "[{lib}] {message}", lib=self.lib_name, message=record.getMessage() + ) + + +def redirect_lib_logging(name: str, level: str | None = None) -> None: + """Redirect stdlib logging from *name* into loguru. + + Adds a bridge handler if one is not already present and disables + propagation so messages are not duplicated. When *level* is None the + handler does not filter — loguru's own level controls visibility. + """ + lib_logger = logging.getLogger(name) + if not any(isinstance(h, _LoguruBridge) for h in lib_logger.handlers): + handler = _LoguruBridge(name) + if level is not None: + handler.setLevel(getattr(logging, level.upper(), logging.WARNING)) + lib_logger.handlers = [handler] + lib_logger.propagate = False diff --git a/tests/agent/test_runner.py b/tests/agent/test_runner.py index 0be615cb9..b821d9bab 100644 --- a/tests/agent/test_runner.py +++ b/tests/agent/test_runner.py @@ -643,7 +643,7 @@ def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path): lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")), ) monkeypatch.setattr( - "nanobot.utils.helpers.logger.warning", + "nanobot.utils.helpers.logger.exception", lambda message, *args: warnings.append(message.format(*args)), ) diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 2ae5cce9f..95865096c 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -306,17 +306,19 @@ async def test_on_error_logs_network_issues_as_warning(monkeypatch) -> None: recorded: list[tuple[str, str]] = [] monkeypatch.setattr( - "nanobot.channels.telegram.logger.warning", + channel.logger, + "warning", lambda message, error: recorded.append(("warning", message.format(error))), ) monkeypatch.setattr( - "nanobot.channels.telegram.logger.error", + channel.logger, + "error", lambda message, error: recorded.append(("error", message.format(error))), ) await channel._on_error(object(), SimpleNamespace(error=NetworkError("proxy disconnected"))) - assert recorded == [("warning", "Telegram network issue: proxy disconnected")] + assert recorded == [("warning", "network issue: proxy disconnected")] @pytest.mark.asyncio @@ -330,13 +332,14 @@ async def test_on_error_summarizes_empty_network_error(monkeypatch) -> None: recorded: list[tuple[str, str]] = [] monkeypatch.setattr( - "nanobot.channels.telegram.logger.warning", + channel.logger, + "warning", lambda message, error: recorded.append(("warning", message.format(error))), ) await channel._on_error(object(), SimpleNamespace(error=NetworkError(""))) - assert recorded == [("warning", "Telegram network issue: NetworkError")] + assert recorded == [("warning", "network issue: NetworkError")] @pytest.mark.asyncio @@ -348,17 +351,19 @@ async def test_on_error_keeps_non_network_exceptions_as_error(monkeypatch) -> No recorded: list[tuple[str, str]] = [] monkeypatch.setattr( - "nanobot.channels.telegram.logger.warning", + channel.logger, + "warning", lambda message, error: recorded.append(("warning", message.format(error))), ) monkeypatch.setattr( - "nanobot.channels.telegram.logger.error", + channel.logger, + "error", lambda message, error: recorded.append(("error", message.format(error))), ) await channel._on_error(object(), SimpleNamespace(error=RuntimeError("boom"))) - assert recorded == [("error", "Telegram error: boom")] + assert recorded == [("error", "error: boom")] @pytest.mark.asyncio diff --git a/tests/test_msteams.py b/tests/test_msteams.py index 0671f9f58..fd71018b1 100644 --- a/tests/test_msteams.py +++ b/tests/test_msteams.py @@ -835,7 +835,7 @@ async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypa ch = make_channel() errors = [] monkeypatch.setattr(msteams_module, "MSTEAMS_AVAILABLE", False) - monkeypatch.setattr(msteams_module.logger, "error", lambda message, *args: errors.append(message.format(*args))) + monkeypatch.setattr(ch.logger, "error", lambda message, *args: errors.append(message.format(*args))) await ch.start() diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 66f7b19a8..de39d1a67 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -467,7 +467,7 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint( yield # pragma: no cover monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client) - monkeypatch.setattr("nanobot.agent.tools.mcp.logger.error", _error) + monkeypatch.setattr("nanobot.agent.tools.mcp.logger.exception", _error) registry = ToolRegistry() stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry) From 653de4a7efde76aead9de8e631df2c778c3a1460 Mon Sep 17 00:00:00 2001 From: hanyuanling Date: Wed, 6 May 2026 14:45:20 +0800 Subject: [PATCH 33/44] fix(agent): gate provider progress deltas --- nanobot/agent/loop.py | 1 + nanobot/agent/runner.py | 2 + tests/agent/test_loop_progress.py | 73 ++++++++++++++++---- tests/agent/test_runner_progress_deltas.py | 79 ++++++++++++++++++++++ 4 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 tests/agent/test_runner_progress_deltas.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index d5e7681f1..07006b057 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -649,6 +649,7 @@ class AgentLoop: context_block_limit=self.context_block_limit, provider_retry_mode=self.provider_retry_mode, progress_callback=on_progress, + stream_progress_deltas=on_stream is not None, retry_wait_callback=on_retry_wait, checkpoint_callback=_checkpoint, injection_callback=_drain_pending, diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index b81df4168..7fe92ad51 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -76,6 +76,7 @@ class AgentRunSpec: context_block_limit: int | None = None provider_retry_mode: str = "standard" progress_callback: Any | None = None + stream_progress_deltas: bool = True retry_wait_callback: Any | None = None checkpoint_callback: Any | None = None injection_callback: Any | None = None @@ -615,6 +616,7 @@ class AgentRunner: wants_streaming = hook.wants_streaming() wants_progress_streaming = ( not wants_streaming + and spec.stream_progress_deltas and spec.progress_callback is not None and getattr(self.provider, "supports_progress_deltas", False) is True ) diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index d08448992..47a63ba02 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -130,11 +130,44 @@ class TestToolEventProgress: assert finish["result"] == "file.txt" @pytest.mark.asyncio - async def test_bus_progress_streams_provider_deltas_for_codex_style_provider( + async def test_non_streaming_channel_does_not_publish_codex_progress_deltas( self, tmp_path: Path, ) -> None: - """Providers that opt in can stream content deltas through _progress messages.""" + """Non-streaming channels should get one final reply, not token progress spam.""" + bus = MessageBus() + provider = MagicMock() + provider.supports_progress_deltas = True + provider.get_default_model.return_value = "openai-codex/gpt-5.5" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Hello", tool_calls=[])) + provider.chat_stream_with_retry = AsyncMock() + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="openai-codex/gpt-5.5") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + await loop._dispatch(InboundMessage( + channel="whatsapp", + sender_id="u1", + chat_id="chat1", + content="say hello", + )) + + outbound = [] + while bus.outbound_size > 0: + outbound.append(await bus.consume_outbound()) + + assert [m.content for m in outbound] == ["Hello"] + assert not any(m.metadata.get("_progress") for m in outbound) + assert not any(m.metadata.get("_streamed") for m in outbound) + provider.chat_stream_with_retry.assert_not_awaited() + provider.chat_with_retry.assert_awaited_once() + + @pytest.mark.asyncio + async def test_streaming_channel_streams_provider_deltas_for_codex_style_provider( + self, + tmp_path: Path, + ) -> None: + """Streaming channels still receive provider deltas through _stream_delta messages.""" bus = MessageBus() provider = MagicMock() provider.supports_progress_deltas = True @@ -156,18 +189,27 @@ class TestToolEventProgress: sender_id="u1", chat_id="chat1", content="say hello", + metadata={"_wants_stream": True}, )) outbound = [] while bus.outbound_size > 0: outbound.append(await bus.consume_outbound()) - progress = [m for m in outbound if m.metadata.get("_progress")] - final = [m for m in outbound if not m.metadata.get("_progress")] + deltas = [m for m in outbound if m.metadata.get("_stream_delta")] + stream_end = [m for m in outbound if m.metadata.get("_stream_end")] + final = [ + m for m in outbound + if not m.metadata.get("_stream_delta") + and not m.metadata.get("_stream_end") + and not m.metadata.get("_turn_end") + ] - assert [m.content for m in progress] == ["Hel", "lo"] - assert final[-2].content == "Hello" - assert (final[-1].metadata or {}).get("_turn_end") is True + assert [m.content for m in deltas] == ["Hel", "lo"] + assert len(stream_end) == 1 + assert final[-1].content == "Hello" + assert final[-1].metadata.get("_streamed") is True + assert outbound[-1].metadata.get("_turn_end") is True provider.chat_with_retry.assert_not_awaited() @pytest.mark.asyncio @@ -197,8 +239,12 @@ class TestToolEventProgress: loop.tools.prepare_call = MagicMock(return_value=(None, {"path": "foo.txt"}, None)) loop.tools.execute = AsyncMock(return_value="ok") + streamed: list[str] = [] progress: list[tuple[str, bool, list[dict] | None]] = [] + async def on_stream(delta: str) -> None: + streamed.append(delta) + async def on_progress( content: str, *, @@ -207,14 +253,15 @@ class TestToolEventProgress: ) -> None: progress.append((content, tool_hint, tool_events)) - final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress) + final_content, _, _, _, _ = await loop._run_agent_loop( + [], + on_progress=on_progress, + on_stream=on_stream, + ) assert final_content == "Done" - assert [item[0] for item in progress[:3]] == [ - "I will", - " inspect it.", - 'custom_tool("foo.txt")', - ] + assert streamed == ["I will", " inspect it."] + assert progress[0][0] == 'custom_tool("foo.txt")' assert all(item[0] != "I will inspect it." for item in progress) @pytest.mark.asyncio diff --git a/tests/agent/test_runner_progress_deltas.py b/tests/agent/test_runner_progress_deltas.py new file mode 100644 index 000000000..13d5ea799 --- /dev/null +++ b/tests/agent/test_runner_progress_deltas.py @@ -0,0 +1,79 @@ +"""Tests for provider progress delta routing in the shared runner.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.agent.runner import AgentRunner, AgentRunSpec +from nanobot.config.schema import AgentDefaults +from nanobot.providers.base import LLMResponse + +_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars + + +@pytest.mark.asyncio +async def test_runner_can_disable_provider_progress_delta_streaming(): + """AgentLoop disables token progress streaming for non-streaming channels.""" + provider = MagicMock() + provider.supports_progress_deltas = True + provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content="done", tool_calls=[], usage={}) + ) + provider.chat_stream_with_retry = AsyncMock() + tools = MagicMock() + tools.get_definitions.return_value = [] + progress_cb = AsyncMock() + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[ + {"role": "system", "content": "system"}, + {"role": "user", "content": "hi"}, + ], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + progress_callback=progress_cb, + stream_progress_deltas=False, + )) + + assert result.final_content == "done" + provider.chat_with_retry.assert_awaited_once() + provider.chat_stream_with_retry.assert_not_awaited() + progress_cb.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_runner_streams_provider_progress_deltas_by_default(): + """Direct runner users keep the existing opt-in provider progress behavior.""" + provider = MagicMock() + provider.supports_progress_deltas = True + + async def chat_stream_with_retry(*, on_content_delta, **kwargs): + await on_content_delta("he") + await on_content_delta("llo") + return LLMResponse(content="hello", tool_calls=[], usage={}) + + provider.chat_stream_with_retry = chat_stream_with_retry + provider.chat_with_retry = AsyncMock() + tools = MagicMock() + tools.get_definitions.return_value = [] + progress_cb = AsyncMock() + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[ + {"role": "system", "content": "system"}, + {"role": "user", "content": "hi"}, + ], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + progress_callback=progress_cb, + )) + + assert result.final_content == "hello" + assert [call.args[0] for call in progress_cb.await_args_list] == ["he", "llo"] + provider.chat_with_retry.assert_not_awaited() From daa4a25c9b83d08eca93afacbbfd896cafe056df Mon Sep 17 00:00:00 2001 From: Tim O'Brien Date: Mon, 4 May 2026 17:56:49 +0000 Subject: [PATCH 34/44] feat(config): add toolHintMaxLength to control tool hint truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add to config (default: 40, range: 20-500). Controls how many characters of tool hints are shown in progress updates (e.g. '$ cd …/project && npm test'). Set to 120+ to see full commands instead of truncated hints: ```json { "agents": { "defaults": { "toolHintMaxLength": 120 } } } ``` - Thread max_length through format_tool_hints → _fmt_known/_fmt_mcp/_fmt_fallback - Make path abbreviation in _abbreviate_command proportional to max_length - Add TestToolHintMaxLength test class with 5 tests - All 41 existing tests pass --- nanobot/agent/loop.py | 6 +++--- nanobot/config/schema.py | 7 +++++++ nanobot/utils/tool_hints.py | 28 +++++++++++++------------ tests/agent/test_tool_hint.py | 39 +++++++++++++++++++++++++++++++++-- 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 07006b057..984711975 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -242,6 +242,7 @@ class AgentLoop: else defaults.max_tool_result_chars ) self.provider_retry_mode = provider_retry_mode + self.tool_hint_max_length = defaults.tool_hint_max_length self.web_config = web_config or WebToolsConfig() self.exec_config = exec_config or ExecToolConfig() self.cron_service = cron_service @@ -471,12 +472,11 @@ class AgentLoop: """Return the chat id shown in runtime metadata for the model.""" return str(msg.metadata.get("context_chat_id") or msg.chat_id) - @staticmethod - def _tool_hint(tool_calls: list) -> str: + def _tool_hint(self, tool_calls: list) -> str: """Format tool calls as concise hints with smart abbreviation.""" from nanobot.utils.tool_hints import format_tool_hints - return format_tool_hints(tool_calls) + return format_tool_hints(tool_calls, max_length=self.tool_hint_max_length) async def _dispatch_command_inline( self, diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 2f20eb99e..aa8b0a5e5 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -81,6 +81,13 @@ class AgentDefaults(Base): max_concurrent_subagents: int = Field(default=1, ge=1) max_tool_result_chars: int = 16_000 provider_retry_mode: Literal["standard", "persistent"] = "standard" + tool_hint_max_length: int = Field( + default=40, + ge=20, + le=500, + validation_alias=AliasChoices("toolHintMaxLength"), + serialization_alias="toolHintMaxLength", + ) # Max characters for tool hint display (e.g. "$ cd …/project && npm test") reasoning_effort: str | None = None # low / medium / high / adaptive - enables LLM thinking mode timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York" unified_session: bool = False # Share one session across all channels (single-user multi-device) diff --git a/nanobot/utils/tool_hints.py b/nanobot/utils/tool_hints.py index 9758700b1..75d3e3521 100644 --- a/nanobot/utils/tool_hints.py +++ b/nanobot/utils/tool_hints.py @@ -27,7 +27,7 @@ _PATH_IN_CMD_RE = re.compile( ) -def format_tool_hints(tool_calls: list) -> str: +def format_tool_hints(tool_calls: list, max_length: int = 40) -> str: """Format tool calls as concise hints with smart abbreviation.""" if not tool_calls: return "" @@ -36,11 +36,11 @@ def format_tool_hints(tool_calls: list) -> str: for tc in tool_calls: fmt = _TOOL_FORMATS.get(tc.name) if fmt: - formatted.append(_fmt_known(tc, fmt)) + formatted.append(_fmt_known(tc, fmt, max_length)) elif tc.name.startswith("mcp_"): - formatted.append(_fmt_mcp(tc)) + formatted.append(_fmt_mcp(tc, max_length)) else: - formatted.append(_fmt_fallback(tc)) + formatted.append(_fmt_fallback(tc, max_length)) hints = [] for hint in formatted: @@ -80,7 +80,7 @@ def _extract_arg(tc, key_args: list[str]) -> str | None: return None -def _fmt_known(tc, fmt: tuple) -> str: +def _fmt_known(tc, fmt: tuple, max_length: int = 40) -> str: """Format a registered tool using its template.""" val = _extract_arg(tc, fmt[0]) if val is None: @@ -88,18 +88,20 @@ def _fmt_known(tc, fmt: tuple) -> str: if fmt[2]: # is_path val = abbreviate_path(val) elif fmt[3]: # is_command - val = _abbreviate_command(val) + val = _abbreviate_command(val, max_len=max_length) return fmt[1].format(val) def _abbreviate_command(cmd: str, max_len: int = 40) -> str: """Abbreviate paths in a command string, then truncate.""" + path_max = max(max_len // 2, 25) + def _replace_path(match: re.Match[str]) -> str: if match.group("double") is not None: - return f'"{abbreviate_path(match.group("double"), max_len=25)}"' + return f'"{abbreviate_path(match.group("double"), max_len=path_max)}"' if match.group("single") is not None: - return f"'{abbreviate_path(match.group('single'), max_len=25)}'" - return abbreviate_path(match.group("bare"), max_len=25) + return f"'{abbreviate_path(match.group('single'), max_len=path_max)}'" + return abbreviate_path(match.group("bare"), max_len=path_max) abbreviated = _PATH_IN_CMD_RE.sub(_replace_path, cmd) if len(abbreviated) <= max_len: @@ -107,7 +109,7 @@ def _abbreviate_command(cmd: str, max_len: int = 40) -> str: return abbreviated[:max_len - 1] + "\u2026" -def _fmt_mcp(tc) -> str: +def _fmt_mcp(tc, max_length: int = 40) -> str: """Format MCP tool as server::tool.""" name = tc.name if "__" in name: @@ -125,13 +127,13 @@ def _fmt_mcp(tc) -> str: val = next((v for v in args.values() if isinstance(v, str) and v), None) if val is None: return f"{server}::{tool}" - return f'{server}::{tool}("{abbreviate_path(val, 40)}")' + return f'{server}::{tool}("{abbreviate_path(val, max_length)}")' -def _fmt_fallback(tc) -> str: +def _fmt_fallback(tc, max_length: int = 40) -> str: """Original formatting logic for unregistered tools.""" args = _get_args(tc) val = next(iter(args.values()), None) if isinstance(args, dict) else None if not isinstance(val, str): return tc.name - return f'{tc.name}("{abbreviate_path(val, 40)}")' if len(val) > 40 else f'{tc.name}("{val}")' + return f'{tc.name}("{abbreviate_path(val, max_length)}")' if len(val) > max_length else f'{tc.name}("{val}")' diff --git a/tests/agent/test_tool_hint.py b/tests/agent/test_tool_hint.py index b8ba99284..ff73fbb5c 100644 --- a/tests/agent/test_tool_hint.py +++ b/tests/agent/test_tool_hint.py @@ -8,9 +8,9 @@ def _tc(name: str, args) -> ToolCallRequest: return ToolCallRequest(id="c1", name=name, arguments=args) -def _hint(calls): +def _hint(calls, max_length=40): """Shortcut for format_tool_hints.""" - return format_tool_hints(calls) + return format_tool_hints(calls, max_length=max_length) class TestToolHintKnownTools: @@ -254,3 +254,38 @@ class TestToolHintMixedFolding: assert "\u00d7" not in result parts = result.split(", ") assert len(parts) == 5 + + +class TestToolHintMaxLength: + """Test max_length parameter controls truncation of tool hints.""" + + def test_exec_default_truncates_at_40(self): + cmd = "cd /very/long/path/to/some/project && npm run build && npm test" + result = _hint([_tc("exec", {"command": cmd})], max_length=40) + assert len(result) <= 50 # "$ " prefix + 40 + ellipsis + assert "\u2026" in result + + def test_exec_larger_max_length_shows_more(self): + cmd = "cd /very/long/path/to/some/project && npm run build && npm test" + short = _hint([_tc("exec", {"command": cmd})], max_length=40) + long = _hint([_tc("exec", {"command": cmd})], max_length=120) + assert len(long) > len(short) + assert "npm test" in long + + def test_exec_max_length_120_shows_full_command(self): + cmd = "cd /home/user/project && npm install && npm run build" + result = _hint([_tc("exec", {"command": cmd})], max_length=120) + assert "npm run build" in result + + def test_fallback_respects_max_length(self): + long_val = "a" * 100 + result = _hint([_tc("custom_tool", {"data": long_val})], max_length=60) + assert "\u2026" in result + result_40 = _hint([_tc("custom_tool", {"data": long_val})], max_length=40) + assert len(result) > len(result_40) + + def test_mcp_respects_max_length(self): + long_url = "https://example.com/very/long/path/to/resource" + result = _hint([_tc("mcp_github__fetch", {"url": long_url})], max_length=80) + result_40 = _hint([_tc("mcp_github__fetch", {"url": long_url})], max_length=40) + assert len(result) >= len(result_40) From 67875d7a15aeabb47d49a825ceb80995af7b9055 Mon Sep 17 00:00:00 2001 From: Tim O'Brien Date: Mon, 4 May 2026 18:14:45 +0000 Subject: [PATCH 35/44] fix: wire toolHintMaxLength through AgentLoop constructors The config field was added but never passed from config to AgentLoop. The value was always falling back to the default (40) regardless of what was set in config.json. Now passes tool_hint_max_length through all AgentLoop() call sites: - nanobot/nanobot.py (main bot) - nanobot/cli/commands.py (CLI agent, dev, webui commands) Also adds documentation in docs/configuration.md. --- docs/configuration.md | 20 ++++++++++++++++++++ nanobot/agent/loop.py | 6 +++++- nanobot/cli/commands.py | 3 +++ nanobot/nanobot.py | 1 + 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index d0a7fe940..f5fb32cb7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1131,3 +1131,23 @@ Disabled skills are excluded from the main agent's skill summary, from always-on | Option | Default | Description | |--------|---------|-------------| | `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. | + +## Tool Hint Max Length + +Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read. + +Set `agents.defaults.toolHintMaxLength` to control the truncation threshold: + +```json +{ + "agents": { + "defaults": { + "toolHintMaxLength": 120 + } + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `agents.defaults.toolHintMaxLength` | `40` | Maximum characters for tool hint display. Range: 20–500. Higher values show more of the command or path; lower values keep hints compact. | diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 984711975..784c4da13 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -198,6 +198,7 @@ class AgentLoop: context_block_limit: int | None = None, max_tool_result_chars: int | None = None, provider_retry_mode: str = "standard", + tool_hint_max_length: int | None = None, web_config: WebToolsConfig | None = None, exec_config: ExecToolConfig | None = None, cron_service: CronService | None = None, @@ -242,7 +243,10 @@ class AgentLoop: else defaults.max_tool_result_chars ) self.provider_retry_mode = provider_retry_mode - self.tool_hint_max_length = defaults.tool_hint_max_length + self.tool_hint_max_length = ( + tool_hint_max_length if tool_hint_max_length is not None + else defaults.tool_hint_max_length + ) self.web_config = web_config or WebToolsConfig() self.exec_config = exec_config or ExecToolConfig() self.cron_service = cron_service diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c54a2bc7c..1f0186f1d 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -557,6 +557,7 @@ def serve( context_block_limit=runtime_config.agents.defaults.context_block_limit, max_tool_result_chars=runtime_config.agents.defaults.max_tool_result_chars, provider_retry_mode=runtime_config.agents.defaults.provider_retry_mode, + tool_hint_max_length=runtime_config.agents.defaults.tool_hint_max_length, web_config=runtime_config.tools.web, exec_config=runtime_config.tools.exec, restrict_to_workspace=runtime_config.tools.restrict_to_workspace, @@ -681,6 +682,7 @@ def _run_gateway( context_block_limit=config.agents.defaults.context_block_limit, max_tool_result_chars=config.agents.defaults.max_tool_result_chars, provider_retry_mode=config.agents.defaults.provider_retry_mode, + tool_hint_max_length=config.agents.defaults.tool_hint_max_length, exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, @@ -1073,6 +1075,7 @@ def agent( context_block_limit=config.agents.defaults.context_block_limit, max_tool_result_chars=config.agents.defaults.max_tool_result_chars, provider_retry_mode=config.agents.defaults.provider_retry_mode, + tool_hint_max_length=config.agents.defaults.tool_hint_max_length, exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 5e5857595..60c6dcdcb 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -76,6 +76,7 @@ class Nanobot: context_block_limit=defaults.context_block_limit, max_tool_result_chars=defaults.max_tool_result_chars, provider_retry_mode=defaults.provider_retry_mode, + tool_hint_max_length=defaults.tool_hint_max_length, web_config=config.tools.web, exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, From 99209a806dcdff9537fd3caa7ac4e6794ef4b15a Mon Sep 17 00:00:00 2001 From: Tim O'Brien Date: Wed, 6 May 2026 05:39:55 +0000 Subject: [PATCH 36/44] fix(tool_hints): pass max_length to abbreviate_path for is_path tools The is_path branch in _fmt_known was not passing max_length to abbreviate_path, so read_file, write_file, edit, list_dir, and web_fetch always truncated paths at 40 chars regardless of config. Now all three branches (is_path, is_command, fallback) honor the configured toolHintMaxLength. --- nanobot/utils/tool_hints.py | 2 +- tests/agent/test_tool_hint.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/nanobot/utils/tool_hints.py b/nanobot/utils/tool_hints.py index 75d3e3521..289870665 100644 --- a/nanobot/utils/tool_hints.py +++ b/nanobot/utils/tool_hints.py @@ -86,7 +86,7 @@ def _fmt_known(tc, fmt: tuple, max_length: int = 40) -> str: if val is None: return tc.name if fmt[2]: # is_path - val = abbreviate_path(val) + val = abbreviate_path(val, max_len=max_length) elif fmt[3]: # is_command val = _abbreviate_command(val, max_len=max_length) return fmt[1].format(val) diff --git a/tests/agent/test_tool_hint.py b/tests/agent/test_tool_hint.py index ff73fbb5c..174eb208d 100644 --- a/tests/agent/test_tool_hint.py +++ b/tests/agent/test_tool_hint.py @@ -289,3 +289,24 @@ class TestToolHintMaxLength: result = _hint([_tc("mcp_github__fetch", {"url": long_url})], max_length=80) result_40 = _hint([_tc("mcp_github__fetch", {"url": long_url})], max_length=40) assert len(result) >= len(result_40) + + def test_path_type_respects_max_length(self): + """Path-type tools (read_file, write_file, etc.) should honor max_length.""" + long_path = "/home/user/.local/share/uv/tools/nanobot/agent/loop.py" + short = _hint([_tc("read_file", {"path": long_path})], max_length=40) + long = _hint([_tc("read_file", {"path": long_path})], max_length=120) + assert len(long) > len(short) + + def test_edit_path_respects_max_length(self): + """edit (is_path=True) should honor max_length, not stay hardcoded at 40.""" + long_path = "/home/user/projects/nanobot/src/agent/loop.py" + short = _hint([_tc("edit", {"file_path": long_path})], max_length=40) + long = _hint([_tc("edit", {"file_path": long_path})], max_length=120) + assert len(long) > len(short) + + def test_list_dir_path_respects_max_length(self): + """list_dir (is_path=True) should honor max_length.""" + long_path = "/home/user/.local/share/uv/tools/nanobot/" + short = _hint([_tc("list_dir", {"path": long_path})], max_length=40) + long = _hint([_tc("list_dir", {"path": long_path})], max_length=120) + assert len(long) > len(short) From 4fad19dc174768938e53e4bd7b2ea8c4d1a27f8b Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 6 May 2026 13:28:48 +0800 Subject: [PATCH 37/44] fix: use sequential MCP server connections to prevent CPU spin asyncio.create_task in connect_mcp_servers creates child tasks for each MCP server, but close_mcp calls stack.aclose() from the main task. anyio CancelScope requires enter/exit in the same task, so the cross-task exit raises RuntimeError which gets silently caught. The orphaned cancel scope keeps retrying via call_soon on every event loop tick, consuming 100% CPU. Fix: remove create_task/gather and connect servers sequentially in the caller task. MCP servers are typically 1-2, so parallel connection provides negligible benefit while introducing the cancel scope hazard. Closes #3638 --- nanobot/agent/tools/mcp.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 04b88386f..6d4e7d6cd 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -436,8 +436,8 @@ async def connect_mcp_servers( """Connect to configured MCP servers and register their tools, resources, prompts. Returns a dict mapping server name -> its dedicated AsyncExitStack. - Each server gets its own stack and runs in its own task to prevent - cancel scope conflicts when multiple MCP servers are configured. + Each server gets its own stack to prevent cancel scope conflicts + when multiple MCP servers are configured. """ from mcp import ClientSession, StdioServerParameters from mcp.client.sse import sse_client @@ -612,19 +612,13 @@ async def connect_mcp_servers( server_stacks: dict[str, AsyncExitStack] = {} - tasks: list[asyncio.Task] = [] for name, cfg in mcp_servers.items(): - task = asyncio.create_task(connect_single_server(name, cfg)) - tasks.append(task) - - results = await asyncio.gather(*tasks, return_exceptions=True) - - for i, result in enumerate(results): - name = list(mcp_servers.keys())[i] - if isinstance(result, BaseException): - if not isinstance(result, asyncio.CancelledError): - logger.error("MCP server '{}' connection task failed: {}", name, result) - elif result is not None and result[1] is not None: + try: + result = await connect_single_server(name, cfg) + except Exception as e: + logger.error("MCP server '{}' connection failed: {}", name, e) + continue + if result is not None and result[1] is not None: server_stacks[result[0]] = result[1] return server_stacks From 790a03ec2877223e39d88ad088265abe1d58a898 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 6 May 2026 14:15:36 +0000 Subject: [PATCH 38/44] feat(webui): polish chat layout and titles Align the WebUI sidebar and chat chrome with the updated design, and generate WebUI session titles asynchronously without blocking turns. Co-authored-by: Cursor --- nanobot/agent/loop.py | 21 ++ nanobot/channels/websocket.py | 18 +- nanobot/session/manager.py | 8 + nanobot/utils/webui_titles.py | 138 +++++++++++++ tests/agent/test_loop_progress.py | 43 ++++ tests/agent/test_loop_save_turn.py | 53 +++++ tests/agent/test_session_manager_history.py | 14 +- tests/channels/test_websocket_channel.py | 53 +++++ webui/src/App.tsx | 60 +++--- webui/src/components/ChatList.tsx | 163 +++++++++------ webui/src/components/ChatPane.tsx | 16 +- webui/src/components/ConnectionBadge.tsx | 14 +- webui/src/components/MessageBubble.tsx | 51 ++++- webui/src/components/Sidebar.tsx | 128 ++++++------ .../src/components/thread/ThreadComposer.tsx | 38 ++-- webui/src/components/thread/ThreadHeader.tsx | 94 +++++++-- webui/src/components/thread/ThreadShell.tsx | 189 ++++++++++++------ .../src/components/thread/ThreadViewport.tsx | 6 +- webui/src/globals.css | 8 +- webui/src/hooks/useNanobotStream.ts | 9 +- webui/src/hooks/useSessions.ts | 3 +- webui/src/i18n/locales/en/common.json | 60 +++++- webui/src/i18n/locales/zh-CN/common.json | 60 +++++- webui/src/lib/api.ts | 2 + webui/src/lib/nanobot-client.ts | 4 +- webui/src/lib/types.ts | 5 + webui/src/tests/api.test.ts | 26 ++- webui/src/tests/app-layout.test.tsx | 112 ++++++++++- webui/src/tests/message-bubble.test.tsx | 42 +++- webui/src/tests/nanobot-client.test.ts | 4 +- webui/src/tests/thread-composer.test.tsx | 31 ++- webui/src/tests/thread-shell.test.tsx | 88 +++++++- webui/src/tests/useNanobotStream.test.tsx | 21 +- 33 files changed, 1270 insertions(+), 312 deletions(-) create mode 100644 nanobot/utils/webui_titles.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 784c4da13..d1952312b 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -55,6 +55,7 @@ from nanobot.utils.progress_events import ( on_progress_accepts_tool_events, ) from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE +from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn if TYPE_CHECKING: from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig @@ -814,6 +815,25 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content="", metadata={**msg.metadata, "_turn_end": True}, )) + if msg.metadata.get("webui") is True: + async def _generate_title_and_notify() -> None: + generated = await maybe_generate_webui_title_after_turn( + channel=msg.channel, + metadata=msg.metadata, + sessions=self.sessions, + session_key=session_key, + provider=self.provider, + model=self.model, + ) + if generated: + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="", + metadata={**msg.metadata, "_session_updated": True}, + )) + + self._schedule_background(_generate_title_and_notify()) except asyncio.CancelledError: logger.info("Task cancelled for session {}", session_key) # Preserve partial context from the interrupted turn so @@ -1003,6 +1023,7 @@ class AgentLoop: key = session_key or msg.session_key session = self.sessions.get_or_create(key) + mark_webui_session(session, msg.metadata) if self._restore_runtime_checkpoint(session): self.sessions.save(session) if self._restore_pending_user_turn(session): diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 0f60c63a8..62e67a5b7 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -1184,12 +1184,15 @@ class WebSocketChannel(BaseChannel): # Auto-attach on first use so clients can one-shot without a separate attach. self._attach(connection, cid) + metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)} + if envelope.get("webui") is True: + metadata["webui"] = True await self._handle_message( sender_id=client_id, chat_id=cid, content=content, media=media_paths or None, - metadata={"remote": getattr(connection, "remote_address", None)}, + metadata=metadata, ) return await self._send_event(connection, "error", detail=f"unknown type: {t!r}") @@ -1233,6 +1236,9 @@ class WebSocketChannel(BaseChannel): if msg.metadata.get("_turn_end"): await self.send_turn_end(msg.chat_id) return + if msg.metadata.get("_session_updated"): + await self.send_session_updated(msg.chat_id) + return text = msg.content if msg.buttons: text = _append_buttons_as_text(text, msg.buttons) @@ -1299,3 +1305,13 @@ class WebSocketChannel(BaseChannel): raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" turn_end ") + + async def send_session_updated(self, chat_id: str) -> None: + """Notify clients that session metadata changed outside the main turn.""" + conns = list(self._subs.get(chat_id, ())) + if not conns: + return + body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id} + raw = json.dumps(body, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" session_updated ") diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 06c7317d0..859d2cca8 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -547,10 +547,13 @@ class SessionManager: data = json.loads(first_line) if data.get("_type") == "metadata": key = data.get("key") or path.stem.replace("_", ":", 1) + metadata = data.get("metadata", {}) + title = metadata.get("title") if isinstance(metadata, dict) else None sessions.append({ "key": key, "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), + "title": title if isinstance(title, str) else "", "path": str(path) }) except Exception: @@ -560,6 +563,11 @@ class SessionManager: "key": repaired.key, "created_at": repaired.created_at.isoformat(), "updated_at": repaired.updated_at.isoformat(), + "title": ( + repaired.metadata.get("title") + if isinstance(repaired.metadata.get("title"), str) + else "" + ), "path": str(path) }) continue diff --git a/nanobot/utils/webui_titles.py b/nanobot/utils/webui_titles.py new file mode 100644 index 000000000..2d363f926 --- /dev/null +++ b/nanobot/utils/webui_titles.py @@ -0,0 +1,138 @@ +"""Helpers for WebUI chat title generation.""" + +from __future__ import annotations + +import re +from typing import Any + +from loguru import logger + +from nanobot.providers.base import LLMProvider +from nanobot.session.manager import Session, SessionManager +from nanobot.utils.helpers import truncate_text + +WEBUI_SESSION_METADATA_KEY = "webui" +WEBUI_TITLE_METADATA_KEY = "title" +WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited" +TITLE_MAX_CHARS = 60 + + +def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool: + """Persist a WebUI marker only when the inbound websocket frame opted in.""" + if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + session.metadata[WEBUI_SESSION_METADATA_KEY] = True + return True + + +def clean_generated_title(raw: str | None) -> str: + text = (raw or "").strip() + if not text: + return "" + text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE) + text = text.strip().strip("\"'`“”‘’") + text = re.sub(r"\s+", " ", text).strip() + text = text.rstrip("。.!!??,,;;:") + if len(text) > TITLE_MAX_CHARS: + text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…" + return text + + +def _title_inputs(session: Session) -> tuple[str, str]: + user_text = "" + assistant_text = "" + for message in session.messages: + role = message.get("role") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + continue + if role == "user" and not user_text: + user_text = content.strip() + elif role == "assistant" and not assistant_text: + assistant_text = content.strip() + if user_text and assistant_text: + break + return user_text, assistant_text + + +async def maybe_generate_webui_title( + *, + sessions: SessionManager, + session_key: str, + provider: LLMProvider, + model: str, +) -> bool: + """Generate and persist a short title for WebUI-owned sessions only.""" + session = sessions.get_or_create(session_key) + if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True: + return False + current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY) + if isinstance(current_title, str) and current_title.strip(): + return False + + user_text, assistant_text = _title_inputs(session) + if not user_text: + return False + + prompt = ( + "Generate a concise title for this chat.\n" + "Rules:\n" + "- Use the same language as the user when practical.\n" + "- 3 to 8 words.\n" + "- No quotes.\n" + "- No punctuation at the end.\n" + "- Return only the title.\n\n" + f"User: {truncate_text(user_text, 1_000)}" + ) + if assistant_text: + prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}" + + try: + response = await provider.chat_with_retry( + [ + { + "role": "system", + "content": ( + "You write short, neutral chat titles. " + "Return only the title text." + ), + }, + {"role": "user", "content": prompt}, + ], + tools=None, + model=model, + max_tokens=32, + temperature=0.2, + retry_mode="standard", + ) + except Exception: + logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True) + return False + + title = clean_generated_title(response.content) + if not title or title.lower().startswith("error"): + return False + session.metadata[WEBUI_TITLE_METADATA_KEY] = title + sessions.save(session) + return True + + +async def maybe_generate_webui_title_after_turn( + *, + channel: str, + metadata: dict[str, Any], + sessions: SessionManager, + session_key: str, + provider: LLMProvider, + model: str, +) -> bool: + if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + return await maybe_generate_webui_title( + sessions=sessions, + session_key=session_key, + provider=provider, + model=model, + ) diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 47a63ba02..ee3f1e3db 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -1,5 +1,6 @@ """Tests for structured tool-event progress metadata emitted by AgentLoop.""" +import asyncio from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -291,6 +292,48 @@ class TestToolEventProgress: assert (outbound[-1].metadata or {}).get("_turn_end") is True assert outbound[-1].chat_id == "chat1" + @pytest.mark.asyncio + async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + title_started = asyncio.Event() + release_title = asyncio.Event() + calls = 0 + + async def chat_with_retry(*_args: object, **_kwargs: object) -> LLMResponse: + nonlocal calls + calls += 1 + if calls == 1: + return LLMResponse(content="Done", tool_calls=[]) + title_started.set() + await release_title.wait() + return LLMResponse(content="Generated title", tool_calls=[]) + + provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry) + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] + + await asyncio.wait_for(loop._dispatch(InboundMessage( + channel="websocket", + sender_id="u1", + chat_id="chat1", + content="say hello", + metadata={"webui": True}, + )), timeout=0.5) + + outbound = [await bus.consume_outbound(), await bus.consume_outbound()] + assert outbound[0].content == "Done" + assert (outbound[1].metadata or {}).get("_turn_end") is True + + await asyncio.wait_for(title_started.wait(), timeout=0.5) + release_title.set() + session_updated = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5) + + assert (session_updated.metadata or {}).get("_session_updated") is True + assert provider.chat_with_retry.await_count == 2 + @pytest.mark.asyncio async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None: bus = MessageBus() diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index c3dd90af2..36b133999 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -8,7 +8,13 @@ from nanobot.agent.context import ContextBuilder from nanobot.agent.loop import AgentLoop from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus +from nanobot.providers.base import LLMResponse from nanobot.session.manager import Session +from nanobot.utils.webui_titles import ( + WEBUI_SESSION_METADATA_KEY, + WEBUI_TITLE_METADATA_KEY, + maybe_generate_webui_title, +) def _mk_loop() -> AgentLoop: @@ -22,9 +28,56 @@ def _mk_loop() -> AgentLoop: def _make_full_loop(tmp_path: Path) -> AgentLoop: provider = MagicMock() provider.get_default_model.return_value = "test-model" + provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Test title")) return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model") +@pytest.mark.asyncio +async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content='"优化 WebUI 侧边栏。"', finish_reason="stop") + ) + session = loop.sessions.get_or_create("websocket:chat-title") + session.metadata[WEBUI_SESSION_METADATA_KEY] = True + session.add_message("user", "帮我优化一下 webui 的 sidebar") + session.add_message("assistant", "可以,我会先调整布局和视觉层级。") + loop.sessions.save(session) + + generated = await maybe_generate_webui_title( + sessions=loop.sessions, + session_key="websocket:chat-title", + provider=loop.provider, + model=loop.model, + ) + + assert generated is True + assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏" + loop.provider.chat_with_retry.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Path) -> None: + loop = _make_full_loop(tmp_path) + loop.provider.chat_with_retry = AsyncMock( + return_value=LLMResponse(content="Plain websocket title", finish_reason="stop") + ) + session = loop.sessions.get_or_create("websocket:custom-client") + session.add_message("user", "hello from a custom websocket client") + loop.sessions.save(session) + + generated = await maybe_generate_webui_title( + sessions=loop.sessions, + session_key="websocket:custom-client", + provider=loop.provider, + model=loop.model, + ) + + assert generated is False + assert WEBUI_TITLE_METADATA_KEY not in session.metadata + loop.provider.chat_with_retry.assert_not_awaited() + + def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None: loop = _mk_loop() session = Session(key="test:runtime-only") diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index b80c774a1..75bc7713d 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -1,4 +1,4 @@ -from nanobot.session.manager import Session +from nanobot.session.manager import Session, SessionManager def _assert_no_orphans(history: list[dict]) -> None: @@ -31,6 +31,18 @@ def _tool_turn(prefix: str, idx: int) -> list[dict]: ] +def test_list_sessions_includes_metadata_title(tmp_path): + manager = SessionManager(tmp_path) + session = manager.get_or_create("websocket:chat-title") + session.metadata["title"] = "自动生成标题" + manager.save(session) + + rows = manager.list_sessions() + + assert rows[0]["key"] == "websocket:chat-title" + assert rows[0]["title"] == "自动生成标题" + + # --- Original regression test (from PR 2075) --- def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls(): diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index db61fc285..f20095388 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -167,6 +167,40 @@ def test_issue_route_secret_matches_empty_secret() -> None: assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True +@pytest.mark.asyncio +async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None: + channel = _ch(bus) + conn = MagicMock() + conn.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + conn, + "webui-client", + {"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True}, + ) + + msg = bus.publish_inbound.await_args.args[0] + assert msg.channel == "websocket" + assert msg.chat_id == "chat-1" + assert msg.metadata["webui"] is True + assert msg.metadata["_wants_stream"] is True + + +@pytest.mark.asyncio +async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> None: + channel = _ch(bus) + conn = MagicMock() + + await channel._dispatch_envelope( + conn, + "custom-client", + {"type": "message", "chat_id": "chat-1", "content": "hello"}, + ) + + msg = bus.publish_inbound.await_args.args[0] + assert "webui" not in msg.metadata + + @pytest.mark.asyncio async def test_send_delivers_json_message_with_media_and_reply() -> None: bus = MagicMock() @@ -306,6 +340,25 @@ async def test_send_turn_end_emits_turn_end_event() -> None: assert body == {"event": "turn_end", "chat_id": "chat-1"} +@pytest.mark.asyncio +async def test_send_session_updated_emits_session_updated_event() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={"_session_updated": True}, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == {"event": "session_updated", "chat_id": "chat-1"} + + @pytest.mark.asyncio async def test_send_non_connection_closed_exception_is_raised() -> None: bus = MagicMock() diff --git a/webui/src/App.tsx b/webui/src/App.tsx index c6ad6f067..0fbb3f54f 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -25,7 +25,7 @@ type BootState = }; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; -const SIDEBAR_WIDTH = 279; +const SIDEBAR_WIDTH = 272; type ShellView = "chat" | "settings"; function readSidebarOpen(): boolean { @@ -99,13 +99,6 @@ export default function App() { return (
-
@@ -121,13 +114,6 @@ export default function App() { return (
-

{t("app.error.title")}

{state.message}

@@ -213,7 +199,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | } }, []); - const onNewChat = useCallback(async () => { + const onCreateChat = useCallback(async () => { try { const chatId = await createChat(); setActiveKey(`websocket:${chatId}`); @@ -226,6 +212,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | } }, [createChat]); + const onNewChat = useCallback(() => { + setActiveKey(null); + setView("chat"); + setMobileSidebarOpen(false); + }, []); + const onSelectChat = useCallback( (key: string) => { setActiveKey(key); @@ -235,6 +227,15 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | [], ); + const onOpenSettings = useCallback(() => { + setView("settings"); + setMobileSidebarOpen(false); + }, []); + + const onTurnEnd = useCallback(() => { + void refresh(); + }, [refresh]); + const onConfirmDelete = useCallback(async () => { if (!pendingDelete) return; const key = pendingDelete.key; @@ -254,7 +255,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | }, [pendingDelete, deleteChat, activeKey, sessions]); const headerTitle = activeSession - ? activeSession.preview || + ? activeSession.title || + activeSession.preview || t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) }) : t("app.brand"); @@ -268,20 +270,10 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | sessions, activeKey, loading, - theme, - onToggleTheme: toggle, - onNewChat: () => { - void onNewChat(); - }, + onNewChat, onSelect: onSelectChat, - onRefresh: () => void refresh(), onRequestDelete: (key: string, label: string) => setPendingDelete({ key, label }), - activeView: view, - onOpenSettings: () => { - setView("settings" as const); - setMobileSidebarOpen(false); - }, }; return ( @@ -296,10 +288,11 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | >

@@ -312,7 +305,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | @@ -331,8 +325,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | session={activeSession} title={headerTitle} onToggleSidebar={toggleSidebar} - onGoHome={() => setActiveKey(null)} onNewChat={onNewChat} + onCreateChat={onCreateChat} + onTurnEnd={onTurnEnd} + theme={theme} + onToggleTheme={toggle} + onOpenSettings={onOpenSettings} hideSidebarToggleOnDesktop={desktopSidebarOpen} /> )} diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index f77f7c1b2..ce7bb17e0 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -8,7 +8,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { relativeTime } from "@/lib/format"; import { cn } from "@/lib/utils"; import type { ChatSummary } from "@/lib/types"; @@ -18,10 +17,11 @@ interface ChatListProps { onSelect: (key: string) => void; onRequestDelete: (key: string, label: string) => void; loading?: boolean; + emptyLabel?: string; } function titleFor(s: ChatSummary, fallbackTitle: string): string { - const p = s.preview?.trim(); + const p = (s.title || s.preview)?.trim(); if (p) return p.length > 48 ? `${p.slice(0, 45)}…` : p; return fallbackTitle; } @@ -32,6 +32,7 @@ export function ChatList({ onSelect, onRequestDelete, loading, + emptyLabel, }: ChatListProps) { const { t } = useTranslation(); if (loading && sessions.length === 0) { @@ -44,73 +45,111 @@ export function ChatList({ if (sessions.length === 0) { return ( -
- {t("chat.noSessions")} +
+ {emptyLabel ?? t("chat.noSessions")}
); } + const groups = groupSessions(sessions, { + today: t("chat.groups.today"), + yesterday: t("chat.groups.yesterday"), + earlier: t("chat.groups.earlier"), + }); + return ( -
    - {sessions.map((s) => { - const active = s.key === activeKey; - const title = titleFor( - s, - t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }), - ); - return ( -
  • -
    - - - - - - event.preventDefault()} - > - { - window.setTimeout(() => onRequestDelete(s.key, title), 0); - }} - className="text-destructive focus:text-destructive" +
    + {groups.map((group) => ( +
    +
    + {group.label} +
    +
      + {group.sessions.map((s) => { + const active = s.key === activeKey; + const title = titleFor( + s, + t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }), + ); + return ( +
    • +
      - - {t("chat.delete")} - - - -
      -
    • - ); - })} -
    + + + + + + event.preventDefault()} + > + { + window.setTimeout(() => onRequestDelete(s.key, title), 0); + }} + className="text-destructive focus:text-destructive" + > + + {t("chat.delete")} + + + +
    +
  • + ); + })} +
+ + ))} +
); } + +function groupSessions( + sessions: ChatSummary[], + labels: { today: string; yesterday: string; earlier: string }, +): Array<{ label: string; sessions: ChatSummary[] }> { + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; + const buckets = new Map(); + + for (const session of sessions) { + const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? ""); + const label = Number.isFinite(timestamp) && timestamp >= startOfToday + ? labels.today + : Number.isFinite(timestamp) && timestamp >= startOfYesterday + ? labels.yesterday + : labels.earlier; + const bucket = buckets.get(label) ?? []; + bucket.push(session); + buckets.set(label, bucket); + } + + return [labels.today, labels.yesterday, labels.earlier] + .map((label) => ({ label, sessions: buckets.get(label) ?? [] })) + .filter((group) => group.sessions.length > 0); +} diff --git a/webui/src/components/ChatPane.tsx b/webui/src/components/ChatPane.tsx index 779d3695a..43fe64914 100644 --- a/webui/src/components/ChatPane.tsx +++ b/webui/src/components/ChatPane.tsx @@ -79,20 +79,8 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
- - - nanobot -

- What's on your mind? + What can I do for you?

Your conversations are persisted locally under the nanobot @@ -105,7 +93,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) { disabled={booting} onSend={handleWelcomeSend} placeholder={ - booting ? "Opening a new chat…" : "Type your message…" + booting ? "Opening a new chat…" : "Ask anything..." } />

diff --git a/webui/src/components/ConnectionBadge.tsx b/webui/src/components/ConnectionBadge.tsx index 354be976f..7616ddbe5 100644 --- a/webui/src/components/ConnectionBadge.tsx +++ b/webui/src/components/ConnectionBadge.tsx @@ -6,21 +6,21 @@ import { useClient } from "@/providers/ClientProvider"; import type { ConnectionStatus } from "@/lib/types"; const COPY: Record = { - idle: { color: "bg-card/40 text-muted-foreground" }, + idle: { color: "text-muted-foreground" }, connecting: { - color: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + color: "text-amber-700 dark:text-amber-300", }, open: { - color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", + color: "text-emerald-700 dark:text-emerald-400", }, reconnecting: { - color: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + color: "text-amber-700 dark:text-amber-300", }, closed: { - color: "bg-card/40 text-muted-foreground", + color: "text-muted-foreground", }, error: { - color: "bg-destructive/10 text-destructive", + color: "text-destructive", }, }; @@ -39,7 +39,7 @@ export function ConnectionBadge() { return ( (null); const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300"; + useEffect(() => { + return () => { + if (copyResetRef.current !== null) { + window.clearTimeout(copyResetRef.current); + } + }; + }, []); + + const onCopyAssistantReply = useCallback(() => { + if (!navigator.clipboard) return; + void navigator.clipboard.writeText(message.content).then(() => { + setCopied(true); + if (copyResetRef.current !== null) { + window.clearTimeout(copyResetRef.current); + } + copyResetRef.current = window.setTimeout(() => { + setCopied(false); + copyResetRef.current = null; + }, 1_500); + }); + }, [message.content]); + if (message.kind === "trace") { return ; } @@ -60,6 +85,7 @@ export function MessageBubble({ message }: MessageBubbleProps) { const empty = message.content.trim().length === 0; const media = message.media ?? []; + const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; return (
{empty && message.isStreaming ? ( @@ -69,6 +95,27 @@ export function MessageBubble({ message }: MessageBubbleProps) { {message.content} {message.isStreaming && } {media.length > 0 ? : null} + {showAssistantActions ? ( +
+ +
+ ) : null} )}
diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index b544fd0ba..52c8de47c 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -1,109 +1,121 @@ -import { Moon, PanelLeftClose, RefreshCcw, Settings, SquarePen, Sun } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + PanelLeftClose, + Search, + SquarePen, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { ChatList } from "@/components/ChatList"; import { ConnectionBadge } from "@/components/ConnectionBadge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; import type { ChatSummary } from "@/lib/types"; interface SidebarProps { sessions: ChatSummary[]; activeKey: string | null; loading: boolean; - theme: "light" | "dark"; - onToggleTheme: () => void; onNewChat: () => void; onSelect: (key: string) => void; - onRefresh: () => void; onRequestDelete: (key: string, label: string) => void; onCollapse: () => void; - activeView?: "chat" | "settings"; - onOpenSettings: () => void; } export function Sidebar(props: SidebarProps) { const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const normalizedQuery = query.trim().toLowerCase(); + const filteredSessions = useMemo(() => { + if (!normalizedQuery) return props.sessions; + return props.sessions.filter((session) => { + const haystack = [ + session.preview, + session.chatId, + session.channel, + session.key, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(normalizedQuery); + }); + }, [normalizedQuery, props.sessions]); + return ( - + ); } diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index d5e5dd65a..5f86190b1 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -10,7 +10,7 @@ import { ArrowUp, ImageIcon, Loader2, - Paperclip, + Plus, X, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -219,8 +219,8 @@ export function ThreadComposer({ className={cn( "relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200", isHero - ? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card shadow-[0_10px_30px_rgba(0,0,0,0.10)]" - : "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card", + ? "max-w-[58rem] rounded-[28px] border border-black/[0.035] bg-card shadow-[0_20px_55px_rgba(15,23,42,0.08)] dark:border-white/[0.06] dark:shadow-[0_24px_55px_rgba(0,0,0,0.34)]" + : "max-w-[49.5rem] rounded-[22px] border border-black/[0.035] bg-card shadow-[0_12px_30px_rgba(15,23,42,0.07)] dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]", "focus-within:ring-1 focus-within:ring-foreground/8", disabled && "opacity-60", isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary", @@ -268,9 +268,9 @@ export function ThreadComposer({ className={cn( "w-full resize-none bg-transparent", isHero - ? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6" + ? "min-h-[78px] px-5 pb-2 pt-5 text-[16px] leading-6" : "min-h-[50px] px-4 pb-1.5 pt-3 text-sm", - "placeholder:text-muted-foreground", + "placeholder:text-muted-foreground/70", "focus:outline-none focus-visible:outline-none", "disabled:cursor-not-allowed", )} @@ -289,7 +289,7 @@ export function ThreadComposer({
@@ -310,10 +310,12 @@ export function ThreadComposer({ onClick={() => fileInputRef.current?.click()} className={cn( "rounded-full text-muted-foreground hover:text-foreground", - isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5", + isHero + ? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card" + : "h-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card", )} > - + {modelLabel ? ( {modelLabel} ) : null} - - {t("thread.composer.sendHint")} - + {!isHero ? ( + + {t("thread.composer.sendHint")} + + ) : null}
- + +
+ + +
+
+ ); + } + return (
@@ -33,19 +82,34 @@ export function ThreadHeader({ > - +
+
+ +
+ +
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 7dc2afaec..45b164b44 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -1,4 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + BarChart3, + BookOpen, + ChevronRight, + Code2, + LayoutGrid, + Lightbulb, + MoreHorizontal, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { AskUserPrompt } from "@/components/thread/AskUserPrompt"; @@ -15,8 +24,13 @@ interface ThreadShellProps { session: ChatSummary | null; title: string; onToggleSidebar: () => void; - onGoHome: () => void; - onNewChat: () => Promise; + onGoHome?: () => void; + onNewChat?: () => void; + onCreateChat?: () => Promise; + onTurnEnd?: () => void; + theme?: "light" | "dark"; + onToggleTheme?: () => void; + onOpenSettings?: () => void; hideSidebarToggleOnDesktop?: boolean; } @@ -28,12 +42,24 @@ function toModelBadgeLabel(modelName: string | null): string | null { return leaf || trimmed; } +const QUICK_ACTION_KEYS = [ + { key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" }, + { key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" }, + { key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" }, + { key: "code", icon: Code2, tone: "text-[#eba45d]" }, + { key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" }, + { key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" }, +] as const; + export function ThreadShell({ session, title, onToggleSidebar, - onGoHome, - onNewChat, + onCreateChat, + onTurnEnd, + theme = "light", + onToggleTheme = () => {}, + onOpenSettings = () => {}, hideSidebarToggleOnDesktop = false, }: ThreadShellProps) { const { t } = useTranslation(); @@ -57,7 +83,7 @@ export function ThreadShell({ setMessages, streamError, dismissStreamError, - } = useNanobotStream(chatId, initial, hasPendingToolCalls); + } = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd); const showHeroComposer = messages.length === 0 && !loading; const pendingAsk = useMemo(() => { for (let index = messages.length - 1; index >= 0; index -= 1) { @@ -125,13 +151,94 @@ export function ThreadShell({ if (booting) return; setBooting(true); pendingFirstRef.current = content; - const newId = await onNewChat(); + const newId = await onCreateChat?.(); if (!newId) { pendingFirstRef.current = null; setBooting(false); } }, - [booting, onNewChat], + [booting, onCreateChat], + ); + + const handleQuickAction = useCallback( + (prompt: string) => { + if (session) { + send(prompt); + return; + } + void handleWelcomeSend(prompt); + }, + [handleWelcomeSend, send, session], + ); + + const quickActions = ( +
+ {QUICK_ACTION_KEYS.map(({ key, icon: Icon, tone }) => { + const title = t(`thread.empty.quickActions.${key}.title`); + const prompt = t(`thread.empty.quickActions.${key}.prompt`); + return ( + + ); + })} +
+ ); + + const composer = ( + <> + {streamError ? ( + + ) : null} + {pendingAsk ? ( + + ) : null} + {session ? ( + + ) : ( + + )} + {showHeroComposer ? quickActions : null} + ); const emptyState = loading ? ( @@ -139,20 +246,10 @@ export function ThreadShell({ {t("thread.loadingConversation")}
) : ( -
-
- - nanobot -
-

- {t("thread.empty.description")} -

+
+

+ {t("thread.empty.greeting")} +

); @@ -161,57 +258,17 @@ export function ThreadShell({ - {streamError ? ( - - ) : null} - {pendingAsk ? ( - - ) : null} - {session ? ( - - ) : ( - - )} - - } + composer={composer} />
); diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 5f4b8d01a..7d4a80f06 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -82,9 +82,9 @@ export function ThreadViewport({
) : ( -
-
-
+
+
+
{emptyState}
{composer}
diff --git a/webui/src/globals.css b/webui/src/globals.css index 1c677432c..802009ee7 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -25,9 +25,9 @@ --input: 0 0% 89.8%; --ring: 0 0% 3.9%; --radius: 0.4375rem; - --sidebar: 0 0% 98%; + --sidebar: 0 0% 98.5%; --sidebar-foreground: 0 0% 3.9%; - --sidebar-accent: 0 0% 96.1%; + --sidebar-accent: 0 0% 95.8%; --sidebar-accent-foreground: 0 0% 9%; --sidebar-border: 0 0% 89.8%; } @@ -52,9 +52,9 @@ --border: 0 0% 18%; --input: 0 0% 18%; --ring: 0 0% 83.1%; - --sidebar: 0 0% 12%; + --sidebar: 0 0% 11.5%; --sidebar-foreground: 0 0% 98%; - --sidebar-accent: 0 0% 16%; + --sidebar-accent: 0 0% 15.5%; --sidebar-accent-foreground: 0 0% 98%; --sidebar-border: 0 0% 18%; } diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index ec0312f7f..b25f5981a 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -38,6 +38,7 @@ export function useNanobotStream( chatId: string | null, initialMessages: UIMessage[] = [], hasPendingToolCalls = false, + onTurnEnd?: () => void, ): { messages: UIMessage[]; isStreaming: boolean; @@ -159,6 +160,12 @@ export function useNanobotStream( setMessages((prev) => prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)), ); + onTurnEnd?.(); + return; + } + + if (ev.event === "session_updated") { + onTurnEnd?.(); return; } @@ -233,7 +240,7 @@ export function useNanobotStream( streamEndTimerRef.current = null; } }; - }, [chatId, client]); + }, [chatId, client, onTurnEnd]); const send = useCallback( (content: string, images?: SendImage[]) => { diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index d16c2a118..e05e16a20 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -61,6 +61,7 @@ export function useSessions(): { chatId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + title: "", preview: "", }, ...prev.filter((s) => s.key !== key), @@ -221,7 +222,7 @@ export function sessionTitle( firstUserMessage?: string, ): string { return deriveTitle( - firstUserMessage || session.preview, + session.title || firstUserMessage || session.preview, i18n.t("chat.newChat"), ); } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 4ae832827..90e2532c3 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -18,11 +18,19 @@ } }, "sidebar": { + "navigation": "Sidebar navigation", + "globalActions": "Global actions", "collapse": "Collapse sidebar", "toggleTheme": "Toggle theme", + "home": "Home", "newChat": "New chat", + "searchAria": "Search chats", + "searchPlaceholder": "Search chats", + "searchResults": "Results", + "noSearchResults": "No matching chats.", "recent": "Recent", "refreshSessions": "Refresh sessions", + "settings": "Settings", "language": { "label": "Language", "ariaLabel": "Change language" @@ -34,7 +42,12 @@ "noSessions": "No sessions yet.", "actions": "Chat actions for {{title}}", "delete": "Delete", - "newChat": "New chat" + "newChat": "New chat", + "groups": { + "today": "Today", + "yesterday": "Yesterday", + "earlier": "Earlier" + } }, "deleteConfirm": { "title": "Delete “{{title}}”?", @@ -53,20 +66,55 @@ "thread": { "loadingConversation": "Loading conversation…", "empty": { - "description": "Ask questions, continue local work, or start a new thread." + "greeting": "What can I do for you?", + "quickActions": { + "plan": { + "title": "Create a project plan", + "prompt": "Create a concise project plan for what I should build next." + }, + "analyze": { + "title": "Analyze this data", + "prompt": "Help me analyze this data and call out the most important patterns." + }, + "brainstorm": { + "title": "Brainstorm ideas", + "prompt": "Brainstorm a few practical ideas and tradeoffs for this problem." + }, + "code": { + "title": "Write code", + "prompt": "Help me write the code for this task, starting with the smallest useful change." + }, + "summarize": { + "title": "Summarize this document", + "prompt": "Summarize this document and list the key takeaways." + }, + "more": { + "title": "More", + "prompt": "Show me a few useful ways you can help in this workspace." + } + } }, "header": { - "toggleSidebar": "Toggle sidebar" + "toggleSidebar": "Toggle sidebar", + "newChat": "Start a new chat", + "toggleTheme": "Toggle theme from header", + "settings": "Open settings" }, "composer": { "placeholderThread": "Type your message…", - "placeholderHero": "What's on your mind?", + "placeholderHero": "Ask anything...", "placeholderOpening": "Opening a new chat…", "placeholderStreaming": "Model is responding…", "inputAria": "Message input", "sendHint": "Enter to send · Shift+Enter for newline", "send": "Send message", "attachImage": "Attach image", + "tools": { + "search": "Search", + "reason": "Reason", + "deepResearch": "Deep research", + "voice": "Voice input" + }, "encoding": "Encoding…", "remove": "Remove attachment", "normalizedSizeHint": "{{orig}} → {{current}} (auto)", @@ -86,7 +134,9 @@ "assistantTyping": "Assistant is typing", "toolSingle": "Using a tool", "toolMany": "Used {{count}} tools", - "imageAttachment": "Image attachment" + "imageAttachment": "Image attachment", + "copyReply": "Copy reply", + "copiedReply": "Copied reply" }, "lightbox": { "title": "Image preview", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 347fec179..57c822317 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -18,11 +18,19 @@ } }, "sidebar": { + "navigation": "侧边栏导航", + "globalActions": "全局操作", "collapse": "收起侧边栏", "toggleTheme": "切换主题", + "home": "首页", "newChat": "新建对话", + "searchAria": "搜索会话", + "searchPlaceholder": "搜索会话", + "searchResults": "搜索结果", + "noSearchResults": "没有匹配的会话。", "recent": "最近对话", "refreshSessions": "刷新会话", + "settings": "设置", "language": { "label": "语言", "ariaLabel": "切换语言" @@ -34,7 +42,12 @@ "noSessions": "还没有会话。", "actions": "“{{title}}” 的会话操作", "delete": "删除", - "newChat": "新建对话" + "newChat": "新建对话", + "groups": { + "today": "今天", + "yesterday": "昨天", + "earlier": "更早" + } }, "deleteConfirm": { "title": "删除“{{title}}”?", @@ -53,20 +66,55 @@ "thread": { "loadingConversation": "正在加载对话…", "empty": { - "description": "可以提问、继续本地工作,或者开启一个新线程。" + "greeting": "我可以帮你做什么?", + "quickActions": { + "plan": { + "title": "创建项目计划", + "prompt": "帮我为接下来要做的事情写一份简洁的项目计划。" + }, + "analyze": { + "title": "分析这些数据", + "prompt": "帮我分析这些数据,并指出最重要的模式。" + }, + "brainstorm": { + "title": "头脑风暴想法", + "prompt": "围绕这个问题头脑风暴几个实用方案,并说明取舍。" + }, + "code": { + "title": "编写代码", + "prompt": "帮我为这个任务写代码,先从最小可用改动开始。" + }, + "summarize": { + "title": "总结这份文档", + "prompt": "帮我总结这份文档,并列出关键要点。" + }, + "more": { + "title": "更多", + "prompt": "展示几个你在这个工作区里可以帮我的实用方式。" + } + } }, "header": { - "toggleSidebar": "切换侧边栏" + "toggleSidebar": "切换侧边栏", + "newChat": "从顶部新建对话", + "toggleTheme": "从顶部切换主题", + "settings": "打开设置" }, "composer": { "placeholderThread": "输入消息…", - "placeholderHero": "你在想什么?", + "placeholderHero": "问任何问题...", "placeholderOpening": "正在打开新对话…", "placeholderStreaming": "模型正在回复…", "inputAria": "消息输入框", "sendHint": "Enter 发送 · Shift+Enter 换行", "send": "发送消息", "attachImage": "添加图片", + "tools": { + "search": "搜索", + "reason": "推理", + "deepResearch": "深度研究", + "voice": "语音输入" + }, "encoding": "处理中…", "remove": "移除附件", "normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)", @@ -86,7 +134,9 @@ "assistantTyping": "助手正在输入", "toolSingle": "正在使用工具", "toolMany": "已使用 {{count}} 个工具", - "imageAttachment": "图片附件" + "imageAttachment": "图片附件", + "copyReply": "复制回复", + "copiedReply": "已复制回复" }, "lightbox": { "title": "图片预览", diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 56fed32c7..95deb9b06 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -42,6 +42,7 @@ export async function listSessions( key: string; created_at: string | null; updated_at: string | null; + title?: string; preview?: string; }; const body = await request<{ sessions: Row[] }>( @@ -53,6 +54,7 @@ export async function listSessions( ...splitKey(s.key), createdAt: s.created_at, updatedAt: s.updated_at, + title: s.title ?? "", preview: s.preview ?? "", })); } diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index f5039f93f..2162cf439 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -185,8 +185,8 @@ export class NanobotClient { this.knownChats.add(chatId); const frame: Outbound = media && media.length > 0 - ? { type: "message", chat_id: chatId, content, media } - : { type: "message", chat_id: chatId, content }; + ? { type: "message", chat_id: chatId, content, media, webui: true } + : { type: "message", chat_id: chatId, content, webui: true }; this.queueSend(frame); } diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index e4c09ba16..c2428115d 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -56,6 +56,7 @@ export interface ChatSummary { chatId: string; createdAt: string | null; updatedAt: string | null; + title?: string; preview: string; } @@ -125,6 +126,7 @@ export type InboundEvent = stream_id?: string; } | { event: "turn_end"; chat_id: string } + | { event: "session_updated"; chat_id: string } | { event: "error"; chat_id?: string; detail?: string }; /** Base64-encoded image attached to an outbound ``message`` envelope. @@ -148,4 +150,7 @@ export type Outbound = chat_id: string; content: string; media?: OutboundMedia[]; + /** Marks messages sent by the embedded WebUI, without changing the + * generic websocket protocol for other clients. */ + webui?: true; }; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index aab940d5c..dc387d241 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api"; +import { deleteSession, fetchSessionMessages, listSessions, updateSettings } from "@/lib/api"; describe("webui API helpers", () => { beforeEach(() => { @@ -48,4 +48,28 @@ describe("webui API helpers", () => { }), ); }); + + it("maps generated session titles from the sessions list", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sessions: [ + { + key: "websocket:chat-1", + created_at: "2026-05-01T10:00:00", + updated_at: "2026-05-01T10:01:00", + title: "优化 WebUI 标题", + }, + ], + }), + } as Response); + + await expect(listSessions("tok")).resolves.toMatchObject([ + { + key: "websocket:chat-1", + title: "优化 WebUI 标题", + preview: "", + }, + ]); + }); }); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 77b9420dd..800fb82aa 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatSummary } from "@/lib/types"; @@ -7,6 +7,7 @@ const connectSpy = vi.fn(); const refreshSpy = vi.fn(); const createChatSpy = vi.fn().mockResolvedValue("chat-1"); const deleteChatSpy = vi.fn(); +const toggleThemeSpy = vi.fn(); let mockSessions: ChatSummary[] = []; vi.mock("@/hooks/useSessions", async (importOriginal) => { @@ -34,7 +35,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => { vi.mock("@/hooks/useTheme", () => ({ useTheme: () => ({ theme: "light" as const, - toggle: vi.fn(), + toggle: toggleThemeSpy, }), })); @@ -74,6 +75,7 @@ describe("App layout", () => { refreshSpy.mockReset(); createChatSpy.mockClear(); deleteChatSpy.mockReset(); + toggleThemeSpy.mockReset(); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -121,8 +123,11 @@ describe("App layout", () => { render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); await waitFor(() => - expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(), + expect( + within(sidebar).getByRole("button", { name: /^First chat$/ }), + ).toBeInTheDocument(), ); fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), { @@ -140,14 +145,24 @@ describe("App layout", () => { ); await waitFor(() => expect( - screen.getByRole("button", { name: /^Second chat$/ }), + within(sidebar).getByRole("button", { name: /^Second chat$/ }), ).toBeInTheDocument(), ); expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument(); expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); - it("opens the Cursor-style settings view from the sidebar", async () => { + it("opens the Cursor-style settings view from the header", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Existing chat", + }, + ]; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { @@ -180,10 +195,95 @@ describe("App layout", () => { render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByRole("button", { name: "Open settings" })); expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument(); expect(screen.getByText("AI")).toBeInTheDocument(); expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); }); + + it("filters sidebar sessions through the lightweight search row", async () => { + mockSessions = [ + { + key: "websocket:chat-alpha", + channel: "websocket", + chatId: "chat-alpha", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + preview: "Project planning notes", + }, + { + key: "websocket:chat-beta", + channel: "websocket", + chatId: "chat-beta", + createdAt: "2026-04-15T10:00:00Z", + updatedAt: "2026-04-15T10:00:00Z", + preview: "Travel ideas", + }, + ]; + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + expect(within(sidebar).getByText("Project planning notes")).toBeInTheDocument(); + expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), { + target: { value: "travel" }, + }); + + expect(within(sidebar).queryByText("Project planning notes")).not.toBeInTheDocument(); + expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument(); + }); + + it("opens a blank start page without creating an empty chat", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Existing chat", + }, + ]; + + const matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("1024px"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + vi.stubGlobal("matchMedia", matchMedia); + + const { container } = render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole("button", { name: "Toggle theme from header" })); + expect(toggleThemeSpy).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); + const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement; + await waitFor(() => expect(desktopAside.style.width).toBe("0px")); + + expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })); + await waitFor(() => expect(desktopAside.style.width).toBe("272px")); + + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" })); + expect(createChatSpy).not.toHaveBeenCalled(); + expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument(); + + expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument(); + }); }); diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index e8dec29ab..773c143c7 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { MessageBubble } from "@/components/MessageBubble"; import type { UIMessage } from "@/lib/types"; @@ -19,6 +19,44 @@ describe("MessageBubble", () => { expect(row).toHaveClass("ml-auto", "flex"); expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]"); + expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument(); + }); + + it("copies completed assistant replies from the action row", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const message: UIMessage = { + id: "a-copy", + role: "assistant", + content: "I can help with the next step.", + createdAt: Date.now(), + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy reply" })); + + expect(writeText).toHaveBeenCalledWith("I can help with the next step."); + await waitFor(() => + expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(), + ); + }); + + it("does not show copy actions for streaming placeholders", () => { + const message: UIMessage = { + id: "a-streaming", + role: "assistant", + content: "", + isStreaming: true, + createdAt: Date.now(), + }; + + render(); + + expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument(); }); it("renders trace messages as collapsible tool groups", () => { diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index b95ef6804..4c7923999 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -116,7 +116,7 @@ describe("NanobotClient", () => { // Attach is sent first because sendMessage adds to knownChats, which // handleOpen re-attaches; then the queued message follows. expect(lastSocket().sent).toContain( - JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello" }), + JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello", webui: true }), ); }); @@ -196,6 +196,7 @@ describe("NanobotClient", () => { chat_id: "chat-x", content: "look", media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }], + webui: true, }); }); @@ -214,6 +215,7 @@ describe("NanobotClient", () => { type: "message", chat_id: "chat-x", content: "hello", + webui: true, }); }); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 17205fb67..3d5c14e75 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -9,15 +9,38 @@ describe("ThreadComposer", () => { , ); expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument(); - const input = screen.getByPlaceholderText("What's on your mind?"); + expect(screen.queryByRole("button", { name: "Search" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reason" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Deep research" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Voice input" })).not.toBeInTheDocument(); + const input = screen.getByPlaceholderText("Ask anything..."); expect(input).toBeInTheDocument(); - expect(input.className).toContain("min-h-[96px]"); - expect(input.parentElement?.className).toContain("max-w-[40rem]"); + expect(input.className).toContain("min-h-[78px]"); + expect(input.parentElement?.className).toContain("max-w-[58rem]"); + }); + + it("keeps the thread composer compact while matching the hero style", () => { + render( + , + ); + + expect(screen.getByText("gpt-4o")).toBeInTheDocument(); + const input = screen.getByPlaceholderText("Type your message..."); + expect(input.className).toContain("min-h-[50px]"); + expect(input.parentElement?.className).toContain("max-w-[49.5rem]"); + expect(input.parentElement?.className).toContain("rounded-[22px]"); + expect(input.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]"); + expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card"); + expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground"); }); }); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index f5dea5960..68a81d1e1 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -86,6 +86,26 @@ describe("ThreadShell", () => { ); }); + it("does not navigate away when clicking the chat title", async () => { + const client = makeClient(); + const onGoHome = vi.fn(); + render(wrap( + client, + {}} + onGoHome={onGoHome} + onNewChat={() => {}} + />, + )); + + await waitFor(() => expect(screen.getByText("Important conversation")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Important conversation")); + + expect(onGoHome).not.toHaveBeenCalled(); + }); + it("restores in-memory messages when switching away and back to a session", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-a"); @@ -199,7 +219,67 @@ describe("ThreadShell", () => { await waitFor(() => { expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument(); }); - expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(); + }); + + it("creates a chat only when the blank landing sends a first message", async () => { + const client = makeClient(); + const onNewChat = vi.fn(); + const onCreateChat = vi.fn().mockResolvedValue("chat-new"); + + render( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + onCreateChat={onCreateChat} + />, + ), + ); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "start for real" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1)); + expect(onNewChat).not.toHaveBeenCalled(); + }); + + it("sends quick action prompts from the empty thread landing", async () => { + const client = makeClient(); + const onNewChat = vi.fn().mockResolvedValue("chat-a"); + + render( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Write code" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Write code" })); + + await waitFor(() => + expect(client.sendMessage).toHaveBeenCalledWith( + "chat-a", + "Help me write the code for this task, starting with the smallest useful change.", + undefined, + ), + ); }); it("does not leak the previous thread when opening a brand-new chat", async () => { @@ -260,10 +340,10 @@ describe("ThreadShell", () => { expect(screen.queryByText("old answer")).not.toBeInTheDocument(); await waitFor(() => - expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(), + expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(), ); - const input = screen.getByPlaceholderText("What's on your mind?"); - expect(input.className).toContain("min-h-[96px]"); + const input = screen.getByPlaceholderText("Ask anything..."); + expect(input.className).toContain("min-h-[78px]"); expect(screen.queryByText("old answer")).not.toBeInTheDocument(); }); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 2c7173174..155ec118e 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -159,7 +159,8 @@ describe("useNanobotStream", () => { it("keeps streaming alive across stream_end and completes on turn_end", () => { const fake = fakeClient(); - const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES), { + const onTurnEnd = vi.fn(); + const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), { wrapper: wrap(fake.client), }); @@ -211,5 +212,23 @@ describe("useNanobotStream", () => { expect(result.current.isStreaming).toBe(false); expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true); + expect(onTurnEnd).toHaveBeenCalledTimes(1); + }); + + it("refreshes session metadata when the server reports a session update", () => { + const fake = fakeClient(); + const onTurnEnd = vi.fn(); + renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-title", { + event: "session_updated", + chat_id: "chat-title", + }); + }); + + expect(onTurnEnd).toHaveBeenCalledTimes(1); }); }); From bad584cb0ed68316a2e46159b6d6e28e1f8d5295 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 6 May 2026 22:50:56 +0800 Subject: [PATCH 39/44] fix(webui): allow LAN access when host is 0.0.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webui bootstrap endpoint (/webui/bootstrap) rejected all non-localhost connections with HTTP 403, preventing the embedded webui from working when accessed from another device on the LAN — even when host was set to 0.0.0.0. Skip the localhost check when the server is explicitly bound to 0.0.0.0 or ::, since that signals intent to accept external connections. --- nanobot/channels/websocket.py | 4 ++- tests/channels/test_websocket_http_routes.py | 38 ++++++++++++++++++++ webui/README.md | 20 +++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 62e67a5b7..7a1e2a06f 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -607,7 +607,9 @@ class WebSocketChannel(BaseChannel): self._api_tokens.pop(token_key, None) def _handle_webui_bootstrap(self, connection: Any) -> Response: - if not _is_localhost(connection): + if self.config.host not in ("0.0.0.0", "::") and not _is_localhost( + connection, + ): return _http_error(403, "webui bootstrap is localhost-only") # Cap outstanding tokens to avoid runaway growth from a misbehaving client. self._purge_expired_issued_tokens() diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 51fd50f4a..e09611956 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -379,3 +379,41 @@ async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) -> headers = {"Authorization": "Bearer live"} assert channel._check_api_token(_LiveReq()) is True + + +class _FakeConn: + """Minimal connection stub with a configurable remote_address.""" + + def __init__(self, remote_address: tuple[str, int]): + self.remote_address = remote_address + + def respond(self, status: int, body: str) -> Any: + from websockets.http11 import Response + + return Response(status=status, body=body.encode()) + + +def test_bootstrap_rejects_non_localhost_by_default(bus: MagicMock) -> None: + channel = _ch(bus, host="127.0.0.1") + conn = _FakeConn(("192.168.1.5", 12345)) + resp = channel._handle_webui_bootstrap(conn) + assert resp.status_code == 403 + + +def test_bootstrap_allows_non_localhost_when_host_is_wildcard(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0") + conn = _FakeConn(("192.168.1.5", 12345)) + resp = channel._handle_webui_bootstrap(conn) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["token"].startswith("nbwt_") + assert body["ws_path"] == "/" + + +def test_bootstrap_allows_non_localhost_when_host_is_ipv6_wildcard( + bus: MagicMock, +) -> None: + channel = _ch(bus, host="::") + conn = _FakeConn(("192.168.1.5", 12345)) + resp = channel._handle_webui_bootstrap(conn) + assert resp.status_code == 200 diff --git a/webui/README.md b/webui/README.md index 602b179e7..056fe85f4 100644 --- a/webui/README.md +++ b/webui/README.md @@ -72,6 +72,26 @@ If your gateway listens on a non-default port, point the dev server at it: NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev ``` +### Access from another device (LAN) + +To use the webui from another device on the same network, set `host` to `"0.0.0.0"` in `~/.nanobot/config.json`: + +```json +{ + "channels": { + "websocket": { + "enabled": true, + "host": "0.0.0.0", + "port": 8765 + } + } +} +``` + +Then open `http://:8765` on the other device. When `host` is `"0.0.0.0"`, the bootstrap endpoint accepts requests from any source instead of restricting to localhost. + +> **Note:** This exposes the gateway to all interfaces. Only use on trusted networks. + ## Build for packaged runtime ```bash From 034bea1a445afcd91ff68c7888ab81e235b97791 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 6 May 2026 23:10:24 +0800 Subject: [PATCH 40/44] fix(webui): require token_issue_secret for non-localhost bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous LAN-access fix (PR #3656) relaxed the bootstrap localhost check when host was 0.0.0.0, but did not require any authentication — any device on the network could obtain a token without credentials. New behavior: - token_issue_secret configured: always validate, regardless of source IP (handles reverse-proxy scenarios where all connections appear as localhost). - No secret configured: only localhost can bootstrap (local dev mode). This supersedes the host-based check from PR #3656. --- nanobot/channels/websocket.py | 18 ++++-- tests/channels/test_websocket_http_routes.py | 61 +++++++++++++++----- webui/README.md | 9 +-- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 7a1e2a06f..58bd1515f 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -531,9 +531,9 @@ class WebSocketChannel(BaseChannel): if got == issue_expected: return self._handle_token_issue_http(connection, request) - # 2. WebUI bootstrap: localhost-only, mints tokens for the embedded UI. + # 2. WebUI bootstrap: mints tokens for the embedded UI. if got == "/webui/bootstrap": - return self._handle_webui_bootstrap(connection) + return self._handle_webui_bootstrap(connection, request) # 3. REST surface for the embedded UI. if got == "/api/sessions": @@ -606,10 +606,16 @@ class WebSocketChannel(BaseChannel): if now > expiry: self._api_tokens.pop(token_key, None) - def _handle_webui_bootstrap(self, connection: Any) -> Response: - if self.config.host not in ("0.0.0.0", "::") and not _is_localhost( - connection, - ): + def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response: + # When token_issue_secret is configured, validate it regardless of + # source IP. This secures deployments behind a reverse proxy (e.g. + # nginx) where all connections appear as localhost. + secret = self.config.token_issue_secret.strip() + if secret: + if not _issue_route_secret_matches(request.headers, secret): + return _http_error(401, "Unauthorized") + elif not _is_localhost(connection): + # No secret configured: only allow localhost (local dev mode). return _http_error(403, "webui bootstrap is localhost-only") # Cap outstanding tokens to avoid runaway growth from a misbehaving client. self._purge_expired_issued_tokens() diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index e09611956..2a87ef372 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -393,27 +393,58 @@ class _FakeConn: return Response(status=status, body=body.encode()) -def test_bootstrap_rejects_non_localhost_by_default(bus: MagicMock) -> None: - channel = _ch(bus, host="127.0.0.1") - conn = _FakeConn(("192.168.1.5", 12345)) - resp = channel._handle_webui_bootstrap(conn) +class _FakeReq: + """Minimal request stub with configurable headers.""" + + def __init__(self, headers: dict[str, str] | None = None): + self.headers = headers or {} + + +_REMOTE = _FakeConn(("192.168.1.5", 12345)) +_LOCAL = _FakeConn(("127.0.0.1", 12345)) +_NO_HEADERS = _FakeReq() + + +def test_bootstrap_rejects_non_localhost_without_secret(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0") + resp = channel._handle_webui_bootstrap(_REMOTE, _NO_HEADERS) assert resp.status_code == 403 -def test_bootstrap_allows_non_localhost_when_host_is_wildcard(bus: MagicMock) -> None: - channel = _ch(bus, host="0.0.0.0") - conn = _FakeConn(("192.168.1.5", 12345)) - resp = channel._handle_webui_bootstrap(conn) +def test_bootstrap_allows_localhost_without_secret(bus: MagicMock) -> None: + channel = _ch(bus, host="127.0.0.1") + resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS) + assert resp.status_code == 200 + + +def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"Authorization": "Bearer wrong"}) + ) + assert resp.status_code == 401 + + +def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"Authorization": "Bearer s3cret"}) + ) assert resp.status_code == 200 body = json.loads(resp.body) assert body["token"].startswith("nbwt_") - assert body["ws_path"] == "/" -def test_bootstrap_allows_non_localhost_when_host_is_ipv6_wildcard( - bus: MagicMock, -) -> None: - channel = _ch(bus, host="::") - conn = _FakeConn(("192.168.1.5", 12345)) - resp = channel._handle_webui_bootstrap(conn) +def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"}) + ) assert resp.status_code == 200 + + +def test_bootstrap_secret_also_enforced_on_localhost(bus: MagicMock) -> None: + """When secret is set, even localhost must provide it (reverse-proxy safety).""" + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS) + assert resp.status_code == 401 diff --git a/webui/README.md b/webui/README.md index 056fe85f4..ae561024e 100644 --- a/webui/README.md +++ b/webui/README.md @@ -74,7 +74,7 @@ NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev ### Access from another device (LAN) -To use the webui from another device on the same network, set `host` to `"0.0.0.0"` in `~/.nanobot/config.json`: +To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure `token_issue_secret` in `~/.nanobot/config.json`: ```json { @@ -82,15 +82,16 @@ To use the webui from another device on the same network, set `host` to `"0.0.0. "websocket": { "enabled": true, "host": "0.0.0.0", - "port": 8765 + "port": 8765, + "tokenIssueSecret": "your-secret-here" } } } ``` -Then open `http://:8765` on the other device. When `host` is `"0.0.0.0"`, the bootstrap endpoint accepts requests from any source instead of restricting to localhost. +Then open `http://:8765` on the other device. The bootstrap endpoint requires the secret via the `Authorization: Bearer ` header (or `X-Nanobot-Auth`). Without a configured secret, only localhost connections can bootstrap. -> **Note:** This exposes the gateway to all interfaces. Only use on trusted networks. +> **Note:** This exposes the gateway to all interfaces. Always set `tokenIssueSecret` on non-local networks. ## Build for packaged runtime From 4efd904ccccabc49504f3582b3b992892b5110a4 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 6 May 2026 23:15:18 +0800 Subject: [PATCH 41/44] fix(webui): require token_issue_secret for LAN access with frontend auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When host is set to 0.0.0.0, the gateway now enforces that either token or token_issue_secret must be configured — it refuses to start otherwise. Bootstrap endpoint behavior: - token_issue_secret configured: always validate regardless of source IP (handles reverse-proxy scenarios where all connections appear as localhost) - No secret: only localhost can bootstrap (local dev mode) The frontend shows an authentication form when bootstrap returns 401/403, persists the secret in localStorage, and retries automatically on reload. --- nanobot/channels/websocket.py | 19 +- tests/channels/test_websocket_http_routes.py | 49 ++++- webui/README.md | 6 +- webui/src/App.tsx | 167 ++++++++++++++---- .../src/components/settings/SettingsView.tsx | 20 +++ webui/src/i18n/locales/en/common.json | 12 ++ webui/src/lib/bootstrap.ts | 38 +++- webui/src/tests/app-layout.test.tsx | 3 + 8 files changed, 265 insertions(+), 49 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 58bd1515f..4838fcece 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -128,6 +128,17 @@ class WebSocketConfig(Base): raise ValueError("token_issue_path must differ from path (the WebSocket upgrade path)") return self + @model_validator(mode="after") + def wildcard_host_requires_auth(self) -> Self: + if self.host not in ("0.0.0.0", "::"): + return self + if self.token.strip() or self.token_issue_secret.strip(): + return self + raise ValueError( + "host is 0.0.0.0 (all interfaces) but neither token nor " + "token_issue_secret is set — set one to prevent unauthenticated access" + ) + def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response: body = json.dumps(data, ensure_ascii=False).encode("utf-8") @@ -607,10 +618,10 @@ class WebSocketChannel(BaseChannel): self._api_tokens.pop(token_key, None) def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response: - # When token_issue_secret is configured, validate it regardless of - # source IP. This secures deployments behind a reverse proxy (e.g. - # nginx) where all connections appear as localhost. - secret = self.config.token_issue_secret.strip() + # When a secret is configured (token_issue_secret or static token), + # validate it regardless of source IP. This secures deployments + # behind a reverse proxy where all connections appear as localhost. + secret = self.config.token_issue_secret.strip() or self.config.token.strip() if secret: if not _issue_route_secret_matches(request.headers, secret): return _http_error(401, "Unauthorized") diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 2a87ef372..40ba19288 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -405,13 +405,52 @@ _LOCAL = _FakeConn(("127.0.0.1", 12345)) _NO_HEADERS = _FakeReq() -def test_bootstrap_rejects_non_localhost_without_secret(bus: MagicMock) -> None: - channel = _ch(bus, host="0.0.0.0") - resp = channel._handle_webui_bootstrap(_REMOTE, _NO_HEADERS) - assert resp.status_code == 403 +def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None: + import pytest + from pydantic_core import ValidationError + + with pytest.raises(ValidationError, match="token"): + _ch(bus, host="0.0.0.0") -def test_bootstrap_allows_localhost_without_secret(bus: MagicMock) -> None: +def test_wildcard_host_with_token_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", token="my-token") + assert channel.config.host == "0.0.0.0" + + +def test_wildcard_host_with_secret_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret") + assert channel.config.host == "0.0.0.0" + + +def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None: + import pytest + from pydantic_core import ValidationError + + with pytest.raises(ValidationError, match="token"): + _ch(bus, host="::") + + +def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="::", tokenIssueSecret="s3cret") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"}) + ) + assert resp.status_code == 200 + + +def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None: + """When only token (not token_issue_secret) is set, bootstrap accepts it.""" + channel = _ch(bus, host="0.0.0.0", token="static-tok") + resp = channel._handle_webui_bootstrap( + _REMOTE, _FakeReq({"Authorization": "Bearer static-tok"}) + ) + assert resp.status_code == 200 + body = json.loads(resp.body) + assert body["token"].startswith("nbwt_") + + +def test_localhost_without_auth_is_valid(bus: MagicMock) -> None: channel = _ch(bus, host="127.0.0.1") resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS) assert resp.status_code == 200 diff --git a/webui/README.md b/webui/README.md index ae561024e..b99874ba0 100644 --- a/webui/README.md +++ b/webui/README.md @@ -74,7 +74,7 @@ NANOBOT_API_URL=http://127.0.0.1:9000 bun run dev ### Access from another device (LAN) -To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure `token_issue_secret` in `~/.nanobot/config.json`: +To use the webui from another device on the same network, set `host` to `"0.0.0.0"` and configure a `token` or `tokenIssueSecret` in `~/.nanobot/config.json`: ```json { @@ -89,9 +89,9 @@ To use the webui from another device on the same network, set `host` to `"0.0.0. } ``` -Then open `http://:8765` on the other device. The bootstrap endpoint requires the secret via the `Authorization: Bearer ` header (or `X-Nanobot-Auth`). Without a configured secret, only localhost connections can bootstrap. +The gateway will refuse to start if `host` is `"0.0.0.0"` and neither `token` nor `tokenIssueSecret` is set. -> **Note:** This exposes the gateway to all interfaces. Always set `tokenIssueSecret` on non-local networks. +Then open `http://:8765` on the other device. The webui will show an authentication form where you enter the secret. It is saved in your browser so you only need to enter it once. ## Build for packaged runtime diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 0fbb3f54f..9eca02688 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -9,14 +9,23 @@ import { preloadMarkdownText } from "@/components/MarkdownText"; import { useSessions } from "@/hooks/useSessions"; import { useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; -import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; +import { + clearSavedSecret, + deriveWsUrl, + fetchBootstrap, + loadSavedSecret, + saveSecret, +} from "@/lib/bootstrap"; import { NanobotClient } from "@/lib/nanobot-client"; import { ClientProvider } from "@/providers/ClientProvider"; import type { ChatSummary } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; type BootState = | { status: "loading" } | { status: "error"; message: string } + | { status: "auth"; failed?: boolean } | { status: "ready"; client: NanobotClient; @@ -28,6 +37,60 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; const SIDEBAR_WIDTH = 272; type ShellView = "chat" | "settings"; +function AuthForm({ + failed, + onSecret, +}: { + failed: boolean; + onSecret: (secret: string) => void; +}) { + const { t } = useTranslation(); + const [value, setValue] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const secret = value.trim(); + if (!secret) return; + setSubmitting(true); + onSecret(secret); + }; + + return ( +
+
+
+

{t("app.auth.title")}

+

{t("app.auth.hint")}

+
+ {failed && ( +

+ {t("app.auth.invalid")} +

+ )} + setValue(e.target.value)} + disabled={submitting} + autoFocus + /> + +
+
+ ); +} + function readSidebarOpen(): boolean { if (typeof window === "undefined") return true; try { @@ -43,40 +106,55 @@ export default function App() { const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); + const bootstrapWithSecret = useCallback( + (secret: string) => { + let cancelled = false; + (async () => { + setState({ status: "loading" }); + try { + const boot = await fetchBootstrap("", secret); + if (cancelled) return; + if (secret) saveSecret(secret); + const url = deriveWsUrl(boot.ws_path, boot.token); + const client = new NanobotClient({ + url, + onReauth: async () => { + try { + const refreshed = await fetchBootstrap("", secret); + return deriveWsUrl(refreshed.ws_path, refreshed.token); + } catch { + return null; + } + }, + }); + client.connect(); + setState({ + status: "ready", + client, + token: boot.token, + modelName: boot.model_name ?? null, + }); + } catch (e) { + if (cancelled) return; + const msg = (e as Error).message; + if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) { + setState({ status: "auth", failed: true }); + } else { + setState({ status: "error", message: msg }); + } + } + })(); + return () => { + cancelled = true; + }; + }, + [], + ); + useEffect(() => { - let cancelled = false; - (async () => { - try { - const boot = await fetchBootstrap(); - if (cancelled) return; - const url = deriveWsUrl(boot.ws_path, boot.token); - const client = new NanobotClient({ - url, - onReauth: async () => { - try { - const refreshed = await fetchBootstrap(); - return deriveWsUrl(refreshed.ws_path, refreshed.token); - } catch { - return null; - } - }, - }); - client.connect(); - setState({ - status: "ready", - client, - token: boot.token, - modelName: boot.model_name ?? null, - }); - } catch (e) { - if (cancelled) return; - setState({ status: "error", message: (e as Error).message }); - } - })(); - return () => { - cancelled = true; - }; - }, []); + const saved = loadSavedSecret(); + return bootstrapWithSecret(saved); + }, [bootstrapWithSecret]); useEffect(() => { const warm = () => preloadMarkdownText(); @@ -110,6 +188,14 @@ export default function App() {
); } + if (state.status === "auth") { + return ( + bootstrapWithSecret(s)} + /> + ); + } if (state.status === "error") { return (
@@ -130,18 +216,26 @@ export default function App() { ); }; + const handleLogout = () => { + if (state.status === "ready") { + state.client.close(); + } + clearSavedSecret(); + setState({ status: "auth" }); + }; + return ( - + ); } -function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | null) => void }) { +function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) { const { t, i18n } = useTranslation(); const { theme, toggle } = useTheme(); const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); @@ -319,6 +413,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | onToggleTheme={toggle} onBackToChat={() => setView("chat")} onModelNameChange={onModelNameChange} + onLogout={onLogout} /> ) : ( void; onBackToChat: () => void; onModelNameChange: (modelName: string | null) => void; + onLogout?: () => void; } export function SettingsView({ onBackToChat, onModelNameChange, + onLogout, }: SettingsViewProps) { const { token } = useClient(); const [settings, setSettings] = useState(null); @@ -115,6 +118,7 @@ export function SettingsView({ dirty={dirty} saving={saving} onSave={save} + onLogout={onLogout} /> ) : null} @@ -129,6 +133,7 @@ function SettingsSection({ dirty, saving, onSave, + onLogout, }: { form: { model: string; @@ -142,7 +147,9 @@ function SettingsSection({ dirty: boolean; saving: boolean; onSave: () => void; + onLogout?: () => void; }) { + const { t } = useTranslation(); return (
@@ -192,6 +199,19 @@ function SettingsSection({
+ + {onLogout && ( +
+

{t("app.account.section")}

+ + + + + +
+ )}
); } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 90e2532c3..0d8221bf8 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -9,6 +9,18 @@ "title": "Couldn't reach nanobot", "gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine." }, + "auth": { + "title": "Authentication required", + "hint": "Enter the secret configured as tokenIssueSecret in your gateway config.", + "placeholder": "Password", + "submit": "Connect", + "invalid": "Invalid password. Try again." + }, + "account": { + "section": "Account", + "logoutHint": "Disconnect this browser from the gateway.", + "logout": "Sign out" + }, "documentTitle": { "base": "nanobot", "chat": "{{title}} · nanobot" diff --git a/webui/src/lib/bootstrap.ts b/webui/src/lib/bootstrap.ts index 66d2b5958..931484a87 100644 --- a/webui/src/lib/bootstrap.ts +++ b/webui/src/lib/bootstrap.ts @@ -1,15 +1,51 @@ import type { BootstrapResponse } from "./types"; +const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret"; + +/** Read a previously saved bootstrap secret from localStorage. */ +export function loadSavedSecret(): string { + if (typeof window === "undefined") return ""; + try { + return window.localStorage.getItem(SECRET_STORAGE_KEY) ?? ""; + } catch { + return ""; + } +} + +/** Persist the bootstrap secret so page reloads don't re-prompt. */ +export function saveSecret(secret: string): void { + try { + window.localStorage.setItem(SECRET_STORAGE_KEY, secret); + } catch { + // ignore storage errors (private mode, etc.) + } +} + +/** Clear the saved bootstrap secret (sign out). */ +export function clearSavedSecret(): void { + try { + window.localStorage.removeItem(SECRET_STORAGE_KEY); + } catch { + // ignore + } +} + /** * Fetch a short-lived token + the WebSocket path from the gateway's - * ``/webui/bootstrap`` endpoint. Localhost-only on the server side. + * ``/webui/bootstrap`` endpoint. */ export async function fetchBootstrap( baseUrl: string = "", + secret: string = "", ): Promise { + const headers: Record = {}; + if (secret) { + headers["X-Nanobot-Auth"] = secret; + } const res = await fetch(`${baseUrl}/webui/bootstrap`, { method: "GET", credentials: "same-origin", + headers, }); if (!res.ok) { throw new Error(`bootstrap failed: HTTP ${res.status}`); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 800fb82aa..25248230e 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -46,6 +46,9 @@ vi.mock("@/lib/bootstrap", () => ({ expires_in: 300, }), deriveWsUrl: vi.fn(() => "ws://test"), + loadSavedSecret: vi.fn(() => ""), + saveSecret: vi.fn(), + clearSavedSecret: vi.fn(), })); vi.mock("@/lib/nanobot-client", () => { From 98c2f7cc27e7c42b55f8038719a5bf729f78e360 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 6 May 2026 23:23:04 +0800 Subject: [PATCH 42/44] fix(weixin): raise exceptions instead of silently dropping messages _send_text() swallowed API errors (non-zero errcode) with just a warning log, and send() had three silent return paths (no client, session paused, no context_token). Neither triggered ChannelManager's retry logic, causing persistent message loss until a new inbound message refreshed the context_token. Now all failure paths raise RuntimeError, matching BaseChannel's contract and enabling proper retry behavior. --- nanobot/channels/weixin.py | 20 +++------- tests/channels/test_weixin_channel.py | 53 +++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index 698acc70e..cd46775f9 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -940,12 +940,8 @@ class WeixinChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: if not self._client or not self._token: - self.logger.warning("client not initialized or not authenticated") - return - try: - self._assert_session_active() - except RuntimeError: - return + raise RuntimeError("WeChat client not initialized or not authenticated") + self._assert_session_active() is_progress = bool((msg.metadata or {}).get("_progress", False)) if not is_progress: @@ -954,11 +950,9 @@ class WeixinChannel(BaseChannel): content = msg.content.strip() ctx_token = self._context_tokens.get(msg.chat_id, "") if not ctx_token: - self.logger.warning( - "no context_token for chat_id={}, cannot send", - msg.chat_id, + raise RuntimeError( + f"No context_token for chat_id={msg.chat_id}, cannot send" ) - return typing_ticket = "" with suppress(Exception): @@ -1128,10 +1122,8 @@ class WeixinChannel(BaseChannel): data = await self._api_post("ilink/bot/sendmessage", body) errcode = data.get("errcode", 0) if errcode and errcode != 0: - self.logger.warning( - "send error (code {}): {}", - errcode, - data.get("errmsg", ""), + raise RuntimeError( + f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}" ) async def _send_media_file( diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 4b9b294a9..1cfeb9dd3 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -319,21 +319,22 @@ async def test_process_message_does_not_fallback_when_top_level_media_exists_but @pytest.mark.asyncio -async def test_send_without_context_token_does_not_send_text() -> None: +async def test_send_without_context_token_raises() -> None: channel, _bus = _make_channel() channel._client = object() channel._token = "token" channel._send_text = AsyncMock() - await channel.send( - type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})() - ) + with pytest.raises(RuntimeError, match="No context_token"): + await channel.send( + type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})() + ) channel._send_text.assert_not_awaited() @pytest.mark.asyncio -async def test_send_does_not_send_when_session_is_paused() -> None: +async def test_send_raises_when_session_is_paused() -> None: channel, _bus = _make_channel() channel._client = object() channel._token = "token" @@ -341,9 +342,10 @@ async def test_send_does_not_send_when_session_is_paused() -> None: channel._pause_session(60) channel._send_text = AsyncMock() - await channel.send( - type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() - ) + with pytest.raises(RuntimeError, match="session paused"): + await channel.send( + type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})() + ) channel._send_text.assert_not_awaited() @@ -1213,3 +1215,38 @@ async def test_send_media_network_error_does_not_double_api_calls() -> None: # _send_media_file called once, _send_text never called channel._send_media_file.assert_awaited_once() channel._send_text.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Tests for _send_text raising on API errors (previously silently swallowed) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_send_text_raises_on_api_error() -> None: + """_send_text must raise RuntimeError when the API returns a non-zero errcode, + matching _send_media_file behavior. This ensures ChannelManager can retry.""" + channel, _bus = _make_channel() + channel._client = httpx.AsyncClient() + channel._token = "token" + channel._api_post = AsyncMock( + return_value={"errcode": -14, "errmsg": "session expired"} + ) + + with pytest.raises(RuntimeError, match="WeChat send text error.*-14"): + await channel._send_text("wx-user", "hello", "ctx-expired") + + channel._api_post.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_text_succeeds_on_zero_errcode() -> None: + """_send_text must NOT raise when errcode is 0.""" + channel, _bus = _make_channel() + channel._client = httpx.AsyncClient() + channel._token = "token" + channel._api_post = AsyncMock(return_value={"errcode": 0}) + + await channel._send_text("wx-user", "hello", "ctx-ok") + + channel._api_post.assert_awaited_once() From 49c07aa45a745c11ec1866e5999d072c96c999ec Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 6 May 2026 23:38:09 +0800 Subject: [PATCH 43/44] style: address code review feedback - Consistent "WeChat" prefix in context_token error message - Use object() instead of httpx.AsyncClient() in new tests to avoid resource leak warnings --- nanobot/channels/weixin.py | 2 +- tests/channels/test_weixin_channel.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index cd46775f9..dff830613 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -951,7 +951,7 @@ class WeixinChannel(BaseChannel): ctx_token = self._context_tokens.get(msg.chat_id, "") if not ctx_token: raise RuntimeError( - f"No context_token for chat_id={msg.chat_id}, cannot send" + f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send" ) typing_ticket = "" diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 1cfeb9dd3..1ca814561 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -325,7 +325,7 @@ async def test_send_without_context_token_raises() -> None: channel._token = "token" channel._send_text = AsyncMock() - with pytest.raises(RuntimeError, match="No context_token"): + with pytest.raises(RuntimeError, match="context_token missing"): await channel.send( type("Msg", (), {"chat_id": "unknown-user", "content": "pong", "media": [], "metadata": {}})() ) @@ -1227,7 +1227,7 @@ async def test_send_text_raises_on_api_error() -> None: """_send_text must raise RuntimeError when the API returns a non-zero errcode, matching _send_media_file behavior. This ensures ChannelManager can retry.""" channel, _bus = _make_channel() - channel._client = httpx.AsyncClient() + channel._client = object() channel._token = "token" channel._api_post = AsyncMock( return_value={"errcode": -14, "errmsg": "session expired"} @@ -1243,7 +1243,7 @@ async def test_send_text_raises_on_api_error() -> None: async def test_send_text_succeeds_on_zero_errcode() -> None: """_send_text must NOT raise when errcode is 0.""" channel, _bus = _make_channel() - channel._client = httpx.AsyncClient() + channel._client = object() channel._token = "token" channel._api_post = AsyncMock(return_value={"errcode": 0}) From ac18a8baadbcf4e3bfa61e3c10fc5dd04b06a678 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 6 May 2026 15:54:15 +0000 Subject: [PATCH 44/44] feat(webui): add localized slash commands Add a session-scoped slash command palette sourced from backend command metadata, and keep welcome-page quick actions localized across all WebUI languages. Co-authored-by: Cursor --- nanobot/channels/websocket.py | 9 + nanobot/command/builtin.py | 101 +++++++- tests/channels/test_websocket_channel.py | 28 ++ .../src/components/thread/ThreadComposer.tsx | 242 +++++++++++++++++- webui/src/components/thread/ThreadShell.tsx | 37 ++- webui/src/i18n/locales/en/common.json | 45 ++++ webui/src/i18n/locales/es/common.json | 74 +++++- webui/src/i18n/locales/fr/common.json | 74 +++++- webui/src/i18n/locales/id/common.json | 74 +++++- webui/src/i18n/locales/ja/common.json | 74 +++++- webui/src/i18n/locales/ko/common.json | 74 +++++- webui/src/i18n/locales/vi/common.json | 74 +++++- webui/src/i18n/locales/zh-CN/common.json | 45 ++++ webui/src/i18n/locales/zh-TW/common.json | 74 +++++- webui/src/lib/api.ts | 23 +- webui/src/lib/types.ts | 8 + webui/src/tests/api.test.ts | 41 ++- webui/src/tests/i18n.test.tsx | 15 ++ webui/src/tests/thread-composer.test.tsx | 50 +++- webui/src/tests/thread-shell.test.tsx | 134 ++++++++++ 20 files changed, 1258 insertions(+), 38 deletions(-) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 4838fcece..7d4d20625 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -32,6 +32,7 @@ from websockets.http11 import Response from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.command.builtin import builtin_command_palette from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base from nanobot.utils.helpers import safe_filename @@ -553,6 +554,9 @@ class WebSocketChannel(BaseChannel): if got == "/api/settings": return self._handle_settings(request) + if got == "/api/commands": + return self._handle_commands(request) + if got == "/api/settings/update": return self._handle_settings_update(request) @@ -708,6 +712,11 @@ class WebSocketChannel(BaseChannel): return _http_error(401, "Unauthorized") return _http_json_response(self._settings_payload()) + def _handle_commands(self, request: WsRequest) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + return _http_json_response({"commands": builtin_command_palette()}) + def _handle_settings_update(self, request: WsRequest) -> Response: if not self._check_api_token(request): return _http_error(401, "Unauthorized") diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 32444a4ba..b71a77f91 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -6,6 +6,7 @@ import asyncio import os import sys from contextlib import suppress +from dataclasses import dataclass from nanobot import __version__ from nanobot.bus.events import OutboundMessage @@ -14,6 +15,88 @@ from nanobot.utils.helpers import build_status_content from nanobot.utils.restart import set_restart_notice_to_env +@dataclass(frozen=True) +class BuiltinCommandSpec: + command: str + title: str + description: str + icon: str + arg_hint: str = "" + + def as_dict(self) -> dict[str, str]: + return { + "command": self.command, + "title": self.title, + "description": self.description, + "icon": self.icon, + "arg_hint": self.arg_hint, + } + + +BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( + BuiltinCommandSpec( + "/new", + "New chat", + "Stop the current task and start a fresh conversation.", + "square-pen", + ), + BuiltinCommandSpec( + "/stop", + "Stop current task", + "Cancel the active agent turn for this chat.", + "square", + ), + BuiltinCommandSpec( + "/restart", + "Restart nanobot", + "Restart the bot process in place.", + "rotate-cw", + ), + BuiltinCommandSpec( + "/status", + "Show status", + "Display runtime, provider, and channel status.", + "activity", + ), + BuiltinCommandSpec( + "/history", + "Show conversation history", + "Print the last N persisted conversation messages.", + "history", + "[n]", + ), + BuiltinCommandSpec( + "/dream", + "Run Dream", + "Manually trigger memory consolidation.", + "sparkles", + ), + BuiltinCommandSpec( + "/dream-log", + "Show Dream log", + "Show what the last Dream consolidation changed.", + "book-open", + ), + BuiltinCommandSpec( + "/dream-restore", + "Restore memory", + "Revert memory to a previous Dream snapshot.", + "undo-2", + ), + BuiltinCommandSpec( + "/help", + "Show help", + "List available slash commands.", + "circle-help", + ), +) + + +def builtin_command_palette() -> list[dict[str, str]]: + """Return structured command metadata for UI command palettes.""" + return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS] + + async def cmd_stop(ctx: CommandContext) -> OutboundMessage: """Cancel all active tasks and subagents for the session.""" loop = ctx.loop @@ -378,18 +461,12 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage: def build_help_text() -> str: """Build canonical help text shared across channels.""" - lines = [ - "🐈 nanobot commands:", - "/new — Stop current task and start a new conversation", - "/stop — Stop the current task", - "/restart — Restart the bot", - "/status — Show bot status", - "/history [n] — Show the last N conversation messages (default 10)", - "/dream — Manually trigger Dream consolidation", - "/dream-log — Show what the last Dream changed", - "/dream-restore — Revert memory to a previous state", - "/help — Show available commands", - ] + lines = ["🐈 nanobot commands:"] + for spec in BUILTIN_COMMAND_SPECS: + command = spec.command + if spec.arg_hint: + command = f"{command} {spec.arg_hint}" + lines.append(f"{command} — {spec.description}") return "\n".join(lines) diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index f20095388..e757551f2 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -563,6 +563,34 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( await server_task +@pytest.mark.asyncio +async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None: + port = 29892 + channel = _ch(bus, port=port) + channel._api_tokens["tok"] = time.monotonic() + 300 + + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + + try: + denied = await _http_get(f"http://127.0.0.1:{port}/api/commands") + assert denied.status_code == 401 + + response = await _http_get( + f"http://127.0.0.1:{port}/api/commands", + headers={"Authorization": "Bearer tok"}, + ) + assert response.status_code == 200 + body = response.json() + commands = {row["command"]: row for row in body["commands"]} + assert commands["/stop"]["title"] == "Stop current task" + assert commands["/history"]["arg_hint"] == "[n]" + assert all("description" in row for row in body["commands"]) + finally: + await channel.stop() + await server_task + + def test_settings_payload_normalizes_camel_case_provider( bus: MagicMock, monkeypatch, diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 5f86190b1..ac994f89e 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -7,11 +7,21 @@ import { type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { + Activity, ArrowUp, + BookOpen, + CircleHelp, + History, ImageIcon, Loader2, Plus, + RotateCw, + Sparkles, + Square, + SquarePen, + Undo2, X, + type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -24,6 +34,7 @@ import { } from "@/hooks/useAttachedImages"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import type { SendImage } from "@/hooks/useNanobotStream"; +import type { SlashCommand } from "@/lib/types"; import { cn } from "@/lib/utils"; /** ````: aligned with the server's MIME whitelist. SVG is @@ -43,6 +54,23 @@ interface ThreadComposerProps { isStreaming?: boolean; modelLabel?: string | null; variant?: "thread" | "hero"; + slashCommands?: SlashCommand[]; +} + +const COMMAND_ICONS: Record = { + activity: Activity, + "book-open": BookOpen, + "circle-help": CircleHelp, + history: History, + "rotate-cw": RotateCw, + sparkles: Sparkles, + square: Square, + "square-pen": SquarePen, + "undo-2": Undo2, +}; + +function slashCommandI18nKey(command: string): string { + return command.replace(/^\//, "").replace(/-/g, "_"); } export function ThreadComposer({ @@ -52,10 +80,13 @@ export function ThreadComposer({ isStreaming = false, modelLabel = null, variant = "thread", + slashCommands = [], }: ThreadComposerProps) { const { t } = useTranslation(); const [value, setValue] = useState(""); const [inlineError, setInlineError] = useState(null); + const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); + const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const textareaRef = useRef(null); const fileInputRef = useRef(null); const chipRefs = useRef(new Map()); @@ -119,6 +150,66 @@ export function ThreadComposer({ && !hasErrors && (value.trim().length > 0 || readyImages.length > 0); + const slashQuery = useMemo(() => { + if (disabled || slashMenuDismissed || !value.startsWith("/")) return null; + const commandToken = value.slice(1); + if (/\s/.test(commandToken)) return null; + return commandToken.toLowerCase(); + }, [disabled, slashMenuDismissed, value]); + + const filteredSlashCommands = useMemo(() => { + if (slashQuery === null) return []; + return slashCommands + .filter((command) => { + const haystack = [ + command.command, + command.title, + command.description, + command.argHint ?? "", + t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, { + defaultValue: "", + }), + t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, { + defaultValue: "", + }), + ].join(" ").toLowerCase(); + return haystack.includes(slashQuery); + }) + .slice(0, 8); + }, [slashCommands, slashQuery, t]); + + const showSlashMenu = filteredSlashCommands.length > 0; + + useEffect(() => { + setSelectedCommandIndex(0); + }, [slashQuery]); + + useEffect(() => { + if (selectedCommandIndex >= filteredSlashCommands.length) { + setSelectedCommandIndex(0); + } + }, [filteredSlashCommands.length, selectedCommandIndex]); + + const resizeTextarea = useCallback(() => { + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 260)}px`; + el.focus(); + }); + }, []); + + const chooseSlashCommand = useCallback( + (command: SlashCommand) => { + setValue(command.argHint ? `${command.command} ` : command.command); + setSlashMenuDismissed(true); + setInlineError(null); + resizeTextarea(); + }, + [resizeTextarea], + ); + const submit = useCallback(() => { if (!canSend) return; const trimmed = value.trim(); @@ -142,16 +233,35 @@ export function ThreadComposer({ // Bubble owns the data URL copy; safe to revoke every staged blob // preview here without affecting the rendered message. clear(); - requestAnimationFrame(() => { - const el = textareaRef.current; - if (el) { - el.style.height = "auto"; - el.focus(); - } - }); - }, [canSend, clear, onSend, readyImages, value]); + setSlashMenuDismissed(false); + resizeTextarea(); + }, [canSend, clear, onSend, readyImages, resizeTextarea, value]); const onKeyDown = (e: ReactKeyboardEvent) => { + if (showSlashMenu) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedCommandIndex((idx) => (idx + 1) % filteredSlashCommands.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedCommandIndex( + (idx) => (idx - 1 + filteredSlashCommands.length) % filteredSlashCommands.length, + ); + return; + } + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { + e.preventDefault(); + chooseSlashCommand(filteredSlashCommands[selectedCommandIndex]); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setSlashMenuDismissed(true); + return; + } + } if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); submit(); @@ -213,8 +323,17 @@ export function ThreadComposer({ onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} - className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} + className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} > + {showSlashMenu ? ( + + ) : null}
setValue(e.target.value)} + onChange={(e) => { + setValue(e.target.value); + setSlashMenuDismissed(false); + }} onInput={onInput} onKeyDown={onKeyDown} onPaste={onPaste} @@ -367,6 +489,106 @@ export function ThreadComposer({ ); } +interface SlashCommandPaletteProps { + commands: SlashCommand[]; + selectedIndex: number; + isHero: boolean; + onHover: (index: number) => void; + onChoose: (command: SlashCommand) => void; +} + +function SlashCommandPalette({ + commands, + selectedIndex, + isHero, + onHover, + onChoose, +}: SlashCommandPaletteProps) { + const { t } = useTranslation(); + return ( +
+
+ {t("thread.composer.slash.label")} +
+
+ {commands.map((command, index) => { + const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp; + const selected = index === selectedIndex; + const commandKey = slashCommandI18nKey(command.command); + const title = t(`thread.composer.slash.commands.${commandKey}.title`, { + defaultValue: command.title, + }); + const description = t(`thread.composer.slash.commands.${commandKey}.description`, { + defaultValue: command.description, + }); + return ( + + ); + })} +
+
+ {t("thread.composer.slash.navigateHint")} + {t("thread.composer.slash.selectHint")} + {t("thread.composer.slash.closeHint")} +
+
+ ); +} + interface AttachmentChipProps { image: AttachedImage; labelRemove: string; diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 45b164b44..f15551ce5 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { BarChart3, BookOpen, @@ -17,7 +17,8 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { ThreadViewport } from "@/components/thread/ThreadViewport"; import { useNanobotStream } from "@/hooks/useNanobotStream"; import { useSessionHistory } from "@/hooks/useSessions"; -import type { ChatSummary, UIMessage } from "@/lib/types"; +import { listSlashCommands } from "@/lib/api"; +import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types"; import { useClient } from "@/providers/ClientProvider"; interface ThreadShellProps { @@ -66,8 +67,9 @@ export function ThreadShell({ const chatId = session?.chatId ?? null; const historyKey = session?.key ?? null; const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); - const { client, modelName } = useClient(); + const { client, modelName, token } = useClient(); const [booting, setBooting] = useState(false); + const [slashCommands, setSlashCommands] = useState([]); const pendingFirstRef = useRef(null); const messageCacheRef = useRef>(new Map()); const lastCachedChatIdRef = useRef(null); @@ -116,17 +118,24 @@ export function ThreadShell({ setMessages(historical); }, [chatId, historical, setMessages]); - useEffect(() => { - if (!chatId) return; + useLayoutEffect(() => { + if (!chatId) { + lastCachedChatIdRef.current = null; + return; + } + if (loading) return; // Skip the first cache write after a chat switch. During that render, // `messages` can still belong to the previous chat until the stream hook // resets its local state for the new session. if (lastCachedChatIdRef.current !== chatId) { lastCachedChatIdRef.current = chatId; + if (messages.length > 0) { + messageCacheRef.current.set(chatId, messages); + } return; } messageCacheRef.current.set(chatId, messages); - }, [chatId, messages]); + }, [chatId, loading, messages]); useEffect(() => { if (!chatId) return; @@ -146,6 +155,21 @@ export function ThreadShell({ setBooting(false); }, [chatId, client, setMessages]); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const commands = await listSlashCommands(token); + if (!cancelled) setSlashCommands(commands); + } catch { + if (!cancelled) setSlashCommands([]); + } + })(); + return () => { + cancelled = true; + }; + }, [token]); + const handleWelcomeSend = useCallback( async (content: string) => { if (booting) return; @@ -222,6 +246,7 @@ export function ThreadShell({ } modelLabel={toModelBadgeLabel(modelName)} variant={showHeroComposer ? "hero" : "thread"} + slashCommands={slashCommands} /> ) : ( (`${base}/api/settings`, token); } +export async function listSlashCommands( + token: string, + base: string = "", +): Promise { + type Row = { + command: string; + title: string; + description: string; + icon: string; + arg_hint?: string; + }; + const body = await request<{ commands: Row[] }>(`${base}/api/commands`, token); + return body.commands.map((command) => ({ + command: command.command, + title: command.title, + description: command.description, + icon: command.icon, + argHint: command.arg_hint ?? "", + })); +} + export async function updateSettings( token: string, update: SettingsUpdate, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index c2428115d..cc5e7ae29 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -89,6 +89,14 @@ export interface SettingsUpdate { provider?: string; } +export interface SlashCommand { + command: string; + title: string; + description: string; + icon: string; + argHint?: string; +} + export type ConnectionStatus = | "idle" | "connecting" diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index dc387d241..aa44651f5 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { deleteSession, fetchSessionMessages, listSessions, updateSettings } from "@/lib/api"; +import { + deleteSession, + fetchSessionMessages, + listSessions, + listSlashCommands, + updateSettings, +} from "@/lib/api"; describe("webui API helpers", () => { beforeEach(() => { @@ -72,4 +78,37 @@ describe("webui API helpers", () => { }, ]); }); + + it("maps slash command metadata from the commands endpoint", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + commands: [ + { + command: "/history", + title: "Show conversation history", + description: "Print the last N messages.", + icon: "history", + arg_hint: "[n]", + }, + ], + }), + } as Response); + + await expect(listSlashCommands("tok")).resolves.toEqual([ + { + command: "/history", + title: "Show conversation history", + description: "Print the last N messages.", + icon: "history", + argHint: "[n]", + }, + ]); + expect(fetch).toHaveBeenCalledWith( + "/api/commands", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); }); diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 66b029577..fb4496f71 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -4,6 +4,9 @@ import { describe, expect, it, vi } from "vitest"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; +import { resources } from "@/i18n"; + +const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"]; describe("webui i18n", () => { it("switches UI copy and document locale through the language switcher", async () => { @@ -41,4 +44,16 @@ describe("webui i18n", () => { expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument(); }); + + it("keeps welcome quick actions localized for every registered locale", () => { + for (const resource of Object.values(resources)) { + const empty = resource.common.thread.empty; + expect(empty.greeting).toBeTruthy(); + for (const key of QUICK_ACTION_KEYS) { + const action = empty.quickActions[key as keyof typeof empty.quickActions]; + expect(action.title).toBeTruthy(); + expect(action.prompt).toBeTruthy(); + } + } + }); }); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 3d5c14e75..9e776291a 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1,7 +1,24 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; +import type { SlashCommand } from "@/lib/types"; + +const COMMANDS: SlashCommand[] = [ + { + command: "/stop", + title: "Stop current task", + description: "Cancel the active agent turn.", + icon: "square", + }, + { + command: "/history", + title: "Show conversation history", + description: "Print the last N persisted messages.", + icon: "history", + argHint: "[n]", + }, +]; describe("ThreadComposer", () => { it("renders a readonly hero model composer when provided", () => { @@ -43,4 +60,35 @@ describe("ThreadComposer", () => { expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card"); expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground"); }); + + it("opens a slash command palette and inserts the selected command", () => { + const onSend = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/" } }); + + expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute( + "aria-selected", + "true", + ); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute( + "aria-selected", + "true", + ); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(input).toHaveValue("/history "); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); + }); }); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 68a81d1e1..3dd47f6b8 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -434,6 +434,138 @@ describe("ThreadShell", () => { }); }); + it("keeps live assistant replies after visiting the blank new-chat page", async () => { + const client = makeClient(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-a/messages")) { + return httpJson({ + key: "websocket:chat-a", + created_at: null, + updated_at: null, + // Simulate a stale history response that has not persisted the + // just-received assistant reply yet. + messages: [{ role: "user", content: "hello" }], + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + const { rerender } = render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument()); + await act(async () => { + client._emitChat("chat-a", { + event: "message", + chat_id: "chat-a", + text: "live assistant reply", + }); + }); + expect(screen.getByText("live assistant reply")).toBeInTheDocument(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + }); + + expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument(); + expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + }); + + await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument()); + }); + + it("does not open slash commands on the blank welcome page", async () => { + const client = makeClient(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/commands")) { + return httpJson({ + commands: [ + { + command: "/stop", + title: "Stop current task", + description: "Cancel the active agent turn.", + icon: "square", + }, + ], + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledWith( + "/api/commands", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + )); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "/" }, + }); + + expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); + }); + it("surfaces a dismissible banner when the stream reports message_too_big", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-a"); @@ -454,6 +586,7 @@ describe("ThreadShell", () => { // No banner yet: only appears once the client emits a matching error. expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await act(async () => {}); await act(async () => { client._emitError({ kind: "message_too_big" }); }); @@ -485,6 +618,7 @@ describe("ThreadShell", () => { ), ); + await act(async () => {}); await act(async () => { client._emitError({ kind: "message_too_big" }); });