mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0228b126ae | ||
|
|
4fd21097e1 | ||
|
|
1581544b7f | ||
|
|
3852956a71 | ||
|
|
bd3f4d7a74 | ||
|
|
e21e71c55b | ||
|
|
cfe75dbb8d | ||
|
|
7afc6a1b33 | ||
|
|
48eea29313 |
@@ -328,15 +328,386 @@ Use nanobot for a real task, report what broke, and then pick a focused improvem
|
||||
- Browse [open issues](https://github.com/HKUDS/nanobot/issues) for problems to investigate.
|
||||
- Open a [pull request](https://github.com/HKUDS/nanobot/pulls) for a focused fix or integration.
|
||||
|
||||
## Contact
|
||||
## Maintainers
|
||||
|
||||
Nanobot was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and is now maintained collaboratively with contributors from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/re-bin"><img src="https://avatars.githubusercontent.com/u/52506698?v=4&s=80" width="80" height="80" alt="Xubin Ren"><br><strong>Xubin Ren</strong></a><br><a href="https://x.com/xubinrencs"><img src="https://img.shields.io/badge/@xubinrencs-000000?style=flat&logo=x&logoColor=white" alt="Xubin Ren on X"></a></td>
|
||||
<td align="center"><a href="https://github.com/chengyongru"><img src="https://avatars.githubusercontent.com/u/61816729?v=4&s=80" width="80" height="80" alt="Yongru Chen"><br><strong>Yongru Chen</strong></a><br><a href="https://x.com/chengyongru"><img src="https://img.shields.io/badge/@chengyongru-000000?style=flat&logo=x&logoColor=white" alt="Yongru Chen on X"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Contributors
|
||||
## Community Contributors
|
||||
|
||||
<a href="https://github.com/HKUDS/nanobot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12&updated=20260210" alt="Contributors" />
|
||||
</a>
|
||||
<!-- contributors:start -->
|
||||
<p>
|
||||
<a href="https://github.com/Athemis"><img src="https://avatars.githubusercontent.com/u/552653?v=4&s=48" width="48" height="48" alt="Athemis"></a>
|
||||
<a href="https://github.com/axelray-dev"><img src="https://avatars.githubusercontent.com/u/110029405?v=4&s=48" width="48" height="48" alt="axelray-dev"></a>
|
||||
<a href="https://github.com/yorkhellen"><img src="https://avatars.githubusercontent.com/u/8706550?v=4&s=48" width="48" height="48" alt="yorkhellen"></a>
|
||||
<a href="https://github.com/04cb"><img src="https://avatars.githubusercontent.com/u/111667698?v=4&s=48" width="48" height="48" alt="04cb"></a>
|
||||
<a href="https://github.com/santhreal"><img src="https://avatars.githubusercontent.com/u/64453045?v=4&s=48" width="48" height="48" alt="santhreal"></a>
|
||||
<a href="https://github.com/yu-xin-c"><img src="https://avatars.githubusercontent.com/u/175149126?v=4&s=48" width="48" height="48" alt="yu-xin-c"></a>
|
||||
<a href="https://github.com/xcosmosbox"><img src="https://avatars.githubusercontent.com/u/56502269?v=4&s=48" width="48" height="48" alt="xcosmosbox"></a>
|
||||
<a href="https://github.com/kunalk16"><img src="https://avatars.githubusercontent.com/u/5303824?v=4&s=48" width="48" height="48" alt="kunalk16"></a>
|
||||
<a href="https://github.com/chaohuang-ai"><img src="https://avatars.githubusercontent.com/u/204865953?v=4&s=48" width="48" height="48" alt="chaohuang-ai"></a>
|
||||
<a href="https://github.com/zayfod"><img src="https://avatars.githubusercontent.com/u/1811339?v=4&s=48" width="48" height="48" alt="zayfod"></a>
|
||||
<a href="https://github.com/nikolasdehor"><img src="https://avatars.githubusercontent.com/u/116851567?v=4&s=48" width="48" height="48" alt="nikolasdehor"></a>
|
||||
<a href="https://github.com/JiajunBernoulli"><img src="https://avatars.githubusercontent.com/u/45968640?v=4&s=48" width="48" height="48" alt="JiajunBernoulli"></a>
|
||||
<a href="https://github.com/flobo3"><img src="https://avatars.githubusercontent.com/u/268352850?v=4&s=48" width="48" height="48" alt="flobo3"></a>
|
||||
<a href="https://github.com/hamb1y"><img src="https://avatars.githubusercontent.com/u/88080063?v=4&s=48" width="48" height="48" alt="hamb1y"></a>
|
||||
<a href="https://github.com/SergioSV96"><img src="https://avatars.githubusercontent.com/u/20419761?v=4&s=48" width="48" height="48" alt="SergioSV96"></a>
|
||||
<a href="https://github.com/KDB-Wind"><img src="https://avatars.githubusercontent.com/u/271925278?v=4&s=48" width="48" height="48" alt="KDB-Wind"></a>
|
||||
<a href="https://github.com/morandot"><img src="https://avatars.githubusercontent.com/u/274257964?v=4&s=48" width="48" height="48" alt="morandot"></a>
|
||||
<a href="https://github.com/coldxiangyu163"><img src="https://avatars.githubusercontent.com/u/134986317?v=4&s=48" width="48" height="48" alt="coldxiangyu163"></a>
|
||||
<a href="https://github.com/boogieLing"><img src="https://avatars.githubusercontent.com/u/64551706?v=4&s=48" width="48" height="48" alt="boogieLing"></a>
|
||||
<a href="https://github.com/michaelxer"><img src="https://avatars.githubusercontent.com/u/52305679?v=4&s=48" width="48" height="48" alt="michaelxer"></a>
|
||||
<a href="https://github.com/aiguozhi123456"><img src="https://avatars.githubusercontent.com/u/126325311?v=4&s=48" width="48" height="48" alt="aiguozhi123456"></a>
|
||||
<a href="https://github.com/pinhua33"><img src="https://avatars.githubusercontent.com/u/251483507?v=4&s=48" width="48" height="48" alt="pinhua33"></a>
|
||||
<a href="https://github.com/pixan-ai"><img src="https://avatars.githubusercontent.com/u/218441143?v=4&s=48" width="48" height="48" alt="pixan-ai"></a>
|
||||
<a href="https://github.com/hussein1362"><img src="https://avatars.githubusercontent.com/u/49703886?v=4&s=48" width="48" height="48" alt="hussein1362"></a>
|
||||
<a href="https://github.com/alekwo"><img src="https://avatars.githubusercontent.com/u/24917047?v=4&s=48" width="48" height="48" alt="alekwo"></a>
|
||||
<a href="https://github.com/haosenwang1018"><img src="https://avatars.githubusercontent.com/u/167664334?v=4&s=48" width="48" height="48" alt="haosenwang1018"></a>
|
||||
<a href="https://github.com/IlyaGusev"><img src="https://avatars.githubusercontent.com/u/2670295?v=4&s=48" width="48" height="48" alt="IlyaGusev"></a>
|
||||
<a href="https://github.com/T3chC0wb0y"><img src="https://avatars.githubusercontent.com/u/68530847?v=4&s=48" width="48" height="48" alt="T3chC0wb0y"></a>
|
||||
<a href="https://github.com/VITOHJL"><img src="https://avatars.githubusercontent.com/u/166518988?v=4&s=48" width="48" height="48" alt="VITOHJL"></a>
|
||||
<a href="https://github.com/macroadster"><img src="https://avatars.githubusercontent.com/u/328366?v=4&s=48" width="48" height="48" alt="macroadster"></a>
|
||||
<a href="https://github.com/Hinotoi-agent"><img src="https://avatars.githubusercontent.com/u/275430060?v=4&s=48" width="48" height="48" alt="Hinotoi-agent"></a>
|
||||
<a href="https://github.com/kingassune"><img src="https://avatars.githubusercontent.com/u/6126851?v=4&s=48" width="48" height="48" alt="kingassune"></a>
|
||||
<a href="https://github.com/goodtiding5"><img src="https://avatars.githubusercontent.com/u/179489?v=4&s=48" width="48" height="48" alt="goodtiding5"></a>
|
||||
<a href="https://github.com/kiplangatkorir"><img src="https://avatars.githubusercontent.com/u/153384040?v=4&s=48" width="48" height="48" alt="kiplangatkorir"></a>
|
||||
<a href="https://github.com/elkaix"><img src="https://avatars.githubusercontent.com/u/197959891?v=4&s=48" width="48" height="48" alt="elkaix"></a>
|
||||
<a href="https://github.com/KimGLee"><img src="https://avatars.githubusercontent.com/u/150593189?v=4&s=48" width="48" height="48" alt="KimGLee"></a>
|
||||
<a href="https://github.com/m11y"><img src="https://avatars.githubusercontent.com/u/1625837?v=4&s=48" width="48" height="48" alt="m11y"></a>
|
||||
<a href="https://github.com/LingaoM"><img src="https://avatars.githubusercontent.com/u/26378606?v=4&s=48" width="48" height="48" alt="LingaoM"></a>
|
||||
<a href="https://github.com/DaryeDev"><img src="https://avatars.githubusercontent.com/u/54469750?v=4&s=48" width="48" height="48" alt="DaryeDev"></a>
|
||||
<a href="https://github.com/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST"></a>
|
||||
<a href="https://github.com/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="xzq-xu"></a>
|
||||
<a href="https://github.com/pikaxinge"><img src="https://avatars.githubusercontent.com/u/68273313?v=4&s=48" width="48" height="48" alt="pikaxinge"></a>
|
||||
<a href="https://github.com/arcdrake22"><img src="https://avatars.githubusercontent.com/u/204617897?v=4&s=48" width="48" height="48" alt="arcdrake22"></a>
|
||||
<a href="https://github.com/JackLuguibin"><img src="https://avatars.githubusercontent.com/u/46274946?v=4&s=48" width="48" height="48" alt="JackLuguibin"></a>
|
||||
<a href="https://github.com/HaisamAbbas"><img src="https://avatars.githubusercontent.com/u/95044189?v=4&s=48" width="48" height="48" alt="HaisamAbbas"></a>
|
||||
<a href="https://github.com/anunay999"><img src="https://avatars.githubusercontent.com/u/16853513?v=4&s=48" width="48" height="48" alt="anunay999"></a>
|
||||
<a href="https://github.com/flaviovs"><img src="https://avatars.githubusercontent.com/u/1832699?v=4&s=48" width="48" height="48" alt="flaviovs"></a>
|
||||
<a href="https://github.com/C-Li"><img src="https://avatars.githubusercontent.com/u/20661667?v=4&s=48" width="48" height="48" alt="C-Li"></a>
|
||||
<a href="https://github.com/Ho1yShif"><img src="https://avatars.githubusercontent.com/u/75815862?v=4&s=48" width="48" height="48" alt="Ho1yShif"></a>
|
||||
<a href="https://github.com/pjhoberman"><img src="https://avatars.githubusercontent.com/u/37924?v=4&s=48" width="48" height="48" alt="pjhoberman"></a>
|
||||
<a href="https://github.com/nghiahsgs"><img src="https://avatars.githubusercontent.com/u/24955327?v=4&s=48" width="48" height="48" alt="nghiahsgs"></a>
|
||||
<a href="https://github.com/Bahtya"><img src="https://avatars.githubusercontent.com/u/34988899?v=4&s=48" width="48" height="48" alt="Bahtya"></a>
|
||||
<a href="https://github.com/tangtaizong666"><img src="https://avatars.githubusercontent.com/u/212687958?v=4&s=48" width="48" height="48" alt="tangtaizong666"></a>
|
||||
<a href="https://github.com/XJPeng12"><img src="https://avatars.githubusercontent.com/u/50786186?v=4&s=48" width="48" height="48" alt="XJPeng12"></a>
|
||||
<a href="https://github.com/yanghan-cyber"><img src="https://avatars.githubusercontent.com/u/188783428?v=4&s=48" width="48" height="48" alt="yanghan-cyber"></a>
|
||||
<a href="https://github.com/ZhouJ-sh"><img src="https://avatars.githubusercontent.com/u/9983860?v=4&s=48" width="48" height="48" alt="ZhouJ-sh"></a>
|
||||
<a href="https://github.com/Yuxin-Lou"><img src="https://avatars.githubusercontent.com/u/117000057?v=4&s=48" width="48" height="48" alt="Yuxin-Lou"></a>
|
||||
<a href="https://github.com/LeoFYH"><img src="https://avatars.githubusercontent.com/u/184173704?v=4&s=48" width="48" height="48" alt="LeoFYH"></a>
|
||||
<a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude"></a>
|
||||
<a href="https://github.com/chris-alexander"><img src="https://avatars.githubusercontent.com/u/2815297?v=4&s=48" width="48" height="48" alt="chris-alexander"></a>
|
||||
<a href="https://github.com/benlenarts"><img src="https://avatars.githubusercontent.com/u/131161?v=4&s=48" width="48" height="48" alt="benlenarts"></a>
|
||||
<a href="https://github.com/outlook84"><img src="https://avatars.githubusercontent.com/u/96007761?v=4&s=48" width="48" height="48" alt="outlook84"></a>
|
||||
<a href="https://github.com/Mrart"><img src="https://avatars.githubusercontent.com/u/5235758?v=4&s=48" width="48" height="48" alt="Mrart"></a>
|
||||
<a href="https://github.com/ramonpaolo"><img src="https://avatars.githubusercontent.com/u/53312850?v=4&s=48" width="48" height="48" alt="ramonpaolo"></a>
|
||||
<a href="https://github.com/huhu-tiger"><img src="https://avatars.githubusercontent.com/u/76894920?v=4&s=48" width="48" height="48" alt="huhu-tiger"></a>
|
||||
<a href="https://github.com/tangjiabin"><img src="https://avatars.githubusercontent.com/u/21021242?v=4&s=48" width="48" height="48" alt="tangjiabin"></a>
|
||||
<a href="https://github.com/yeyitech"><img src="https://avatars.githubusercontent.com/u/231244789?v=4&s=48" width="48" height="48" alt="yeyitech"></a>
|
||||
<a href="https://github.com/Flinn-X"><img src="https://avatars.githubusercontent.com/u/54433526?v=4&s=48" width="48" height="48" alt="Flinn-X"></a>
|
||||
<a href="https://github.com/bingqilinweimaotai"><img src="https://avatars.githubusercontent.com/u/111987281?v=4&s=48" width="48" height="48" alt="bingqilinweimaotai"></a>
|
||||
<a href="https://github.com/Qinnnnnn"><img src="https://avatars.githubusercontent.com/u/14584068?v=4&s=48" width="48" height="48" alt="Qinnnnnn"></a>
|
||||
<a href="https://github.com/HengWeiBin"><img src="https://avatars.githubusercontent.com/u/45145821?v=4&s=48" width="48" height="48" alt="HengWeiBin"></a>
|
||||
<a href="https://github.com/waelantar"><img src="https://avatars.githubusercontent.com/u/70063334?v=4&s=48" width="48" height="48" alt="waelantar"></a>
|
||||
<a href="https://github.com/tanishra"><img src="https://avatars.githubusercontent.com/u/100482827?v=4&s=48" width="48" height="48" alt="tanishra"></a>
|
||||
<a href="https://github.com/olgagaga"><img src="https://avatars.githubusercontent.com/u/75477960?v=4&s=48" width="48" height="48" alt="olgagaga"></a>
|
||||
<a href="https://github.com/masterlyj"><img src="https://avatars.githubusercontent.com/u/167326996?v=4&s=48" width="48" height="48" alt="masterlyj"></a>
|
||||
<a href="https://github.com/xgzlucario"><img src="https://avatars.githubusercontent.com/u/48748794?v=4&s=48" width="48" height="48" alt="xgzlucario"></a>
|
||||
<a href="https://github.com/dzydzydzy7"><img src="https://avatars.githubusercontent.com/u/32220064?v=4&s=48" width="48" height="48" alt="dzydzydzy7"></a>
|
||||
<a href="https://github.com/dajiaohuang"><img src="https://avatars.githubusercontent.com/u/108231307?v=4&s=48" width="48" height="48" alt="dajiaohuang"></a>
|
||||
<a href="https://github.com/concertypin"><img src="https://avatars.githubusercontent.com/u/55056558?v=4&s=48" width="48" height="48" alt="concertypin"></a>
|
||||
<a href="https://github.com/WangCheng0116"><img src="https://avatars.githubusercontent.com/u/111694270?v=4&s=48" width="48" height="48" alt="WangCheng0116"></a>
|
||||
<a href="https://github.com/yarikoptic"><img src="https://avatars.githubusercontent.com/u/39889?v=4&s=48" width="48" height="48" alt="yarikoptic"></a>
|
||||
<a href="https://github.com/lukemilby"><img src="https://avatars.githubusercontent.com/u/966940?v=4&s=48" width="48" height="48" alt="lukemilby"></a>
|
||||
<a href="https://github.com/gongpx20069"><img src="https://avatars.githubusercontent.com/u/21985921?v=4&s=48" width="48" height="48" alt="gongpx20069"></a>
|
||||
<a href="https://github.com/tobrien"><img src="https://avatars.githubusercontent.com/u/36787?v=4&s=48" width="48" height="48" alt="tobrien"></a>
|
||||
<a href="https://github.com/Shiniese"><img src="https://avatars.githubusercontent.com/u/135589327?v=4&s=48" width="48" height="48" alt="Shiniese"></a>
|
||||
<a href="https://github.com/shawnWXN"><img src="https://avatars.githubusercontent.com/u/47786182?v=4&s=48" width="48" height="48" alt="shawnWXN"></a>
|
||||
<a href="https://github.com/sbyinin"><img src="https://avatars.githubusercontent.com/u/2064038?v=4&s=48" width="48" height="48" alt="sbyinin"></a>
|
||||
<a href="https://github.com/nne998"><img src="https://avatars.githubusercontent.com/u/148901?v=4&s=48" width="48" height="48" alt="nne998"></a>
|
||||
<a href="https://github.com/lahuman"><img src="https://avatars.githubusercontent.com/u/6156679?v=4&s=48" width="48" height="48" alt="lahuman"></a>
|
||||
<a href="https://github.com/hlgone"><img src="https://avatars.githubusercontent.com/u/152462991?v=4&s=48" width="48" height="48" alt="hlgone"></a>
|
||||
<a href="https://github.com/franciscomaestre"><img src="https://avatars.githubusercontent.com/u/2027043?v=4&s=48" width="48" height="48" alt="franciscomaestre"></a>
|
||||
<a href="https://github.com/fat-operator"><img src="https://avatars.githubusercontent.com/u/105777951?v=4&s=48" width="48" height="48" alt="fat-operator"></a>
|
||||
<a href="https://github.com/shixi-li"><img src="https://avatars.githubusercontent.com/u/40780706?v=4&s=48" width="48" height="48" alt="shixi-li"></a>
|
||||
<a href="https://github.com/who96"><img src="https://avatars.githubusercontent.com/u/44131846?v=4&s=48" width="48" height="48" alt="who96"></a>
|
||||
<a href="https://github.com/cyzlmh"><img src="https://avatars.githubusercontent.com/u/24603258?v=4&s=48" width="48" height="48" alt="cyzlmh"></a>
|
||||
<a href="https://github.com/zhuzhh"><img src="https://avatars.githubusercontent.com/u/41102272?v=4&s=48" width="48" height="48" alt="zhuzhh"></a>
|
||||
<a href="https://github.com/zpljd258"><img src="https://avatars.githubusercontent.com/u/11162658?v=4&s=48" width="48" height="48" alt="zpljd258"></a>
|
||||
<a href="https://github.com/cms19859230182-lang"><img src="https://avatars.githubusercontent.com/u/276597748?v=4&s=48" width="48" height="48" alt="cms19859230182-lang"></a>
|
||||
<a href="https://github.com/amplifierplus"><img src="https://avatars.githubusercontent.com/u/160200579?v=4&s=48" width="48" height="48" alt="amplifierplus"></a>
|
||||
<a href="https://github.com/LZDQ"><img src="https://avatars.githubusercontent.com/u/45907809?v=4&s=48" width="48" height="48" alt="LZDQ"></a>
|
||||
<a href="https://github.com/wb213"><img src="https://avatars.githubusercontent.com/u/488412?v=4&s=48" width="48" height="48" alt="wb213"></a>
|
||||
<a href="https://github.com/shaun0927"><img src="https://avatars.githubusercontent.com/u/70629228?v=4&s=48" width="48" height="48" alt="shaun0927"></a>
|
||||
<a href="https://github.com/wzrayyy"><img src="https://avatars.githubusercontent.com/u/143233939?v=4&s=48" width="48" height="48" alt="wzrayyy"></a>
|
||||
<a href="https://github.com/LHMQ878"><img src="https://avatars.githubusercontent.com/u/205284459?v=4&s=48" width="48" height="48" alt="LHMQ878"></a>
|
||||
<a href="https://github.com/Michael-lhh"><img src="https://avatars.githubusercontent.com/u/41994684?v=4&s=48" width="48" height="48" alt="Michael-lhh"></a>
|
||||
<a href="https://github.com/Mizarka"><img src="https://avatars.githubusercontent.com/u/253529828?v=4&s=48" width="48" height="48" alt="Mizarka"></a>
|
||||
<a href="https://github.com/rick2047"><img src="https://avatars.githubusercontent.com/u/16410?v=4&s=48" width="48" height="48" alt="rick2047"></a>
|
||||
<a href="https://github.com/kuchazi-yy"><img src="https://avatars.githubusercontent.com/u/73976601?v=4&s=48" width="48" height="48" alt="kuchazi-yy"></a>
|
||||
<a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol-zero-0"></a>
|
||||
<a href="https://github.com/subalkum"><img src="https://avatars.githubusercontent.com/u/180379485?v=4&s=48" width="48" height="48" alt="subalkum"></a>
|
||||
<a href="https://github.com/vystartasv"><img src="https://avatars.githubusercontent.com/u/34380849?v=4&s=48" width="48" height="48" alt="vystartasv"></a>
|
||||
<a href="https://github.com/ZJUCQR"><img src="https://avatars.githubusercontent.com/u/138299253?v=4&s=48" width="48" height="48" alt="ZJUCQR"></a>
|
||||
<a href="https://github.com/ZegWe"><img src="https://avatars.githubusercontent.com/u/22636524?v=4&s=48" width="48" height="48" alt="ZegWe"></a>
|
||||
<a href="https://github.com/ZhangYuanhan-AI"><img src="https://avatars.githubusercontent.com/u/18485270?v=4&s=48" width="48" height="48" alt="ZhangYuanhan-AI"></a>
|
||||
<a href="https://github.com/chtangwin"><img src="https://avatars.githubusercontent.com/u/8316617?v=4&s=48" width="48" height="48" alt="chtangwin"></a>
|
||||
<a href="https://github.com/dxtime"><img src="https://avatars.githubusercontent.com/u/8173810?v=4&s=48" width="48" height="48" alt="dxtime"></a>
|
||||
<a href="https://github.com/ethanclaw"><img src="https://avatars.githubusercontent.com/u/262543029?v=4&s=48" width="48" height="48" alt="ethanclaw"></a>
|
||||
<a href="https://github.com/WufeiHalf"><img src="https://avatars.githubusercontent.com/u/103879607?v=4&s=48" width="48" height="48" alt="WufeiHalf"></a>
|
||||
<a href="https://github.com/stutiredboy"><img src="https://avatars.githubusercontent.com/u/345208?v=4&s=48" width="48" height="48" alt="stutiredboy"></a>
|
||||
<a href="https://github.com/stupidloud"><img src="https://avatars.githubusercontent.com/u/56048681?v=4&s=48" width="48" height="48" alt="stupidloud"></a>
|
||||
<a href="https://github.com/asif786ka"><img src="https://avatars.githubusercontent.com/u/6130514?v=4&s=48" width="48" height="48" alt="asif786ka"></a>
|
||||
<a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla"></a>
|
||||
<a href="https://github.com/cypggs"><img src="https://avatars.githubusercontent.com/u/3694954?v=4&s=48" width="48" height="48" alt="cypggs"></a>
|
||||
<a href="https://github.com/web-flow"><img src="https://avatars.githubusercontent.com/u/19864447?v=4&s=48" width="48" height="48" alt="web-flow"></a>
|
||||
<a href="https://github.com/eliumusk"><img src="https://avatars.githubusercontent.com/u/123090877?v=4&s=48" width="48" height="48" alt="eliumusk"></a>
|
||||
<a href="https://github.com/mikaku9944"><img src="https://avatars.githubusercontent.com/u/66119379?v=4&s=48" width="48" height="48" alt="mikaku9944"></a>
|
||||
<a href="https://github.com/mamamiyear"><img src="https://avatars.githubusercontent.com/u/14191296?v=4&s=48" width="48" height="48" alt="mamamiyear"></a>
|
||||
<a href="https://github.com/jr551"><img src="https://avatars.githubusercontent.com/u/2920328?v=4&s=48" width="48" height="48" alt="jr551"></a>
|
||||
<a href="https://github.com/invictus-z"><img src="https://avatars.githubusercontent.com/u/108621936?v=4&s=48" width="48" height="48" alt="invictus-z"></a>
|
||||
<a href="https://github.com/imfondof"><img src="https://avatars.githubusercontent.com/u/39022581?v=4&s=48" width="48" height="48" alt="imfondof"></a>
|
||||
<a href="https://github.com/hyoukadev"><img src="https://avatars.githubusercontent.com/u/17965578?v=4&s=48" width="48" height="48" alt="hyoukadev"></a>
|
||||
<a href="https://github.com/hata33"><img src="https://avatars.githubusercontent.com/u/79907651?v=4&s=48" width="48" height="48" alt="hata33"></a>
|
||||
<a href="https://github.com/fengxiaohu"><img src="https://avatars.githubusercontent.com/u/23492381?v=4&s=48" width="48" height="48" alt="fengxiaohu"></a>
|
||||
<a href="https://github.com/vivganes"><img src="https://avatars.githubusercontent.com/u/2035886?v=4&s=48" width="48" height="48" alt="vivganes"></a>
|
||||
<a href="https://github.com/themavik"><img src="https://avatars.githubusercontent.com/u/179817126?v=4&s=48" width="48" height="48" alt="themavik"></a>
|
||||
<a href="https://github.com/flyzstu"><img src="https://avatars.githubusercontent.com/u/94161727?v=4&s=48" width="48" height="48" alt="flyzstu"></a>
|
||||
<a href="https://github.com/pikaqqqqqq"><img src="https://avatars.githubusercontent.com/u/20340136?v=4&s=48" width="48" height="48" alt="pikaqqqqqq"></a>
|
||||
<a href="https://github.com/wyjBot"><img src="https://avatars.githubusercontent.com/u/70993189?v=4&s=48" width="48" height="48" alt="wyjBot"></a>
|
||||
<a href="https://github.com/pblocz"><img src="https://avatars.githubusercontent.com/u/9288574?v=4&s=48" width="48" height="48" alt="pblocz"></a>
|
||||
<a href="https://github.com/niradler"><img src="https://avatars.githubusercontent.com/u/6292980?v=4&s=48" width="48" height="48" alt="niradler"></a>
|
||||
<a href="https://github.com/longle325"><img src="https://avatars.githubusercontent.com/u/140832783?v=4&s=48" width="48" height="48" alt="longle325"></a>
|
||||
<a href="https://github.com/primit1v0"><img src="https://avatars.githubusercontent.com/u/119784372?v=4&s=48" width="48" height="48" alt="primit1v0"></a>
|
||||
<a href="https://github.com/honjiaxuan"><img src="https://avatars.githubusercontent.com/u/13818528?v=4&s=48" width="48" height="48" alt="honjiaxuan"></a>
|
||||
<a href="https://github.com/DeeJ4yNg"><img src="https://avatars.githubusercontent.com/u/99658722?v=4&s=48" width="48" height="48" alt="DeeJ4yNg"></a>
|
||||
<a href="https://github.com/danielphang"><img src="https://avatars.githubusercontent.com/u/1204069?v=4&s=48" width="48" height="48" alt="danielphang"></a>
|
||||
<a href="https://github.com/yanalialiuk"><img src="https://avatars.githubusercontent.com/u/193742981?v=4&s=48" width="48" height="48" alt="yanalialiuk"></a>
|
||||
<a href="https://github.com/zhouzhuojie"><img src="https://avatars.githubusercontent.com/u/658840?v=4&s=48" width="48" height="48" alt="zhouzhuojie"></a>
|
||||
<a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x"></a>
|
||||
<a href="https://github.com/yrk111222"><img src="https://avatars.githubusercontent.com/u/185151020?v=4&s=48" width="48" height="48" alt="yrk111222"></a>
|
||||
<a href="https://github.com/Xerxes-cn"><img src="https://avatars.githubusercontent.com/u/58462889?v=4&s=48" width="48" height="48" alt="Xerxes-cn"></a>
|
||||
<a href="https://github.com/suger-m"><img src="https://avatars.githubusercontent.com/u/240725677?v=4&s=48" width="48" height="48" alt="suger-m"></a>
|
||||
<a href="https://github.com/mengyhang"><img src="https://avatars.githubusercontent.com/u/148381938?v=4&s=48" width="48" height="48" alt="mengyhang"></a>
|
||||
<a href="https://github.com/Liwx1014"><img src="https://avatars.githubusercontent.com/u/186271593?v=4&s=48" width="48" height="48" alt="Liwx1014"></a>
|
||||
<a href="https://github.com/Shizoqua"><img src="https://avatars.githubusercontent.com/u/136805224?v=4&s=48" width="48" height="48" alt="Shizoqua"></a>
|
||||
<a href="https://github.com/KailBug"><img src="https://avatars.githubusercontent.com/u/66873219?v=4&s=48" width="48" height="48" alt="KailBug"></a>
|
||||
<a href="https://github.com/19emtuck"><img src="https://avatars.githubusercontent.com/u/956861?v=4&s=48" width="48" height="48" alt="19emtuck"></a>
|
||||
<a href="https://github.com/tsubasakong"><img src="https://avatars.githubusercontent.com/u/97429702?v=4&s=48" width="48" height="48" alt="tsubasakong"></a>
|
||||
<a href="https://github.com/wseng"><img src="https://avatars.githubusercontent.com/u/6572161?v=4&s=48" width="48" height="48" alt="wseng"></a>
|
||||
<a href="https://github.com/3927o"><img src="https://avatars.githubusercontent.com/u/53431636?v=4&s=48" width="48" height="48" alt="3927o"></a>
|
||||
<a href="https://github.com/FloRainRJY"><img src="https://avatars.githubusercontent.com/u/146079207?v=4&s=48" width="48" height="48" alt="FloRainRJY"></a>
|
||||
<a href="https://github.com/agbocsardi"><img src="https://avatars.githubusercontent.com/u/17645046?v=4&s=48" width="48" height="48" alt="agbocsardi"></a>
|
||||
<a href="https://github.com/JilunSun7274"><img src="https://avatars.githubusercontent.com/u/268303062?v=4&s=48" width="48" height="48" alt="JilunSun7274"></a>
|
||||
<a href="https://github.com/dvejmz"><img src="https://avatars.githubusercontent.com/u/9487006?v=4&s=48" width="48" height="48" alt="dvejmz"></a>
|
||||
<a href="https://github.com/ddadaal"><img src="https://avatars.githubusercontent.com/u/8363856?v=4&s=48" width="48" height="48" alt="ddadaal"></a>
|
||||
<a href="https://github.com/jiehaoZ"><img src="https://avatars.githubusercontent.com/u/51368211?v=4&s=48" width="48" height="48" alt="jiehaoZ"></a>
|
||||
<a href="https://github.com/Lbin91"><img src="https://avatars.githubusercontent.com/u/26209763?v=4&s=48" width="48" height="48" alt="Lbin91"></a>
|
||||
<a href="https://github.com/Alex-yang00"><img src="https://avatars.githubusercontent.com/u/57132813?v=4&s=48" width="48" height="48" alt="Alex-yang00"></a>
|
||||
<a href="https://github.com/xek"><img src="https://avatars.githubusercontent.com/u/107911?v=4&s=48" width="48" height="48" alt="xek"></a>
|
||||
<a href="https://github.com/Harvey-Mackie"><img src="https://avatars.githubusercontent.com/u/38426388?v=4&s=48" width="48" height="48" alt="Harvey-Mackie"></a>
|
||||
<a href="https://github.com/chenyahui"><img src="https://avatars.githubusercontent.com/u/6067594?v=4&s=48" width="48" height="48" alt="chenyahui"></a>
|
||||
<a href="https://github.com/angleyanalbedo"><img src="https://avatars.githubusercontent.com/u/100198247?v=4&s=48" width="48" height="48" alt="angleyanalbedo"></a>
|
||||
<a href="https://github.com/adabarbulescu"><img src="https://avatars.githubusercontent.com/u/94562950?v=4&s=48" width="48" height="48" alt="adabarbulescu"></a>
|
||||
<a href="https://github.com/yoheinishikubo"><img src="https://avatars.githubusercontent.com/u/17715848?v=4&s=48" width="48" height="48" alt="yoheinishikubo"></a>
|
||||
<a href="https://github.com/WormW"><img src="https://avatars.githubusercontent.com/u/24667814?v=4&s=48" width="48" height="48" alt="WormW"></a>
|
||||
<a href="https://github.com/WhalerO"><img src="https://avatars.githubusercontent.com/u/68461696?v=4&s=48" width="48" height="48" alt="WhalerO"></a>
|
||||
<a href="https://github.com/thomya"><img src="https://avatars.githubusercontent.com/u/5235056?v=4&s=48" width="48" height="48" alt="thomya"></a>
|
||||
<a href="https://github.com/Tejas1Koli"><img src="https://avatars.githubusercontent.com/u/181818824?v=4&s=48" width="48" height="48" alt="Tejas1Koli"></a>
|
||||
<a href="https://github.com/Seeratul"><img src="https://avatars.githubusercontent.com/u/126798754?v=4&s=48" width="48" height="48" alt="Seeratul"></a>
|
||||
<a href="https://github.com/SJK-py"><img src="https://avatars.githubusercontent.com/u/201669535?v=4&s=48" width="48" height="48" alt="SJK-py"></a>
|
||||
<a href="https://github.com/RongLei-intel"><img src="https://avatars.githubusercontent.com/u/81341556?v=4&s=48" width="48" height="48" alt="RongLei-intel"></a>
|
||||
<a href="https://github.com/QQQ300kuai"><img src="https://avatars.githubusercontent.com/u/55626566?v=4&s=48" width="48" height="48" alt="QQQ300kuai"></a>
|
||||
<a href="https://github.com/MiguelPF"><img src="https://avatars.githubusercontent.com/u/1163236?v=4&s=48" width="48" height="48" alt="MiguelPF"></a>
|
||||
<a href="https://github.com/mterhar"><img src="https://avatars.githubusercontent.com/u/938684?v=4&s=48" width="48" height="48" alt="mterhar"></a>
|
||||
<a href="https://github.com/Pringlas"><img src="https://avatars.githubusercontent.com/u/28577663?v=4&s=48" width="48" height="48" alt="Pringlas"></a>
|
||||
<a href="https://github.com/pjbakker"><img src="https://avatars.githubusercontent.com/u/1267780?v=4&s=48" width="48" height="48" alt="pjbakker"></a>
|
||||
<a href="https://github.com/luojiaaoo"><img src="https://avatars.githubusercontent.com/u/62821977?v=4&s=48" width="48" height="48" alt="luojiaaoo"></a>
|
||||
<a href="https://github.com/NearlCrews"><img src="https://avatars.githubusercontent.com/u/23341701?v=4&s=48" width="48" height="48" alt="NearlCrews"></a>
|
||||
<a href="https://github.com/yongPhone"><img src="https://avatars.githubusercontent.com/u/29919651?v=4&s=48" width="48" height="48" alt="yongPhone"></a>
|
||||
<a href="https://github.com/ZXGERIC"><img src="https://avatars.githubusercontent.com/u/25354180?v=4&s=48" width="48" height="48" alt="ZXGERIC"></a>
|
||||
<a href="https://github.com/erikmackinnon"><img src="https://avatars.githubusercontent.com/u/40612473?v=4&s=48" width="48" height="48" alt="erikmackinnon"></a>
|
||||
<a href="https://github.com/rickererer"><img src="https://avatars.githubusercontent.com/u/289160634?v=4&s=48" width="48" height="48" alt="rickererer"></a>
|
||||
<a href="https://github.com/ferkans-amir"><img src="https://avatars.githubusercontent.com/u/212877286?v=4&s=48" width="48" height="48" alt="ferkans-amir"></a>
|
||||
<a href="https://github.com/for13to1"><img src="https://avatars.githubusercontent.com/u/115892874?v=4&s=48" width="48" height="48" alt="for13to1"></a>
|
||||
<a href="https://github.com/futurist"><img src="https://avatars.githubusercontent.com/u/159167?v=4&s=48" width="48" height="48" alt="futurist"></a>
|
||||
<a href="https://github.com/Maaannnn"><img src="https://avatars.githubusercontent.com/u/105716414?v=4&s=48" width="48" height="48" alt="Maaannnn"></a>
|
||||
<a href="https://github.com/rubychilds"><img src="https://avatars.githubusercontent.com/u/1305077?v=4&s=48" width="48" height="48" alt="rubychilds"></a>
|
||||
<a href="https://github.com/init-new-world"><img src="https://avatars.githubusercontent.com/u/36530844?v=4&s=48" width="48" height="48" alt="init-new-world"></a>
|
||||
<a href="https://github.com/Idealist17"><img src="https://avatars.githubusercontent.com/u/55554642?v=4&s=48" width="48" height="48" alt="Idealist17"></a>
|
||||
<a href="https://github.com/gola"><img src="https://avatars.githubusercontent.com/u/31429180?v=4&s=48" width="48" height="48" alt="gola"></a>
|
||||
<a href="https://github.com/greyishsong"><img src="https://avatars.githubusercontent.com/u/49446254?v=4&s=48" width="48" height="48" alt="greyishsong"></a>
|
||||
<a href="https://github.com/h4nz4"><img src="https://avatars.githubusercontent.com/u/18464660?v=4&s=48" width="48" height="48" alt="h4nz4"></a>
|
||||
<a href="https://github.com/hoaresky"><img src="https://avatars.githubusercontent.com/u/25839923?v=4&s=48" width="48" height="48" alt="hoaresky"></a>
|
||||
<a href="https://github.com/hcanyz"><img src="https://avatars.githubusercontent.com/u/8407922?v=4&s=48" width="48" height="48" alt="hcanyz"></a>
|
||||
<a href="https://github.com/himax12"><img src="https://avatars.githubusercontent.com/u/122690580?v=4&s=48" width="48" height="48" alt="himax12"></a>
|
||||
<a href="https://github.com/Zeknes"><img src="https://avatars.githubusercontent.com/u/117632598?v=4&s=48" width="48" height="48" alt="Zeknes"></a>
|
||||
<a href="https://github.com/ZhihaoZhang97"><img src="https://avatars.githubusercontent.com/u/31653817?v=4&s=48" width="48" height="48" alt="ZhihaoZhang97"></a>
|
||||
<a href="https://github.com/linziyanleo"><img src="https://avatars.githubusercontent.com/u/24808982?v=4&s=48" width="48" height="48" alt="linziyanleo"></a>
|
||||
<a href="https://github.com/akinolur"><img src="https://avatars.githubusercontent.com/u/126256260?v=4&s=48" width="48" height="48" alt="akinolur"></a>
|
||||
<a href="https://github.com/alairjt"><img src="https://avatars.githubusercontent.com/u/678781?v=4&s=48" width="48" height="48" alt="alairjt"></a>
|
||||
<a href="https://github.com/noki1928"><img src="https://avatars.githubusercontent.com/u/200396425?v=4&s=48" width="48" height="48" alt="noki1928"></a>
|
||||
<a href="https://github.com/barreler126"><img src="https://avatars.githubusercontent.com/u/129291861?v=4&s=48" width="48" height="48" alt="barreler126"></a>
|
||||
<a href="https://github.com/bllackhu"><img src="https://avatars.githubusercontent.com/u/194945539?v=4&s=48" width="48" height="48" alt="bllackhu"></a>
|
||||
<a href="https://github.com/brendanlevy-clarahealth"><img src="https://avatars.githubusercontent.com/u/262887138?v=4&s=48" width="48" height="48" alt="brendanlevy-clarahealth"></a>
|
||||
<a href="https://github.com/cdkey85"><img src="https://avatars.githubusercontent.com/u/7017432?v=4&s=48" width="48" height="48" alt="cdkey85"></a>
|
||||
<a href="https://github.com/ducheng121"><img src="https://avatars.githubusercontent.com/u/134901105?v=4&s=48" width="48" height="48" alt="ducheng121"></a>
|
||||
<a href="https://github.com/SamZhu19921116"><img src="https://avatars.githubusercontent.com/u/39799950?v=4&s=48" width="48" height="48" alt="SamZhu19921116"></a>
|
||||
<a href="https://github.com/justforyoudear"><img src="https://avatars.githubusercontent.com/u/50271514?v=4&s=48" width="48" height="48" alt="justforyoudear"></a>
|
||||
<a href="https://github.com/choiking"><img src="https://avatars.githubusercontent.com/u/13400202?v=4&s=48" width="48" height="48" alt="choiking"></a>
|
||||
<a href="https://github.com/cocolato"><img src="https://avatars.githubusercontent.com/u/35182391?v=4&s=48" width="48" height="48" alt="cocolato"></a>
|
||||
<a href="https://github.com/luomaohao"><img src="https://avatars.githubusercontent.com/u/36148933?v=4&s=48" width="48" height="48" alt="luomaohao"></a>
|
||||
<a href="https://github.com/codedragoncom"><img src="https://avatars.githubusercontent.com/u/5353092?v=4&s=48" width="48" height="48" alt="codedragoncom"></a>
|
||||
<a href="https://github.com/d1ago"><img src="https://avatars.githubusercontent.com/u/143279157?v=4&s=48" width="48" height="48" alt="d1ago"></a>
|
||||
<a href="https://github.com/daliu858"><img src="https://avatars.githubusercontent.com/u/213729810?v=4&s=48" width="48" height="48" alt="daliu858"></a>
|
||||
<a href="https://github.com/danielyangfei"><img src="https://avatars.githubusercontent.com/u/9975680?v=4&s=48" width="48" height="48" alt="danielyangfei"></a>
|
||||
<a href="https://github.com/dingyanyi2019"><img src="https://avatars.githubusercontent.com/u/230930993?v=4&s=48" width="48" height="48" alt="dingyanyi2019"></a>
|
||||
<a href="https://github.com/djmaze"><img src="https://avatars.githubusercontent.com/u/7229?v=4&s=48" width="48" height="48" alt="djmaze"></a>
|
||||
<a href="https://github.com/dulltackle"><img src="https://avatars.githubusercontent.com/u/45963660?v=4&s=48" width="48" height="48" alt="dulltackle"></a>
|
||||
<a href="https://github.com/samyzhh"><img src="https://avatars.githubusercontent.com/u/13554741?v=4&s=48" width="48" height="48" alt="samyzhh"></a>
|
||||
<a href="https://github.com/shen0122"><img src="https://avatars.githubusercontent.com/u/145903102?v=4&s=48" width="48" height="48" alt="shen0122"></a>
|
||||
<a href="https://github.com/shenchengtsi"><img src="https://avatars.githubusercontent.com/u/228445050?v=4&s=48" width="48" height="48" alt="shenchengtsi"></a>
|
||||
<a href="https://github.com/sidkang"><img src="https://avatars.githubusercontent.com/u/6175895?v=4&s=48" width="48" height="48" alt="sidkang"></a>
|
||||
<a href="https://github.com/skiyo"><img src="https://avatars.githubusercontent.com/u/224273?v=4&s=48" width="48" height="48" alt="skiyo"></a>
|
||||
<a href="https://github.com/sontianye"><img src="https://avatars.githubusercontent.com/u/162393000?v=4&s=48" width="48" height="48" alt="sontianye"></a>
|
||||
<a href="https://github.com/spartan077"><img src="https://avatars.githubusercontent.com/u/118879019?v=4&s=48" width="48" height="48" alt="spartan077"></a>
|
||||
<a href="https://github.com/tercerapersona"><img src="https://avatars.githubusercontent.com/u/16053355?v=4&s=48" width="48" height="48" alt="tercerapersona"></a>
|
||||
<a href="https://github.com/tlguszz1010"><img src="https://avatars.githubusercontent.com/u/62739187?v=4&s=48" width="48" height="48" alt="tlguszz1010"></a>
|
||||
<a href="https://github.com/vandazia"><img src="https://avatars.githubusercontent.com/u/56904192?v=4&s=48" width="48" height="48" alt="vandazia"></a>
|
||||
<a href="https://github.com/vincentchen0x2-dev"><img src="https://avatars.githubusercontent.com/u/262490969?v=4&s=48" width="48" height="48" alt="vincentchen0x2-dev"></a>
|
||||
<a href="https://github.com/tianrking"><img src="https://avatars.githubusercontent.com/u/10758833?v=4&s=48" width="48" height="48" alt="tianrking"></a>
|
||||
<a href="https://github.com/wcmolin"><img src="https://avatars.githubusercontent.com/u/11606262?v=4&s=48" width="48" height="48" alt="wcmolin"></a>
|
||||
<a href="https://github.com/dynames0098"><img src="https://avatars.githubusercontent.com/u/16553686?v=4&s=48" width="48" height="48" alt="dynames0098"></a>
|
||||
<a href="https://github.com/knightconnorp"><img src="https://avatars.githubusercontent.com/u/143191129?v=4&s=48" width="48" height="48" alt="knightconnorp"></a>
|
||||
<a href="https://github.com/wymcmh"><img src="https://avatars.githubusercontent.com/u/5070729?v=4&s=48" width="48" height="48" alt="wymcmh"></a>
|
||||
<a href="https://github.com/weijun-xia"><img src="https://avatars.githubusercontent.com/u/293320877?v=4&s=48" width="48" height="48" alt="weijun-xia"></a>
|
||||
<a href="https://github.com/yaotutu"><img src="https://avatars.githubusercontent.com/u/21394924?v=4&s=48" width="48" height="48" alt="yaotutu"></a>
|
||||
<a href="https://github.com/yeounhyeok"><img src="https://avatars.githubusercontent.com/u/141844100?v=4&s=48" width="48" height="48" alt="yeounhyeok"></a>
|
||||
<a href="https://github.com/Endeavour-Yuan"><img src="https://avatars.githubusercontent.com/u/50094541?v=4&s=48" width="48" height="48" alt="Endeavour-Yuan"></a>
|
||||
<a href="https://github.com/ziuus"><img src="https://avatars.githubusercontent.com/u/64656661?v=4&s=48" width="48" height="48" alt="ziuus"></a>
|
||||
<a href="https://github.com/dsxyy"><img src="https://avatars.githubusercontent.com/u/8911760?v=4&s=48" width="48" height="48" alt="dsxyy"></a>
|
||||
<a href="https://github.com/azhengzz"><img src="https://avatars.githubusercontent.com/u/30361780?v=4&s=48" width="48" height="48" alt="azhengzz"></a>
|
||||
<a href="https://github.com/jhkim43"><img src="https://avatars.githubusercontent.com/u/139941582?v=4&s=48" width="48" height="48" alt="jhkim43"></a>
|
||||
<a href="https://github.com/kimkitsuragi26"><img src="https://avatars.githubusercontent.com/u/263307076?v=4&s=48" width="48" height="48" alt="kimkitsuragi26"></a>
|
||||
<a href="https://github.com/kinchahoy"><img src="https://avatars.githubusercontent.com/u/6504381?v=4&s=48" width="48" height="48" alt="kinchahoy"></a>
|
||||
<a href="https://github.com/A11Might"><img src="https://avatars.githubusercontent.com/u/38397074?v=4&s=48" width="48" height="48" alt="A11Might"></a>
|
||||
<a href="https://github.com/kronk307"><img src="https://avatars.githubusercontent.com/u/264627887?v=4&s=48" width="48" height="48" alt="kronk307"></a>
|
||||
<a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo"></a>
|
||||
<a href="https://github.com/lang07123"><img src="https://avatars.githubusercontent.com/u/7733095?v=4&s=48" width="48" height="48" alt="lang07123"></a>
|
||||
<a href="https://github.com/tetratorus"><img src="https://avatars.githubusercontent.com/u/4226174?v=4&s=48" width="48" height="48" alt="tetratorus"></a>
|
||||
<a href="https://github.com/spinvettel"><img src="https://avatars.githubusercontent.com/u/82635206?v=4&s=48" width="48" height="48" alt="spinvettel"></a>
|
||||
<a href="https://github.com/li-yazhou"><img src="https://avatars.githubusercontent.com/u/17548940?v=4&s=48" width="48" height="48" alt="li-yazhou"></a>
|
||||
<a href="https://github.com/ALIZE126"><img src="https://avatars.githubusercontent.com/u/79365356?v=4&s=48" width="48" height="48" alt="ALIZE126"></a>
|
||||
<a href="https://github.com/Rheasilvia"><img src="https://avatars.githubusercontent.com/u/29389840?v=4&s=48" width="48" height="48" alt="Rheasilvia"></a>
|
||||
<a href="https://github.com/mru4913"><img src="https://avatars.githubusercontent.com/u/31579276?v=4&s=48" width="48" height="48" alt="mru4913"></a>
|
||||
<a href="https://github.com/mt-huerta"><img src="https://avatars.githubusercontent.com/u/5499466?v=4&s=48" width="48" height="48" alt="mt-huerta"></a>
|
||||
<a href="https://github.com/mytechdream"><img src="https://avatars.githubusercontent.com/u/114465679?v=4&s=48" width="48" height="48" alt="mytechdream"></a>
|
||||
<a href="https://github.com/nikube"><img src="https://avatars.githubusercontent.com/u/63295277?v=4&s=48" width="48" height="48" alt="nikube"></a>
|
||||
<a href="https://github.com/npodbielski"><img src="https://avatars.githubusercontent.com/u/796782?v=4&s=48" width="48" height="48" alt="npodbielski"></a>
|
||||
<a href="https://github.com/oriengy"><img src="https://avatars.githubusercontent.com/u/50244473?v=4&s=48" width="48" height="48" alt="oriengy"></a>
|
||||
<a href="https://github.com/popcell"><img src="https://avatars.githubusercontent.com/u/70359868?v=4&s=48" width="48" height="48" alt="popcell"></a>
|
||||
<a href="https://github.com/qixinbo"><img src="https://avatars.githubusercontent.com/u/6218739?v=4&s=48" width="48" height="48" alt="qixinbo"></a>
|
||||
<a href="https://github.com/qulllee"><img src="https://avatars.githubusercontent.com/u/113170232?v=4&s=48" width="48" height="48" alt="qulllee"></a>
|
||||
<a href="https://github.com/rav-melisono"><img src="https://avatars.githubusercontent.com/u/165779938?v=4&s=48" width="48" height="48" alt="rav-melisono"></a>
|
||||
<a href="https://github.com/razzh7"><img src="https://avatars.githubusercontent.com/u/67299806?v=4&s=48" width="48" height="48" alt="razzh7"></a>
|
||||
<a href="https://github.com/rise2689"><img src="https://avatars.githubusercontent.com/u/268597299?v=4&s=48" width="48" height="48" alt="rise2689"></a>
|
||||
<a href="https://github.com/hlibr"><img src="https://avatars.githubusercontent.com/u/5793607?v=4&s=48" width="48" height="48" alt="hlibr"></a>
|
||||
<a href="https://github.com/gthieleb"><img src="https://avatars.githubusercontent.com/u/21332468?v=4&s=48" width="48" height="48" alt="gthieleb"></a>
|
||||
<a href="https://github.com/Rafa-Ross"><img src="https://avatars.githubusercontent.com/u/279471146?v=4&s=48" width="48" height="48" alt="Rafa-Ross"></a>
|
||||
<a href="https://github.com/korruz"><img src="https://avatars.githubusercontent.com/u/79794883?v=4&s=48" width="48" height="48" alt="korruz"></a>
|
||||
<a href="https://github.com/hyudryu"><img src="https://avatars.githubusercontent.com/u/22283864?v=4&s=48" width="48" height="48" alt="hyudryu"></a>
|
||||
<a href="https://github.com/breitburg"><img src="https://avatars.githubusercontent.com/u/25728414?v=4&s=48" width="48" height="48" alt="breitburg"></a>
|
||||
<a href="https://github.com/IlyaSemenov"><img src="https://avatars.githubusercontent.com/u/128121?v=4&s=48" width="48" height="48" alt="IlyaSemenov"></a>
|
||||
<a href="https://github.com/Tevkanbot"><img src="https://avatars.githubusercontent.com/u/143351134?v=4&s=48" width="48" height="48" alt="Tevkanbot"></a>
|
||||
<a href="https://github.com/JakeRowe19"><img src="https://avatars.githubusercontent.com/u/117069245?v=4&s=48" width="48" height="48" alt="JakeRowe19"></a>
|
||||
<a href="https://github.com/JamesWrigley"><img src="https://avatars.githubusercontent.com/u/5361518?v=4&s=48" width="48" height="48" alt="JamesWrigley"></a>
|
||||
<a href="https://github.com/La-Volpe"><img src="https://avatars.githubusercontent.com/u/5852615?v=4&s=48" width="48" height="48" alt="La-Volpe"></a>
|
||||
<a href="https://github.com/JavisPeng"><img src="https://avatars.githubusercontent.com/u/18676680?v=4&s=48" width="48" height="48" alt="JavisPeng"></a>
|
||||
<a href="https://github.com/Jefsky"><img src="https://avatars.githubusercontent.com/u/7386165?v=4&s=48" width="48" height="48" alt="Jefsky"></a>
|
||||
<a href="https://github.com/letzdoo-js"><img src="https://avatars.githubusercontent.com/u/12003829?v=4&s=48" width="48" height="48" alt="letzdoo-js"></a>
|
||||
<a href="https://github.com/95256155o"><img src="https://avatars.githubusercontent.com/u/74103710?v=4&s=48" width="48" height="48" alt="95256155o"></a>
|
||||
<a href="https://github.com/joel611"><img src="https://avatars.githubusercontent.com/u/5180124?v=4&s=48" width="48" height="48" alt="joel611"></a>
|
||||
<a href="https://github.com/NiceLargeHuo"><img src="https://avatars.githubusercontent.com/u/306099191?v=4&s=48" width="48" height="48" alt="NiceLargeHuo"></a>
|
||||
<a href="https://github.com/kamalakarrao"><img src="https://avatars.githubusercontent.com/u/15045455?v=4&s=48" width="48" height="48" alt="kamalakarrao"></a>
|
||||
<a href="https://github.com/KEEPSLAMDUNK"><img src="https://avatars.githubusercontent.com/u/155275575?v=4&s=48" width="48" height="48" alt="KEEPSLAMDUNK"></a>
|
||||
<a href="https://github.com/krisLu"><img src="https://avatars.githubusercontent.com/u/92515202?v=4&s=48" width="48" height="48" alt="krisLu"></a>
|
||||
<a href="https://github.com/Krislu1221"><img src="https://avatars.githubusercontent.com/u/258380416?v=4&s=48" width="48" height="48" alt="Krislu1221"></a>
|
||||
<a href="https://github.com/kyya"><img src="https://avatars.githubusercontent.com/u/13448248?v=4&s=48" width="48" height="48" alt="kyya"></a>
|
||||
<a href="https://github.com/rreben"><img src="https://avatars.githubusercontent.com/u/4026131?v=4&s=48" width="48" height="48" alt="rreben"></a>
|
||||
<a href="https://github.com/ATECHPCS"><img src="https://avatars.githubusercontent.com/u/125108010?v=4&s=48" width="48" height="48" alt="ATECHPCS"></a>
|
||||
<a href="https://github.com/adrianhoehne"><img src="https://avatars.githubusercontent.com/u/19731088?v=4&s=48" width="48" height="48" alt="adrianhoehne"></a>
|
||||
<a href="https://github.com/Aisht669"><img src="https://avatars.githubusercontent.com/u/36147411?v=4&s=48" width="48" height="48" alt="Aisht669"></a>
|
||||
<a href="https://github.com/AlbertWang688"><img src="https://avatars.githubusercontent.com/u/36430404?v=4&s=48" width="48" height="48" alt="AlbertWang688"></a>
|
||||
<a href="https://github.com/AlexanderMerkel"><img src="https://avatars.githubusercontent.com/u/105279319?v=4&s=48" width="48" height="48" alt="AlexanderMerkel"></a>
|
||||
<a href="https://github.com/khmylov"><img src="https://avatars.githubusercontent.com/u/1044282?v=4&s=48" width="48" height="48" alt="khmylov"></a>
|
||||
<a href="https://github.com/karimluna"><img src="https://avatars.githubusercontent.com/u/195384419?v=4&s=48" width="48" height="48" alt="karimluna"></a>
|
||||
<a href="https://github.com/Bayern4ever-dot"><img src="https://avatars.githubusercontent.com/u/67447782?v=4&s=48" width="48" height="48" alt="Bayern4ever-dot"></a>
|
||||
<a href="https://github.com/abhinavaditya811"><img src="https://avatars.githubusercontent.com/u/40894851?v=4&s=48" width="48" height="48" alt="abhinavaditya811"></a>
|
||||
<a href="https://github.com/bjoshuanoah"><img src="https://avatars.githubusercontent.com/u/1885253?v=4&s=48" width="48" height="48" alt="bjoshuanoah"></a>
|
||||
<a href="https://github.com/hanouticelina"><img src="https://avatars.githubusercontent.com/u/36770234?v=4&s=48" width="48" height="48" alt="hanouticelina"></a>
|
||||
<a href="https://github.com/quanmou"><img src="https://avatars.githubusercontent.com/u/7821404?v=4&s=48" width="48" height="48" alt="quanmou"></a>
|
||||
<a href="https://github.com/ClaytonWWilson"><img src="https://avatars.githubusercontent.com/u/31804874?v=4&s=48" width="48" height="48" alt="ClaytonWWilson"></a>
|
||||
<a href="https://github.com/kaseru"><img src="https://avatars.githubusercontent.com/u/5975972?v=4&s=48" width="48" height="48" alt="kaseru"></a>
|
||||
<a href="https://github.com/danielemden"><img src="https://avatars.githubusercontent.com/u/265470?v=4&s=48" width="48" height="48" alt="danielemden"></a>
|
||||
<a href="https://github.com/dmarkey"><img src="https://avatars.githubusercontent.com/u/1159924?v=4&s=48" width="48" height="48" alt="dmarkey"></a>
|
||||
<a href="https://github.com/desmondsow"><img src="https://avatars.githubusercontent.com/u/7720601?v=4&s=48" width="48" height="48" alt="desmondsow"></a>
|
||||
<a href="https://github.com/intelliot"><img src="https://avatars.githubusercontent.com/u/81505?v=4&s=48" width="48" height="48" alt="intelliot"></a>
|
||||
<a href="https://github.com/eugenechae"><img src="https://avatars.githubusercontent.com/u/1910247?v=4&s=48" width="48" height="48" alt="eugenechae"></a>
|
||||
<a href="https://github.com/ehs208"><img src="https://avatars.githubusercontent.com/u/109217208?v=4&s=48" width="48" height="48" alt="ehs208"></a>
|
||||
<a href="https://github.com/Felix8568"><img src="https://avatars.githubusercontent.com/u/86166271?v=4&s=48" width="48" height="48" alt="Felix8568"></a>
|
||||
<a href="https://github.com/GabrielWithTina"><img src="https://avatars.githubusercontent.com/u/2384004?v=4&s=48" width="48" height="48" alt="GabrielWithTina"></a>
|
||||
<a href="https://github.com/georgeatparallel"><img src="https://avatars.githubusercontent.com/u/297992784?v=4&s=48" width="48" height="48" alt="georgeatparallel"></a>
|
||||
<a href="https://github.com/SHLE1"><img src="https://avatars.githubusercontent.com/u/101321085?v=4&s=48" width="48" height="48" alt="SHLE1"></a>
|
||||
<a href="https://github.com/lzmjlrt"><img src="https://avatars.githubusercontent.com/u/62170398?v=4&s=48" width="48" height="48" alt="lzmjlrt"></a>
|
||||
<a href="https://github.com/saimonventura"><img src="https://avatars.githubusercontent.com/u/3719710?v=4&s=48" width="48" height="48" alt="saimonventura"></a>
|
||||
<a href="https://github.com/SIDD-KIDD"><img src="https://avatars.githubusercontent.com/u/162047739?v=4&s=48" width="48" height="48" alt="SIDD-KIDD"></a>
|
||||
<a href="https://github.com/sihyeonn"><img src="https://avatars.githubusercontent.com/u/24850223?v=4&s=48" width="48" height="48" alt="sihyeonn"></a>
|
||||
<a href="https://github.com/Seym0n"><img src="https://avatars.githubusercontent.com/u/119116740?v=4&s=48" width="48" height="48" alt="Seym0n"></a>
|
||||
<a href="https://github.com/fyhertz"><img src="https://avatars.githubusercontent.com/u/2746007?v=4&s=48" width="48" height="48" alt="fyhertz"></a>
|
||||
<a href="https://github.com/sohamb117"><img src="https://avatars.githubusercontent.com/u/36938330?v=4&s=48" width="48" height="48" alt="sohamb117"></a>
|
||||
<a href="https://github.com/Solaris-star"><img src="https://avatars.githubusercontent.com/u/67425364?v=4&s=48" width="48" height="48" alt="Solaris-star"></a>
|
||||
<a href="https://github.com/Syoc"><img src="https://avatars.githubusercontent.com/u/9057210?v=4&s=48" width="48" height="48" alt="Syoc"></a>
|
||||
<a href="https://github.com/tedyyan"><img src="https://avatars.githubusercontent.com/u/2662290?v=4&s=48" width="48" height="48" alt="tedyyan"></a>
|
||||
<a href="https://github.com/xuayan-nokia"><img src="https://avatars.githubusercontent.com/u/87028154?v=4&s=48" width="48" height="48" alt="xuayan-nokia"></a>
|
||||
<a href="https://github.com/TheAutomatic"><img src="https://avatars.githubusercontent.com/u/5350578?v=4&s=48" width="48" height="48" alt="TheAutomatic"></a>
|
||||
<a href="https://github.com/TomLisankie"><img src="https://avatars.githubusercontent.com/u/92654?v=4&s=48" width="48" height="48" alt="TomLisankie"></a>
|
||||
<a href="https://github.com/tamvicky"><img src="https://avatars.githubusercontent.com/u/9824871?v=4&s=48" width="48" height="48" alt="tamvicky"></a>
|
||||
<a href="https://github.com/MVS-source"><img src="https://avatars.githubusercontent.com/u/72023257?v=4&s=48" width="48" height="48" alt="MVS-source"></a>
|
||||
<a href="https://github.com/wingkwong"><img src="https://avatars.githubusercontent.com/u/35857179?v=4&s=48" width="48" height="48" alt="wingkwong"></a>
|
||||
<a href="https://github.com/wenjielei1990"><img src="https://avatars.githubusercontent.com/u/182426847?v=4&s=48" width="48" height="48" alt="wenjielei1990"></a>
|
||||
<a href="https://github.com/Wenzhang-Chen"><img src="https://avatars.githubusercontent.com/u/212304734?v=4&s=48" width="48" height="48" alt="Wenzhang-Chen"></a>
|
||||
<a href="https://github.com/wesleyzhangwq"><img src="https://avatars.githubusercontent.com/u/275724973?v=4&s=48" width="48" height="48" alt="wesleyzhangwq"></a>
|
||||
<a href="https://github.com/XiaoHuo888-hue"><img src="https://avatars.githubusercontent.com/u/315183888?v=4&s=48" width="48" height="48" alt="XiaoHuo888-hue"></a>
|
||||
<a href="https://github.com/Lyt060814"><img src="https://avatars.githubusercontent.com/u/182195098?v=4&s=48" width="48" height="48" alt="Lyt060814"></a>
|
||||
<a href="https://github.com/limdingwen"><img src="https://avatars.githubusercontent.com/u/1744967?v=4&s=48" width="48" height="48" alt="limdingwen"></a>
|
||||
<a href="https://github.com/luc-nguyen-cake"><img src="https://avatars.githubusercontent.com/u/308145655?v=4&s=48" width="48" height="48" alt="luc-nguyen-cake"></a>
|
||||
<a href="https://github.com/maciejwojcik86"><img src="https://avatars.githubusercontent.com/u/122781058?v=4&s=48" width="48" height="48" alt="maciejwojcik86"></a>
|
||||
<a href="https://github.com/mvanhorn"><img src="https://avatars.githubusercontent.com/u/455140?v=4&s=48" width="48" height="48" alt="mvanhorn"></a>
|
||||
<a href="https://github.com/twiddles"><img src="https://avatars.githubusercontent.com/u/242461?v=4&s=48" width="48" height="48" alt="twiddles"></a>
|
||||
<a href="https://github.com/maxmilian"><img src="https://avatars.githubusercontent.com/u/3001335?v=4&s=48" width="48" height="48" alt="maxmilian"></a>
|
||||
<a href="https://github.com/mrbob-git"><img src="https://avatars.githubusercontent.com/u/202024716?v=4&s=48" width="48" height="48" alt="mrbob-git"></a>
|
||||
<a href="https://github.com/MuataSr"><img src="https://avatars.githubusercontent.com/u/177951810?v=4&s=48" width="48" height="48" alt="MuataSr"></a>
|
||||
<a href="https://github.com/Neutralmilkzzz"><img src="https://avatars.githubusercontent.com/u/216463318?v=4&s=48" width="48" height="48" alt="Neutralmilkzzz"></a>
|
||||
<a href="https://github.com/nblondiau"><img src="https://avatars.githubusercontent.com/u/6884594?v=4&s=48" width="48" height="48" alt="nblondiau"></a>
|
||||
<a href="https://github.com/omdv"><img src="https://avatars.githubusercontent.com/u/4576131?v=4&s=48" width="48" height="48" alt="omdv"></a>
|
||||
<a href="https://github.com/rbankole"><img src="https://avatars.githubusercontent.com/u/25436617?v=4&s=48" width="48" height="48" alt="rbankole"></a>
|
||||
<a href="https://github.com/orrinwitt"><img src="https://avatars.githubusercontent.com/u/9917194?v=4&s=48" width="48" height="48" alt="orrinwitt"></a>
|
||||
<a href="https://github.com/pjperez"><img src="https://avatars.githubusercontent.com/u/747936?v=4&s=48" width="48" height="48" alt="pjperez"></a>
|
||||
<a href="https://github.com/pve"><img src="https://avatars.githubusercontent.com/u/37116?v=4&s=48" width="48" height="48" alt="pve"></a>
|
||||
<a href="https://github.com/PeterDaveHello"><img src="https://avatars.githubusercontent.com/u/3691490?v=4&s=48" width="48" height="48" alt="PeterDaveHello"></a>
|
||||
<a href="https://github.com/Molunerfinn"><img src="https://avatars.githubusercontent.com/u/12621342?v=4&s=48" width="48" height="48" alt="Molunerfinn"></a>
|
||||
<a href="https://github.com/power88"><img src="https://avatars.githubusercontent.com/u/24859241?v=4&s=48" width="48" height="48" alt="power88"></a>
|
||||
<a href="https://github.com/srajasimman"><img src="https://avatars.githubusercontent.com/u/15092596?v=4&s=48" width="48" height="48" alt="srajasimman"></a>
|
||||
<a href="https://github.com/groudas"><img src="https://avatars.githubusercontent.com/u/18154989?v=4&s=48" width="48" height="48" alt="groudas"></a>
|
||||
<a href="https://github.com/RohitDayanand"><img src="https://avatars.githubusercontent.com/u/66650100?v=4&s=48" width="48" height="48" alt="RohitDayanand"></a>
|
||||
<a href="https://github.com/katafractari"><img src="https://avatars.githubusercontent.com/u/1299228?v=4&s=48" width="48" height="48" alt="katafractari"></a>
|
||||
<a href="https://github.com/rudy-of-the-corner"><img src="https://avatars.githubusercontent.com/u/188991374?v=4&s=48" width="48" height="48" alt="rudy-of-the-corner"></a>
|
||||
</p>
|
||||
<!-- contributors:end -->
|
||||
|
||||
<p align="center">
|
||||
<em> Thanks for visiting ✨ nanobot!</em><br><br>
|
||||
|
||||
+13
-6
@@ -425,7 +425,7 @@ class AgentRunner:
|
||||
) -> AgentRunResult:
|
||||
final_content: str | None = None
|
||||
tools_used: list[str] = []
|
||||
usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
usage = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
error: str | None = None
|
||||
stop_reason = "completed"
|
||||
tool_events: list[dict[str, str]] = []
|
||||
@@ -1384,11 +1384,6 @@ class AgentRunner:
|
||||
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _accumulate_usage(target: dict[str, int], addition: dict[str, int]) -> None:
|
||||
for key, value in addition.items():
|
||||
target[key] = target.get(key, 0) + value
|
||||
|
||||
@staticmethod
|
||||
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
|
||||
merged = dict(left)
|
||||
@@ -1396,6 +1391,18 @@ class AgentRunner:
|
||||
merged[key] = merged.get(key, 0) + value
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _accumulate_usage(total: dict[str, int], request: dict[str, int]) -> None:
|
||||
"""Fold one model request into the current turn's usage."""
|
||||
total["request_count"] = total.get("request_count", 0) + 1
|
||||
prompt_tokens = request.get("prompt_tokens")
|
||||
if prompt_tokens is not None and prompt_tokens >= 0:
|
||||
total["context_tokens"] = prompt_tokens
|
||||
for key, value in request.items():
|
||||
if key in {"context_tokens", "request_count"} or value < 0:
|
||||
continue
|
||||
total[key] = total.get(key, 0) + value
|
||||
|
||||
async def _execute_tools(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
|
||||
@@ -100,6 +100,7 @@ class TurnModelUpdatedEvent(OutboundEvent):
|
||||
model: str
|
||||
model_preset: str | None = None
|
||||
context_window_tokens: int | None = None
|
||||
fallback: bool = False
|
||||
|
||||
|
||||
def outbound_message_for_event(
|
||||
|
||||
@@ -426,6 +426,7 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
|
||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||
self._reasoning_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||
|
||||
# -- Subscription bookkeeping -------------------------------------------
|
||||
|
||||
@@ -482,6 +483,9 @@ class WebSocketChannel(BaseChannel):
|
||||
for key in tuple(self._stream_text_buffers):
|
||||
if key[0] == chat_id:
|
||||
self._stream_text_buffers.pop(key, None)
|
||||
for key in tuple(self._reasoning_text_buffers):
|
||||
if key[0] == chat_id:
|
||||
self._reasoning_text_buffers.pop(key, None)
|
||||
|
||||
async def _discard_connection_owned_chat(
|
||||
self,
|
||||
@@ -1641,11 +1645,22 @@ class WebSocketChannel(BaseChannel):
|
||||
include_source=include_source,
|
||||
transcript_overrides=transcript_overrides,
|
||||
)
|
||||
if (
|
||||
not persisted
|
||||
and phase in {"answer", "complete"}
|
||||
and (metadata or {}).get("webui") is True
|
||||
):
|
||||
return self._retain_turn_on_transcript_failure(
|
||||
chat_id,
|
||||
persisted=persisted,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _retain_turn_on_transcript_failure(
|
||||
chat_id: str,
|
||||
*,
|
||||
persisted: bool,
|
||||
metadata: dict[str, Any] | None,
|
||||
phase: str,
|
||||
) -> bool:
|
||||
if not persisted and phase in {"answer", "complete"} and (metadata or {}).get("webui") is True:
|
||||
owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
mark_websocket_turn_transcript_persistence_failed(
|
||||
chat_id,
|
||||
@@ -1653,6 +1668,34 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
return persisted
|
||||
|
||||
def _persist_turn_stream_event(
|
||||
self,
|
||||
chat_id: str,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
completed_text: str | None,
|
||||
metadata: dict[str, Any] | None,
|
||||
phase: str,
|
||||
include_source: bool = False,
|
||||
) -> bool:
|
||||
"""Persist the canonical end of a live stream, never its wire chunks."""
|
||||
if not self._temporary_chats.should_persist_transcript(chat_id):
|
||||
return True
|
||||
persisted = self._transcripts.prepare_and_append_stream_event(
|
||||
chat_id,
|
||||
event,
|
||||
completed_text=completed_text,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
include_source=include_source,
|
||||
)
|
||||
return self._retain_turn_on_transcript_failure(
|
||||
chat_id,
|
||||
persisted=persisted,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
event = outbound_event_from_message(msg)
|
||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
||||
@@ -1685,6 +1728,7 @@ class WebSocketChannel(BaseChannel):
|
||||
model_name=event.model,
|
||||
model_preset=event.model_preset,
|
||||
context_window_tokens=event.context_window_tokens,
|
||||
fallback=event.fallback,
|
||||
)
|
||||
return
|
||||
if isinstance(event, UserInputEvent):
|
||||
@@ -1834,9 +1878,12 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._persist_turn_transcript_event(
|
||||
stream_key = (chat_id, str(stream_id or ""))
|
||||
self._reasoning_text_buffers.setdefault(stream_key, []).append(delta)
|
||||
self._persist_turn_stream_event(
|
||||
chat_id,
|
||||
body,
|
||||
completed_text=None,
|
||||
metadata=meta,
|
||||
phase="reasoning",
|
||||
)
|
||||
@@ -1862,9 +1909,12 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._persist_turn_transcript_event(
|
||||
stream_key = (chat_id, str(stream_id or ""))
|
||||
reasoning_text = "".join(self._reasoning_text_buffers.pop(stream_key, []))
|
||||
self._persist_turn_stream_event(
|
||||
chat_id,
|
||||
body,
|
||||
completed_text=reasoning_text or None,
|
||||
metadata=meta,
|
||||
phase="reasoning",
|
||||
)
|
||||
@@ -1912,6 +1962,7 @@ class WebSocketChannel(BaseChannel):
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
meta = metadata or {}
|
||||
stream_key = (chat_id, str(stream_id or ""))
|
||||
completed_text: str | None = None
|
||||
if stream_end:
|
||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||
buffered = (
|
||||
@@ -1923,6 +1974,7 @@ class WebSocketChannel(BaseChannel):
|
||||
buffered.append(delta)
|
||||
full_text = "".join(buffered)
|
||||
rewritten = self._media.rewrite_local_markdown_images(full_text)
|
||||
completed_text = rewritten
|
||||
if delta or rewritten != full_text:
|
||||
body["text"] = rewritten
|
||||
else:
|
||||
@@ -1938,9 +1990,10 @@ class WebSocketChannel(BaseChannel):
|
||||
body["resuming"] = True
|
||||
if stream_end and merge_next:
|
||||
body["merge_next"] = True
|
||||
self._persist_turn_transcript_event(
|
||||
self._persist_turn_stream_event(
|
||||
chat_id,
|
||||
body,
|
||||
completed_text=completed_text,
|
||||
metadata=meta,
|
||||
phase="answer",
|
||||
include_source=True,
|
||||
@@ -1997,6 +2050,7 @@ class WebSocketChannel(BaseChannel):
|
||||
# carries a durable incomplete marker. The HTTP replay path can
|
||||
# recover the latter from session history after a gateway restart.
|
||||
clear_websocket_turn_if_current(chat_id, turn_owner)
|
||||
self._clear_stream_buffers(chat_id)
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
if not conns:
|
||||
return
|
||||
@@ -2102,6 +2156,7 @@ class WebSocketChannel(BaseChannel):
|
||||
model_name: Any,
|
||||
model_preset: Any = None,
|
||||
context_window_tokens: Any = None,
|
||||
fallback: bool = False,
|
||||
) -> None:
|
||||
"""Notify one chat's subscribers which model is handling its current request."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
@@ -2120,6 +2175,8 @@ class WebSocketChannel(BaseChannel):
|
||||
body["model_preset"] = model_preset.strip()
|
||||
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
|
||||
body["context_window_tokens"] = context_window_tokens
|
||||
if fallback:
|
||||
body["fallback"] = True
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
||||
|
||||
@@ -2073,6 +2073,21 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||
"model_preset": "Deep Research",
|
||||
"context_window_tokens": 128_000,
|
||||
}
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
event=TurnModelUpdatedEvent(
|
||||
model="deepseek/deepseek-chat",
|
||||
model_preset="Deep Research",
|
||||
fallback=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
fallback_payload = json.loads(chat_one.send.call_args.args[0])
|
||||
assert fallback_payload["fallback"] is True
|
||||
chat_two.send.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -2348,8 +2363,9 @@ async def test_send_delta_preserves_webui_source_metadata() -> None:
|
||||
assert second["event"] == "stream_end"
|
||||
assert second["source"] == source
|
||||
lines = read_transcript_lines("websocket:chat-source-stream")
|
||||
assert lines[-2]["source"] == source
|
||||
assert lines[-1]["source"] == source
|
||||
assert lines[-1]["event"] == "stream_end"
|
||||
assert lines[-1]["text"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2374,6 +2390,8 @@ async def test_send_delta_marks_resuming_stream_end() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
|
||||
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
|
||||
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
@@ -2403,6 +2421,12 @@ async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
|
||||
"second",
|
||||
]
|
||||
assert ("chat-1", "sid") not in channel._stream_text_buffers
|
||||
lines = read_transcript_lines("websocket:chat-1")
|
||||
assert [line["event"] for line in lines] == ["stream_end", "stream_end"]
|
||||
assert [line["text"] for line in lines] == ["first ", "first second"]
|
||||
body = build_webui_thread_response("websocket:chat-1")
|
||||
assert body is not None
|
||||
assert body["messages"][-1]["content"] == "first second"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2596,7 +2620,8 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
|
||||
|
||||
assert channel._subs == {}
|
||||
lines = read_transcript_lines("websocket:chat-1")
|
||||
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
|
||||
assert [line["event"] for line in lines] == ["stream_end", "turn_end"]
|
||||
assert lines[0]["text"] == "hello world"
|
||||
body = build_webui_thread_response("websocket:chat-1")
|
||||
assert body is not None
|
||||
assert body["messages"][-1]["role"] == "assistant"
|
||||
@@ -2604,6 +2629,77 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
|
||||
assert body["messages"][-1]["latencyMs"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_transcript_writes_once_per_completed_segment(monkeypatch) -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
append = MagicMock()
|
||||
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
|
||||
|
||||
await channel.send_delta("chat-write-rate", "one", stream_id="s1")
|
||||
await channel.send_delta("chat-write-rate", " two", stream_id="s1")
|
||||
await channel.send_delta("chat-write-rate", " three", stream_id="s1")
|
||||
|
||||
append.assert_not_called()
|
||||
|
||||
await channel.send_delta("chat-write-rate", "", stream_id="s1", stream_end=True)
|
||||
|
||||
append.assert_called_once()
|
||||
persisted = append.call_args.args[1]
|
||||
assert persisted["event"] == "stream_end"
|
||||
assert persisted["text"] == "one two three"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_transcript_persists_one_canonical_record(monkeypatch) -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
append = MagicMock()
|
||||
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
|
||||
|
||||
await channel.send_reasoning_delta("chat-reasoning-write-rate", "plan ", stream_id="r1")
|
||||
await channel.send_reasoning_delta("chat-reasoning-write-rate", "then act", stream_id="r1")
|
||||
|
||||
append.assert_not_called()
|
||||
|
||||
await channel.send_reasoning_end("chat-reasoning-write-rate", stream_id="r1")
|
||||
|
||||
append.assert_called_once()
|
||||
persisted = append.call_args.args[1]
|
||||
assert persisted["event"] == "reasoning_end"
|
||||
assert persisted["text"] == "plan then act"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_end_discards_unclosed_stream_buffers() -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
|
||||
await channel.send_delta("chat-unclosed", "partial", stream_id="s1")
|
||||
await channel.send_reasoning_delta("chat-unclosed", "thinking", stream_id="r1")
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-unclosed",
|
||||
content="",
|
||||
event=TurnEndEvent(),
|
||||
))
|
||||
|
||||
assert channel._stream_text_buffers == {}
|
||||
assert channel._reasoning_text_buffers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||
bus = MagicMock()
|
||||
|
||||
@@ -509,8 +509,6 @@ class FallbackProvider(LLMProvider):
|
||||
)
|
||||
continue
|
||||
|
||||
await self._notify_fallback_model(fallback_model)
|
||||
|
||||
fallback_kwargs = {
|
||||
**kwargs,
|
||||
"model": fallback_model,
|
||||
@@ -541,6 +539,11 @@ class FallbackProvider(LLMProvider):
|
||||
fallback_response = await call(fallback_provider, fallback_kwargs)
|
||||
|
||||
if fallback_response.finish_reason != "error":
|
||||
# Do not publish a model switch merely because a fallback was
|
||||
# attempted. A fallback can fail just like the primary, and
|
||||
# the WebUI would otherwise show a misleading success signal.
|
||||
# Publish only after this response is known to be usable.
|
||||
await self._notify_fallback_model(fallback_model)
|
||||
logger.info(
|
||||
"Fallback '{}' succeeded after primary '{}' failed",
|
||||
fallback_model, primary_model,
|
||||
|
||||
@@ -251,6 +251,26 @@ def _refusal_event_key(
|
||||
)
|
||||
|
||||
|
||||
def _reasoning_summary_event_key(
|
||||
item_id: object,
|
||||
summary_index: object,
|
||||
) -> tuple[str | None, int] | None:
|
||||
"""Identify one reasoning summary part across its text deltas."""
|
||||
if not isinstance(summary_index, int) or isinstance(summary_index, bool):
|
||||
return None
|
||||
return (
|
||||
item_id if isinstance(item_id, str) else None,
|
||||
summary_index,
|
||||
)
|
||||
|
||||
|
||||
def _separate_reasoning_part(content: str | None, part: str) -> str:
|
||||
"""Separate summary parts only when the provider supplied no whitespace."""
|
||||
if content and not content[-1].isspace() and not part[0].isspace():
|
||||
return "\n" + part
|
||||
return part
|
||||
|
||||
|
||||
def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
|
||||
"""Return only text not already surfaced by refusal deltas."""
|
||||
if not streamed_text:
|
||||
@@ -342,6 +362,7 @@ async def consume_sse_with_reasoning(
|
||||
usage: dict[str, int] = {}
|
||||
reasoning_content: str | None = None
|
||||
streamed_reasoning = False
|
||||
reasoning_summary_key: tuple[str | None, int] | None = None
|
||||
refusal_seen = False
|
||||
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
||||
emitted_refusal_text = ""
|
||||
@@ -406,6 +427,18 @@ async def consume_sse_with_reasoning(
|
||||
elif event_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = event.get("delta") or ""
|
||||
if delta_text:
|
||||
summary_key = _reasoning_summary_event_key(
|
||||
event.get("item_id"),
|
||||
event.get("summary_index"),
|
||||
)
|
||||
if (
|
||||
summary_key is not None
|
||||
and reasoning_summary_key is not None
|
||||
and summary_key != reasoning_summary_key
|
||||
):
|
||||
delta_text = _separate_reasoning_part(reasoning_content, delta_text)
|
||||
if summary_key is not None:
|
||||
reasoning_summary_key = summary_key
|
||||
reasoning_content = (reasoning_content or "") + delta_text
|
||||
streamed_reasoning = True
|
||||
if on_reasoning_delta:
|
||||
@@ -538,7 +571,10 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
|
||||
text = summary.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "".join(parts) or None
|
||||
content = ""
|
||||
for part in parts:
|
||||
content += _separate_reasoning_part(content, part)
|
||||
return content or None
|
||||
|
||||
|
||||
def parse_response_output(
|
||||
|
||||
@@ -495,6 +495,7 @@ def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserve
|
||||
if context.runtime is not None
|
||||
else None
|
||||
),
|
||||
fallback=True,
|
||||
),
|
||||
metadata=context.metadata,
|
||||
)
|
||||
|
||||
+89
-23
@@ -770,6 +770,36 @@ class WebUITranscriptRecorder:
|
||||
record.update(transcript_overrides)
|
||||
return self.append(chat_id, record)
|
||||
|
||||
def prepare_and_append_stream_event(
|
||||
self,
|
||||
chat_id: str,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
completed_text: str | None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
phase: str | None = None,
|
||||
include_source: bool = False,
|
||||
) -> bool:
|
||||
"""Annotate every live stream event, but persist only completed segments.
|
||||
|
||||
Delta frames are a transport concern: retaining each token-sized chunk
|
||||
would turn rendering cadence into disk-write cadence. The matching end
|
||||
event carries the canonical segment text used by history replay.
|
||||
"""
|
||||
self.prepare_event(
|
||||
chat_id,
|
||||
event,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
include_source=include_source,
|
||||
)
|
||||
if event.get("event") in {"delta", "reasoning_delta"}:
|
||||
return True
|
||||
record = dict(event)
|
||||
if completed_text is not None:
|
||||
record["text"] = completed_text
|
||||
return self.append(chat_id, record)
|
||||
|
||||
def append_user_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -1903,13 +1933,24 @@ def replay_transcript_to_ui_messages(
|
||||
kept.append(m)
|
||||
messages = kept
|
||||
|
||||
def stamp_latency(latency_ms: int) -> None:
|
||||
def stamp_completion(
|
||||
*,
|
||||
latency_ms: int | None = None,
|
||||
usage: dict[str, int] | None = None,
|
||||
context_window_tokens: int | None = None,
|
||||
) -> None:
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace":
|
||||
completion: dict[str, Any] = {"isStreaming": False}
|
||||
if latency_ms is not None:
|
||||
completion["latencyMs"] = latency_ms
|
||||
if usage:
|
||||
completion["usage"] = usage
|
||||
if context_window_tokens is not None:
|
||||
completion["contextWindowTokens"] = context_window_tokens
|
||||
messages[i] = {
|
||||
**messages[i],
|
||||
"latencyMs": latency_ms,
|
||||
"isStreaming": False,
|
||||
**completion,
|
||||
}
|
||||
return
|
||||
|
||||
@@ -2215,30 +2256,27 @@ def replay_transcript_to_ui_messages(
|
||||
turn_fields = _turn_fields(rec, "answer")
|
||||
source_fields = _source_fields(rec)
|
||||
if isinstance(final_text, str):
|
||||
if buffer_message_id is None:
|
||||
buffer_message_id = find_active_placeholder(messages, turn_fields)
|
||||
if buffer_message_id is None:
|
||||
buffer_message_id = _new_id("buf", idx)
|
||||
messages.append(
|
||||
{
|
||||
"id": buffer_message_id,
|
||||
"role": "assistant",
|
||||
messages.append({
|
||||
"id": buffer_message_id,
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"isStreaming": True,
|
||||
"createdAt": _created_at_ms(rec, idx),
|
||||
})
|
||||
for i, m in enumerate(messages):
|
||||
if m.get("id") == buffer_message_id:
|
||||
messages[i] = {
|
||||
**m,
|
||||
"content": final_text,
|
||||
"isStreaming": True,
|
||||
**turn_fields,
|
||||
**source_fields,
|
||||
"createdAt": _created_at_ms(rec, idx),
|
||||
},
|
||||
)
|
||||
else:
|
||||
for i, m in enumerate(messages):
|
||||
if m.get("id") == buffer_message_id:
|
||||
messages[i] = {
|
||||
**m,
|
||||
"content": final_text,
|
||||
"isStreaming": True,
|
||||
**turn_fields,
|
||||
**source_fields,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
if merge_next:
|
||||
buffer_parts = [final_text]
|
||||
elif source_fields and buffer_message_id is not None:
|
||||
@@ -2274,6 +2312,16 @@ def replay_transcript_to_ui_messages(
|
||||
if ev == "reasoning_end":
|
||||
if suppress_until_turn_end:
|
||||
continue
|
||||
text = rec.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
close_file_edit_phase_before_activity()
|
||||
attach_reasoning_chunk(
|
||||
messages,
|
||||
text,
|
||||
idx,
|
||||
_turn_fields(rec, "reasoning"),
|
||||
_created_at_ms(rec, idx),
|
||||
)
|
||||
close_reasoning(messages)
|
||||
continue
|
||||
|
||||
@@ -2401,8 +2449,26 @@ def replay_transcript_to_ui_messages(
|
||||
messages[i] = {**m, "isStreaming": False}
|
||||
prune_reasoning_only()
|
||||
lat = rec.get("latency_ms")
|
||||
if isinstance(lat, (int, float)) and lat >= 0:
|
||||
stamp_latency(int(lat))
|
||||
usage = rec.get("usage")
|
||||
sanitized_usage = (
|
||||
{
|
||||
key: value
|
||||
for key, value in cast(dict[object, object], usage).items()
|
||||
if isinstance(key, str) and type(value) is int and value >= 0
|
||||
}
|
||||
if isinstance(usage, dict)
|
||||
else None
|
||||
)
|
||||
context_window = rec.get("context_window_tokens")
|
||||
stamp_completion(
|
||||
latency_ms=int(lat) if isinstance(lat, (int, float)) and lat >= 0 else None,
|
||||
usage=sanitized_usage,
|
||||
context_window_tokens=(
|
||||
int(context_window)
|
||||
if isinstance(context_window, (int, float)) and context_window >= 0
|
||||
else None
|
||||
),
|
||||
)
|
||||
buffer_message_id = None
|
||||
buffer_parts = []
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refresh the native contributor avatar wall in README.md."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, cast
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
REPOSITORY = "HKUDS/nanobot"
|
||||
README = Path(__file__).resolve().parents[1] / "README.md"
|
||||
START = "<!-- contributors:start -->"
|
||||
END = "<!-- contributors:end -->"
|
||||
PER_PAGE = 100
|
||||
MAINTAINERS = {"re-bin", "chengyongru"}
|
||||
|
||||
|
||||
class Contributor(TypedDict):
|
||||
login: str
|
||||
type: str
|
||||
html_url: str
|
||||
avatar_url: str
|
||||
|
||||
|
||||
def fetch_contributors() -> list[Contributor]:
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "nanobot-readme",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
if token := os.environ.get("GITHUB_TOKEN"):
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
contributors: list[Contributor] = []
|
||||
page = 1
|
||||
while True:
|
||||
url = f"https://api.github.com/repos/{REPOSITORY}/contributors?per_page={PER_PAGE}&page={page}"
|
||||
with urlopen(Request(url, headers=headers), timeout=30) as response: # noqa: S310
|
||||
batch = cast(list[Contributor], json.load(response))
|
||||
contributors.extend(batch)
|
||||
if len(batch) < PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return [
|
||||
contributor
|
||||
for contributor in contributors
|
||||
if contributor.get("login")
|
||||
and contributor.get("type") != "Bot"
|
||||
and not contributor["login"].lower().endswith("[bot]")
|
||||
and contributor["login"].lower() not in MAINTAINERS
|
||||
]
|
||||
|
||||
|
||||
def render_wall(contributors: list[Contributor]) -> str:
|
||||
avatars = [
|
||||
(
|
||||
f'<a href="{contributor["html_url"]}">'
|
||||
f'<img src="{contributor["avatar_url"]}&s=48" '
|
||||
f'width="48" height="48" alt="{contributor["login"]}"></a>'
|
||||
)
|
||||
for contributor in contributors
|
||||
]
|
||||
wall = "\n".join(avatars)
|
||||
return f"{START}\n<p>\n{wall}\n</p>\n{END}"
|
||||
|
||||
|
||||
def update_readme(*, check: bool) -> bool:
|
||||
current = README.read_text()
|
||||
before, separator, tail = current.partition(START)
|
||||
if not separator or END not in tail:
|
||||
raise SystemExit("README contributor markers are missing")
|
||||
|
||||
_, _, after = tail.partition(END)
|
||||
updated = f"{before}{render_wall(fetch_contributors())}{after}"
|
||||
if updated == current:
|
||||
return False
|
||||
if check:
|
||||
raise SystemExit("README contributor wall is out of date")
|
||||
README.write_text(updated)
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="fail when README.md is out of date")
|
||||
args = parser.parse_args()
|
||||
print("Updated README.md" if update_readme(check=args.check) else "README.md is current")
|
||||
@@ -1070,6 +1070,8 @@ async def test_runner_accumulates_usage_and_preserves_cached_tokens():
|
||||
assert result.usage["prompt_tokens"] == 300 # 100 + 200
|
||||
assert result.usage["completion_tokens"] == 30 # 10 + 20
|
||||
assert result.usage["cached_tokens"] == 230 # 80 + 150
|
||||
assert result.usage["context_tokens"] == 200
|
||||
assert result.usage["request_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -584,9 +584,10 @@ class TestFallbackOnPrimaryError:
|
||||
assert restored.payload == state.payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reports_the_fallback_model_before_its_request(self) -> None:
|
||||
async def test_reports_only_the_successful_fallback_model(self) -> None:
|
||||
primary = _FakeProvider("primary", _error_response())
|
||||
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
||||
failed_fallback = _FakeProvider("failed", _error_response("backup overloaded"))
|
||||
successful_fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
||||
fallback_models: list[str] = []
|
||||
|
||||
async def _observe(model: str) -> None:
|
||||
@@ -594,8 +595,11 @@ class TestFallbackOnPrimaryError:
|
||||
|
||||
fb = FallbackProvider(
|
||||
primary=primary,
|
||||
fallback_presets=[_fallback("fallback-a", provider="backup")],
|
||||
provider_factory=MagicMock(return_value=fallback),
|
||||
fallback_presets=[
|
||||
_fallback("fallback-a", provider="backup"),
|
||||
_fallback("fallback-b", provider="backup"),
|
||||
],
|
||||
provider_factory=MagicMock(side_effect=[failed_fallback, successful_fallback]),
|
||||
fallback_model_observer=_observe,
|
||||
)
|
||||
|
||||
@@ -605,7 +609,7 @@ class TestFallbackOnPrimaryError:
|
||||
)
|
||||
|
||||
assert result.content == "fallback ok"
|
||||
assert fallback_models == ["fallback-a"]
|
||||
assert fallback_models == ["fallback-b"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_primary_error_before_fallback(self) -> None:
|
||||
|
||||
@@ -379,12 +379,14 @@ async def test_runner_calls_run_level_hooks_on_success():
|
||||
"done",
|
||||
"completed",
|
||||
None,
|
||||
{
|
||||
"prompt_tokens": 3,
|
||||
"completion_tokens": 2,
|
||||
"total_tokens": 5,
|
||||
"provider_tokens": 5,
|
||||
},
|
||||
{
|
||||
"prompt_tokens": 3,
|
||||
"completion_tokens": 2,
|
||||
"total_tokens": 5,
|
||||
"provider_tokens": 5,
|
||||
"request_count": 1,
|
||||
"context_tokens": 3,
|
||||
},
|
||||
["user", "assistant"],
|
||||
),
|
||||
("on_finally", "completed", None),
|
||||
|
||||
@@ -1056,8 +1056,24 @@ class TestConsumeSse:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_delta_extracted(self):
|
||||
response = _SseResponse([
|
||||
{"type": "response.reasoning_summary_text.delta", "delta": "thinking "},
|
||||
{"type": "response.reasoning_summary_text.delta", "delta": "briefly"},
|
||||
{
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": "rs_1",
|
||||
"summary_index": 0,
|
||||
"delta": "thinking ",
|
||||
},
|
||||
{
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": "rs_1",
|
||||
"summary_index": 0,
|
||||
"delta": "briefly",
|
||||
},
|
||||
{
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": "rs_1",
|
||||
"summary_index": 1,
|
||||
"delta": "Checking result",
|
||||
},
|
||||
{"type": "response.output_text.delta", "delta": "answer"},
|
||||
{"type": "response.completed", "response": {"status": "completed"}},
|
||||
])
|
||||
@@ -1075,8 +1091,8 @@ class TestConsumeSse:
|
||||
assert tool_calls == []
|
||||
assert finish_reason == "stop"
|
||||
assert usage == {}
|
||||
assert reasoning == "thinking briefly"
|
||||
assert deltas == ["thinking ", "briefly"]
|
||||
assert reasoning == "thinking briefly\nChecking result"
|
||||
assert deltas == ["thinking ", "briefly", "\nChecking result"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_from_completed_response(self):
|
||||
@@ -1087,7 +1103,7 @@ class TestConsumeSse:
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{"type": "reasoning", "summary": [
|
||||
{"type": "summary_text", "text": "cached "},
|
||||
{"type": "summary_text", "text": "cached"},
|
||||
{"type": "summary_text", "text": "summary"},
|
||||
]},
|
||||
],
|
||||
@@ -1097,7 +1113,7 @@ class TestConsumeSse:
|
||||
|
||||
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
|
||||
|
||||
assert reasoning == "cached summary"
|
||||
assert reasoning == "cached\nsummary"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_commits_exact_items_only_after_completed_event(self):
|
||||
|
||||
@@ -393,6 +393,55 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
||||
assert msgs[1]["latencyMs"] == 42
|
||||
|
||||
|
||||
def test_replay_canonical_completed_stream_records() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{"event": "user", "chat_id": "canonical", "text": "q"},
|
||||
{"event": "reasoning_end", "chat_id": "canonical", "text": "think"},
|
||||
{"event": "stream_end", "chat_id": "canonical", "text": "answer"},
|
||||
{"event": "turn_end", "chat_id": "canonical", "latency_ms": 42},
|
||||
])
|
||||
|
||||
assert len(msgs) == 2
|
||||
assert msgs[1]["content"] == "answer"
|
||||
assert msgs[1]["reasoning"] == "think"
|
||||
assert msgs[1]["latencyMs"] == 42
|
||||
|
||||
|
||||
def test_replay_turn_end_preserves_usage_semantics(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:t-usage"
|
||||
for event in (
|
||||
{"event": "user", "chat_id": "t-usage", "text": "q"},
|
||||
{"event": "message", "chat_id": "t-usage", "text": "a"},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "t-usage",
|
||||
"latency_ms": 18_200,
|
||||
"usage": {
|
||||
"prompt_tokens": 12_400,
|
||||
"completion_tokens": 823,
|
||||
"cached_tokens": 9_672,
|
||||
"context_tokens": 8_200,
|
||||
"request_count": 3,
|
||||
},
|
||||
"context_window_tokens": 128_000,
|
||||
},
|
||||
):
|
||||
append_transcript_object(key, event)
|
||||
|
||||
messages = replay_transcript_to_ui_messages(read_transcript_lines(key))
|
||||
|
||||
assert messages[-1]["usage"] == {
|
||||
"prompt_tokens": 12_400,
|
||||
"completion_tokens": 823,
|
||||
"cached_tokens": 9_672,
|
||||
"context_tokens": 8_200,
|
||||
"request_count": 3,
|
||||
}
|
||||
assert messages[-1]["contextWindowTokens"] == 128_000
|
||||
assert messages[-1]["latencyMs"] == 18_200
|
||||
|
||||
|
||||
def test_replay_uses_persisted_created_at_ms() -> None:
|
||||
msgs = replay_transcript_to_ui_messages(
|
||||
[
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
|
||||
import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight";
|
||||
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
|
||||
import { relativeTime, visibleSessionPreview } from "@/lib/format";
|
||||
import {
|
||||
COLLAPSED_CHATS_VISIBLE_COUNT,
|
||||
displayTitle,
|
||||
@@ -906,14 +906,6 @@ export const ChatList = memo(function ChatList({
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackTitle = t("chat.fallbackTitle", {
|
||||
id: s.chatId.slice(0, 6),
|
||||
});
|
||||
const generatedTitle = s.title?.trim() || "";
|
||||
const tooltipTitle =
|
||||
titleOverrides[s.key]?.trim() ||
|
||||
generatedTitle ||
|
||||
deriveTitle(s.preview, fallbackTitle);
|
||||
const isPinned = pinned.has(s.key);
|
||||
const isArchived = archived.has(s.key);
|
||||
const preview = visibleSessionPreview(s.preview);
|
||||
@@ -959,8 +951,7 @@ export const ChatList = memo(function ChatList({
|
||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<SidebarItemTooltip label={tooltipTitle}>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
if (deleteSelectionMode) {
|
||||
@@ -1029,8 +1020,7 @@ export const ChatList = memo(function ChatList({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</SidebarItemTooltip>
|
||||
</button>
|
||||
<SessionActivityIndicator state={activityState} />
|
||||
{!deleteSelectionMode ? (
|
||||
<DropdownMenu
|
||||
@@ -1435,10 +1425,7 @@ function ActivePaneRows({
|
||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<SidebarItemTooltip
|
||||
label={pane.handle ? `@${pane.handle.name} · ${pane.title}` : pane.title}
|
||||
>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
if (deleteSelectionMode) {
|
||||
@@ -1474,8 +1461,7 @@ function ActivePaneRows({
|
||||
{isPinned ? <PinnedChatIndicator /> : null}
|
||||
<SidebarSelectionTrack active={active} handle={pane.handle} />
|
||||
</span>
|
||||
</button>
|
||||
</SidebarItemTooltip>
|
||||
</button>
|
||||
<SessionActivityIndicator state={activityState} />
|
||||
{!deleteSelectionMode ? <DropdownMenu
|
||||
modal={false}
|
||||
@@ -1652,8 +1638,7 @@ function TemporaryChatSection({
|
||||
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<SidebarItemTooltip label={title}>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(session.key)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
@@ -1666,8 +1651,7 @@ function TemporaryChatSection({
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
</SidebarItemTooltip>
|
||||
</button>
|
||||
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
|
||||
{onClose ? (
|
||||
<button
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
|
||||
import {
|
||||
fmtDateTime,
|
||||
formatCompactTokenCount,
|
||||
formatMessageEndTime,
|
||||
} from "@/lib/format";
|
||||
import { toMediaAttachment } from "@/lib/media";
|
||||
import { matchingSlashCommand } from "@/lib/slash-command";
|
||||
import { sessionHandleColor } from "@/lib/session-handle";
|
||||
@@ -50,6 +54,7 @@ import type {
|
||||
UIMessage,
|
||||
MessageDeliveryErrorKind,
|
||||
MessageDeliveryStatus,
|
||||
TurnUsage,
|
||||
} from "@/lib/types";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
@@ -60,6 +65,8 @@ interface MessageBubbleProps {
|
||||
temporary?: boolean;
|
||||
/** When false, hide this message's copy button. Default true. */
|
||||
showCopyAction?: boolean;
|
||||
/** Show opt-in per-turn token usage for completed assistant messages. */
|
||||
showTurnUsage?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
slashCommands?: SlashCommand[];
|
||||
@@ -176,6 +183,116 @@ function MessageCopyButton({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function compactDuration(milliseconds: number): string {
|
||||
const seconds = milliseconds / 1_000;
|
||||
if (seconds < 10) return `${seconds.toFixed(1)}s`;
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}m ${Math.round(seconds % 60)}s`;
|
||||
}
|
||||
|
||||
function TurnUsageMeta({ usage }: { usage: TurnUsage }) {
|
||||
const { t } = useTranslation();
|
||||
const context = usage.context_tokens;
|
||||
const prompt = usage.prompt_tokens;
|
||||
const completion = usage.completion_tokens;
|
||||
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
|
||||
const formatTokens = (tokens: number) => `${approximate}${formatCompactTokenCount(tokens)}`;
|
||||
const parts: string[] = [];
|
||||
const details: string[] = [];
|
||||
|
||||
if (typeof context === "number" && context >= 0) {
|
||||
parts.push(t("message.usage.context", {
|
||||
tokens: formatTokens(context),
|
||||
defaultValue: "{{tokens}} context",
|
||||
}));
|
||||
if (typeof prompt === "number") {
|
||||
details.push(t("message.usage.turnInput", {
|
||||
tokens: formatTokens(prompt),
|
||||
defaultValue: "{{tokens}} turn input",
|
||||
}));
|
||||
}
|
||||
} else if (typeof prompt === "number") {
|
||||
parts.push(t("message.usage.turnInput", {
|
||||
tokens: formatTokens(prompt),
|
||||
defaultValue: "{{tokens}} turn input",
|
||||
}));
|
||||
}
|
||||
|
||||
if (typeof usage.request_count === "number" && usage.request_count > 0) {
|
||||
const requestKey = usage.request_count === 1
|
||||
? "message.usage.request"
|
||||
: "message.usage.requests";
|
||||
details.push(t(requestKey, {
|
||||
count: usage.request_count,
|
||||
defaultValue: usage.request_count === 1 ? "{{count}} request" : "{{count}} requests",
|
||||
}));
|
||||
}
|
||||
|
||||
if (typeof completion === "number") {
|
||||
const output = t("message.usage.output", {
|
||||
tokens: formatTokens(completion),
|
||||
defaultValue: "{{tokens}} out",
|
||||
});
|
||||
if (parts.length > 0) details.push(output);
|
||||
else parts.push(output);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof usage.cached_tokens === "number"
|
||||
&& typeof prompt === "number"
|
||||
&& prompt > 0
|
||||
) {
|
||||
details.push(t("message.usage.cached", {
|
||||
percent: Math.round(Math.min(1, usage.cached_tokens / prompt) * 100),
|
||||
defaultValue: "{{percent}}% cached",
|
||||
}));
|
||||
}
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
if (approximate) {
|
||||
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
|
||||
}
|
||||
const usageMeta = (
|
||||
<span
|
||||
data-turn-usage
|
||||
tabIndex={details.length ? 0 : undefined}
|
||||
className={cn(
|
||||
"text-[11px] leading-none text-muted-foreground/70 tabular-nums",
|
||||
details.length && "cursor-help",
|
||||
)}
|
||||
>
|
||||
{parts.join(" · ")}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (details.length === 0) return usageMeta;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{usageMeta}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-96 whitespace-nowrap"
|
||||
>
|
||||
{details.join(" · ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnLatencyMeta({ latencyMs }: { latencyMs: number }) {
|
||||
return (
|
||||
<span
|
||||
data-turn-latency
|
||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||
>
|
||||
{compactDuration(latencyMs)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function deliveryErrorCopy(
|
||||
kind: MessageDeliveryErrorKind | undefined,
|
||||
t: (key: string) => string,
|
||||
@@ -324,6 +441,7 @@ export function MessageBubble({
|
||||
isTurnStreaming = false,
|
||||
temporary = false,
|
||||
showCopyAction = true,
|
||||
showTurnUsage = false,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
slashCommands = [],
|
||||
@@ -479,7 +597,21 @@ export function MessageBubble({
|
||||
&& (!empty || hasReasoning || media.length > 0);
|
||||
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
|
||||
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0;
|
||||
const showAssistantFooterRow = showCopyButton || showForkButton || showAssistantTimestamp;
|
||||
const showUsage = (
|
||||
showTurnUsage
|
||||
&& message.role === "assistant"
|
||||
&& !!message.usage
|
||||
&& !message.isStreaming
|
||||
);
|
||||
const showLatency = (
|
||||
message.role === "assistant"
|
||||
&& !!message.usage
|
||||
&& !message.isStreaming
|
||||
&& typeof message.latencyMs === "number"
|
||||
&& message.latencyMs >= 0
|
||||
);
|
||||
const showAssistantFooterRow =
|
||||
showCopyButton || showForkButton || showAssistantTimestamp || showUsage || showLatency;
|
||||
const showAssistantFooterSlot =
|
||||
message.role === "assistant"
|
||||
&& (!empty || hasReasoning || media.length > 0);
|
||||
@@ -545,6 +677,8 @@ export function MessageBubble({
|
||||
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showUsage ? <TurnUsageMeta usage={message.usage!} /> : null}
|
||||
{showLatency ? <TurnLatencyMeta latencyMs={message.latencyMs!} /> : null}
|
||||
{showAssistantTimestamp ? (
|
||||
<MessageTimestamp
|
||||
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
|
||||
@@ -576,7 +710,6 @@ function UserQuotedContext({ text, label }: { text: string; label: string }) {
|
||||
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
|
||||
)}
|
||||
aria-label={label}
|
||||
title={text}
|
||||
>
|
||||
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0 line-clamp-3 whitespace-pre-wrap text-[13px]/[1.45] [overflow-wrap:anywhere]">
|
||||
|
||||
@@ -407,6 +407,22 @@ export function AppearanceSettings({
|
||||
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
|
||||
description={tx(
|
||||
"settings.help.showTurnUsage",
|
||||
"Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
|
||||
)}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={localPrefs.showTurnUsage}
|
||||
onChange={(showTurnUsage) => (
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, showTurnUsage }))
|
||||
)}
|
||||
ariaLabel={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
|
||||
label={localPrefs.showTurnUsage ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.brandLogos", "Brand logos")}
|
||||
description={tx(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
|
||||
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
|
||||
import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model";
|
||||
@@ -55,6 +56,7 @@ export { isAgentActivityMember, isReasoningOnlyAssistant };
|
||||
interface ActivityCounts {
|
||||
reasoningSteps: number;
|
||||
toolCalls: number;
|
||||
modelSegments: number;
|
||||
cliCount: number;
|
||||
mcpCount: number;
|
||||
fileCount: number;
|
||||
@@ -91,9 +93,14 @@ function countActivity(
|
||||
): ActivityCounts {
|
||||
let reasoningSteps = 0;
|
||||
let toolCalls = 0;
|
||||
let modelSegments = 0;
|
||||
const cliCount = cliRuns.length;
|
||||
const mcpCount = mcpRuns.length;
|
||||
for (const m of messages) {
|
||||
if (m.activityKind === "model") {
|
||||
modelSegments += 1;
|
||||
continue;
|
||||
}
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
reasoningSteps += 1;
|
||||
continue;
|
||||
@@ -110,6 +117,7 @@ function countActivity(
|
||||
return {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
modelSegments,
|
||||
cliCount,
|
||||
mcpCount,
|
||||
fileCount: fileEdits.length,
|
||||
@@ -131,8 +139,8 @@ interface AgentActivityClusterProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Outer fold wrapping interleaved reasoning-only assistant rows and tool-trace rows.
|
||||
* Fixed max height with inner scroll and a single flat list of activity rows.
|
||||
* One fold wrapping the complete middle of a turn: reasoning, model segments,
|
||||
* tool traces, and file edits. The final assistant answer stays outside it.
|
||||
*/
|
||||
export function AgentActivityCluster({
|
||||
messages,
|
||||
@@ -165,6 +173,7 @@ export function AgentActivityCluster({
|
||||
const {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
modelSegments,
|
||||
cliCount,
|
||||
mcpCount,
|
||||
fileCount,
|
||||
@@ -186,9 +195,8 @@ export function AgentActivityCluster({
|
||||
? outerOpenLocal
|
||||
: isTurnStreaming || completionHoldOpen || (wasTurnStreaming && !isTurnStreaming);
|
||||
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || modelSegments > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||
const hasOnlyFileActivity = fileCount > 0 && activityMessages.every(messageHasOnlyFileActivity);
|
||||
const hasNonReasoningActivity = toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||
const durationMs = activityDurationMs(
|
||||
activityMessages,
|
||||
isTurnStreaming,
|
||||
@@ -197,28 +205,16 @@ export function AgentActivityCluster({
|
||||
startedAtMs,
|
||||
);
|
||||
const activityDuration = formatActivityDuration(durationMs);
|
||||
const thoughtLabel = hasNonReasoningActivity
|
||||
? isTurnStreaming
|
||||
? t("message.activityWorkingFor", {
|
||||
duration: activityDuration,
|
||||
defaultValue: "Working for {{duration}}",
|
||||
})
|
||||
: durationMs <= 0
|
||||
? t("message.activityWorked", { defaultValue: "Worked" })
|
||||
const activityLabel = isTurnStreaming
|
||||
? t("message.activityWorkingFor", {
|
||||
duration: activityDuration,
|
||||
defaultValue: "Working for {{duration}}",
|
||||
})
|
||||
: durationMs <= 0
|
||||
? t("message.activityWorked", { defaultValue: "Worked" })
|
||||
: t("message.activityWorkedFor", {
|
||||
duration: activityDuration,
|
||||
defaultValue: "Worked for {{duration}}",
|
||||
})
|
||||
: isTurnStreaming
|
||||
? t("message.activityThinkingFor", {
|
||||
duration: activityDuration,
|
||||
defaultValue: "Thinking for {{duration}}",
|
||||
})
|
||||
: durationMs <= 0
|
||||
? t("message.activityThought", { defaultValue: "Thought" })
|
||||
: t("message.activityThoughtFor", {
|
||||
duration: activityDuration,
|
||||
defaultValue: "Thought for {{duration}}",
|
||||
});
|
||||
|
||||
const cancelActivityScrollFrame = useCallback(() => {
|
||||
@@ -338,7 +334,7 @@ export function AgentActivityCluster({
|
||||
<ThinkingReasoningShell
|
||||
active={isTurnStreaming}
|
||||
expanded={outerExpanded}
|
||||
label={thoughtLabel}
|
||||
label={activityLabel}
|
||||
viewportRef={activityScrollRef}
|
||||
contentRef={activityContentRef}
|
||||
fadeTop={activityScrollFade.top}
|
||||
@@ -352,6 +348,7 @@ export function AgentActivityCluster({
|
||||
active={isTurnStreaming}
|
||||
cliAppsByName={cliAppsByName}
|
||||
mcpPresetsByName={mcpPresetsByName}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
{fileEdits.length ? (
|
||||
<FileEditGroup
|
||||
@@ -417,15 +414,28 @@ function ActivityMessageTimeline({
|
||||
active,
|
||||
cliAppsByName,
|
||||
mcpPresetsByName,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
messages: UIMessage[];
|
||||
active: boolean;
|
||||
cliAppsByName: Map<string, CliAppInfo>;
|
||||
mcpPresetsByName: Map<string, McpPresetInfo>;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const items: ReactNode[] = [];
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
if (message.activityKind === "model") {
|
||||
items.push(
|
||||
<ActivityModelMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
active={active}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isReasoningOnlyAssistant(message)) {
|
||||
items.push(
|
||||
<ReasoningRow
|
||||
@@ -451,6 +461,40 @@ function ActivityMessageTimeline({
|
||||
return <>{items}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep an intermediate assistant segment as normal Markdown. The activity
|
||||
* surface owns ordering and lifecycle, not a reduced rendering mode: users
|
||||
* should see the same prose, links, and code treatment before and after the
|
||||
* surrounding turn is folded.
|
||||
*/
|
||||
function ActivityModelMessage({
|
||||
message,
|
||||
active,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
active: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
if (!message.content.trim()) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="activity-model-message"
|
||||
data-assistant-selectable={active && message.isStreaming ? undefined : "true"}
|
||||
className="w-full min-w-0 py-1 text-[15px]"
|
||||
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||
>
|
||||
<MarkdownText
|
||||
streaming={active && !!message.isStreaming}
|
||||
preserveStreamingLayout
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityTraceList({
|
||||
lines,
|
||||
active,
|
||||
|
||||
@@ -6,37 +6,27 @@ import {
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import { CircleHelp, Sparkles } from "lucide-react";
|
||||
import { Check, CircleHelp, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
} from "@/components/ui/floating-surface";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ModelPresetOption {
|
||||
name: string;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
}
|
||||
|
||||
interface ModelPresetBadgeProps {
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
modelPreset?: string | null;
|
||||
modelPresets?: ModelPresetOption[];
|
||||
onPresetChange?: (name: string) => void;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
isHero: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
const pickerWidthClassName = "w-[min(18rem,calc(100vw-2rem))]";
|
||||
const LONG_PRESS_MS = 400;
|
||||
const PRESS_SLOP_PX = 8;
|
||||
const PILL_GAP_PX = 4;
|
||||
const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const;
|
||||
const HANDOFF_THRESHOLD = 0.56;
|
||||
const DOCK_MAX_SCALE = 1.08;
|
||||
const DOCK_RADIUS = 1.5;
|
||||
const SETTLE_MS = 200;
|
||||
|
||||
interface PresetGesture {
|
||||
active: boolean;
|
||||
@@ -55,15 +45,6 @@ interface PresetMotion {
|
||||
settling: boolean;
|
||||
}
|
||||
|
||||
const LONG_PRESS_MS = 400;
|
||||
const PRESS_SLOP_PX = 8;
|
||||
const PILL_GAP_PX = 4;
|
||||
const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const;
|
||||
const HANDOFF_THRESHOLD = 0.56;
|
||||
const DOCK_MAX_SCALE = 1.08;
|
||||
const DOCK_RADIUS = 1.5;
|
||||
const SETTLE_MS = 180;
|
||||
|
||||
function wrapIndex(index: number, length: number): number {
|
||||
return ((index % length) + length) % length;
|
||||
}
|
||||
@@ -86,12 +67,40 @@ function preventTouchScroll(event: TouchEvent) {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
}
|
||||
|
||||
function compactModelName(model?: string | null): string | null {
|
||||
const value = model?.trim();
|
||||
if (!value) return null;
|
||||
return value.split("/").at(-1) || value;
|
||||
}
|
||||
|
||||
export interface ModelPresetOption {
|
||||
name: string;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
}
|
||||
|
||||
interface ModelPresetBadgeProps {
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
modelPreset?: string | null;
|
||||
modelPresets?: ModelPresetOption[];
|
||||
onPresetChange?: (name: string) => void;
|
||||
onRequestComposerFocus?: () => void;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
isHero: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function ModelPresetBadge({
|
||||
label,
|
||||
modelDetail,
|
||||
modelPreset,
|
||||
modelPresets = [],
|
||||
onPresetChange,
|
||||
onRequestComposerFocus,
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup = false,
|
||||
@@ -99,6 +108,12 @@ export function ModelPresetBadge({
|
||||
isHero,
|
||||
onClick,
|
||||
}: ModelPresetBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [motion, setMotion] = useState<PresetMotion | null>(null);
|
||||
const [motionWidth, setMotionWidth] = useState<number | null>(null);
|
||||
const gestureRef = useRef<PresetGesture | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const activeName = modelPreset?.trim() || "";
|
||||
const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName);
|
||||
const activePreset: ModelPresetOption = {
|
||||
@@ -107,97 +122,78 @@ export function ModelPresetBadge({
|
||||
model: modelDetail ?? modelPresets[listedIndex]?.model,
|
||||
provider: provider || modelPresets[listedIndex]?.provider,
|
||||
};
|
||||
const fallbackPreset = fallbackModelName
|
||||
? modelPresets.find((preset) => preset.model?.trim() === fallbackModelName.trim())
|
||||
: undefined;
|
||||
const fallbackDisplayLabel = fallbackPreset?.name
|
||||
|| fallbackModelName?.trim().split(/[/:]/).pop()
|
||||
|| null;
|
||||
const displayLabel = fallbackDisplayLabel || label;
|
||||
const displayModelDetail = fallbackPreset
|
||||
? fallbackPreset.model
|
||||
: fallbackModelName
|
||||
? null
|
||||
: modelDetail;
|
||||
const displayProvider = fallbackPreset?.provider
|
||||
|| (fallbackModelName ? inferProviderFromModelName(fallbackModelName) : provider);
|
||||
const presets = !activeName
|
||||
? modelPresets
|
||||
: listedIndex < 0
|
||||
? [activePreset, ...modelPresets]
|
||||
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
|
||||
const interactive = Boolean(onClick);
|
||||
const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
|
||||
const opensSetup = Boolean(onClick);
|
||||
const canSwitch = !opensSetup && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
|
||||
const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName));
|
||||
const pillHeight = isHero ? 32 : 36;
|
||||
const pillStride = pillHeight + PILL_GAP_PX;
|
||||
const [motion, setMotion] = useState<PresetMotion | null>(null);
|
||||
const gestureRef = useRef<PresetGesture | null>(null);
|
||||
const clickAnimationFrameRef = useRef<number | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const suppressClickTimerRef = useRef<number | null>(null);
|
||||
const switchModelLabel = t("thread.composer.switchModel", {
|
||||
defaultValue: "Switch model for this chat",
|
||||
});
|
||||
|
||||
function clearGesture() {
|
||||
const selectPreset = (name: string) => {
|
||||
setOpen(false);
|
||||
if (name !== activeName) onPresetChange?.(name);
|
||||
requestAnimationFrame(() => onRequestComposerFocus?.());
|
||||
};
|
||||
|
||||
const clearGesture = () => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture?.timer) clearTimeout(gesture.timer);
|
||||
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll);
|
||||
gestureRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearMotion = () => {
|
||||
setMotion(null);
|
||||
setMotionWidth(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSwitch) {
|
||||
clearGesture();
|
||||
setMotion(null);
|
||||
clearMotion();
|
||||
}
|
||||
return () => {
|
||||
clearGesture();
|
||||
if (clickAnimationFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(clickAnimationFrameRef.current);
|
||||
clickAnimationFrameRef.current = null;
|
||||
}
|
||||
if (suppressClickTimerRef.current !== null) {
|
||||
window.clearTimeout(suppressClickTimerRef.current);
|
||||
suppressClickTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
return clearGesture;
|
||||
}, [canSwitch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!motion?.settling) return;
|
||||
const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80);
|
||||
const timer = setTimeout(clearMotion, SETTLE_MS + 80);
|
||||
return () => clearTimeout(timer);
|
||||
}, [motion?.settling]);
|
||||
|
||||
function updateMotion(gesture: PresetGesture, clientY: number) {
|
||||
const updateMotion = (gesture: PresetGesture, clientY: number) => {
|
||||
const raw = -(clientY - gesture.startY) / pillStride;
|
||||
gesture.step = stepWithHysteresis(raw, gesture.step);
|
||||
setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false });
|
||||
}
|
||||
|
||||
function suppressFollowingClick() {
|
||||
suppressClickRef.current = true;
|
||||
if (suppressClickTimerRef.current !== null) {
|
||||
window.clearTimeout(suppressClickTimerRef.current);
|
||||
}
|
||||
suppressClickTimerRef.current = window.setTimeout(() => {
|
||||
suppressClickRef.current = false;
|
||||
suppressClickTimerRef.current = null;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function cycleToNextPreset() {
|
||||
if (!canSwitch || motion) return;
|
||||
const nextVirtualIndex = currentIndex + 1;
|
||||
const next = presets[wrapIndex(nextVirtualIndex, presets.length)];
|
||||
if (!next || next.name === activeName) return;
|
||||
|
||||
// Mount the same five-pill track one step before its destination, then
|
||||
// settle it into place so clicks share the drag interaction's motion.
|
||||
setMotion({ index: nextVirtualIndex, remainder: -1, settling: false });
|
||||
clickAnimationFrameRef.current = window.requestAnimationFrame(() => {
|
||||
clickAnimationFrameRef.current = null;
|
||||
setMotion({ index: nextVirtualIndex, remainder: 0, settling: true });
|
||||
onPresetChange?.(next.name);
|
||||
setMotion({
|
||||
index: gesture.baseIndex + gesture.step,
|
||||
remainder: raw - gesture.step,
|
||||
settling: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function handleClick() {
|
||||
if (interactive) {
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
if (suppressClickRef.current) return;
|
||||
cycleToNextPreset();
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLElement>) {
|
||||
if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return;
|
||||
const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (!canSwitch || gestureRef.current || motion) return;
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
const gesture: PresetGesture = {
|
||||
active: false,
|
||||
@@ -212,16 +208,19 @@ export function ModelPresetBadge({
|
||||
gesture.timer = setTimeout(() => {
|
||||
if (gestureRef.current !== gesture) return;
|
||||
gesture.active = true;
|
||||
setMotionWidth(Math.round(gesture.target.getBoundingClientRect().width) || null);
|
||||
updateMotion(gesture, gesture.latestY);
|
||||
gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false });
|
||||
try {
|
||||
gesture.target.setPointerCapture(gesture.pointerId);
|
||||
} catch { /* The pointer may already have ended. */ }
|
||||
} catch {
|
||||
// The pointer may already have ended.
|
||||
}
|
||||
}, LONG_PRESS_MS);
|
||||
gestureRef.current = gesture;
|
||||
}
|
||||
};
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLElement>) {
|
||||
const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
gesture.latestY = event.clientY;
|
||||
@@ -231,27 +230,26 @@ export function ModelPresetBadge({
|
||||
}
|
||||
event.preventDefault();
|
||||
updateMotion(gesture, event.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
function finishGesture(event: PointerEvent<HTMLElement>, commit: boolean) {
|
||||
const finishGesture = (event: PointerEvent<HTMLButtonElement>, commit: boolean) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
clearGesture();
|
||||
if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture?.(gesture.pointerId);
|
||||
}
|
||||
if (gesture.active) suppressFollowingClick();
|
||||
if (!commit || !gesture.active) {
|
||||
setMotion(null);
|
||||
clearMotion();
|
||||
return;
|
||||
}
|
||||
suppressClickRef.current = true;
|
||||
const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)];
|
||||
setMotion((current) => current && { ...current, remainder: 0, settling: true });
|
||||
if (selected && selected.name !== activeName) onPresetChange?.(selected.name);
|
||||
}
|
||||
if (selected && selected.name !== activeName) selectPreset(selected.name);
|
||||
};
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||
if (!canSwitch) return;
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const targetByKey: Record<string, number> = {
|
||||
ArrowUp: currentIndex - 1,
|
||||
ArrowDown: currentIndex + 1,
|
||||
@@ -262,141 +260,229 @@ export function ModelPresetBadge({
|
||||
if (target === undefined) return;
|
||||
event.preventDefault();
|
||||
const next = presets[wrapIndex(target, presets.length)];
|
||||
if (next?.name !== activeName) onPresetChange?.(next.name);
|
||||
}
|
||||
if (next?.name !== activeName) selectPreset(next.name);
|
||||
};
|
||||
|
||||
const previewIndex = wrapIndex(motion?.index ?? currentIndex, presets.length);
|
||||
const previewPreset = presets[previewIndex];
|
||||
const Container = interactive || canSwitch ? "button" : "span";
|
||||
const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0;
|
||||
const tooltipLabel = fallbackModelName
|
||||
|| [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
|
||||
|
||||
const badge = (
|
||||
<Container
|
||||
data-switching={motion ? "true" : undefined}
|
||||
data-settling={motion?.settling ? "true" : undefined}
|
||||
aria-label={label}
|
||||
aria-orientation={canSwitch ? "vertical" : undefined}
|
||||
aria-valuemax={canSwitch ? presets.length - 1 : undefined}
|
||||
aria-valuemin={canSwitch ? 0 : undefined}
|
||||
aria-valuenow={canSwitch ? previewIndex : undefined}
|
||||
aria-valuetext={canSwitch ? previewPreset?.name || label : undefined}
|
||||
role={canSwitch ? "spinbutton" : undefined}
|
||||
tabIndex={!interactive && !canSwitch ? 0 : undefined}
|
||||
type={interactive || canSwitch ? "button" : undefined}
|
||||
onClick={interactive || canSwitch ? handleClick : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={(event) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture && gesture.pointerId === event.pointerId && !gesture.active) clearGesture();
|
||||
}}
|
||||
onPointerUp={(event) => finishGesture(event, true)}
|
||||
onPointerCancel={(event) => finishGesture(event, false)}
|
||||
onLostPointerCapture={(event) => finishGesture(event, false)}
|
||||
onContextMenu={(event) => {
|
||||
if (gestureRef.current?.active) event.preventDefault();
|
||||
}}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
style={{ touchAction: canSwitch ? "manipulation" : undefined }}
|
||||
className={cn(
|
||||
"thread-composer-model-badge group/model-badge relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] justify-end appearance-none border-0 bg-transparent p-0 shadow-none",
|
||||
interactive && "cursor-pointer",
|
||||
canSwitch && "cursor-grab select-none focus-visible:outline-none",
|
||||
motion && "z-10 cursor-grabbing",
|
||||
isHero ? "h-8" : "h-9",
|
||||
)}
|
||||
>
|
||||
<PresetPill
|
||||
className={motion && "invisible"}
|
||||
label={label}
|
||||
modelDetail={modelDetail}
|
||||
provider={provider}
|
||||
needsSetup={needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
isHero={isHero}
|
||||
/>
|
||||
{motion ? (
|
||||
<span
|
||||
data-testid="composer-model-pill-viewport"
|
||||
className={cn(
|
||||
"composer-model-pill-viewport pointer-events-none absolute right-0 w-max max-w-[calc(44vw+0.5rem)] overflow-hidden bg-transparent pl-2 sm:max-w-[18.5rem]",
|
||||
isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<span
|
||||
data-testid="composer-model-pill-track"
|
||||
data-settling={motion.settling ? "true" : undefined}
|
||||
className="composer-model-pill-track ml-auto flex w-max max-w-full flex-col items-end gap-1 will-change-transform"
|
||||
onTransitionEnd={(event) => {
|
||||
if (motion.settling && event.currentTarget === event.target) setMotion(null);
|
||||
}}
|
||||
style={{
|
||||
paddingTop: isHero ? "10px" : "12px",
|
||||
transform: `translate3d(0, ${trackOffset}px, 0)`,
|
||||
}}
|
||||
>
|
||||
{PILL_OFFSETS.map((offset) => {
|
||||
const virtualIndex = motion.index + offset;
|
||||
const preset = presets[wrapIndex(virtualIndex, presets.length)];
|
||||
const scale = motion.settling ? 1 : dockScale(offset - motion.remainder);
|
||||
return (
|
||||
<PresetPill
|
||||
key={virtualIndex}
|
||||
label={preset.name}
|
||||
modelDetail={preset.model}
|
||||
provider={preset.provider}
|
||||
isHero={isHero}
|
||||
offset={offset}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Container>
|
||||
const pill = (
|
||||
<PresetPill
|
||||
label={displayLabel}
|
||||
modelDetail={displayModelDetail}
|
||||
provider={displayProvider}
|
||||
providerLabel={fallbackModelName ? null : providerLabel}
|
||||
needsSetup={needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
fallbackFromLabel={fallbackModelName ? label : null}
|
||||
isHero={isHero}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltipLabel) return badge;
|
||||
if (!canSwitch) {
|
||||
const Container = opensSetup ? "button" : "span";
|
||||
return (
|
||||
<Container
|
||||
aria-label={fallbackModelName ? `${displayLabel} (fallback from ${label})` : label}
|
||||
type={opensSetup ? "button" : undefined}
|
||||
onClick={opensSetup ? onClick : undefined}
|
||||
className={cn(
|
||||
"thread-composer-model-badge group inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] appearance-none border-0 bg-transparent p-0 shadow-none",
|
||||
opensSetup && "cursor-pointer focus-visible:outline-none",
|
||||
isHero ? "h-8" : "h-9",
|
||||
)}
|
||||
>
|
||||
{pill}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className="max-w-[min(24rem,calc(100vw-2rem))] break-all"
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) requestAnimationFrame(() => onRequestComposerFocus?.());
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-switching={motion ? "true" : undefined}
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={(event) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture && gesture.pointerId === event.pointerId && !gesture.active) clearGesture();
|
||||
}}
|
||||
onPointerUp={(event) => finishGesture(event, true)}
|
||||
onPointerCancel={(event) => finishGesture(event, false)}
|
||||
onLostPointerCapture={(event) => finishGesture(event, false)}
|
||||
onContextMenu={(event) => {
|
||||
if (gestureRef.current?.active) event.preventDefault();
|
||||
}}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClickCapture={(event) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
touchAction: "manipulation",
|
||||
width: motionWidth ? `${motionWidth}px` : undefined,
|
||||
}}
|
||||
className={cn(
|
||||
"thread-composer-model-badge group relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] cursor-pointer appearance-none border-0 bg-transparent p-0 shadow-none focus-visible:outline-none",
|
||||
motion && "z-10 cursor-grabbing",
|
||||
!motion && "cursor-grab",
|
||||
isHero ? "h-8" : "h-9",
|
||||
)}
|
||||
>
|
||||
{tooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{motion ? (
|
||||
<>
|
||||
<span data-testid="composer-model-pill-layout" className="invisible inline-flex h-full shrink-0" aria-hidden>
|
||||
{pill}
|
||||
</span>
|
||||
<span
|
||||
data-testid="composer-model-pill-viewport"
|
||||
className={cn(
|
||||
"composer-model-pill-viewport pointer-events-none absolute -left-2 right-0 overflow-hidden bg-transparent",
|
||||
isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<span
|
||||
data-testid="composer-model-pill-track"
|
||||
data-settling={motion.settling ? "true" : undefined}
|
||||
className="composer-model-pill-track ml-auto flex w-[calc(100%-0.5rem)] flex-col items-end gap-1 will-change-transform"
|
||||
onTransitionEnd={(event) => {
|
||||
if (motion.settling && event.currentTarget === event.target) clearMotion();
|
||||
}}
|
||||
style={{
|
||||
paddingTop: isHero ? "10px" : "12px",
|
||||
transform: `translate3d(0, ${-pillStride * (2 + motion.remainder)}px, 0)`,
|
||||
}}
|
||||
>
|
||||
{PILL_OFFSETS.map((offset) => {
|
||||
const preset = presets[wrapIndex(motion.index + offset, presets.length)];
|
||||
return (
|
||||
<PresetPill
|
||||
key={motion.index + offset}
|
||||
label={preset.name}
|
||||
modelDetail={preset.model}
|
||||
provider={preset.provider}
|
||||
isHero={isHero}
|
||||
offset={offset}
|
||||
scale={motion.settling ? 1 : dockScale(offset - motion.remainder)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : pill}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
role="dialog"
|
||||
aria-label={switchModelLabel}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
const content = event.currentTarget;
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const selected = content.querySelector<HTMLElement>(
|
||||
'[role="option"][aria-selected="true"]',
|
||||
);
|
||||
selected?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
pickerWidthClassName,
|
||||
"origin-[var(--radix-popover-content-transform-origin)] p-1.5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-bottom-1 data-[state=open]:slide-in-from-bottom-1 duration-200 ease-out motion-reduce:animate-none",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={switchModelLabel}
|
||||
className="max-h-[min(16rem,var(--radix-popover-content-available-height))] overflow-y-auto py-1 scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
{presets.map((preset) => (
|
||||
<PresetOption
|
||||
key={preset.name}
|
||||
preset={preset}
|
||||
selected={preset.name === activeName}
|
||||
onSelect={selectPreset}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetOption({
|
||||
preset,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
preset: ModelPresetOption;
|
||||
selected: boolean;
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
const detail = compactModelName(preset.model);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-label={preset.name}
|
||||
aria-selected={selected}
|
||||
onClick={() => onSelect(preset.name)}
|
||||
className={cn(
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
"flex min-h-9 w-full cursor-pointer gap-2.5 px-2.5 py-1.5 text-left hover:bg-muted/55",
|
||||
selected && "bg-muted/55 text-foreground",
|
||||
)}
|
||||
>
|
||||
<PresetProviderIcon
|
||||
label={preset.name}
|
||||
modelDetail={detail}
|
||||
provider={preset.provider}
|
||||
isHero={false}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5 overflow-hidden whitespace-nowrap">
|
||||
<span className="shrink-0 text-[13px] font-medium text-foreground">{preset.name}</span>
|
||||
{detail && detail !== preset.name ? (
|
||||
<span className="truncate text-[12px] text-muted-foreground">{detail}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{selected ? <Check className="h-4 w-4 shrink-0 text-foreground/80" aria-hidden /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetPill({
|
||||
className,
|
||||
label,
|
||||
modelDetail,
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup = false,
|
||||
fallbackModelName,
|
||||
fallbackFromLabel,
|
||||
isHero,
|
||||
offset,
|
||||
scale,
|
||||
}: {
|
||||
className?: string | false | null;
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
fallbackFromLabel?: string | null;
|
||||
isHero: boolean;
|
||||
offset?: number;
|
||||
scale?: number;
|
||||
@@ -406,13 +492,10 @@ function PresetPill({
|
||||
const inferredProvider = needsSetup
|
||||
? null
|
||||
: provider || inferProviderFromModelName(modelDetail || label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
const logoTestId = offset !== undefined
|
||||
? undefined
|
||||
: needsSetup
|
||||
? "composer-model-setup-icon"
|
||||
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
|
||||
const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
|
||||
const fallbackTitle = fallbackModelName
|
||||
? `${fallbackFromLabel || label} · using ${fallbackModelName}`
|
||||
: title;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = labelRef.current;
|
||||
@@ -428,14 +511,14 @@ function PresetPill({
|
||||
<span
|
||||
data-fallback={fallbackModelName ? "true" : undefined}
|
||||
data-preset-offset={offset}
|
||||
title={fallbackTitle || undefined}
|
||||
className={cn(
|
||||
"composer-model-badge composer-model-pill inline-flex h-full w-fit max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
|
||||
offset === undefined && "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible/model-badge:ring-2 group-focus-visible/model-badge:ring-ring/45",
|
||||
"composer-model-badge composer-model-pill inline-flex h-full max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
|
||||
"w-fit",
|
||||
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible:ring-2 group-focus-visible:ring-ring/45",
|
||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
|
||||
offset !== undefined && "composer-model-pill-dock",
|
||||
className,
|
||||
)}
|
||||
style={scale === undefined ? undefined : {
|
||||
height: `${isHero ? 32 : 36}px`,
|
||||
@@ -443,46 +526,14 @@ function PresetPill({
|
||||
zIndex: Math.round(scale * 100),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-testid={logoTestId}
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center overflow-hidden",
|
||||
needsSetup ? "text-amber-800 dark:text-amber-200" : "rounded-full border bg-background",
|
||||
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
|
||||
)}
|
||||
style={{
|
||||
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
|
||||
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{needsSetup ? (
|
||||
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
|
||||
) : logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : brand ? (
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-full w-full place-items-center rounded-full text-white",
|
||||
isHero ? "text-[7.5px]" : "text-[8px]",
|
||||
)}
|
||||
style={{ backgroundColor: brand.color }}
|
||||
>
|
||||
{brand.initials.slice(0, 2)}
|
||||
</span>
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
|
||||
)}
|
||||
</span>
|
||||
<PresetProviderIcon
|
||||
label={label}
|
||||
modelDetail={modelDetail}
|
||||
provider={inferredProvider}
|
||||
needsSetup={needsSetup}
|
||||
testId={needsSetup ? "composer-model-setup-icon" : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`}
|
||||
isHero={isHero}
|
||||
/>
|
||||
<span
|
||||
ref={labelRef}
|
||||
className={cn(
|
||||
@@ -495,3 +546,63 @@ function PresetPill({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetProviderIcon({
|
||||
label,
|
||||
modelDetail,
|
||||
provider,
|
||||
needsSetup = false,
|
||||
testId,
|
||||
isHero,
|
||||
}: {
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
provider?: string | null;
|
||||
needsSetup?: boolean;
|
||||
testId?: string;
|
||||
isHero: boolean;
|
||||
}) {
|
||||
const inferredProvider = needsSetup
|
||||
? null
|
||||
: provider || inferProviderFromModelName(modelDetail || label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center",
|
||||
needsSetup && "text-amber-800 dark:text-amber-200",
|
||||
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{needsSetup ? (
|
||||
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
|
||||
) : logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", isHero ? "h-3.5 w-3.5" : "h-[18px] w-[18px]")}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : brand ? (
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-full w-full place-items-center rounded-full text-white",
|
||||
isHero ? "text-[7.5px]" : "text-[8px]",
|
||||
)}
|
||||
style={{ backgroundColor: brand.color }}
|
||||
>
|
||||
{brand.initials.slice(0, 2)}
|
||||
</span>
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ import {
|
||||
readDraggedSession,
|
||||
} from "@/lib/session-drag";
|
||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||
import { formatCompactTokenCount } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const VOICE_SHORTCUT_CODE = "KeyD";
|
||||
@@ -178,6 +179,107 @@ function getVoiceShortcutLabel(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ComposerContextUsage {
|
||||
contextTokens: number;
|
||||
contextWindowTokens?: number;
|
||||
}
|
||||
|
||||
function ComposerContextBadge({ usage }: { usage: ComposerContextUsage | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!usage || !Number.isFinite(usage.contextTokens) || usage.contextTokens < 0) return null;
|
||||
|
||||
const context = formatCompactTokenCount(usage.contextTokens);
|
||||
const contextWindow = typeof usage.contextWindowTokens === "number"
|
||||
&& Number.isFinite(usage.contextWindowTokens)
|
||||
&& usage.contextWindowTokens > 0
|
||||
? usage.contextWindowTokens
|
||||
: null;
|
||||
const capacity = contextWindow !== null
|
||||
? ` / ${formatCompactTokenCount(contextWindow)}`
|
||||
: "";
|
||||
// The meter is meaningful only with a known capacity. Do not turn an
|
||||
// otherwise unknown total into a second, text-heavy control beside the
|
||||
// model picker.
|
||||
if (contextWindow === null) return null;
|
||||
const percentage = Math.min(100, Math.round(usage.contextTokens / contextWindow * 100));
|
||||
const status = percentage >= 90
|
||||
? "critical"
|
||||
: percentage >= 75
|
||||
? "caution"
|
||||
: "normal";
|
||||
const contextDescription = t("thread.composer.context.tooltip", {
|
||||
defaultValue: "Context · {{tokens}}{{capacity}}",
|
||||
tokens: context,
|
||||
capacity,
|
||||
});
|
||||
const meterDescription = t("thread.composer.context.meterDescription", {
|
||||
defaultValue: "{{context}}. {{percent}}% used.",
|
||||
context: contextDescription,
|
||||
percent: percentage,
|
||||
});
|
||||
const ringCircumference = 2 * Math.PI * 6;
|
||||
const ringLength = ringCircumference * percentage / 100;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={80}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="composer-context-usage"
|
||||
aria-label={meterDescription}
|
||||
className={cn(
|
||||
"inline-flex size-5 shrink-0 items-center justify-center rounded-full",
|
||||
"text-muted-foreground/75 transition-colors hover:text-foreground/85",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-[15px] shrink-0 -rotate-90",
|
||||
status === "critical" && "text-destructive",
|
||||
status === "caution" && "text-amber-600 dark:text-amber-400",
|
||||
status === "normal" && "text-muted-foreground/75",
|
||||
)}
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${ringLength} ${ringCircumference}`}
|
||||
data-testid="composer-context-meter"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
className="w-fit max-w-[calc(100vw-2rem)] rounded-full border-border/70 px-2.5 py-1 text-xs font-medium shadow-[0_8px_24px_rgba(15,23,42,0.13)]"
|
||||
>
|
||||
<span className="whitespace-nowrap tabular-nums">{contextDescription}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadComposerProps {
|
||||
onSend: (
|
||||
content: string,
|
||||
@@ -198,6 +300,7 @@ interface ThreadComposerProps {
|
||||
modelNeedsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
onModelBadgeClick?: () => void;
|
||||
contextUsage?: ComposerContextUsage | null;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
cliApps?: CliAppInfo[];
|
||||
@@ -895,6 +998,7 @@ export function ThreadComposer({
|
||||
modelNeedsSetup = false,
|
||||
fallbackModelName = null,
|
||||
onModelBadgeClick,
|
||||
contextUsage = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
cliApps = [],
|
||||
@@ -2435,6 +2539,7 @@ export function ThreadComposer({
|
||||
modelPreset={modelPreset}
|
||||
modelPresets={modelPresets}
|
||||
onPresetChange={onModelPresetChange}
|
||||
onRequestComposerFocus={() => textareaRef.current?.focus()}
|
||||
provider={modelProvider}
|
||||
providerLabel={modelProviderLabel}
|
||||
needsSetup={modelNeedsSetup}
|
||||
@@ -2443,6 +2548,7 @@ export function ThreadComposer({
|
||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{!voiceRecorder.isRecording ? <ComposerContextBadge usage={contextUsage} /> : null}
|
||||
{showVoiceButton ? (
|
||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||
<Tooltip>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
|
||||
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
|
||||
import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
@@ -29,10 +30,9 @@ export type DisplayUnit = TurnUnit;
|
||||
export function buildDisplayUnits(
|
||||
messages: UIMessage[],
|
||||
isStreaming = false,
|
||||
activeTurnId: string | null = null,
|
||||
): DisplayUnit[] {
|
||||
return normalizeActivityTimeline(messages, {
|
||||
preserveTrailingActivity: isStreaming,
|
||||
});
|
||||
return projectActivityTimeline(messages, isStreaming ? activeTurnId : undefined);
|
||||
}
|
||||
|
||||
export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
|
||||
@@ -68,8 +68,12 @@ export function ThreadMessages({
|
||||
onQuoteSelection,
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const showTurnUsage = useShowTurnUsage();
|
||||
const messageListRef = useRef<HTMLDivElement>(null);
|
||||
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
|
||||
const units = useMemo(
|
||||
() => buildDisplayUnits(messages, isStreaming, activeTurnId),
|
||||
[activeTurnId, isStreaming, messages],
|
||||
);
|
||||
const forkBoundaryAfterUnitIndex = useMemo(
|
||||
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
|
||||
[forkBoundaryMessageCount, units],
|
||||
@@ -156,6 +160,7 @@ export function ThreadMessages({
|
||||
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
||||
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
||||
temporary={temporary}
|
||||
showTurnUsage={showTurnUsage}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
@@ -237,6 +242,7 @@ interface ThreadDisplayUnitProps {
|
||||
showForkBoundary: boolean;
|
||||
forkBoundaryLabel: string;
|
||||
temporary: boolean;
|
||||
showTurnUsage: boolean;
|
||||
cliApps: CliAppInfo[];
|
||||
mcpPresets: McpPresetInfo[];
|
||||
slashCommands: SlashCommand[];
|
||||
@@ -255,6 +261,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
showForkBoundary,
|
||||
forkBoundaryLabel,
|
||||
temporary,
|
||||
showTurnUsage,
|
||||
cliApps,
|
||||
mcpPresets,
|
||||
slashCommands,
|
||||
@@ -293,6 +300,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
message={unit.message}
|
||||
isTurnStreaming={isTurnStreaming}
|
||||
temporary={temporary}
|
||||
showTurnUsage={showTurnUsage}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
@@ -321,6 +329,7 @@ function threadDisplayUnitPropsEqual(
|
||||
&& previous.showForkBoundary === next.showForkBoundary
|
||||
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
|
||||
&& previous.temporary === next.temporary
|
||||
&& previous.showTurnUsage === next.showTurnUsage
|
||||
&& previous.cliApps === next.cliApps
|
||||
&& previous.mcpPresets === next.mcpPresets
|
||||
&& previous.slashCommands === next.slashCommands
|
||||
|
||||
@@ -8,7 +8,10 @@ import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
|
||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import {
|
||||
ThreadComposer,
|
||||
type ComposerContextUsage,
|
||||
} from "@/components/thread/ThreadComposer";
|
||||
import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
|
||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||
@@ -83,6 +86,30 @@ function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function latestComposerContextUsage(messages: UIMessage[]): ComposerContextUsage | null {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const contextTokens = message.usage?.context_tokens;
|
||||
if (
|
||||
message.role !== "assistant"
|
||||
|| message.kind === "trace"
|
||||
|| message.isStreaming
|
||||
|| typeof contextTokens !== "number"
|
||||
|| !Number.isFinite(contextTokens)
|
||||
|| contextTokens < 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
contextTokens,
|
||||
...(typeof message.contextWindowTokens === "number"
|
||||
? { contextWindowTokens: message.contextWindowTokens }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function snapshotPreservesMessage(
|
||||
current: MessageShape,
|
||||
candidate: MessageShape,
|
||||
@@ -400,7 +427,7 @@ function toModelBadgeInfo(
|
||||
);
|
||||
return {
|
||||
label,
|
||||
model: model?.trim() || null,
|
||||
model: toModelBadgeLabel(model),
|
||||
provider,
|
||||
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
|
||||
needsSetup,
|
||||
@@ -803,8 +830,12 @@ export function ThreadShell({
|
||||
}, []);
|
||||
|
||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
||||
const composerContextUsage = useMemo(
|
||||
() => latestComposerContextUsage(displayMessages),
|
||||
[displayMessages],
|
||||
);
|
||||
const currentGoalState = messagesReady ? goalState : undefined;
|
||||
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
||||
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
||||
const restoredViewportTurnId = useMemo(
|
||||
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
||||
@@ -882,9 +913,17 @@ export function ThreadShell({
|
||||
useEffect(() => {
|
||||
setLocalModelPreset(null);
|
||||
}, [session?.key, sessionModelPreset]);
|
||||
const configuredPresetNames = useMemo(
|
||||
() => new Set(settings?.model_presets.map((preset) => preset.name) ?? []),
|
||||
[settings],
|
||||
);
|
||||
const activeModelPreset = (
|
||||
localModelPreset
|
||||
|| sessionModelPreset
|
||||
(localModelPreset && (!settings || configuredPresetNames.has(localModelPreset))
|
||||
? localModelPreset
|
||||
: null)
|
||||
|| (sessionModelPreset && (!settings || configuredPresetNames.has(sessionModelPreset))
|
||||
? sessionModelPreset
|
||||
: null)
|
||||
|| settings?.agent.model_preset
|
||||
|| "default"
|
||||
);
|
||||
@@ -958,13 +997,10 @@ export function ThreadShell({
|
||||
}
|
||||
setFallbackModelName(null);
|
||||
return client.onChat(chatId, (event) => {
|
||||
if (event.event !== "turn_model_updated") return;
|
||||
const activeModel = event.model_name.trim();
|
||||
setFallbackModelName(
|
||||
modelBadge.model && activeModel !== modelBadge.model ? activeModel : null,
|
||||
);
|
||||
if (event.event !== "turn_model_updated" || event.fallback !== true) return;
|
||||
setFallbackModelName(event.model_name);
|
||||
});
|
||||
}, [chatId, client, modelBadge.model]);
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyKey || !chatId || loading) return;
|
||||
@@ -1454,7 +1490,7 @@ export function ThreadShell({
|
||||
: t("thread.composer.placeholderThread")
|
||||
}
|
||||
modelLabel={modelBadgeLabel}
|
||||
modelDetail={toModelBadgeLabel(modelBadge.model)}
|
||||
modelDetail={modelBadge.model}
|
||||
modelPreset={activeModelPreset}
|
||||
modelPresets={modelPresetOptions}
|
||||
onModelPresetChange={handleModelPresetChange}
|
||||
@@ -1463,6 +1499,7 @@ export function ThreadShell({
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
contextUsage={composerContextUsage}
|
||||
variant={composerVariant}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
@@ -1501,7 +1538,7 @@ export function ThreadShell({
|
||||
: t("thread.composer.placeholderHero")
|
||||
}
|
||||
modelLabel={modelBadgeLabel}
|
||||
modelDetail={toModelBadgeLabel(modelBadge.model)}
|
||||
modelDetail={modelBadge.model}
|
||||
modelPreset={activeModelPreset}
|
||||
modelPresets={modelPresetOptions}
|
||||
onModelPresetChange={handleModelPresetChange}
|
||||
@@ -1510,6 +1547,7 @@ export function ThreadShell({
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
contextUsage={composerContextUsage}
|
||||
variant="hero"
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
|
||||
+40
-34
@@ -165,11 +165,19 @@
|
||||
pointer-events: none;
|
||||
background-color: rgb(236 141 49);
|
||||
opacity: 0;
|
||||
transition: opacity 220ms ease-out;
|
||||
transition: opacity 180ms ease-out;
|
||||
}
|
||||
|
||||
.composer-model-badge[data-fallback="true"] {
|
||||
border-color: rgb(236 141 49 / 0.38);
|
||||
}
|
||||
|
||||
.composer-model-badge[data-fallback="true"]::before {
|
||||
opacity: 1;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.dark .composer-model-badge[data-fallback="true"]::before {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.composer-model-badge > * {
|
||||
@@ -695,11 +703,38 @@
|
||||
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
|
||||
}
|
||||
|
||||
.thread-composer-model-badge:not([data-switching="true"]):active
|
||||
> .composer-model-pill {
|
||||
.thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@keyframes composer-model-pill-viewport-enter {
|
||||
from {
|
||||
transform: scale(0.9074);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.composer-model-pill-viewport {
|
||||
transform-origin: right center;
|
||||
animation: composer-model-pill-viewport-enter 210ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||
}
|
||||
|
||||
.composer-model-pill-dock {
|
||||
transform-origin: right center;
|
||||
transition-property: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.composer-model-pill-track[data-settling="true"],
|
||||
.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock {
|
||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes preset-name-shake {
|
||||
0%,
|
||||
100% {
|
||||
@@ -715,37 +750,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes composer-model-pill-viewport-enter {
|
||||
from {
|
||||
transform: scale(0.9074);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.composer-model-pill-viewport {
|
||||
transform-origin: right center;
|
||||
animation: composer-model-pill-viewport-enter 210ms
|
||||
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||
}
|
||||
|
||||
.composer-model-pill-dock {
|
||||
transform-origin: right center;
|
||||
transition-property: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.composer-model-pill-track[data-settling="true"],
|
||||
.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock {
|
||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.thread-composer-model-badge:active > .composer-model-pill {
|
||||
.thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ type PendingStreamEvent =
|
||||
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
||||
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
||||
|
||||
const STREAM_END_IDLE_DELAY_MS = 1000;
|
||||
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
||||
|
||||
/**
|
||||
@@ -63,8 +62,8 @@ const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
||||
*
|
||||
* Lookup rule: reasoning can only extend the current reasoning placeholder.
|
||||
* Once ordinary answer text has appeared, the next reasoning chunk starts a
|
||||
* fresh Thought block so streamed output stays in arrival order:
|
||||
* Thought -> answer -> Thought -> answer.
|
||||
* fresh activity surface so streamed output stays in arrival order while the
|
||||
* final answer remains the only visible answer bubble.
|
||||
*/
|
||||
function attachReasoningChunk(
|
||||
prev: UIMessage[],
|
||||
@@ -183,16 +182,6 @@ export interface SubmittedTurn {
|
||||
sideChannel: boolean;
|
||||
}
|
||||
|
||||
function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
||||
if (
|
||||
ev.event === "delta"
|
||||
|| ev.event === "reasoning_delta"
|
||||
|| ev.event === "file_edit"
|
||||
) return true;
|
||||
return ev.event === "message"
|
||||
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
||||
}
|
||||
|
||||
function eventTurnId(ev: InboundEvent): string | undefined {
|
||||
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
||||
}
|
||||
@@ -296,14 +285,6 @@ export function useNanobotStream(
|
||||
const streamTimerRef = useRef<number | null>(null);
|
||||
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||
const sideChannelTurnIdsRef = useRef<Set<string>>(new Set());
|
||||
/** 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);
|
||||
|
||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||
|
||||
@@ -319,26 +300,11 @@ export function useNanobotStream(
|
||||
pendingStreamEventsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const cancelStreamEndTimer = useCallback(() => {
|
||||
if (streamEndTimerRef.current === null) return;
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const isSideChannelEvent = useCallback((ev: InboundEvent) => {
|
||||
const turnId = eventTurnId(ev);
|
||||
return turnId !== undefined && sideChannelTurnIdsRef.current.has(turnId);
|
||||
}, []);
|
||||
|
||||
const scheduleStreamEndTimer = useCallback((turn: UIMessageTurnFields = {}) => {
|
||||
cancelStreamEndTimer();
|
||||
streamEndTimerRef.current = setTimeout(() => {
|
||||
streamEndTimerRef.current = null;
|
||||
setIsStreaming(false);
|
||||
setMessages((prev) => finalizeStreamedTurn(prev, turn));
|
||||
}, STREAM_END_IDLE_DELAY_MS);
|
||||
}, [cancelStreamEndTimer]);
|
||||
|
||||
const createActivitySegmentId = useCallback((activate = true) => {
|
||||
activitySegmentCounterRef.current += 1;
|
||||
const id = `activity-${activitySegmentCounterRef.current}`;
|
||||
@@ -387,7 +353,6 @@ export function useNanobotStream(
|
||||
(event) => event.turn.turnId !== rejectedTurnId,
|
||||
);
|
||||
sideChannelTurnIdsRef.current.delete(rejectedTurnId);
|
||||
cancelStreamEndTimer();
|
||||
setMessages((prev) => {
|
||||
const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId);
|
||||
if (rejectedRows.length === 0) return prev;
|
||||
@@ -438,7 +403,7 @@ export function useNanobotStream(
|
||||
setRunStartedAt(remainingStartedAt);
|
||||
setIsStreaming(hasRemainingRun);
|
||||
if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false;
|
||||
}, [cancelStreamEndTimer, chatId, client]);
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => client.onError(applyStreamError), [applyStreamError, client]);
|
||||
|
||||
@@ -659,15 +624,6 @@ export function useNanobotStream(
|
||||
return () => document.removeEventListener("visibilitychange", flushOnReturn);
|
||||
}, [flushPendingStreamEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onStatus((status) => {
|
||||
if (status !== "reconnecting" && status !== "closed") return;
|
||||
// A transport drop does not prove the backend turn completed. Keep the
|
||||
// semantic running state intact so queued guidance is not flushed early.
|
||||
cancelStreamEndTimer();
|
||||
});
|
||||
}, [cancelStreamEndTimer, client]);
|
||||
|
||||
// Reset local state when switching chats. Do not reset on every
|
||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||
// history response after the optimistic first message has already rendered.
|
||||
@@ -690,9 +646,8 @@ export function useNanobotStream(
|
||||
clearPendingStreamWork();
|
||||
sideChannelTurnIdsRef.current.clear();
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
cancelStreamEndTimer();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId, client, cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
|
||||
}, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPendingToolCalls) setIsStreaming(true);
|
||||
@@ -774,12 +729,6 @@ export function useNanobotStream(
|
||||
return;
|
||||
}
|
||||
const sideChannelEvent = isSideChannelEvent(ev);
|
||||
if (
|
||||
streamEndTimerRef.current !== null
|
||||
&& !sideChannelEvent
|
||||
&& eventExtendsModelActivity(ev)
|
||||
) cancelStreamEndTimer();
|
||||
|
||||
if (ev.event === "delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||
@@ -822,14 +771,13 @@ export function useNanobotStream(
|
||||
});
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
if (ev.resuming) {
|
||||
cancelStreamEndTimer();
|
||||
setIsStreaming(true);
|
||||
if (!mergeNext) {
|
||||
setMessages((prev) => finalizeStreamedTurn(prev, turn));
|
||||
}
|
||||
return;
|
||||
}
|
||||
scheduleStreamEndTimer(turn);
|
||||
// ``stream_end`` closes the current answer segment, not the turn.
|
||||
// Tools and follow-up model segments may still arrive before the
|
||||
// definitive ``turn_end`` event.
|
||||
setIsStreaming(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -868,9 +816,8 @@ export function useNanobotStream(
|
||||
setGoalState(ev.goal_state);
|
||||
}
|
||||
setRunStartedAt(null);
|
||||
// Definitive signal that the turn is fully complete. Cancel any
|
||||
// pending debounce timer and stop the loading indicator immediately.
|
||||
cancelStreamEndTimer();
|
||||
// Definitive signal that the turn is fully complete, so stop the
|
||||
// loading indicator immediately.
|
||||
setIsStreaming(false);
|
||||
const completedAt = Date.now();
|
||||
setMessages((prev) => {
|
||||
@@ -884,6 +831,10 @@ export function useNanobotStream(
|
||||
finalized,
|
||||
{
|
||||
...(latencyMs !== undefined ? { latencyMs } : {}),
|
||||
...(ev.usage ? { usage: ev.usage } : {}),
|
||||
...(typeof ev.context_window_tokens === "number"
|
||||
? { contextWindowTokens: ev.context_window_tokens }
|
||||
: {}),
|
||||
completedAt,
|
||||
},
|
||||
ev.turn_id,
|
||||
@@ -1013,8 +964,9 @@ export function useNanobotStream(
|
||||
|
||||
// A complete (non-streamed) assistant message. If a stream was in
|
||||
// flight, drop the placeholder so we don't render the text twice.
|
||||
// Streaming state is closed by ``stream_end`` when present, or by
|
||||
// ``turn_end`` for non-streamed and tool-heavy turns.
|
||||
// ``turn_end`` is the turn boundary. ``stream_end`` only closes the
|
||||
// current text segment so a following tool/reasoning segment remains
|
||||
// part of the same live activity surface.
|
||||
clearActivitySegment();
|
||||
setMessages((prev) => {
|
||||
const activeId = buffer.current?.messageId;
|
||||
@@ -1088,7 +1040,6 @@ export function useNanobotStream(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ``attached`` frames aren't actionable here.
|
||||
};
|
||||
|
||||
const unsub = client.onChat(chatId, handle);
|
||||
@@ -1099,12 +1050,11 @@ export function useNanobotStream(
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
clearPendingStreamWork();
|
||||
cancelStreamEndTimer();
|
||||
};
|
||||
}, [
|
||||
applyStreamError,
|
||||
cancelStreamEndTimer,
|
||||
chatId,
|
||||
closeActiveAssistantStream,
|
||||
client,
|
||||
clearActivitySegment,
|
||||
clearPendingStreamWork,
|
||||
@@ -1114,7 +1064,6 @@ export function useNanobotStream(
|
||||
isSideChannelEvent,
|
||||
onTurnEnd,
|
||||
schedulePendingStreamFlush,
|
||||
scheduleStreamEndTimer,
|
||||
]);
|
||||
|
||||
const send = useCallback(
|
||||
@@ -1133,7 +1082,6 @@ export function useNanobotStream(
|
||||
: content;
|
||||
flushPendingStreamEvents();
|
||||
if (finalizeActiveTurn) {
|
||||
cancelStreamEndTimer();
|
||||
setIsStreaming(false);
|
||||
}
|
||||
const turnId = crypto.randomUUID();
|
||||
@@ -1188,7 +1136,7 @@ export function useNanobotStream(
|
||||
client.sendMessage(chatId, outboundContent, wireMedia, clientOptions);
|
||||
return { turnId, userMessageId, sideChannel };
|
||||
},
|
||||
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
@@ -1209,7 +1157,6 @@ export function useNanobotStream(
|
||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||
|
||||
const reconcileTurnComplete = useCallback(() => {
|
||||
cancelStreamEndTimer();
|
||||
clearPendingStreamWork();
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
@@ -1218,7 +1165,7 @@ export function useNanobotStream(
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
setRunStartedAt(null);
|
||||
setIsStreaming(false);
|
||||
}, [cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
|
||||
}, [clearActivitySegment, clearPendingStreamWork]);
|
||||
|
||||
const transcribeAudio = useCallback(
|
||||
(dataUrl: string, options?: { durationMs?: number }) =>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
LOCAL_PREFS_CHANGED_EVENT,
|
||||
readLocalPreferences,
|
||||
type LocalPreferences,
|
||||
} from "@/lib/local-preferences";
|
||||
|
||||
export function useShowTurnUsage(): boolean {
|
||||
const [showTurnUsage, setShowTurnUsage] = useState(
|
||||
() => readLocalPreferences().showTurnUsage,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setShowTurnUsage(readLocalPreferences().showTurnUsage);
|
||||
const refreshFromLocalPreferenceEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<Partial<LocalPreferences> | undefined>).detail;
|
||||
setShowTurnUsage(
|
||||
detail ? detail.showTurnUsage === true : readLocalPreferences().showTurnUsage,
|
||||
);
|
||||
};
|
||||
window.addEventListener("storage", refresh);
|
||||
window.addEventListener("focus", refresh);
|
||||
window.addEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent);
|
||||
return () => {
|
||||
window.removeEventListener("storage", refresh);
|
||||
window.removeEventListener("focus", refresh);
|
||||
window.removeEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return showTurnUsage;
|
||||
}
|
||||
@@ -196,6 +196,7 @@
|
||||
"density": "Density",
|
||||
"activityMode": "Activity detail",
|
||||
"fileEditDisplay": "File edit display",
|
||||
"showTurnUsage": "Show per-turn token usage",
|
||||
"codeWrap": "Code wrapping",
|
||||
"brandLogos": "Brand logos",
|
||||
"maxResults": "Max results",
|
||||
@@ -241,6 +242,7 @@
|
||||
"density": "Stored only in this browser.",
|
||||
"activityMode": "Choose how much agent activity chrome to show by default.",
|
||||
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
|
||||
"showTurnUsage": "Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
|
||||
"codeWrap": "Keep long code lines readable on smaller screens.",
|
||||
"brandLogos": "Show third-party provider and CLI logos in Settings.",
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
@@ -1192,6 +1194,11 @@
|
||||
"removeQuotedContext": "Remove quoted context",
|
||||
"modelNotConfigured": "Model not configured",
|
||||
"configureModel": "Configure model",
|
||||
"switchModel": "Switch model for this chat",
|
||||
"context": {
|
||||
"tooltip": "Context · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% used."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Queued guidance",
|
||||
"guide": "Guide",
|
||||
@@ -1424,7 +1431,16 @@
|
||||
"fileEditShowMoreLines": "Show {{count}} more lines",
|
||||
"fileEditShowFewerLines": "Show fewer lines",
|
||||
"fileEditOpenFile": "Open file",
|
||||
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change."
|
||||
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change.",
|
||||
"usage": {
|
||||
"context": "{{tokens}} context",
|
||||
"turnInput": "{{tokens}} turn input",
|
||||
"request": "{{count}} request",
|
||||
"requests": "{{count}} requests",
|
||||
"output": "{{tokens}} out",
|
||||
"cached": "{{percent}}% cached",
|
||||
"estimated": "Includes estimated usage"
|
||||
}
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Image preview",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"density": "Densidad",
|
||||
"activityMode": "Detalle de actividad",
|
||||
"fileEditDisplay": "Vista de edición de archivos",
|
||||
"showTurnUsage": "Mostrar uso de tokens por turno",
|
||||
"codeWrap": "Ajuste de código",
|
||||
"maxResults": "Resultados máximos",
|
||||
"timeout": "Tiempo de espera",
|
||||
@@ -185,6 +186,7 @@
|
||||
"density": "Solo se guarda en este navegador.",
|
||||
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
|
||||
"fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o diferencias.",
|
||||
"showTurnUsage": "Muestra el contexto, la entrada acumulada, la salida, la tasa de caché y las llamadas al modelo debajo de los mensajes del asistente. Solo se guarda en este navegador.",
|
||||
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
|
||||
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
||||
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
||||
@@ -1179,6 +1181,11 @@
|
||||
"removeQuotedContext": "Quitar contexto citado",
|
||||
"modelNotConfigured": "Modelo no configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"switchModel": "Cambiar el modelo de este chat",
|
||||
"context": {
|
||||
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}} % usado."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Guía en cola",
|
||||
"guide": "Guiar",
|
||||
@@ -1395,6 +1402,15 @@
|
||||
"fileEditShowFewerLines": "Mostrar menos líneas",
|
||||
"fileEditOpenFile": "Abrir archivo",
|
||||
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
|
||||
"usage": {
|
||||
"context": "{{tokens}} de contexto",
|
||||
"turnInput": "{{tokens}} de entrada en el turno",
|
||||
"request": "{{count}} solicitud",
|
||||
"requests": "{{count}} solicitudes",
|
||||
"output": "{{tokens}} de salida",
|
||||
"cached": "{{percent}}% en caché",
|
||||
"estimated": "Incluye uso estimado"
|
||||
},
|
||||
"activityThinkingFor": "Pensando durante {{duration}}",
|
||||
"activityThought": "Pensamiento completado",
|
||||
"activityThoughtFor": "Pensó durante {{duration}}",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"density": "Densité",
|
||||
"activityMode": "Détail d’activité",
|
||||
"fileEditDisplay": "Affichage des modifications de fichiers",
|
||||
"showTurnUsage": "Afficher l’utilisation des tokens par tour",
|
||||
"codeWrap": "Retour à la ligne du code",
|
||||
"maxResults": "Résultats max.",
|
||||
"timeout": "Délai d’attente",
|
||||
@@ -185,6 +186,7 @@
|
||||
"density": "Enregistré seulement dans ce navigateur.",
|
||||
"activityMode": "Choisissez le niveau de détail de l’activité de l’agent affiché par défaut.",
|
||||
"fileEditDisplay": "Choisissez si l’activité de modification affiche le nombre de lignes ou les différences.",
|
||||
"showTurnUsage": "Affiche le contexte, l’entrée cumulée, la sortie, le taux de cache et le nombre d’appels au modèle sous les messages de l’assistant. Enregistré uniquement dans ce navigateur.",
|
||||
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
|
||||
"maxResults": "Résultats renvoyés par chaque appel web_search.",
|
||||
"timeout": "Nombre de secondes avant l’expiration d’une requête de recherche.",
|
||||
@@ -1178,6 +1180,11 @@
|
||||
"removeQuotedContext": "Supprimer le contexte cité",
|
||||
"modelNotConfigured": "Modèle non configuré",
|
||||
"configureModel": "Configurer le modèle",
|
||||
"switchModel": "Changer le modèle de cette conversation",
|
||||
"context": {
|
||||
"tooltip": "Contexte · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}} % utilisé."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Guidage en attente",
|
||||
"guide": "Guider",
|
||||
@@ -1394,6 +1401,15 @@
|
||||
"fileEditShowFewerLines": "Afficher moins de lignes",
|
||||
"fileEditOpenFile": "Ouvrir le fichier",
|
||||
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
|
||||
"usage": {
|
||||
"context": "{{tokens}} de contexte",
|
||||
"turnInput": "{{tokens}} en entrée sur ce tour",
|
||||
"request": "{{count}} requête",
|
||||
"requests": "{{count}} requêtes",
|
||||
"output": "{{tokens}} en sortie",
|
||||
"cached": "{{percent}} % en cache",
|
||||
"estimated": "Inclut une estimation de l’utilisation"
|
||||
},
|
||||
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
||||
"activityThought": "Réflexion terminée",
|
||||
"activityThoughtFor": "Réflexion terminée en {{duration}}",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"density": "Kerapatan",
|
||||
"activityMode": "Detail aktivitas",
|
||||
"fileEditDisplay": "Tampilan perubahan file",
|
||||
"showTurnUsage": "Tampilkan penggunaan token per giliran",
|
||||
"codeWrap": "Bungkus kode",
|
||||
"maxResults": "Hasil maksimum",
|
||||
"timeout": "Batas waktu",
|
||||
@@ -185,6 +186,7 @@
|
||||
"density": "Hanya disimpan di browser ini.",
|
||||
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
|
||||
"fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.",
|
||||
"showTurnUsage": "Tampilkan konteks, input kumulatif, output, rasio cache, dan jumlah panggilan model di bawah pesan asisten. Hanya disimpan di browser ini.",
|
||||
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
|
||||
"maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.",
|
||||
"timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.",
|
||||
@@ -1178,6 +1180,11 @@
|
||||
"removeQuotedContext": "Hapus konteks kutipan",
|
||||
"modelNotConfigured": "Model belum dikonfigurasi",
|
||||
"configureModel": "Konfigurasi model",
|
||||
"switchModel": "Ganti model untuk percakapan ini",
|
||||
"context": {
|
||||
"tooltip": "Konteks · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% digunakan."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Panduan antrean",
|
||||
"guide": "Pandu",
|
||||
@@ -1394,6 +1401,15 @@
|
||||
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
|
||||
"fileEditOpenFile": "Buka file",
|
||||
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
|
||||
"usage": {
|
||||
"context": "Konteks {{tokens}}",
|
||||
"turnInput": "Input giliran {{tokens}}",
|
||||
"request": "{{count}} permintaan",
|
||||
"requests": "{{count}} permintaan",
|
||||
"output": "Output {{tokens}}",
|
||||
"cached": "{{percent}}% di-cache",
|
||||
"estimated": "Termasuk penggunaan perkiraan"
|
||||
},
|
||||
"activityThinkingFor": "Berpikir selama {{duration}}",
|
||||
"activityThought": "Selesai berpikir",
|
||||
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"density": "表示密度",
|
||||
"activityMode": "アクティビティ詳細",
|
||||
"fileEditDisplay": "ファイル編集表示",
|
||||
"showTurnUsage": "ターンごとのトークン使用量を表示",
|
||||
"codeWrap": "コードの折り返し",
|
||||
"maxResults": "最大結果数",
|
||||
"timeout": "タイムアウト",
|
||||
@@ -185,6 +186,7 @@
|
||||
"density": "このブラウザーにのみ保存されます。",
|
||||
"activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。",
|
||||
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
|
||||
"showTurnUsage": "アシスタントのメッセージの下に、コンテキスト、累積入力、出力、キャッシュ率、モデル呼び出し回数を表示します。このブラウザーにのみ保存されます。",
|
||||
"codeWrap": "小さな画面でも長いコード行を読みやすくします。",
|
||||
"maxResults": "各 web_search 呼び出しで返す結果数です。",
|
||||
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
|
||||
@@ -1178,6 +1180,11 @@
|
||||
"removeQuotedContext": "引用したコンテキストを削除",
|
||||
"modelNotConfigured": "モデルが未設定です",
|
||||
"configureModel": "モデルを設定",
|
||||
"switchModel": "この会話で使うモデルを切り替える",
|
||||
"context": {
|
||||
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}。{{percent}}% 使用中"
|
||||
},
|
||||
"queued": {
|
||||
"label": "保留中のガイド",
|
||||
"guide": "ガイド",
|
||||
@@ -1394,6 +1401,15 @@
|
||||
"fileEditShowFewerLines": "表示行数を減らす",
|
||||
"fileEditOpenFile": "ファイルを開く",
|
||||
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
|
||||
"usage": {
|
||||
"context": "コンテキスト {{tokens}}",
|
||||
"turnInput": "ターン入力 {{tokens}}",
|
||||
"request": "リクエスト {{count}} 回",
|
||||
"requests": "リクエスト {{count}} 回",
|
||||
"output": "出力 {{tokens}}",
|
||||
"cached": "キャッシュ {{percent}}%",
|
||||
"estimated": "推定使用量を含みます"
|
||||
},
|
||||
"activityThinkingFor": "{{duration}}考えています",
|
||||
"activityThought": "思考しました",
|
||||
"activityThoughtFor": "{{duration}}考えました",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"density": "밀도",
|
||||
"activityMode": "활동 상세",
|
||||
"fileEditDisplay": "파일 편집 표시",
|
||||
"showTurnUsage": "턴별 토큰 사용량 표시",
|
||||
"codeWrap": "코드 줄바꿈",
|
||||
"maxResults": "최대 결과 수",
|
||||
"timeout": "타임아웃",
|
||||
@@ -185,6 +186,7 @@
|
||||
"density": "이 브라우저에만 저장됩니다.",
|
||||
"activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.",
|
||||
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.",
|
||||
"showTurnUsage": "어시스턴트 메시지 아래에 컨텍스트, 누적 입력, 출력, 캐시 비율, 모델 호출 횟수를 표시합니다. 이 브라우저에만 저장됩니다.",
|
||||
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
|
||||
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
|
||||
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
|
||||
@@ -1178,6 +1180,11 @@
|
||||
"removeQuotedContext": "인용한 문맥 제거",
|
||||
"modelNotConfigured": "모델이 설정되지 않음",
|
||||
"configureModel": "모델 설정",
|
||||
"switchModel": "이 대화에서 사용할 모델 전환",
|
||||
"context": {
|
||||
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% 사용 중."
|
||||
},
|
||||
"queued": {
|
||||
"label": "대기 중인 안내",
|
||||
"guide": "안내",
|
||||
@@ -1394,6 +1401,15 @@
|
||||
"fileEditShowFewerLines": "줄 줄이기",
|
||||
"fileEditOpenFile": "파일 열기",
|
||||
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
|
||||
"usage": {
|
||||
"context": "컨텍스트 {{tokens}}",
|
||||
"turnInput": "턴 입력 {{tokens}}",
|
||||
"request": "요청 {{count}}회",
|
||||
"requests": "요청 {{count}}회",
|
||||
"output": "출력 {{tokens}}",
|
||||
"cached": "캐시됨 {{percent}}%",
|
||||
"estimated": "예상 사용량 포함"
|
||||
},
|
||||
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
||||
"activityThought": "생각함",
|
||||
"activityThoughtFor": "{{duration}} 동안 생각함",
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"density": "Densidade",
|
||||
"activityMode": "Detalhe de atividade",
|
||||
"fileEditDisplay": "Exibição de edição de arquivo",
|
||||
"showTurnUsage": "Mostrar uso de tokens por turno",
|
||||
"codeWrap": "Quebra de linha no código",
|
||||
"brandLogos": "Logos de marca",
|
||||
"maxResults": "Máx. de resultados",
|
||||
@@ -241,6 +242,7 @@
|
||||
"density": "Armazenado apenas neste navegador.",
|
||||
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
|
||||
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
|
||||
"showTurnUsage": "Mostra contexto, entrada acumulada, saída, taxa de cache e chamadas do modelo abaixo das mensagens do assistente. Salvo somente neste navegador.",
|
||||
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
|
||||
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
|
||||
"maxResults": "Resultados retornados por cada chamada de web_search.",
|
||||
@@ -1192,6 +1194,11 @@
|
||||
"removeQuotedContext": "Remover contexto citado",
|
||||
"modelNotConfigured": "Modelo não configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"switchModel": "Alternar o modelo desta conversa",
|
||||
"context": {
|
||||
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% usado."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Guia em fila",
|
||||
"guide": "Guiar",
|
||||
@@ -1424,7 +1431,16 @@
|
||||
"fileEditShowMoreLines": "Mostrar mais {{count}} linhas",
|
||||
"fileEditShowFewerLines": "Mostrar menos linhas",
|
||||
"fileEditOpenFile": "Abrir arquivo",
|
||||
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa."
|
||||
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.",
|
||||
"usage": {
|
||||
"context": "{{tokens}} de contexto",
|
||||
"turnInput": "{{tokens}} de entrada na rodada",
|
||||
"request": "{{count}} chamada",
|
||||
"requests": "{{count}} chamadas",
|
||||
"output": "{{tokens}} de saída",
|
||||
"cached": "{{percent}}% em cache",
|
||||
"estimated": "Inclui uso estimado"
|
||||
}
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Pré-visualização de imagem",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"density": "Mật độ",
|
||||
"activityMode": "Chi tiết hoạt động",
|
||||
"fileEditDisplay": "Hiển thị sửa tệp",
|
||||
"showTurnUsage": "Hiển thị mức dùng token theo lượt",
|
||||
"codeWrap": "Xuống dòng mã",
|
||||
"maxResults": "Kết quả tối đa",
|
||||
"timeout": "Thời gian chờ",
|
||||
@@ -185,6 +186,7 @@
|
||||
"density": "Chỉ lưu trong trình duyệt này.",
|
||||
"activityMode": "Chọn mức chi tiết hoạt động của tác nhân hiển thị mặc định.",
|
||||
"fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay khác biệt.",
|
||||
"showTurnUsage": "Hiển thị ngữ cảnh, đầu vào tích lũy, đầu ra, tỷ lệ bộ nhớ đệm và số lần gọi mô hình bên dưới tin nhắn của trợ lý. Chỉ được lưu trong trình duyệt này.",
|
||||
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
|
||||
"maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.",
|
||||
"timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.",
|
||||
@@ -1178,6 +1180,11 @@
|
||||
"removeQuotedContext": "Xóa ngữ cảnh được trích dẫn",
|
||||
"modelNotConfigured": "Chưa cấu hình mô hình",
|
||||
"configureModel": "Cấu hình mô hình",
|
||||
"switchModel": "Chuyển mô hình cho cuộc trò chuyện này",
|
||||
"context": {
|
||||
"tooltip": "Ngữ cảnh · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. Đã dùng {{percent}}%."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Hướng dẫn đang chờ",
|
||||
"guide": "Hướng dẫn",
|
||||
@@ -1394,6 +1401,15 @@
|
||||
"fileEditShowFewerLines": "Hiển thị ít dòng hơn",
|
||||
"fileEditOpenFile": "Mở tệp",
|
||||
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
|
||||
"usage": {
|
||||
"context": "Ngữ cảnh {{tokens}}",
|
||||
"turnInput": "Đầu vào lượt {{tokens}}",
|
||||
"request": "{{count}} yêu cầu",
|
||||
"requests": "{{count}} yêu cầu",
|
||||
"output": "Đầu ra {{tokens}}",
|
||||
"cached": "{{percent}}% đã lưu đệm",
|
||||
"estimated": "Bao gồm mức sử dụng ước tính"
|
||||
},
|
||||
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
|
||||
"activityThought": "Đã suy nghĩ",
|
||||
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
|
||||
|
||||
@@ -196,6 +196,7 @@
|
||||
"density": "密度",
|
||||
"activityMode": "活动详情",
|
||||
"fileEditDisplay": "文件编辑显示",
|
||||
"showTurnUsage": "显示每轮 Token 用量",
|
||||
"codeWrap": "代码换行",
|
||||
"brandLogos": "品牌 Logo",
|
||||
"maxResults": "最大结果数",
|
||||
@@ -241,6 +242,7 @@
|
||||
"density": "只保存在此浏览器中。",
|
||||
"activityMode": "选择默认显示多少智能体活动详情。",
|
||||
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
|
||||
"showTurnUsage": "在助手消息下显示上下文、累计输入、输出、缓存率和模型调用次数;仅保存在此浏览器。",
|
||||
"codeWrap": "让长代码行在小屏幕上也易读。",
|
||||
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
|
||||
"maxResults": "每次 web_search 调用返回的结果数。",
|
||||
@@ -1191,6 +1193,11 @@
|
||||
"removeQuotedContext": "移除引用内容",
|
||||
"modelNotConfigured": "模型未配置",
|
||||
"configureModel": "配置模型",
|
||||
"switchModel": "切换本次对话所用模型",
|
||||
"context": {
|
||||
"tooltip": "上下文 · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}。已使用 {{percent}}%。"
|
||||
},
|
||||
"queued": {
|
||||
"label": "排队中的引导消息",
|
||||
"guide": "引导",
|
||||
@@ -1424,7 +1431,16 @@
|
||||
"fileEditShowMoreLines": "显示剩余 {{count}} 行",
|
||||
"fileEditShowFewerLines": "收起部分行",
|
||||
"fileEditOpenFile": "打开文件",
|
||||
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。"
|
||||
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
|
||||
"usage": {
|
||||
"context": "上下文 {{tokens}}",
|
||||
"turnInput": "本轮输入 {{tokens}}",
|
||||
"request": "{{count}} 次模型调用",
|
||||
"requests": "{{count}} 次模型调用",
|
||||
"output": "输出 {{tokens}}",
|
||||
"cached": "缓存 {{percent}}%",
|
||||
"estimated": "包含估算用量"
|
||||
}
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "图片预览",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"density": "密度",
|
||||
"activityMode": "活動細節",
|
||||
"fileEditDisplay": "檔案編輯顯示",
|
||||
"showTurnUsage": "顯示每輪 Token 用量",
|
||||
"codeWrap": "程式碼換行",
|
||||
"maxResults": "最大結果數",
|
||||
"timeout": "逾時",
|
||||
@@ -185,6 +186,7 @@
|
||||
"density": "只儲存在此瀏覽器中。",
|
||||
"activityMode": "選擇預設顯示多少智能體活動細節。",
|
||||
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
|
||||
"showTurnUsage": "在助理訊息下顯示上下文、累計輸入、輸出、快取率和模型呼叫次數;僅儲存在此瀏覽器。",
|
||||
"codeWrap": "讓長程式碼行在小螢幕上也易讀。",
|
||||
"maxResults": "每次呼叫 web_search 所回傳的結果數。",
|
||||
"timeout": "搜尋供應商請求逾時前的秒數。",
|
||||
@@ -1178,6 +1180,11 @@
|
||||
"removeQuotedContext": "移除引用內容",
|
||||
"modelNotConfigured": "尚未設定模型",
|
||||
"configureModel": "設定模型",
|
||||
"switchModel": "切換此對話使用的模型",
|
||||
"context": {
|
||||
"tooltip": "上下文 · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}。已使用 {{percent}}%。"
|
||||
},
|
||||
"queued": {
|
||||
"label": "佇列中的引導訊息",
|
||||
"guide": "引導",
|
||||
@@ -1393,6 +1400,15 @@
|
||||
"fileEditShowFewerLines": "收起部分行",
|
||||
"fileEditOpenFile": "開啟檔案",
|
||||
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
|
||||
"usage": {
|
||||
"context": "上下文 {{tokens}}",
|
||||
"turnInput": "本輪輸入 {{tokens}}",
|
||||
"request": "{{count}} 次模型請求",
|
||||
"requests": "{{count}} 次模型請求",
|
||||
"output": "輸出 {{tokens}}",
|
||||
"cached": "快取 {{percent}}%",
|
||||
"estimated": "包含估算用量"
|
||||
},
|
||||
"activityThinkingFor": "思考中,已 {{duration}}",
|
||||
"activityThought": "已思考",
|
||||
"activityThoughtFor": "已思考 {{duration}}",
|
||||
|
||||
+199
-184
@@ -1,5 +1,8 @@
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
/** A completed turn has two surfaces: one activity container and one final
|
||||
* answer. An active turn temporarily preserves arrival order so visible
|
||||
* Markdown never moves when a later tool starts. */
|
||||
export type TurnUnit =
|
||||
| {
|
||||
type: "activity";
|
||||
@@ -9,106 +12,103 @@ export type TurnUnit =
|
||||
}
|
||||
| { type: "message"; message: UIMessage };
|
||||
|
||||
interface NormalizeActivityTimelineOptions {
|
||||
preserveTrailingActivity?: boolean;
|
||||
}
|
||||
|
||||
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
|
||||
if (message.role !== "assistant" || message.kind === "trace") return false;
|
||||
if (message.content.trim().length > 0) return false;
|
||||
if (message.activityKind === "model" || message.content.trim().length > 0) return false;
|
||||
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
|
||||
}
|
||||
|
||||
export function isAgentActivityMember(message: UIMessage): boolean {
|
||||
return isReasoningOnlyAssistant(message) || message.kind === "trace";
|
||||
return isReasoningOnlyAssistant(message) || message.kind === "trace" || message.activityKind === "model";
|
||||
}
|
||||
|
||||
export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
|
||||
if (messages.length === 0) return false;
|
||||
const last = messages[messages.length - 1];
|
||||
if (!isAgentActivityMember(last)) return false;
|
||||
const last = messages.at(-1);
|
||||
if (!last || !isAgentActivityMember(last)) return false;
|
||||
if (last.isStreaming || last.reasoningStreaming) return true;
|
||||
|
||||
let trailingStart = messages.length - 1;
|
||||
while (
|
||||
trailingStart > 0
|
||||
&& isAgentActivityMember(messages[trailingStart - 1])
|
||||
) {
|
||||
trailingStart -= 1;
|
||||
}
|
||||
|
||||
const trailing = messages.slice(trailingStart);
|
||||
if (trailing.some((message) => message.isStreaming || message.reasoningStreaming)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previous = messages[trailingStart - 1];
|
||||
if (!previous || previous.role !== "assistant" || isAgentActivityMember(previous)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const trailingTurnIds = new Set(
|
||||
trailing
|
||||
.map((message) => message.turnId)
|
||||
.filter((turnId): turnId is string => typeof turnId === "string" && turnId.length > 0),
|
||||
);
|
||||
if (!previous.turnId) return trailingTurnIds.size > 0;
|
||||
return trailingTurnIds.size > 0 && !trailingTurnIds.has(previous.turnId);
|
||||
const lastTurnId = last.turnId;
|
||||
const previous = messages.at(-2);
|
||||
// A trace without a visible answer is an unfinished turn on replay. Once a
|
||||
// final assistant answer exists after it, the activity is simply history.
|
||||
return !previous
|
||||
|| previous.role !== "assistant"
|
||||
|| isAgentActivityMember(previous)
|
||||
|| previous.turnId !== lastTurnId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project completed or replayed gateway rows into the stable shape:
|
||||
*
|
||||
* user → [one live/completed activity surface] → [one final answer]
|
||||
*
|
||||
* Assistant text that is followed by another activity is an intermediate model
|
||||
* segment. It remains in the activity timeline, where the renderer keeps its
|
||||
* normal Markdown surface, but it never creates a second answer bubble. This
|
||||
* is the same causal model used by Codex-style transcripts.
|
||||
*/
|
||||
export function normalizeActivityTimeline(
|
||||
messages: UIMessage[],
|
||||
options: NormalizeActivityTimelineOptions = {},
|
||||
): TurnUnit[] {
|
||||
const units: TurnUnit[] = [];
|
||||
let turnMessages: UIMessage[] = [];
|
||||
let activeTurnId: string | undefined;
|
||||
let activeTurnStartedAtMs: number | undefined;
|
||||
|
||||
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
|
||||
if (turnMessages.length === 0) {
|
||||
const flushTurn = () => {
|
||||
if (!turnMessages.length) {
|
||||
activeTurnId = undefined;
|
||||
activeTurnStartedAtMs = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const turnUnits: TurnUnit[] = [];
|
||||
const turnStartedAtMs = activeTurnStartedAtMs;
|
||||
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
|
||||
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
|
||||
let visibleIndex = 0;
|
||||
let activityMessages: UIMessage[] = [];
|
||||
const ordered = orderMessagesByTurnSeq(turnMessages);
|
||||
const lastActivityIndex = ordered.reduce(
|
||||
(index, message, current) => isRawActivity(message) ? current : index,
|
||||
-1,
|
||||
);
|
||||
const answerIndices = ordered
|
||||
.map((message, index) => ({ message, index }))
|
||||
.filter(({ message }) => isAssistantAnswer(message));
|
||||
const finalAnswerIndex = answerIndices.at(-1)?.index;
|
||||
// A replay can deliver a completed answer before a late trace row. Keep
|
||||
// that answer visible, but place the late activity in the single activity
|
||||
// surface before it. An answer followed by more activity is an
|
||||
// intermediate model segment and stays in that surface in turn order.
|
||||
const hasFinalAnswer = finalAnswerIndex !== undefined
|
||||
&& (finalAnswerIndex > lastActivityIndex || ordered[finalAnswerIndex].isStreaming !== true);
|
||||
|
||||
const flushActivityMessages = () => {
|
||||
if (!activityMessages.length) return;
|
||||
pushActivityUnits(
|
||||
turnUnits,
|
||||
activityMessages,
|
||||
visibleMessages.slice(visibleIndex),
|
||||
turnStartedAtMs,
|
||||
);
|
||||
activityMessages = [];
|
||||
};
|
||||
|
||||
for (const message of orderedTurnMessages) {
|
||||
if (isAgentActivityMember(message)) {
|
||||
activityMessages.push(message);
|
||||
continue;
|
||||
const activity: UIMessage[] = [];
|
||||
const answers: UIMessage[] = [];
|
||||
ordered.forEach((message, index) => {
|
||||
if (isRawActivity(message)) {
|
||||
activity.push(message);
|
||||
} else if (isAssistantAnswer(message)) {
|
||||
if (message.reasoning?.trim() || message.reasoningStreaming) {
|
||||
activity.push(reasoningOnlyMessageFromAnswer(message));
|
||||
}
|
||||
if (hasFinalAnswer && index === finalAnswerIndex) {
|
||||
answers.push(stripInlineReasoning(message));
|
||||
} else {
|
||||
activity.push(modelActivitySnippet(message));
|
||||
}
|
||||
} else {
|
||||
activity.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
if (assistantHasInlineReasoning(message)) {
|
||||
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
|
||||
flushActivityMessages();
|
||||
turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
|
||||
visibleIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushActivityMessages();
|
||||
turnUnits.push({ type: "message", message });
|
||||
visibleIndex += 1;
|
||||
if (activity.length) {
|
||||
units.push({
|
||||
type: "activity",
|
||||
messages: activity,
|
||||
turnLatencyMs: activityTurnLatencyMs(activity, ordered),
|
||||
startedAtMs: activeTurnStartedAtMs,
|
||||
});
|
||||
}
|
||||
if (answers.length) {
|
||||
units.push({ type: "message", message: mergeAssistantAnswers(answers) });
|
||||
}
|
||||
|
||||
flushActivityMessages();
|
||||
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
|
||||
turnMessages = [];
|
||||
activeTurnId = undefined;
|
||||
activeTurnStartedAtMs = undefined;
|
||||
@@ -122,131 +122,142 @@ export function normalizeActivityTimeline(
|
||||
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) {
|
||||
flushTurn();
|
||||
}
|
||||
if (message.turnId) {
|
||||
activeTurnId = message.turnId;
|
||||
}
|
||||
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) flushTurn();
|
||||
if (message.turnId) activeTurnId = message.turnId;
|
||||
turnMessages.push(message);
|
||||
}
|
||||
|
||||
flushTurn(options);
|
||||
flushTurn();
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep an in-flight turn in arrival order. Until ``turn_end`` there is no
|
||||
* reliable way to know whether an assistant text segment is the final answer
|
||||
* or commentary before another tool call. Reclassifying it when that tool
|
||||
* arrives makes an already-visible Markdown tree jump between containers.
|
||||
*
|
||||
* Completed turns still use ``normalizeActivityTimeline`` and collapse into
|
||||
* one audit surface plus the final answer. While the turn is active, text and
|
||||
* contiguous activity phases stay in arrival order. A later tool therefore
|
||||
* appends a Working surface after existing Markdown instead of reparenting it.
|
||||
*/
|
||||
export function projectActivityTimeline(
|
||||
messages: UIMessage[],
|
||||
liveTurnId?: string | null,
|
||||
): TurnUnit[] {
|
||||
if (liveTurnId === undefined) return normalizeActivityTimeline(messages);
|
||||
|
||||
const liveStart = findLiveTurnStart(messages, liveTurnId);
|
||||
if (liveStart < 0) return normalizeActivityTimeline(messages);
|
||||
const nextPrompt = messages.findIndex(
|
||||
(message, index) => index > liveStart && message.role === "user",
|
||||
);
|
||||
const liveEnd = nextPrompt < 0 ? messages.length : nextPrompt;
|
||||
|
||||
return [
|
||||
...normalizeActivityTimeline(messages.slice(0, liveStart)),
|
||||
...projectLiveTurn(messages.slice(liveStart, liveEnd)),
|
||||
...normalizeActivityTimeline(messages.slice(liveEnd)),
|
||||
];
|
||||
}
|
||||
|
||||
function findLiveTurnStart(messages: UIMessage[], liveTurnId: string | null): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role !== "user") continue;
|
||||
if (liveTurnId === null || message.turnId === liveTurnId) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
|
||||
const units: TurnUnit[] = [];
|
||||
const prompt = messages[0];
|
||||
const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined;
|
||||
let activity: UIMessage[] = [];
|
||||
|
||||
if (prompt?.role === "user") units.push({ type: "message", message: prompt });
|
||||
|
||||
const flushActivity = () => {
|
||||
if (!activity.length) return;
|
||||
units.push({
|
||||
type: "activity",
|
||||
messages: activity,
|
||||
turnLatencyMs: activityTurnLatencyMs(activity, activity),
|
||||
startedAtMs,
|
||||
});
|
||||
activity = [];
|
||||
};
|
||||
|
||||
for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) {
|
||||
if (isRawActivity(message)) {
|
||||
activity.push(message);
|
||||
continue;
|
||||
}
|
||||
if (isAssistantAnswer(message)) {
|
||||
if (message.reasoning?.trim() || message.reasoningStreaming) {
|
||||
activity.push(reasoningOnlyMessageFromAnswer(message));
|
||||
}
|
||||
flushActivity();
|
||||
units.push({ type: "message", message: stripInlineReasoning(message) });
|
||||
continue;
|
||||
}
|
||||
activity.push(message);
|
||||
}
|
||||
|
||||
flushActivity();
|
||||
return units;
|
||||
}
|
||||
|
||||
function isRawActivity(message: UIMessage): boolean {
|
||||
return isAgentActivityMember(message);
|
||||
}
|
||||
|
||||
function isAssistantAnswer(message: UIMessage): boolean {
|
||||
return message.role === "assistant" && message.kind !== "trace" && message.content.trim().length > 0;
|
||||
}
|
||||
|
||||
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
|
||||
if (
|
||||
messages.length < 2
|
||||
|| !messages.every((message) => Number.isFinite(message.turnSeq))
|
||||
) {
|
||||
if (messages.length < 2 || !messages.every((message) => Number.isFinite(message.turnSeq))) {
|
||||
return messages;
|
||||
}
|
||||
return messages
|
||||
.map((message, index) => ({ message, index }))
|
||||
.sort((left, right) => {
|
||||
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
|
||||
return bySeq || left.index - right.index;
|
||||
})
|
||||
.sort((left, right) => (left.message.turnSeq! - right.message.turnSeq!) || (left.index - right.index))
|
||||
.map(({ message }) => message);
|
||||
}
|
||||
|
||||
function normalizeCompletedTurnUnits(
|
||||
turnUnits: TurnUnit[],
|
||||
options: NormalizeActivityTimelineOptions,
|
||||
): TurnUnit[] {
|
||||
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits;
|
||||
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits;
|
||||
|
||||
let trailingStart = turnUnits.length - 1;
|
||||
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") {
|
||||
trailingStart -= 1;
|
||||
}
|
||||
|
||||
const previous = turnUnits[trailingStart - 1];
|
||||
if (
|
||||
!previous
|
||||
|| previous.type !== "message"
|
||||
|| previous.message.role !== "assistant"
|
||||
) {
|
||||
return turnUnits;
|
||||
}
|
||||
|
||||
return [
|
||||
...turnUnits.slice(0, trailingStart - 1),
|
||||
...turnUnits.slice(trailingStart),
|
||||
previous,
|
||||
];
|
||||
}
|
||||
|
||||
function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
|
||||
const visibleMessages: UIMessage[] = [];
|
||||
for (const message of messages) {
|
||||
if (isAgentActivityMember(message)) continue;
|
||||
visibleMessages.push(assistantHasInlineReasoning(message) ? stripInlineReasoning(message) : message);
|
||||
}
|
||||
return visibleMessages;
|
||||
}
|
||||
|
||||
function validCreatedAtMs(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function pushActivityUnits(
|
||||
units: TurnUnit[],
|
||||
activityMessages: UIMessage[],
|
||||
visibleMessages: UIMessage[],
|
||||
startedAtMs?: number,
|
||||
) {
|
||||
let runMessages: UIMessage[] = [];
|
||||
let runBucket: "file" | "other" | undefined;
|
||||
let runSegmentId: string | undefined;
|
||||
|
||||
const flushRun = () => {
|
||||
if (!runMessages.length) return;
|
||||
units.push({
|
||||
type: "activity",
|
||||
messages: runMessages,
|
||||
turnLatencyMs: activityTurnLatencyMs(runMessages, visibleMessages),
|
||||
startedAtMs,
|
||||
});
|
||||
runMessages = [];
|
||||
runBucket = undefined;
|
||||
runSegmentId = undefined;
|
||||
function mergeAssistantAnswers(answers: UIMessage[]): UIMessage {
|
||||
const first = answers[0];
|
||||
const last = answers.at(-1)!;
|
||||
const media = answers.flatMap((message) => message.media ?? []);
|
||||
const images = answers.flatMap((message) => message.images ?? []);
|
||||
const merged: UIMessage = {
|
||||
...first,
|
||||
...last,
|
||||
id: first.id,
|
||||
content: answers.map((message) => message.content.trim()).filter(Boolean).join("\n\n"),
|
||||
createdAt: first.createdAt,
|
||||
isStreaming: answers.some((message) => message.isStreaming),
|
||||
};
|
||||
|
||||
for (const message of activityMessages) {
|
||||
const bucket = isFileEditActivityMessage(message) ? "file" : "other";
|
||||
const segmentId = message.activitySegmentId;
|
||||
const segmentChanged =
|
||||
bucket === "file"
|
||||
&& runBucket === "file"
|
||||
&& !!runSegmentId
|
||||
&& !!segmentId
|
||||
&& runSegmentId !== segmentId;
|
||||
if ((runBucket && bucket !== runBucket) || segmentChanged) {
|
||||
flushRun();
|
||||
}
|
||||
runBucket = bucket;
|
||||
if (segmentId) runSegmentId = segmentId;
|
||||
runMessages.push(message);
|
||||
}
|
||||
|
||||
flushRun();
|
||||
if (media.length) merged.media = media;
|
||||
else delete merged.media;
|
||||
if (images.length) merged.images = images;
|
||||
else delete merged.images;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isFileEditActivityMessage(message: UIMessage): boolean {
|
||||
return message.kind === "trace" && !!message.fileEdits?.length;
|
||||
}
|
||||
|
||||
function assistantHasInlineReasoning(message: UIMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant"
|
||||
&& message.kind !== "trace"
|
||||
&& message.content.trim().length > 0
|
||||
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
|
||||
);
|
||||
function modelActivitySnippet(message: UIMessage): UIMessage {
|
||||
return {
|
||||
...stripInlineReasoning(message),
|
||||
id: `${message.id}-activity`,
|
||||
activityKind: "model",
|
||||
turnPhase: "activity",
|
||||
// Keep the source stream state so the activity surface can render this
|
||||
// segment with the same Markdown streaming semantics as a normal answer.
|
||||
isStreaming: message.isStreaming,
|
||||
};
|
||||
}
|
||||
|
||||
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
||||
@@ -273,13 +284,17 @@ function stripInlineReasoning(message: UIMessage): UIMessage {
|
||||
return next;
|
||||
}
|
||||
|
||||
function activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: UIMessage[]): number | undefined {
|
||||
for (let i = visibleMessages.length - 1; i >= 0; i -= 1) {
|
||||
const latency = visibleMessages[i].latencyMs;
|
||||
function validCreatedAtMs(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function activityTurnLatencyMs(activityMessages: UIMessage[], allMessages: UIMessage[]): number | undefined {
|
||||
for (let index = allMessages.length - 1; index >= 0; index -= 1) {
|
||||
const latency = allMessages[index].latencyMs;
|
||||
if (isValidLatency(latency)) return latency;
|
||||
}
|
||||
for (let i = activityMessages.length - 1; i >= 0; i -= 1) {
|
||||
const latency = activityMessages[i].latencyMs;
|
||||
for (let index = activityMessages.length - 1; index >= 0; index -= 1) {
|
||||
const latency = activityMessages[index].latencyMs;
|
||||
if (isValidLatency(latency)) return latency;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import i18n, { currentLocale } from "@/i18n";
|
||||
|
||||
/** Compact token counts for dense runtime metadata (for example, 74.9K). */
|
||||
export function formatCompactTokenCount(value: number): string {
|
||||
if (value < 1_000) return Math.round(value).toLocaleString();
|
||||
if (value < 1_000_000) {
|
||||
const digits = value < 100_000 ? 1 : 0;
|
||||
return `${Number((value / 1_000).toFixed(digits))}K`;
|
||||
}
|
||||
const digits = value < 100_000_000 ? 1 : 0;
|
||||
return `${Number((value / 1_000_000).toFixed(digits))}M`;
|
||||
}
|
||||
|
||||
const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
|
||||
"hi",
|
||||
"hello",
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface LocalPreferences {
|
||||
codeWrap: boolean;
|
||||
brandLogos: boolean;
|
||||
fileEditDisplayMode: FileEditDisplayMode;
|
||||
showTurnUsage: boolean;
|
||||
}
|
||||
|
||||
export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
|
||||
@@ -19,6 +20,7 @@ export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
||||
codeWrap: true,
|
||||
brandLogos: false,
|
||||
fileEditDisplayMode: "summary",
|
||||
showTurnUsage: false,
|
||||
};
|
||||
|
||||
export function normalizeFileEditDisplayMode(value: unknown): FileEditDisplayMode {
|
||||
@@ -36,6 +38,7 @@ export function readLocalPreferences(): LocalPreferences {
|
||||
codeWrap: parsed.codeWrap !== false,
|
||||
brandLogos: parsed.brandLogos === true,
|
||||
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
|
||||
showTurnUsage: parsed.showTurnUsage === true,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_LOCAL_PREFS;
|
||||
|
||||
@@ -136,7 +136,7 @@ export function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
|
||||
|
||||
export function stampLastAssistantCompletion(
|
||||
prev: UIMessage[],
|
||||
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
|
||||
completion: Pick<UIMessage, "latencyMs" | "completedAt" | "usage" | "contextWindowTokens">,
|
||||
turnId?: string,
|
||||
): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
|
||||
+26
-1
@@ -39,6 +39,16 @@ export interface UIMediaAttachment {
|
||||
|
||||
export interface UIMessageSource { kind: "cron" | "local_trigger" | "trigger" | string; label?: string; }
|
||||
|
||||
export interface TurnUsage {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
cached_tokens?: number;
|
||||
context_tokens?: number;
|
||||
request_count?: number;
|
||||
estimated_tokens?: number;
|
||||
[key: string]: number | undefined;
|
||||
}
|
||||
|
||||
export interface UIMessage {
|
||||
id: string;
|
||||
role: Role;
|
||||
@@ -56,6 +66,9 @@ export interface UIMessage {
|
||||
fileEdits?: UIFileEdit[];
|
||||
/** Activity rows created during the same agent phase share one collapsible block. */
|
||||
activitySegmentId?: string;
|
||||
/** Internal projection marker for assistant text emitted before a later tool.
|
||||
* It is not a wire message and is rendered as a compact activity row. */
|
||||
activityKind?: "model";
|
||||
/** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
|
||||
images?: UIImage[];
|
||||
/** Signed or local UI-renderable media attachments. */
|
||||
@@ -77,6 +90,10 @@ export interface UIMessage {
|
||||
latencyMs?: number;
|
||||
/** Client epoch milliseconds when the definitive ``turn_end`` was received. */
|
||||
completedAt?: number;
|
||||
/** Additive model usage for this turn; context_tokens is the final request only. */
|
||||
usage?: TurnUsage;
|
||||
/** Configured context-window capacity for the model used by this turn. */
|
||||
contextWindowTokens?: number;
|
||||
/** Lightweight provenance for proactive assistant messages. */
|
||||
source?: UIMessageSource;
|
||||
/** Structured provenance for a message delivered by another session. */
|
||||
@@ -1225,7 +1242,12 @@ export interface InboundTurnMetadata {
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string; temporary?: boolean }
|
||||
| {
|
||||
event: "attached";
|
||||
chat_id: string;
|
||||
temporary?: boolean;
|
||||
usage?: TurnUsage;
|
||||
}
|
||||
| {
|
||||
event: "message_accepted";
|
||||
chat_id: string;
|
||||
@@ -1313,11 +1335,14 @@ export type InboundEvent =
|
||||
chat_id: string;
|
||||
model_name: string;
|
||||
model_preset?: string | null;
|
||||
fallback?: boolean;
|
||||
}
|
||||
| ({
|
||||
event: "turn_end";
|
||||
chat_id: string;
|
||||
latency_ms?: number;
|
||||
usage?: TurnUsage;
|
||||
context_window_tokens?: number;
|
||||
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
|
||||
goal_state?: GoalStateWsPayload;
|
||||
} & InboundTurnMetadata)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { DEFAULT_LOCAL_PREFS, writeLocalPreferences } from "@/lib/local-preferences";
|
||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
@@ -139,6 +140,34 @@ function installReducedMotion() {
|
||||
}
|
||||
|
||||
describe("AgentActivityCluster", () => {
|
||||
it("keeps intermediate assistant output as normal Markdown inside live activity", async () => {
|
||||
await act(async () => {
|
||||
await preloadMarkdownText();
|
||||
});
|
||||
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={[
|
||||
{
|
||||
id: "model-activity",
|
||||
role: "assistant",
|
||||
content: "**partial answer**",
|
||||
activityKind: "model",
|
||||
isStreaming: true,
|
||||
createdAt: 1,
|
||||
},
|
||||
]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const block = screen.getByTestId("activity-model-message");
|
||||
await waitFor(() => expect(block.querySelector("strong")).not.toBeNull());
|
||||
expect(block.querySelector("strong")).toHaveTextContent("partial answer");
|
||||
expect(screen.queryByTestId("activity-step")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("jumps to the latest activity when opened", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
@@ -398,7 +427,7 @@ describe("AgentActivityCluster", () => {
|
||||
vi.advanceTimersByTime(301);
|
||||
});
|
||||
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute(
|
||||
expect(screen.getByRole("button", { name: "Worked" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
@@ -422,7 +451,7 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Thought" });
|
||||
const button = screen.getByRole("button", { name: "Worked" });
|
||||
expect(button).toHaveAttribute("data-thread-disclosure");
|
||||
const chevron = button.querySelector("svg");
|
||||
expect(chevron).toBeInTheDocument();
|
||||
@@ -449,7 +478,7 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Thought for 12s")).toBeInTheDocument();
|
||||
expect(screen.getByText("Worked for 12s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels mixed tool activity as work instead of thought", () => {
|
||||
@@ -481,8 +510,8 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Thought")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Worked")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders file edits as one-line activity rows", async () => {
|
||||
|
||||
@@ -160,6 +160,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.rows.activityMode",
|
||||
"settings.rows.fileEditDisplay",
|
||||
"settings.rows.codeWrap",
|
||||
"settings.rows.showTurnUsage",
|
||||
"settings.rows.brandLogos",
|
||||
"settings.rows.currentModel",
|
||||
"settings.rows.localServiceAccess",
|
||||
@@ -171,6 +172,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.help.activityMode",
|
||||
"settings.help.fileEditDisplay",
|
||||
"settings.help.codeWrap",
|
||||
"settings.help.showTurnUsage",
|
||||
"settings.help.brandLogos",
|
||||
"settings.help.currentModel",
|
||||
"settings.help.localServiceAccess",
|
||||
@@ -291,6 +293,13 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
|
||||
"message.openLink",
|
||||
"message.openAttachment",
|
||||
"message.skill",
|
||||
"message.usage.context",
|
||||
"message.usage.turnInput",
|
||||
"message.usage.request",
|
||||
"message.usage.requests",
|
||||
"message.usage.output",
|
||||
"message.usage.cached",
|
||||
"message.usage.estimated",
|
||||
"settings.channels.connectionChecks",
|
||||
"settings.channels.open",
|
||||
];
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
|
||||
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_LOCAL_PREFS,
|
||||
LOCAL_PREFS_STORAGE_KEY,
|
||||
writeLocalPreferences,
|
||||
} from "@/lib/local-preferences";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
McpPresetInfo,
|
||||
@@ -67,6 +73,11 @@ const MCP_PRESETS: McpPresetInfo[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function PreferenceAwareMessageBubble({ message }: { message: UIMessage }) {
|
||||
const showTurnUsage = useShowTurnUsage();
|
||||
return <MessageBubble message={message} showTurnUsage={showTurnUsage} />;
|
||||
}
|
||||
|
||||
const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
command: "/model",
|
||||
@@ -95,6 +106,10 @@ const SLASH_COMMANDS: SlashCommand[] = [
|
||||
];
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(LOCAL_PREFS_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("renders user messages as right-aligned pills", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u1",
|
||||
@@ -245,6 +260,7 @@ describe("MessageBubble", () => {
|
||||
|
||||
const quote = screen.getByLabelText("Quoted context");
|
||||
expect(quote).toHaveTextContent("selected assistant excerpt");
|
||||
expect(quote).not.toHaveAttribute("title");
|
||||
expect(screen.queryByText("Quoted context")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("What about this?")).toBeInTheDocument();
|
||||
|
||||
@@ -1011,4 +1027,117 @@ describe("MessageBubble", () => {
|
||||
expect(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides turn usage by default and leaves latency visible when the preference changes", async () => {
|
||||
const message: UIMessage = {
|
||||
id: "turn-usage-preference",
|
||||
role: "assistant",
|
||||
content: "done",
|
||||
createdAt: Date.now(),
|
||||
latencyMs: 18_200,
|
||||
usage: {
|
||||
prompt_tokens: 12_400,
|
||||
completion_tokens: 823,
|
||||
context_tokens: 8_100,
|
||||
request_count: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<PreferenceAwareMessageBubble message={message} />);
|
||||
|
||||
expect(container.querySelector("[data-turn-usage]")).not.toBeInTheDocument();
|
||||
expect(container.querySelector("[data-turn-latency]")).toHaveTextContent("18s");
|
||||
|
||||
act(() => {
|
||||
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, showTurnUsage: true });
|
||||
});
|
||||
expect(await screen.findByText("8.1K context")).toHaveAttribute("data-turn-usage");
|
||||
|
||||
act(() => {
|
||||
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, showTurnUsage: false });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("[data-turn-usage]")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(container.querySelector("[data-turn-latency]")).toHaveTextContent("18s");
|
||||
});
|
||||
|
||||
it("shows consecutive contexts while keeping each turn's aggregate cost in details", async () => {
|
||||
const turns: UIMessage[] = [
|
||||
{
|
||||
id: "turn-a",
|
||||
role: "assistant",
|
||||
content: "first reply",
|
||||
createdAt: Date.now(),
|
||||
usage: {
|
||||
prompt_tokens: 111_000,
|
||||
completion_tokens: 333,
|
||||
cached_tokens: 55_500,
|
||||
context_tokens: 56_000,
|
||||
request_count: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "turn-b",
|
||||
role: "assistant",
|
||||
content: "second reply",
|
||||
createdAt: Date.now(),
|
||||
usage: {
|
||||
prompt_tokens: 57_000,
|
||||
completion_tokens: 192,
|
||||
cached_tokens: 55_290,
|
||||
context_tokens: 57_000,
|
||||
request_count: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
render(<>{turns.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} showTurnUsage />
|
||||
))}</>);
|
||||
|
||||
const turnAUsage = screen.getByText("56K context");
|
||||
const turnBUsage = screen.getByText("57K context");
|
||||
expect(turnAUsage).toHaveAttribute("data-turn-usage");
|
||||
expect(turnBUsage).toHaveAttribute("data-turn-usage");
|
||||
expect(screen.queryByText(/111K turn input/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.focus(turnAUsage);
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
"111K turn input · 2 requests · 333 out · 50% cached",
|
||||
);
|
||||
|
||||
fireEvent.blur(turnAUsage);
|
||||
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.focus(turnBUsage);
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
"57K turn input · 1 request · 192 out · 97% cached",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to clearly labeled estimated turn input when context is unavailable", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-estimated-usage",
|
||||
role: "assistant",
|
||||
content: "done",
|
||||
createdAt: Date.now(),
|
||||
usage: {
|
||||
prompt_tokens: 1_250,
|
||||
completion_tokens: 90,
|
||||
request_count: 1,
|
||||
estimated_tokens: 1_340,
|
||||
},
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} showTurnUsage />);
|
||||
|
||||
const usage = screen.getByText("~1.3K turn input");
|
||||
expect(usage).not.toHaveTextContent("context");
|
||||
fireEvent.focus(usage);
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
"1 request · ~90 out · Includes estimated usage",
|
||||
);
|
||||
expect(screen.getByRole("tooltip")).not.toHaveTextContent("cached");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1724,6 +1724,7 @@ describe("NanobotClient", () => {
|
||||
chat_id: "chat-a",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
model_preset: "Deep Research",
|
||||
fallback: true,
|
||||
});
|
||||
|
||||
expect(chatHandler).toHaveBeenCalledWith({
|
||||
@@ -1731,6 +1732,7 @@ describe("NanobotClient", () => {
|
||||
chat_id: "chat-a",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
model_preset: "Deep Research",
|
||||
fallback: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { LOCAL_PREFS_STORAGE_KEY } from "@/lib/local-preferences";
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
|
||||
|
||||
@@ -10,6 +11,44 @@ describe("Settings overview and appearance", () => {
|
||||
installSettingsViewTestHooks();
|
||||
|
||||
|
||||
it("defaults per-turn token usage off and persists changes across remounts", async () => {
|
||||
const renderAppearance = () => renderSettingsView({
|
||||
initialSection: "appearance",
|
||||
initialSettings: settingsPayload(),
|
||||
showSidebar: true,
|
||||
});
|
||||
|
||||
renderAppearance();
|
||||
|
||||
const title = "Show per-turn token usage";
|
||||
const description = (
|
||||
"Show context, aggregate input, output, cache rate, and model calls under assistant messages. "
|
||||
+ "Stored only in this browser."
|
||||
);
|
||||
expect(screen.getByText(description)).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: title }));
|
||||
await waitFor(() => {
|
||||
const saved = JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) || "{}");
|
||||
expect(saved.showTurnUsage).toBe(true);
|
||||
});
|
||||
|
||||
cleanup();
|
||||
renderAppearance();
|
||||
expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: title }));
|
||||
await waitFor(() => {
|
||||
const saved = JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) || "{}");
|
||||
expect(saved.showTurnUsage).toBe(false);
|
||||
});
|
||||
|
||||
cleanup();
|
||||
renderAppearance();
|
||||
expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("persists the file edit display local preference", async () => {
|
||||
renderSettingsView({
|
||||
initialSection: "appearance",
|
||||
|
||||
@@ -317,9 +317,9 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string {
|
||||
}
|
||||
|
||||
const MODEL_PRESETS = [
|
||||
{ name: "kimi", provider: "moonshot" },
|
||||
{ name: "dflash", provider: "deepseek" },
|
||||
{ name: "dspro", provider: "deepseek" },
|
||||
{ name: "kimi", model: "moonshot/kimi-k2.5", provider: "moonshot" },
|
||||
{ name: "dflash", model: "deepseek/deepseek-v4-flash", provider: "deepseek" },
|
||||
{ name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" },
|
||||
];
|
||||
|
||||
function renderPresetComposer(variant: "thread" | "hero" = "thread") {
|
||||
@@ -337,28 +337,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
badge: screen.getByRole("spinbutton", { name: "kimi" }),
|
||||
badge: screen.getByRole("button", { name: "kimi" }),
|
||||
onPresetChange,
|
||||
};
|
||||
}
|
||||
|
||||
function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) {
|
||||
fireEvent.pointerDown(badge, {
|
||||
button,
|
||||
clientY,
|
||||
isPrimary: true,
|
||||
pointerId,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
}
|
||||
|
||||
function longPress(badge: HTMLElement, pointerId = 7) {
|
||||
pointerDown(badge, pointerId);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ThreadComposer", () => {
|
||||
it("locks an async send and keeps the draft when it is rejected", async () => {
|
||||
let resolveSend!: (accepted: boolean) => void;
|
||||
@@ -538,12 +521,42 @@ describe("ThreadComposer", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByRole("spinbutton", { name: "gpt-5.6-sol" });
|
||||
const badge = screen.getByRole("button", { name: "gpt-5.6-sol" });
|
||||
expect(badge).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]");
|
||||
expect(badge).not.toHaveClass("w-[5.75rem]");
|
||||
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a compact context meter beside the model selector", async () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
modelLabel="gpt-5.6-sol"
|
||||
modelPreset="gpt-5-6-sol"
|
||||
modelProvider="openai_codex"
|
||||
contextUsage={{
|
||||
contextTokens: 74_900,
|
||||
contextWindowTokens: 1_000_000,
|
||||
}}
|
||||
placeholder="Ask anything..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const context = screen.getByTestId("composer-context-usage");
|
||||
expect(context).toHaveClass("size-5", "rounded-full");
|
||||
expect(context).not.toHaveTextContent("Context 74.9K / 1M");
|
||||
expect(screen.getByTestId("composer-context-meter")).toBeInTheDocument();
|
||||
expect(context).toHaveAccessibleName(
|
||||
"Context · 74.9K / 1M. 7% used.",
|
||||
);
|
||||
|
||||
fireEvent.focus(context);
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent("Context · 74.9K / 1M");
|
||||
expect(tooltip.parentElement).toHaveClass("rounded-full", "px-2.5", "py-1");
|
||||
expect(tooltip.parentElement).not.toHaveTextContent("Available");
|
||||
});
|
||||
|
||||
it("keeps the thread composer compact while matching the hero style", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
@@ -559,7 +572,9 @@ describe("ThreadComposer", () => {
|
||||
const modelPill = screen.getByText("gpt-4o").closest(".composer-model-pill");
|
||||
expect(modelPill).toHaveClass("font-medium", "text-foreground/70");
|
||||
expect(modelPill).not.toHaveClass("font-semibold");
|
||||
expect(screen.getByTestId("composer-model-logo-openai")).toBeInTheDocument();
|
||||
const providerLogo = screen.getByTestId("composer-model-logo-openai");
|
||||
expect(providerLogo).toBeInTheDocument();
|
||||
expect(providerLogo).not.toHaveClass("border", "bg-background");
|
||||
const input = screen.getByPlaceholderText("Type your message...");
|
||||
expect(input.className).toContain("min-h-[50px]");
|
||||
expect(input.className).toContain("text-[16px]");
|
||||
@@ -571,141 +586,53 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows model details in the shared tooltip without a native title", async () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
modelLabel="gpt-4o"
|
||||
modelDetail="gpt-4o"
|
||||
modelProvider="openai"
|
||||
modelProviderLabel="OpenAI"
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByLabelText("gpt-4o");
|
||||
expect(badge).not.toHaveAttribute("title");
|
||||
fireEvent.focus(badge);
|
||||
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent("gpt-4o · OpenAI");
|
||||
});
|
||||
|
||||
it("smoothly cycles to the next preset on click", () => {
|
||||
vi.useFakeTimers();
|
||||
let runFrame: FrameRequestCallback | null = null;
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
runFrame = callback;
|
||||
return 1;
|
||||
});
|
||||
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
|
||||
const { badge, onPresetChange } = renderPresetComposer();
|
||||
|
||||
fireEvent.click(badge);
|
||||
|
||||
expect(badge).toHaveAttribute("data-switching", "true");
|
||||
const track = screen.getByTestId("composer-model-pill-track");
|
||||
expect(track).not.toHaveAttribute("data-settling");
|
||||
expect(track).toHaveStyle({ transform: "translate3d(0, -40px, 0)" });
|
||||
|
||||
act(() => runFrame?.(16));
|
||||
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dflash");
|
||||
expect(badge).toHaveAttribute("data-settling", "true");
|
||||
expect(track).toHaveAttribute("data-settling", "true");
|
||||
expect(track).toHaveStyle({ transform: "translate3d(0, -80px, 0)" });
|
||||
|
||||
act(() => vi.advanceTimersByTime(260));
|
||||
expect(badge).not.toHaveAttribute("data-switching");
|
||||
});
|
||||
|
||||
it("scrolls complete preset pills after a left-button long press and wraps", () => {
|
||||
vi.useFakeTimers();
|
||||
it("opens a model picker and switches presets with one click", async () => {
|
||||
const { badge, onPresetChange } = renderPresetComposer();
|
||||
expect(badge).toHaveClass("h-9");
|
||||
expect(badge).toHaveStyle({ touchAction: "manipulation" });
|
||||
const idleTouchMove = new Event("touchmove", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
badge.dispatchEvent(idleTouchMove);
|
||||
expect(idleTouchMove.defaultPrevented).toBe(false);
|
||||
pointerDown(badge);
|
||||
fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
fireEvent.pointerUp(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
|
||||
expect(onPresetChange).not.toHaveBeenCalled();
|
||||
|
||||
longPress(badge);
|
||||
expect(badge).toHaveAttribute("data-switching", "true");
|
||||
const viewport = screen.getByTestId("composer-model-pill-viewport");
|
||||
expect(viewport).toHaveClass(
|
||||
"right-0",
|
||||
"w-max",
|
||||
"max-w-[calc(44vw+0.5rem)]",
|
||||
"overflow-hidden",
|
||||
"-top-3",
|
||||
"-bottom-3",
|
||||
);
|
||||
const track = screen.getByTestId("composer-model-pill-track");
|
||||
expect(track).toHaveClass("w-max", "max-w-full", "items-end", "gap-1");
|
||||
const activeTouchMove = new Event("touchmove", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
badge.dispatchEvent(activeTouchMove);
|
||||
expect(activeTouchMove.defaultPrevented).toBe(true);
|
||||
const pills = track.querySelectorAll<HTMLElement>(".composer-model-pill");
|
||||
expect(pills).toHaveLength(5);
|
||||
expect(Array.from(pills).every((pill) => pill.classList.contains("w-fit"))).toBe(true);
|
||||
expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true);
|
||||
expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true);
|
||||
const centeredPill = track.querySelector<HTMLElement>("[data-preset-offset='0']");
|
||||
expect(centeredPill).toHaveTextContent("kimi");
|
||||
expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" });
|
||||
expect(
|
||||
track.querySelector<HTMLElement>("[data-preset-offset='1']"),
|
||||
).toHaveStyle({ transform: "scale(1.0200)" });
|
||||
|
||||
fireEvent.pointerMove(badge, {
|
||||
clientY: 122,
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("kimi");
|
||||
fireEvent.pointerMove(badge, {
|
||||
clientY: 123,
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("dspro");
|
||||
fireEvent.pointerUp(badge, {
|
||||
clientY: 123,
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dspro");
|
||||
expect(badge).toHaveClass("w-fit");
|
||||
fireEvent.click(badge);
|
||||
expect(onPresetChange).toHaveBeenCalledTimes(1);
|
||||
expect(badge).toHaveAttribute("data-settling", "true");
|
||||
expect(track).toHaveAttribute("data-settling", "true");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(260);
|
||||
});
|
||||
expect(badge).not.toHaveAttribute("data-switching");
|
||||
expect(badge).not.toHaveAttribute("data-settling");
|
||||
const picker = screen.getByRole("dialog", { name: "Switch model for this chat" });
|
||||
expect(picker).toHaveClass("w-[min(18rem,calc(100vw-2rem))]");
|
||||
expect(badge).toHaveClass("w-fit");
|
||||
expect(badge.querySelector(".composer-model-pill")).not.toHaveClass("w-full");
|
||||
expect(within(picker).getAllByRole("option")).toHaveLength(3);
|
||||
expect(within(picker).getByRole("option", { name: "dflash" })).toHaveTextContent(
|
||||
/dflash\s*deepseek-v4-flash/,
|
||||
);
|
||||
expect(within(picker).getByRole("option", { name: "kimi" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(document.activeElement).toBe(within(picker).getByRole("option", { name: "kimi" }));
|
||||
fireEvent.click(within(picker).getByRole("option", { name: "dspro" }));
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dspro");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(badge).toHaveClass("w-fit");
|
||||
});
|
||||
|
||||
it("supports the same long-press switcher in hero mode and cancels pointercancel", () => {
|
||||
it("keeps long-press drag switching alongside the click picker", () => {
|
||||
vi.useFakeTimers();
|
||||
const { badge, onPresetChange } = renderPresetComposer();
|
||||
|
||||
fireEvent.pointerDown(badge, { pointerId: 1, pointerType: "touch", clientY: 100 });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(screen.getByTestId("composer-model-pill-viewport")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("composer-model-pill-layout")).toHaveClass("invisible");
|
||||
expect(screen.getByTestId("composer-model-pill-track")).not.toHaveClass("transition-transform");
|
||||
|
||||
fireEvent.pointerMove(badge, { pointerId: 1, pointerType: "touch", clientY: 56 });
|
||||
fireEvent.pointerUp(badge, { pointerId: 1, pointerType: "touch", clientY: 56 });
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dflash");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses the same click picker in hero mode", () => {
|
||||
const { badge, onPresetChange } = renderPresetComposer("hero");
|
||||
expect(badge).toHaveClass("h-8");
|
||||
longPress(badge, 9);
|
||||
expect(badge).toHaveAttribute("data-switching", "true");
|
||||
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
|
||||
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
|
||||
expect(badge).not.toHaveAttribute("data-switching");
|
||||
expect(onPresetChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(badge);
|
||||
fireEvent.click(screen.getByRole("option", { name: "dflash" }));
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dflash");
|
||||
});
|
||||
|
||||
it("transcribes voice input into the composer without sending", async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("ThreadMessages", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status", { name: "Thinking for 5s" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status", { name: "Working for 5s" })).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ThreadMessages
|
||||
@@ -178,6 +178,66 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph);
|
||||
});
|
||||
|
||||
it("keeps live Markdown mounted when a later tool activity arrives", async () => {
|
||||
await act(async () => {
|
||||
await preloadMarkdownText();
|
||||
});
|
||||
const turnId = "turn-live-order";
|
||||
const prompt: UIMessage = {
|
||||
id: "u-live",
|
||||
role: "user",
|
||||
content: "research this",
|
||||
createdAt: 1,
|
||||
turnId,
|
||||
turnPhase: "prompt",
|
||||
turnSeq: 0,
|
||||
};
|
||||
const commentary: UIMessage = {
|
||||
id: "a-commentary",
|
||||
role: "assistant",
|
||||
content: "**I will check that.**",
|
||||
createdAt: 2,
|
||||
isStreaming: false,
|
||||
turnId,
|
||||
turnPhase: "answer",
|
||||
turnSeq: 1,
|
||||
};
|
||||
const { rerender } = render(
|
||||
<ThreadMessages
|
||||
messages={[prompt, commentary]}
|
||||
isStreaming
|
||||
activeTurnId={turnId}
|
||||
/>,
|
||||
);
|
||||
const paragraph = await screen.findByText("I will check that.");
|
||||
expect(paragraph.closest("[data-testid='activity-model-message']")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
prompt,
|
||||
commentary,
|
||||
{
|
||||
id: "tool-live",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "web_search()",
|
||||
traces: ["web_search()"],
|
||||
createdAt: 3,
|
||||
turnId,
|
||||
turnPhase: "activity",
|
||||
turnSeq: 2,
|
||||
},
|
||||
]}
|
||||
isStreaming
|
||||
activeTurnId={turnId}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("I will check that.")).toBe(paragraph);
|
||||
expect(screen.getByText(/working/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a follow-up action for text selected within one completed answer", async () => {
|
||||
const onQuoteSelection = vi.fn();
|
||||
render(
|
||||
@@ -319,12 +379,12 @@ describe("ThreadMessages", () => {
|
||||
expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits));
|
||||
expect(unitKeysForDisplay(liveUnits)).toEqual([
|
||||
"turn-turn-1-user",
|
||||
"turn-turn-1-activity-1",
|
||||
"turn-turn-1-answer-1",
|
||||
"turn-turn-1-answer-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps file edits as their own activity row inside a turn", () => {
|
||||
it("keeps file edits inside the single activity surface for a turn", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
@@ -364,14 +424,16 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]);
|
||||
expect(units).toHaveLength(1);
|
||||
expect(units[0].type).toBe("activity");
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
"r2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps ordinary tool activity in one Thought block across segment ids", () => {
|
||||
it("keeps ordinary tool activity in one activity block across segment ids", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
@@ -449,10 +511,12 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units[2]).toMatchObject({
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "a1",
|
||||
@@ -504,8 +568,8 @@ describe("ThreadMessages", () => {
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming />);
|
||||
|
||||
expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/edited foo\.txt/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("times live activity from the user turn start", () => {
|
||||
@@ -731,22 +795,18 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "a1",
|
||||
content: "partial answer",
|
||||
},
|
||||
});
|
||||
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units).toHaveLength(1);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"t0",
|
||||
"a1-activity",
|
||||
"t1",
|
||||
]);
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming />);
|
||||
|
||||
const answer = screen.getByText("partial answer");
|
||||
const liveActivity = screen.getByRole("button", { name: /working/i });
|
||||
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(liveActivity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("moves late activity before a completed assistant answer", () => {
|
||||
@@ -779,10 +839,9 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units[2]).toMatchObject({
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1", "t1"]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "a1",
|
||||
@@ -793,7 +852,7 @@ describe("ThreadMessages", () => {
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
const answer = screen.getByText("Hong Kong is hot today.");
|
||||
const laterActivity = screen.getAllByText(/thought/i).at(-1);
|
||||
const laterActivity = screen.getByRole("button", { name: /worked/i });
|
||||
expect(laterActivity).toBeTruthy();
|
||||
expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
@@ -834,7 +893,7 @@ describe("ThreadMessages", () => {
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
const thought = screen.getAllByText(/thought/i).at(-1);
|
||||
const thought = screen.getByRole("button", { name: /worked/i });
|
||||
const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
|
||||
expect(thought).toBeTruthy();
|
||||
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
@@ -876,18 +935,16 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(4);
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"thought",
|
||||
]);
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
|
||||
"web",
|
||||
]);
|
||||
expect(units[2]).toMatchObject({
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "answer" },
|
||||
});
|
||||
expect(units[3]).toMatchObject({
|
||||
expect(units[2]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "next-user" },
|
||||
});
|
||||
@@ -1018,7 +1075,7 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows copy on every assistant slice while keeping fork on the last slice", () => {
|
||||
it("projects assistant slices into one answer with one action set", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "early",
|
||||
@@ -1050,8 +1107,9 @@ describe("ThreadMessages", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "Fork" })).toHaveLength(1);
|
||||
expect(screen.getByText("starting…")).toBeInTheDocument();
|
||||
expect(screen.getByText("final reply")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1095,7 +1153,7 @@ describe("ThreadMessages", () => {
|
||||
|
||||
rerender(<ThreadMessages {...props} isStreaming={false} activeTurnId={null} />);
|
||||
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(3);
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(2);
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1192,13 +1250,15 @@ describe("ThreadMessages", () => {
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows copy on adjacent assistant text slices", () => {
|
||||
it("projects adjacent assistant text slices into one answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 },
|
||||
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 },
|
||||
];
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
expect(screen.getByText("part one")).toBeInTheDocument();
|
||||
expect(screen.getByText("part two")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not count failed optimistic messages in assistant fork indices", () => {
|
||||
@@ -1280,7 +1340,6 @@ describe("ThreadMessages", () => {
|
||||
.filter(Boolean);
|
||||
|
||||
expect(assistantFlags).toEqual([
|
||||
["a1", false],
|
||||
["a2", true],
|
||||
["a3", true],
|
||||
]);
|
||||
|
||||
@@ -609,12 +609,33 @@ describe("ThreadShell", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const badge = await screen.findByLabelText("fast");
|
||||
expect(badge).not.toHaveAttribute("title");
|
||||
fireEvent.focus(badge);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"fast · gpt-5.5 · OpenAI Codex",
|
||||
expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the current preset while a renamed session reference is stale", async () => {
|
||||
const client = makeClient();
|
||||
const settings = settingsWithFastPreset();
|
||||
settings.agent.model_preset = "fast";
|
||||
settings.model_presets = settings.model_presets.map((preset) => ({
|
||||
...preset,
|
||||
active: preset.name === "fast",
|
||||
}));
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("renamed-preset", "old-fast")}
|
||||
title="Renamed preset"
|
||||
onToggleSidebar={() => {}}
|
||||
settingsSnapshot={settings}
|
||||
/>,
|
||||
"openai-codex/gpt-5.5",
|
||||
),
|
||||
);
|
||||
|
||||
expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches through every named preset while preserving call-order priority", async () => {
|
||||
@@ -641,19 +662,18 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
const { rerender } = render(view("default"));
|
||||
|
||||
const badge = await screen.findByRole("spinbutton", { name: "Default" });
|
||||
const badge = await screen.findByRole("button", { name: "Default" });
|
||||
expect(badge).toHaveTextContent("Default");
|
||||
fireEvent.keyDown(badge, { key: "ArrowDown" });
|
||||
fireEvent.click(badge);
|
||||
fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i }));
|
||||
|
||||
expect(client.sendSystemCommand).toHaveBeenCalledWith(
|
||||
"preset-order",
|
||||
"/model fast",
|
||||
);
|
||||
expect(await screen.findByText("fast")).toBeInTheDocument();
|
||||
fireEvent.keyDown(
|
||||
screen.getByRole("spinbutton", { name: "fast" }),
|
||||
{ key: "End" },
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "fast" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: /^extra\b/i }));
|
||||
expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
|
||||
"preset-order",
|
||||
"/model extra",
|
||||
@@ -696,15 +716,11 @@ describe("ThreadShell", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const badge = await screen.findByLabelText("fast");
|
||||
fireEvent.focus(badge);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"fast · gpt-4 · Company Proxy",
|
||||
);
|
||||
expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("only highlights fallback model updates without replacing the preset label", async () => {
|
||||
it("shows the effective fallback model in the composer badge", async () => {
|
||||
const client = makeClient();
|
||||
render(wrap(
|
||||
client,
|
||||
@@ -718,7 +734,8 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
|
||||
expect(await screen.findByText("Default")).toBeInTheDocument();
|
||||
const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement;
|
||||
const configuredLogo = await screen.findByTestId("composer-model-logo-openai_codex");
|
||||
const configuredBadge = configuredLogo.parentElement;
|
||||
expect(configuredBadge).not.toBeNull();
|
||||
expect(configuredBadge).toHaveClass("composer-model-badge");
|
||||
expect(configuredBadge).not.toHaveAttribute("data-fallback");
|
||||
@@ -728,31 +745,34 @@ describe("ThreadShell", () => {
|
||||
event: "turn_model_updated",
|
||||
chat_id: "fallback-model",
|
||||
model_name: "openai-codex/gpt-5.5",
|
||||
model_preset: "Default",
|
||||
});
|
||||
});
|
||||
|
||||
expect(configuredBadge).not.toHaveAttribute("data-fallback");
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
client._emitChat("fallback-model", {
|
||||
event: "turn_model_updated",
|
||||
chat_id: "fallback-model",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
fallback: true,
|
||||
});
|
||||
});
|
||||
|
||||
const logo = screen.getByTestId("composer-model-logo-openai_codex");
|
||||
const logo = await screen.findByTestId("composer-model-logo-deepseek");
|
||||
const badge = logo.parentElement;
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge).toBe(configuredBadge);
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Default")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("deepseek-chat")).toBeInTheDocument();
|
||||
expect(badge).toHaveAttribute("data-fallback", "true");
|
||||
expect(badge).not.toHaveAttribute("title");
|
||||
expect(logo).not.toHaveAttribute("data-fallback");
|
||||
const trigger = screen.getByLabelText("Default");
|
||||
fireEvent.focus(trigger);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent("deepseek/deepseek-chat");
|
||||
expect(badge).toHaveAttribute(
|
||||
"title",
|
||||
"Default · using deepseek/deepseek-chat",
|
||||
);
|
||||
expect(logo).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
client._emitChat("fallback-model", {
|
||||
@@ -766,12 +786,7 @@ describe("ThreadShell", () => {
|
||||
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||
).not.toHaveAttribute("data-fallback");
|
||||
});
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
"Default · gpt-5.5 · OpenAI Codex",
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||
).toBe(badge);
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens model settings from the unconfigured model badge", async () => {
|
||||
@@ -1084,10 +1099,8 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
const { rerender } = render(view(null));
|
||||
|
||||
fireEvent.keyDown(
|
||||
await screen.findByRole("spinbutton", { name: "Default" }),
|
||||
{ key: "ArrowDown" },
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Default" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i }));
|
||||
expect(await screen.findByText("fast")).toBeInTheDocument();
|
||||
expect(client.sendSystemCommand).not.toHaveBeenCalled();
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("ThreadViewport", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const disclosure = screen.getByRole("button", { name: "Thought" });
|
||||
const disclosure = screen.getByRole("button", { name: "Worked" });
|
||||
fireEvent.pointerDown(disclosure, { button: 0 });
|
||||
expect(takeUserControl).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
||||
@@ -350,6 +350,51 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("stamps provider usage and latest context on the completed answer", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-usage", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-usage", {
|
||||
event: "delta",
|
||||
chat_id: "chat-usage",
|
||||
text: "done",
|
||||
turn_id: "turn-usage",
|
||||
});
|
||||
fake.emit("chat-usage", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-usage",
|
||||
turn_id: "turn-usage",
|
||||
latency_ms: 18_200,
|
||||
context_window_tokens: 128_000,
|
||||
usage: {
|
||||
prompt_tokens: 12_400,
|
||||
completion_tokens: 823,
|
||||
cached_tokens: 9_672,
|
||||
context_tokens: 8_100,
|
||||
request_count: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
content: "done",
|
||||
isStreaming: false,
|
||||
latencyMs: 18_200,
|
||||
contextWindowTokens: 128_000,
|
||||
usage: {
|
||||
prompt_tokens: 12_400,
|
||||
completion_tokens: 823,
|
||||
cached_tokens: 9_672,
|
||||
context_tokens: 8_100,
|
||||
request_count: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves proactive automation source metadata on complete assistant messages", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), {
|
||||
@@ -2488,7 +2533,7 @@ describe("useNanobotStream", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets stream_end finish streaming while side-channel status replies arrive", () => {
|
||||
it("keeps the turn active after stream_end while side-channel replies arrive", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fake = fakeClient();
|
||||
@@ -2530,6 +2575,19 @@ describe("useNanobotStream", () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-status-loop", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-status-loop",
|
||||
turn_id: promptTurnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
|
||||
isStreaming: false,
|
||||
@@ -2589,7 +2647,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
content: "Initial findings",
|
||||
isStreaming: false,
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
||||
Reference in New Issue
Block a user