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.