feat(webui): improve beta turn completion and streaming UX

This commit is contained in:
ramonpaolo 2026-05-01 12:38:22 -03:00 committed by Xubin Ren
parent 5853d5dfda
commit 76e3f74df7
18 changed files with 850 additions and 28 deletions

View File

@ -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

View File

@ -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 ")

711
webui/package-lock.json generated
View File

@ -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,

View File

@ -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<string | null>(null);
@ -31,6 +31,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId,
initial,
hasPendingToolCalls,
);
useEffect(() => {

View File

@ -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<HTMLInputElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
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",
)}
>
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
{isStreaming ? (
<Loader2 className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4", "animate-spin")} />
) : (
<ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
)}
</Button>
</div>
</div>

View File

@ -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<string | null>(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({
<ThreadComposer
onSend={send}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
@ -191,6 +192,7 @@ export function ThreadShell({
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
placeholder={
booting
? t("thread.composer.placeholderOpening")

View File

@ -37,6 +37,7 @@ export interface SendImage {
export function useNanobotStream(
chatId: string | null,
initialMessages: UIMessage[] = [],
hasPendingToolCalls = false,
): {
messages: UIMessage[];
isStreaming: boolean;
@ -51,9 +52,23 @@ export function useNanobotStream(
} {
const { client } = useClient();
const [messages, setMessages] = useState<UIMessage[]>(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<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(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<ReturnType<typeof setTimeout> | 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);
},

View File

@ -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,
};
}

View File

@ -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",

View File

@ -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",

View File

@ -62,6 +62,7 @@
"placeholderThread": "Saisissez votre message…",
"placeholderHero": "Quavez-vous en tête ?",
"placeholderOpening": "Ouverture dune 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",

View File

@ -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",

View File

@ -62,6 +62,7 @@
"placeholderThread": "メッセージを入力…",
"placeholderHero": "何を考えていますか?",
"placeholderOpening": "新しいチャットを開いています…",
"placeholderStreaming": "モデルが応答しています…",
"inputAria": "メッセージ入力欄",
"sendHint": "Enter で送信 · Shift+Enter で改行",
"send": "メッセージを送信",

View File

@ -62,6 +62,7 @@
"placeholderThread": "메시지를 입력하세요…",
"placeholderHero": "무슨 생각을 하고 있나요?",
"placeholderOpening": "새 채팅을 여는 중…",
"placeholderStreaming": "모델이 응답 중입니다…",
"inputAria": "메시지 입력",
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
"send": "메시지 보내기",

View File

@ -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",

View File

@ -62,6 +62,7 @@
"placeholderThread": "输入消息…",
"placeholderHero": "你在想什么?",
"placeholderOpening": "正在打开新对话…",
"placeholderStreaming": "模型正在回复…",
"inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行",
"send": "发送消息",

View File

@ -62,6 +62,7 @@
"placeholderThread": "輸入訊息…",
"placeholderHero": "你在想什麼?",
"placeholderOpening": "正在開啟新對話…",
"placeholderStreaming": "模型正在回覆…",
"inputAria": "訊息輸入框",
"sendHint": "Enter 送出 · Shift+Enter 換行",
"send": "送出訊息",

View File

@ -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.