Merge branch 'main' into perplexity-search
This commit is contained in:
commit
bf6c33a987
14
CHANGELOG.md
14
CHANGELOG.md
@ -11,6 +11,15 @@ Status: unreleased.
|
||||
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
|
||||
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
|
||||
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
|
||||
- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.
|
||||
- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.
|
||||
- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.
|
||||
- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.
|
||||
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
|
||||
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
|
||||
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
|
||||
- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.
|
||||
- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.
|
||||
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
|
||||
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
|
||||
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
|
||||
@ -25,6 +34,9 @@ Status: unreleased.
|
||||
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
||||
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
||||
|
||||
### Fixes
|
||||
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
||||
|
||||
## 2026.1.24-3
|
||||
|
||||
### Fixes
|
||||
@ -58,7 +70,7 @@ Status: unreleased.
|
||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||
- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.
|
||||
- UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg.
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||
|
||||
@ -484,7 +484,7 @@ Thanks to all clawtributors:
|
||||
<a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></a>
|
||||
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
|
||||
<a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a>
|
||||
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a>
|
||||
<a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a>
|
||||
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a>
|
||||
<a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a>
|
||||
<a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a>
|
||||
@ -504,7 +504,7 @@ Thanks to all clawtributors:
|
||||
<a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a>
|
||||
<a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a>
|
||||
<a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a>
|
||||
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
||||
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
|
||||
<a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a>
|
||||
<a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
@ -788,6 +788,14 @@
|
||||
{
|
||||
"source": "/install/railway/",
|
||||
"destination": "/railway"
|
||||
},
|
||||
{
|
||||
"source": "/gcp",
|
||||
"destination": "/platforms/gcp"
|
||||
},
|
||||
{
|
||||
"source": "/gcp/",
|
||||
"destination": "/platforms/gcp"
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
@ -1057,6 +1065,7 @@
|
||||
"platforms/linux",
|
||||
"platforms/fly",
|
||||
"platforms/hetzner",
|
||||
"platforms/gcp",
|
||||
"platforms/exe-dev"
|
||||
]
|
||||
},
|
||||
|
||||
239
docs/platforms/digitalocean.md
Normal file
239
docs/platforms/digitalocean.md
Normal file
@ -0,0 +1,239 @@
|
||||
---
|
||||
summary: "Clawdbot on DigitalOcean (cheapest paid VPS option)"
|
||||
read_when:
|
||||
- Setting up Clawdbot on DigitalOcean
|
||||
- Looking for cheap VPS hosting for Clawdbot
|
||||
---
|
||||
|
||||
# Clawdbot on DigitalOcean
|
||||
|
||||
## Goal
|
||||
|
||||
Run a persistent Clawdbot Gateway on DigitalOcean for **$6/month** (or $4/mo with reserved pricing).
|
||||
|
||||
If you want something even cheaper, see [Oracle Cloud (Free Tier)](#oracle-cloud-free-alternative) at the bottom — it's **actually free forever**.
|
||||
|
||||
## Cost Comparison (2026)
|
||||
|
||||
| Provider | Plan | Specs | Price/mo | Notes |
|
||||
|----------|------|-------|----------|-------|
|
||||
| **Oracle Cloud** | Always Free ARM | 4 OCPU, 24GB RAM | **$0** | Best value, requires ARM-compatible setup |
|
||||
| **Hetzner** | CX22 | 2 vCPU, 4GB RAM | €3.79 (~$4) | Cheapest paid, EU datacenters |
|
||||
| **DigitalOcean** | Basic | 1 vCPU, 1GB RAM | $6 | Easy UI, good docs |
|
||||
| **Vultr** | Cloud Compute | 1 vCPU, 1GB RAM | $6 | Many locations |
|
||||
| **Linode** | Nanode | 1 vCPU, 1GB RAM | $5 | Now part of Akamai |
|
||||
|
||||
**Recommendation:**
|
||||
- **Free:** Oracle Cloud ARM (if you can handle the signup process)
|
||||
- **Paid:** Hetzner CX22 (best specs per dollar) — see [Hetzner guide](/platforms/hetzner)
|
||||
- **Easy:** DigitalOcean (this guide) — beginner-friendly UI
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- DigitalOcean account ([signup with $200 free credit](https://m.do.co/c/signup))
|
||||
- SSH key pair (or willingness to use password auth)
|
||||
- ~20 minutes
|
||||
|
||||
## 1) Create a Droplet
|
||||
|
||||
1. Log into [DigitalOcean](https://cloud.digitalocean.com/)
|
||||
2. Click **Create → Droplets**
|
||||
3. Choose:
|
||||
- **Region:** Closest to you (or your users)
|
||||
- **Image:** Ubuntu 24.04 LTS
|
||||
- **Size:** Basic → Regular → **$6/mo** (1 vCPU, 1GB RAM, 25GB SSD)
|
||||
- **Authentication:** SSH key (recommended) or password
|
||||
4. Click **Create Droplet**
|
||||
5. Note the IP address
|
||||
|
||||
## 2) Connect via SSH
|
||||
|
||||
```bash
|
||||
ssh root@YOUR_DROPLET_IP
|
||||
```
|
||||
|
||||
## 3) Install Clawdbot
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
apt update && apt upgrade -y
|
||||
|
||||
# Install Node.js 22
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt install -y nodejs
|
||||
|
||||
# Install Clawdbot
|
||||
curl -fsSL https://clawd.bot/install.sh | bash
|
||||
|
||||
# Verify
|
||||
clawdbot --version
|
||||
```
|
||||
|
||||
## 4) Run Onboarding
|
||||
|
||||
```bash
|
||||
clawdbot onboard --install-daemon
|
||||
```
|
||||
|
||||
The wizard will walk you through:
|
||||
- Model auth (API keys or OAuth)
|
||||
- Channel setup (Telegram, WhatsApp, Discord, etc.)
|
||||
- Gateway token (auto-generated)
|
||||
- Daemon installation (systemd)
|
||||
|
||||
## 5) Verify the Gateway
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
clawdbot status
|
||||
|
||||
# Check service
|
||||
systemctl status clawdbot
|
||||
|
||||
# View logs
|
||||
journalctl -u clawdbot -f
|
||||
```
|
||||
|
||||
## 6) Access the Dashboard
|
||||
|
||||
The gateway binds to loopback by default. To access the Control UI:
|
||||
|
||||
**Option A: SSH Tunnel (recommended)**
|
||||
```bash
|
||||
# From your local machine
|
||||
ssh -L 18789:localhost:18789 root@YOUR_DROPLET_IP
|
||||
|
||||
# Then open: http://localhost:18789
|
||||
```
|
||||
|
||||
**Option B: Tailscale (easier long-term)**
|
||||
```bash
|
||||
# On the droplet
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
tailscale up
|
||||
|
||||
# Configure gateway to bind to Tailscale
|
||||
clawdbot config set gateway.bind tailnet
|
||||
clawdbot gateway restart
|
||||
```
|
||||
|
||||
Then access via your Tailscale IP: `http://100.x.x.x:18789`
|
||||
|
||||
## 7) Connect Your Channels
|
||||
|
||||
### Telegram
|
||||
```bash
|
||||
clawdbot pairing list telegram
|
||||
clawdbot pairing approve telegram <CODE>
|
||||
```
|
||||
|
||||
### WhatsApp
|
||||
```bash
|
||||
clawdbot channels login whatsapp
|
||||
# Scan QR code
|
||||
```
|
||||
|
||||
See [Channels](/channels) for other providers.
|
||||
|
||||
---
|
||||
|
||||
## Optimizations for 1GB RAM
|
||||
|
||||
The $6 droplet only has 1GB RAM. To keep things running smoothly:
|
||||
|
||||
### Add swap (recommended)
|
||||
```bash
|
||||
fallocate -l 2G /swapfile
|
||||
chmod 600 /swapfile
|
||||
mkswap /swapfile
|
||||
swapon /swapfile
|
||||
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
```
|
||||
|
||||
### Use a lighter model
|
||||
If you're hitting OOMs, consider:
|
||||
- Using API-based models (Claude, GPT) instead of local models
|
||||
- Setting `agents.defaults.model.primary` to a smaller model
|
||||
|
||||
### Monitor memory
|
||||
```bash
|
||||
free -h
|
||||
htop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Persistence
|
||||
|
||||
All state lives in:
|
||||
- `~/.clawdbot/` — config, credentials, session data
|
||||
- `~/clawd/` — workspace (SOUL.md, memory, etc.)
|
||||
|
||||
These survive reboots. Back them up periodically:
|
||||
```bash
|
||||
tar -czvf clawdbot-backup.tar.gz ~/.clawdbot ~/clawd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Oracle Cloud Free Alternative
|
||||
|
||||
Oracle Cloud offers **Always Free** ARM instances that are significantly more powerful:
|
||||
|
||||
| What you get | Specs |
|
||||
|--------------|-------|
|
||||
| **4 OCPUs** | ARM Ampere A1 |
|
||||
| **24GB RAM** | More than enough |
|
||||
| **200GB storage** | Block volume |
|
||||
| **Forever free** | No credit card charges |
|
||||
|
||||
### Quick setup:
|
||||
1. Sign up at [oracle.com/cloud/free](https://www.oracle.com/cloud/free/)
|
||||
2. Create a VM.Standard.A1.Flex instance (ARM)
|
||||
3. Choose Oracle Linux or Ubuntu
|
||||
4. Allocate up to 4 OCPU / 24GB RAM within free tier
|
||||
5. Follow the same Clawdbot install steps above
|
||||
|
||||
**Caveats:**
|
||||
- Signup can be finicky (retry if it fails)
|
||||
- ARM architecture — most things work, but some binaries need ARM builds
|
||||
- Oracle may reclaim idle instances (keep them active)
|
||||
|
||||
For the full Oracle guide, see the [community docs](https://gist.github.com/rssnyder/51e3cfedd730e7dd5f4a816143b25dbd).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Gateway won't start
|
||||
```bash
|
||||
clawdbot gateway status
|
||||
clawdbot doctor --non-interactive
|
||||
journalctl -u clawdbot --no-pager -n 50
|
||||
```
|
||||
|
||||
### Port already in use
|
||||
```bash
|
||||
lsof -i :18789
|
||||
kill <PID>
|
||||
```
|
||||
|
||||
### Out of memory
|
||||
```bash
|
||||
# Check memory
|
||||
free -h
|
||||
|
||||
# Add more swap
|
||||
# Or upgrade to $12/mo droplet (2GB RAM)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Hetzner guide](/platforms/hetzner) — cheaper, more powerful
|
||||
- [Docker install](/install/docker) — containerized setup
|
||||
- [Tailscale](/gateway/tailscale) — secure remote access
|
||||
- [Configuration](/gateway/configuration) — full config reference
|
||||
498
docs/platforms/gcp.md
Normal file
498
docs/platforms/gcp.md
Normal file
@ -0,0 +1,498 @@
|
||||
---
|
||||
summary: "Run Clawdbot Gateway 24/7 on a GCP Compute Engine VM (Docker) with durable state"
|
||||
read_when:
|
||||
- You want Clawdbot running 24/7 on GCP
|
||||
- You want a production-grade, always-on Gateway on your own VM
|
||||
- You want full control over persistence, binaries, and restart behavior
|
||||
---
|
||||
|
||||
# Clawdbot on GCP Compute Engine (Docker, Production VPS Guide)
|
||||
|
||||
## Goal
|
||||
|
||||
Run a persistent Clawdbot Gateway on a GCP Compute Engine VM using Docker, with durable state, baked-in binaries, and safe restart behavior.
|
||||
|
||||
If you want "Clawdbot 24/7 for ~$5-12/mo", this is a reliable setup on Google Cloud.
|
||||
Pricing varies by machine type and region; pick the smallest VM that fits your workload and scale up if you hit OOMs.
|
||||
|
||||
## What are we doing (simple terms)?
|
||||
|
||||
- Create a GCP project and enable billing
|
||||
- Create a Compute Engine VM
|
||||
- Install Docker (isolated app runtime)
|
||||
- Start the Clawdbot Gateway in Docker
|
||||
- Persist `~/.clawdbot` + `~/clawd` on the host (survives restarts/rebuilds)
|
||||
- Access the Control UI from your laptop via an SSH tunnel
|
||||
|
||||
The Gateway can be accessed via:
|
||||
- SSH port forwarding from your laptop
|
||||
- Direct port exposure if you manage firewalling and tokens yourself
|
||||
|
||||
This guide uses Debian on GCP Compute Engine.
|
||||
Ubuntu also works; map packages accordingly.
|
||||
For the generic Docker flow, see [Docker](/install/docker).
|
||||
|
||||
---
|
||||
|
||||
## Quick path (experienced operators)
|
||||
|
||||
1) Create GCP project + enable Compute Engine API
|
||||
2) Create Compute Engine VM (e2-small, Debian 12, 20GB)
|
||||
3) SSH into the VM
|
||||
4) Install Docker
|
||||
5) Clone Clawdbot repository
|
||||
6) Create persistent host directories
|
||||
7) Configure `.env` and `docker-compose.yml`
|
||||
8) Bake required binaries, build, and launch
|
||||
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
- GCP account (free tier eligible for e2-micro)
|
||||
- gcloud CLI installed (or use Cloud Console)
|
||||
- SSH access from your laptop
|
||||
- Basic comfort with SSH + copy/paste
|
||||
- ~20-30 minutes
|
||||
- Docker and Docker Compose
|
||||
- Model auth credentials
|
||||
- Optional provider credentials
|
||||
- WhatsApp QR
|
||||
- Telegram bot token
|
||||
- Gmail OAuth
|
||||
|
||||
---
|
||||
|
||||
## 1) Install gcloud CLI (or use Console)
|
||||
|
||||
**Option A: gcloud CLI** (recommended for automation)
|
||||
|
||||
Install from https://cloud.google.com/sdk/docs/install
|
||||
|
||||
Initialize and authenticate:
|
||||
|
||||
```bash
|
||||
gcloud init
|
||||
gcloud auth login
|
||||
```
|
||||
|
||||
**Option B: Cloud Console**
|
||||
|
||||
All steps can be done via the web UI at https://console.cloud.google.com
|
||||
|
||||
---
|
||||
|
||||
## 2) Create a GCP project
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
gcloud projects create my-clawdbot-project --name="Clawdbot Gateway"
|
||||
gcloud config set project my-clawdbot-project
|
||||
```
|
||||
|
||||
Enable billing at https://console.cloud.google.com/billing (required for Compute Engine).
|
||||
|
||||
Enable the Compute Engine API:
|
||||
|
||||
```bash
|
||||
gcloud services enable compute.googleapis.com
|
||||
```
|
||||
|
||||
**Console:**
|
||||
|
||||
1. Go to IAM & Admin > Create Project
|
||||
2. Name it and create
|
||||
3. Enable billing for the project
|
||||
4. Navigate to APIs & Services > Enable APIs > search "Compute Engine API" > Enable
|
||||
|
||||
---
|
||||
|
||||
## 3) Create the VM
|
||||
|
||||
**Machine types:**
|
||||
|
||||
| Type | Specs | Cost | Notes |
|
||||
|------|-------|------|-------|
|
||||
| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Recommended |
|
||||
| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | May OOM under load |
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
gcloud compute instances create clawdbot-gateway \
|
||||
--zone=us-central1-a \
|
||||
--machine-type=e2-small \
|
||||
--boot-disk-size=20GB \
|
||||
--image-family=debian-12 \
|
||||
--image-project=debian-cloud
|
||||
```
|
||||
|
||||
**Console:**
|
||||
|
||||
1. Go to Compute Engine > VM instances > Create instance
|
||||
2. Name: `clawdbot-gateway`
|
||||
3. Region: `us-central1`, Zone: `us-central1-a`
|
||||
4. Machine type: `e2-small`
|
||||
5. Boot disk: Debian 12, 20GB
|
||||
6. Create
|
||||
|
||||
---
|
||||
|
||||
## 4) SSH into the VM
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
gcloud compute ssh clawdbot-gateway --zone=us-central1-a
|
||||
```
|
||||
|
||||
**Console:**
|
||||
|
||||
Click the "SSH" button next to your VM in the Compute Engine dashboard.
|
||||
|
||||
Note: SSH key propagation can take 1-2 minutes after VM creation. If connection is refused, wait and retry.
|
||||
|
||||
---
|
||||
|
||||
## 5) Install Docker (on the VM)
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y git curl ca-certificates
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
Log out and back in for the group change to take effect:
|
||||
|
||||
```bash
|
||||
exit
|
||||
```
|
||||
|
||||
Then SSH back in:
|
||||
|
||||
```bash
|
||||
gcloud compute ssh clawdbot-gateway --zone=us-central1-a
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Clone the Clawdbot repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
```
|
||||
|
||||
This guide assumes you will build a custom image to guarantee binary persistence.
|
||||
|
||||
---
|
||||
|
||||
## 7) Create persistent host directories
|
||||
|
||||
Docker containers are ephemeral.
|
||||
All long-lived state must live on the host.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.clawdbot
|
||||
mkdir -p ~/clawd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Configure environment variables
|
||||
|
||||
Create `.env` in the repository root.
|
||||
|
||||
```bash
|
||||
CLAWDBOT_IMAGE=clawdbot:latest
|
||||
CLAWDBOT_GATEWAY_TOKEN=change-me-now
|
||||
CLAWDBOT_GATEWAY_BIND=lan
|
||||
CLAWDBOT_GATEWAY_PORT=18789
|
||||
|
||||
CLAWDBOT_CONFIG_DIR=/home/$USER/.clawdbot
|
||||
CLAWDBOT_WORKSPACE_DIR=/home/$USER/clawd
|
||||
|
||||
GOG_KEYRING_PASSWORD=change-me-now
|
||||
XDG_CONFIG_HOME=/home/node/.clawdbot
|
||||
```
|
||||
|
||||
Generate strong secrets:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**Do not commit this file.**
|
||||
|
||||
---
|
||||
|
||||
## 9) Docker Compose configuration
|
||||
|
||||
Create or update `docker-compose.yml`.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
clawdbot-gateway:
|
||||
image: ${CLAWDBOT_IMAGE}
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOME=/home/node
|
||||
- NODE_ENV=production
|
||||
- TERM=xterm-256color
|
||||
- CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND}
|
||||
- CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT}
|
||||
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN}
|
||||
- GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD}
|
||||
- XDG_CONFIG_HOME=${XDG_CONFIG_HOME}
|
||||
- PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
volumes:
|
||||
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
|
||||
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
|
||||
ports:
|
||||
# Recommended: keep the Gateway loopback-only on the VM; access via SSH tunnel.
|
||||
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
|
||||
- "127.0.0.1:${CLAWDBOT_GATEWAY_PORT}:18789"
|
||||
|
||||
# Optional: only if you run iOS/Android nodes against this VM and need Canvas host.
|
||||
# If you expose this publicly, read /gateway/security and firewall accordingly.
|
||||
# - "18793:18793"
|
||||
command:
|
||||
[
|
||||
"node",
|
||||
"dist/index.js",
|
||||
"gateway",
|
||||
"--bind",
|
||||
"${CLAWDBOT_GATEWAY_BIND}",
|
||||
"--port",
|
||||
"${CLAWDBOT_GATEWAY_PORT}"
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10) Bake required binaries into the image (critical)
|
||||
|
||||
Installing binaries inside a running container is a trap.
|
||||
Anything installed at runtime will be lost on restart.
|
||||
|
||||
All external binaries required by skills must be installed at image build time.
|
||||
|
||||
The examples below show three common binaries only:
|
||||
- `gog` for Gmail access
|
||||
- `goplaces` for Google Places
|
||||
- `wacli` for WhatsApp
|
||||
|
||||
These are examples, not a complete list.
|
||||
You may install as many binaries as needed using the same pattern.
|
||||
|
||||
If you add new skills later that depend on additional binaries, you must:
|
||||
1. Update the Dockerfile
|
||||
2. Rebuild the image
|
||||
3. Restart the containers
|
||||
|
||||
**Example Dockerfile**
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Example binary 1: Gmail CLI
|
||||
RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog
|
||||
|
||||
# Example binary 2: Google Places CLI
|
||||
RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces
|
||||
|
||||
# Example binary 3: WhatsApp CLI
|
||||
RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli
|
||||
|
||||
# Add more binaries below using the same pattern
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY scripts ./scripts
|
||||
|
||||
RUN corepack enable
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node","dist/index.js"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11) Build and launch
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d clawdbot-gateway
|
||||
```
|
||||
|
||||
Verify binaries:
|
||||
|
||||
```bash
|
||||
docker compose exec clawdbot-gateway which gog
|
||||
docker compose exec clawdbot-gateway which goplaces
|
||||
docker compose exec clawdbot-gateway which wacli
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
/usr/local/bin/gog
|
||||
/usr/local/bin/goplaces
|
||||
/usr/local/bin/wacli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12) Verify Gateway
|
||||
|
||||
```bash
|
||||
docker compose logs -f clawdbot-gateway
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```
|
||||
[gateway] listening on ws://0.0.0.0:18789
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13) Access from your laptop
|
||||
|
||||
Create an SSH tunnel to forward the Gateway port:
|
||||
|
||||
```bash
|
||||
gcloud compute ssh clawdbot-gateway --zone=us-central1-a -- -L 18789:127.0.0.1:18789
|
||||
```
|
||||
|
||||
Open in your browser:
|
||||
|
||||
`http://127.0.0.1:18789/`
|
||||
|
||||
Paste your gateway token.
|
||||
|
||||
---
|
||||
|
||||
## What persists where (source of truth)
|
||||
|
||||
Clawdbot runs in Docker, but Docker is not the source of truth.
|
||||
All long-lived state must survive restarts, rebuilds, and reboots.
|
||||
|
||||
| Component | Location | Persistence mechanism | Notes |
|
||||
|---|---|---|---|
|
||||
| Gateway config | `/home/node/.clawdbot/` | Host volume mount | Includes `clawdbot.json`, tokens |
|
||||
| Model auth profiles | `/home/node/.clawdbot/` | Host volume mount | OAuth tokens, API keys |
|
||||
| Skill configs | `/home/node/.clawdbot/skills/` | Host volume mount | Skill-level state |
|
||||
| Agent workspace | `/home/node/clawd/` | Host volume mount | Code and agent artifacts |
|
||||
| WhatsApp session | `/home/node/.clawdbot/` | Host volume mount | Preserves QR login |
|
||||
| Gmail keyring | `/home/node/.clawdbot/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
|
||||
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
|
||||
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
|
||||
| OS packages | Container filesystem | Docker image | Do not install at runtime |
|
||||
| Docker container | Ephemeral | Restartable | Safe to destroy |
|
||||
|
||||
---
|
||||
|
||||
## Updates
|
||||
|
||||
To update Clawdbot on the VM:
|
||||
|
||||
```bash
|
||||
cd ~/clawdbot
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**SSH connection refused**
|
||||
|
||||
SSH key propagation can take 1-2 minutes after VM creation. Wait and retry.
|
||||
|
||||
**OS Login issues**
|
||||
|
||||
Check your OS Login profile:
|
||||
|
||||
```bash
|
||||
gcloud compute os-login describe-profile
|
||||
```
|
||||
|
||||
Ensure your account has the required IAM permissions (Compute OS Login or Compute OS Admin Login).
|
||||
|
||||
**Out of memory (OOM)**
|
||||
|
||||
If using e2-micro and hitting OOM, upgrade to e2-small or e2-medium:
|
||||
|
||||
```bash
|
||||
# Stop the VM first
|
||||
gcloud compute instances stop clawdbot-gateway --zone=us-central1-a
|
||||
|
||||
# Change machine type
|
||||
gcloud compute instances set-machine-type clawdbot-gateway \
|
||||
--zone=us-central1-a \
|
||||
--machine-type=e2-small
|
||||
|
||||
# Start the VM
|
||||
gcloud compute instances start clawdbot-gateway --zone=us-central1-a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service accounts (security best practice)
|
||||
|
||||
For personal use, your default user account works fine.
|
||||
|
||||
For automation or CI/CD pipelines, create a dedicated service account with minimal permissions:
|
||||
|
||||
1. Create a service account:
|
||||
```bash
|
||||
gcloud iam service-accounts create clawdbot-deploy \
|
||||
--display-name="Clawdbot Deployment"
|
||||
```
|
||||
|
||||
2. Grant Compute Instance Admin role (or narrower custom role):
|
||||
```bash
|
||||
gcloud projects add-iam-policy-binding my-clawdbot-project \
|
||||
--member="serviceAccount:clawdbot-deploy@my-clawdbot-project.iam.gserviceaccount.com" \
|
||||
--role="roles/compute.instanceAdmin.v1"
|
||||
```
|
||||
|
||||
Avoid using the Owner role for automation. Use the principle of least privilege.
|
||||
|
||||
See https://cloud.google.com/iam/docs/understanding-roles for IAM role details.
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
- Set up messaging channels: [Channels](/channels)
|
||||
- Pair local devices as nodes: [Nodes](/nodes)
|
||||
- Configure the Gateway: [Gateway configuration](/gateway/configuration)
|
||||
@ -27,6 +27,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
|
||||
- Railway (one-click): [Railway](/railway)
|
||||
- Fly.io: [Fly.io](/platforms/fly)
|
||||
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
|
||||
- GCP (Compute Engine): [GCP](/platforms/gcp)
|
||||
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
|
||||
## Common links
|
||||
|
||||
354
docs/platforms/raspberry-pi.md
Normal file
354
docs/platforms/raspberry-pi.md
Normal file
@ -0,0 +1,354 @@
|
||||
---
|
||||
summary: "Clawdbot on Raspberry Pi (budget self-hosted setup)"
|
||||
read_when:
|
||||
- Setting up Clawdbot on a Raspberry Pi
|
||||
- Running Clawdbot on ARM devices
|
||||
- Building a cheap always-on personal AI
|
||||
---
|
||||
|
||||
# Clawdbot on Raspberry Pi
|
||||
|
||||
## Goal
|
||||
|
||||
Run a persistent, always-on Clawdbot Gateway on a Raspberry Pi for **~$35-80** one-time cost (no monthly fees).
|
||||
|
||||
Perfect for:
|
||||
- 24/7 personal AI assistant
|
||||
- Home automation hub
|
||||
- Low-power, always-available Telegram/WhatsApp bot
|
||||
|
||||
## Hardware Requirements
|
||||
|
||||
| Pi Model | RAM | Works? | Notes |
|
||||
|----------|-----|--------|-------|
|
||||
| **Pi 5** | 4GB/8GB | ✅ Best | Fastest, recommended |
|
||||
| **Pi 4** | 4GB | ✅ Good | Sweet spot for most users |
|
||||
| **Pi 4** | 2GB | ✅ OK | Works, add swap |
|
||||
| **Pi 4** | 1GB | ⚠️ Tight | Possible with swap, minimal config |
|
||||
| **Pi 3B+** | 1GB | ⚠️ Slow | Works but sluggish |
|
||||
| **Pi Zero 2 W** | 512MB | ❌ | Not recommended |
|
||||
|
||||
**Minimum specs:** 1GB RAM, 1 core, 500MB disk
|
||||
**Recommended:** 2GB+ RAM, 64-bit OS, 16GB+ SD card (or USB SSD)
|
||||
|
||||
## What You'll Need
|
||||
|
||||
- Raspberry Pi 4 or 5 (2GB+ recommended)
|
||||
- MicroSD card (16GB+) or USB SSD (better performance)
|
||||
- Power supply (official Pi PSU recommended)
|
||||
- Network connection (Ethernet or WiFi)
|
||||
- ~30 minutes
|
||||
|
||||
## 1) Flash the OS
|
||||
|
||||
Use **Raspberry Pi OS Lite (64-bit)** — no desktop needed for a headless server.
|
||||
|
||||
1. Download [Raspberry Pi Imager](https://www.raspberrypi.com/software/)
|
||||
2. Choose OS: **Raspberry Pi OS Lite (64-bit)**
|
||||
3. Click the gear icon (⚙️) to pre-configure:
|
||||
- Set hostname: `gateway-host`
|
||||
- Enable SSH
|
||||
- Set username/password
|
||||
- Configure WiFi (if not using Ethernet)
|
||||
4. Flash to your SD card / USB drive
|
||||
5. Insert and boot the Pi
|
||||
|
||||
## 2) Connect via SSH
|
||||
|
||||
```bash
|
||||
ssh user@gateway-host
|
||||
# or use the IP address
|
||||
ssh user@192.168.x.x
|
||||
```
|
||||
|
||||
## 3) System Setup
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install essential packages
|
||||
sudo apt install -y git curl build-essential
|
||||
|
||||
# Set timezone (important for cron/reminders)
|
||||
sudo timedatectl set-timezone America/Chicago # Change to your timezone
|
||||
```
|
||||
|
||||
## 4) Install Node.js 22 (ARM64)
|
||||
|
||||
```bash
|
||||
# Install Node.js via NodeSource
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
# Verify
|
||||
node --version # Should show v22.x.x
|
||||
npm --version
|
||||
```
|
||||
|
||||
## 5) Add Swap (Important for 2GB or less)
|
||||
|
||||
Swap prevents out-of-memory crashes:
|
||||
|
||||
```bash
|
||||
# Create 2GB swap file
|
||||
sudo fallocate -l 2G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
|
||||
# Make permanent
|
||||
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
|
||||
|
||||
# Optimize for low RAM (reduce swappiness)
|
||||
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
|
||||
sudo sysctl -p
|
||||
```
|
||||
|
||||
## 6) Install Clawdbot
|
||||
|
||||
### Option A: Standard Install (Recommended)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install.sh | bash
|
||||
```
|
||||
|
||||
### Option B: Hackable Install (For tinkering)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
npm install
|
||||
npm run build
|
||||
npm link
|
||||
```
|
||||
|
||||
The hackable install gives you direct access to logs and code — useful for debugging ARM-specific issues.
|
||||
|
||||
## 7) Run Onboarding
|
||||
|
||||
```bash
|
||||
clawdbot onboard --install-daemon
|
||||
```
|
||||
|
||||
Follow the wizard:
|
||||
1. **Gateway mode:** Local
|
||||
2. **Auth:** API keys recommended (OAuth can be finicky on headless Pi)
|
||||
3. **Channels:** Telegram is easiest to start with
|
||||
4. **Daemon:** Yes (systemd)
|
||||
|
||||
## 8) Verify Installation
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
clawdbot status
|
||||
|
||||
# Check service
|
||||
sudo systemctl status clawdbot
|
||||
|
||||
# View logs
|
||||
journalctl -u clawdbot -f
|
||||
```
|
||||
|
||||
## 9) Access the Dashboard
|
||||
|
||||
Since the Pi is headless, use an SSH tunnel:
|
||||
|
||||
```bash
|
||||
# From your laptop/desktop
|
||||
ssh -L 18789:localhost:18789 user@gateway-host
|
||||
|
||||
# Then open in browser
|
||||
open http://localhost:18789
|
||||
```
|
||||
|
||||
Or use Tailscale for always-on access:
|
||||
|
||||
```bash
|
||||
# On the Pi
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up
|
||||
|
||||
# Update config
|
||||
clawdbot config set gateway.bind tailnet
|
||||
sudo systemctl restart clawdbot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Use a USB SSD (Huge Improvement)
|
||||
|
||||
SD cards are slow and wear out. A USB SSD dramatically improves performance:
|
||||
|
||||
```bash
|
||||
# Check if booting from USB
|
||||
lsblk
|
||||
```
|
||||
|
||||
See [Pi USB boot guide](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#usb-mass-storage-boot) for setup.
|
||||
|
||||
### Reduce Memory Usage
|
||||
|
||||
```bash
|
||||
# Disable GPU memory allocation (headless)
|
||||
echo 'gpu_mem=16' | sudo tee -a /boot/config.txt
|
||||
|
||||
# Disable Bluetooth if not needed
|
||||
sudo systemctl disable bluetooth
|
||||
```
|
||||
|
||||
### Monitor Resources
|
||||
|
||||
```bash
|
||||
# Check memory
|
||||
free -h
|
||||
|
||||
# Check CPU temperature
|
||||
vcgencmd measure_temp
|
||||
|
||||
# Live monitoring
|
||||
htop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ARM-Specific Notes
|
||||
|
||||
### Binary Compatibility
|
||||
|
||||
Most Clawdbot features work on ARM64, but some external binaries may need ARM builds:
|
||||
|
||||
| Tool | ARM64 Status | Notes |
|
||||
|------|--------------|-------|
|
||||
| Node.js | ✅ | Works great |
|
||||
| WhatsApp (Baileys) | ✅ | Pure JS, no issues |
|
||||
| Telegram | ✅ | Pure JS, no issues |
|
||||
| gog (Gmail CLI) | ⚠️ | Check for ARM release |
|
||||
| Chromium (browser) | ✅ | `sudo apt install chromium-browser` |
|
||||
|
||||
If a skill fails, check if its binary has an ARM build. Many Go/Rust tools do; some don't.
|
||||
|
||||
### 32-bit vs 64-bit
|
||||
|
||||
**Always use 64-bit OS.** Node.js and many modern tools require it. Check with:
|
||||
|
||||
```bash
|
||||
uname -m
|
||||
# Should show: aarch64 (64-bit) not armv7l (32-bit)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Model Setup
|
||||
|
||||
Since the Pi is just the Gateway (models run in the cloud), use API-based models:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "anthropic/claude-sonnet-4-20250514",
|
||||
"fallbacks": ["openai/gpt-4o-mini"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Don't try to run local LLMs on a Pi** — even small models are too slow. Let Claude/GPT do the heavy lifting.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Start on Boot
|
||||
|
||||
The onboarding wizard sets this up, but to verify:
|
||||
|
||||
```bash
|
||||
# Check service is enabled
|
||||
sudo systemctl is-enabled clawdbot
|
||||
|
||||
# Enable if not
|
||||
sudo systemctl enable clawdbot
|
||||
|
||||
# Start on boot
|
||||
sudo systemctl start clawdbot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Out of Memory (OOM)
|
||||
|
||||
```bash
|
||||
# Check memory
|
||||
free -h
|
||||
|
||||
# Add more swap (see Step 5)
|
||||
# Or reduce services running on the Pi
|
||||
```
|
||||
|
||||
### Slow Performance
|
||||
|
||||
- Use USB SSD instead of SD card
|
||||
- Disable unused services: `sudo systemctl disable cups bluetooth avahi-daemon`
|
||||
- Check CPU throttling: `vcgencmd get_throttled` (should return `0x0`)
|
||||
|
||||
### Service Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
journalctl -u clawdbot --no-pager -n 100
|
||||
|
||||
# Common fix: rebuild
|
||||
cd ~/clawdbot # if using hackable install
|
||||
npm run build
|
||||
sudo systemctl restart clawdbot
|
||||
```
|
||||
|
||||
### ARM Binary Issues
|
||||
|
||||
If a skill fails with "exec format error":
|
||||
1. Check if the binary has an ARM64 build
|
||||
2. Try building from source
|
||||
3. Or use a Docker container with ARM support
|
||||
|
||||
### WiFi Drops
|
||||
|
||||
For headless Pis on WiFi:
|
||||
|
||||
```bash
|
||||
# Disable WiFi power management
|
||||
sudo iwconfig wlan0 power off
|
||||
|
||||
# Make permanent
|
||||
echo 'wireless-power off' | sudo tee -a /etc/network/interfaces
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cost Comparison
|
||||
|
||||
| Setup | One-Time Cost | Monthly Cost | Notes |
|
||||
|-------|---------------|--------------|-------|
|
||||
| **Pi 4 (2GB)** | ~$45 | $0 | + power (~$5/yr) |
|
||||
| **Pi 4 (4GB)** | ~$55 | $0 | Recommended |
|
||||
| **Pi 5 (4GB)** | ~$60 | $0 | Best performance |
|
||||
| **Pi 5 (8GB)** | ~$80 | $0 | Overkill but future-proof |
|
||||
| DigitalOcean | $0 | $6/mo | $72/year |
|
||||
| Hetzner | $0 | €3.79/mo | ~$50/year |
|
||||
|
||||
**Break-even:** A Pi pays for itself in ~6-12 months vs cloud VPS.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Linux guide](/platforms/linux) — general Linux setup
|
||||
- [DigitalOcean guide](/platforms/digitalocean) — cloud alternative
|
||||
- [Hetzner guide](/platforms/hetzner) — Docker setup
|
||||
- [Tailscale](/gateway/tailscale) — remote access
|
||||
- [Nodes](/nodes) — pair your laptop/phone with the Pi gateway
|
||||
145
docs/providers/claude-max-api-proxy.md
Normal file
145
docs/providers/claude-max-api-proxy.md
Normal file
@ -0,0 +1,145 @@
|
||||
---
|
||||
summary: "Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint"
|
||||
read_when:
|
||||
- You want to use Claude Max subscription with OpenAI-compatible tools
|
||||
- You want a local API server that wraps Claude Code CLI
|
||||
- You want to save money by using subscription instead of API keys
|
||||
---
|
||||
# Claude Max API Proxy
|
||||
|
||||
**claude-max-api-proxy** is a community tool that exposes your Claude Max/Pro subscription as an OpenAI-compatible API endpoint. This allows you to use your subscription with any tool that supports the OpenAI API format.
|
||||
|
||||
## Why Use This?
|
||||
|
||||
| Approach | Cost | Best For |
|
||||
|----------|------|----------|
|
||||
| Anthropic API | Pay per token (~$15/M input, $75/M output for Opus) | Production apps, high volume |
|
||||
| Claude Max subscription | $200/month flat | Personal use, development, unlimited usage |
|
||||
|
||||
If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy can save you significant money.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Your App → claude-max-api-proxy → Claude Code CLI → Anthropic (via subscription)
|
||||
(OpenAI format) (converts format) (uses your login)
|
||||
```
|
||||
|
||||
The proxy:
|
||||
1. Accepts OpenAI-format requests at `http://localhost:3456/v1/chat/completions`
|
||||
2. Converts them to Claude Code CLI commands
|
||||
3. Returns responses in OpenAI format (streaming supported)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Requires Node.js 20+ and Claude Code CLI
|
||||
npm install -g claude-max-api-proxy
|
||||
|
||||
# Verify Claude CLI is authenticated
|
||||
claude --version
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Start the server
|
||||
|
||||
```bash
|
||||
claude-max-api
|
||||
# Server runs at http://localhost:3456
|
||||
```
|
||||
|
||||
### Test it
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:3456/health
|
||||
|
||||
# List models
|
||||
curl http://localhost:3456/v1/models
|
||||
|
||||
# Chat completion
|
||||
curl http://localhost:3456/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "claude-opus-4",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'
|
||||
```
|
||||
|
||||
### With Clawdbot
|
||||
|
||||
You can point Clawdbot at the proxy as a custom OpenAI-compatible endpoint:
|
||||
|
||||
```json5
|
||||
{
|
||||
env: {
|
||||
OPENAI_API_KEY: "not-needed",
|
||||
OPENAI_BASE_URL: "http://localhost:3456/v1"
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/claude-opus-4" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Available Models
|
||||
|
||||
| Model ID | Maps To |
|
||||
|----------|---------|
|
||||
| `claude-opus-4` | Claude Opus 4 |
|
||||
| `claude-sonnet-4` | Claude Sonnet 4 |
|
||||
| `claude-haiku-4` | Claude Haiku 4 |
|
||||
|
||||
## Auto-Start on macOS
|
||||
|
||||
Create a LaunchAgent to run the proxy automatically:
|
||||
|
||||
```bash
|
||||
cat > ~/Library/LaunchAgents/com.claude-max-api.plist << 'EOF'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.claude-max-api</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/node</string>
|
||||
<string>/usr/local/lib/node_modules/claude-max-api-proxy/dist/server/standalone.js</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/opt/homebrew/bin:~/.local/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-max-api.plist
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- **npm:** https://www.npmjs.com/package/claude-max-api-proxy
|
||||
- **GitHub:** https://github.com/atalovesyou/claude-max-api-proxy
|
||||
- **Issues:** https://github.com/atalovesyou/claude-max-api-proxy/issues
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a **community tool**, not officially supported by Anthropic or Clawdbot
|
||||
- Requires an active Claude Max/Pro subscription with Claude Code CLI authenticated
|
||||
- The proxy runs locally and does not send data to any third-party servers
|
||||
- Streaming responses are fully supported
|
||||
|
||||
## See Also
|
||||
|
||||
- [Anthropic provider](/providers/anthropic) - Native Clawdbot integration with Claude Code CLI OAuth
|
||||
- [OpenAI provider](/providers/openai) - For OpenAI/Codex subscriptions
|
||||
@ -51,5 +51,9 @@ See [Venice AI](/providers/venice).
|
||||
|
||||
- [Deepgram (audio transcription)](/providers/deepgram)
|
||||
|
||||
## Community tools
|
||||
|
||||
- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
|
||||
@ -14,6 +14,7 @@ deployments work at a high level.
|
||||
- **Railway** (one‑click + browser setup): [Railway](/railway)
|
||||
- **Fly.io**: [Fly.io](/platforms/fly)
|
||||
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
|
||||
- **GCP (Compute Engine)**: [GCP](/platforms/gcp)
|
||||
- **exe.dev** (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
- **AWS (EC2/Lightsail/free tier)**: works well too. Video guide:
|
||||
https://x.com/techfrenAJ/status/2014934471095812547
|
||||
|
||||
@ -63,16 +63,28 @@ export async function sendGroupMessage({
|
||||
const story = [{ inline: [text] }];
|
||||
const sentAt = Date.now();
|
||||
|
||||
// Format reply ID as @ud (with dots) - required for Tlon to recognize thread replies
|
||||
let formattedReplyId = replyToId;
|
||||
if (replyToId && /^\d+$/.test(replyToId)) {
|
||||
try {
|
||||
formattedReplyId = formatUd(BigInt(replyToId));
|
||||
} catch {
|
||||
// Fall back to raw ID if formatting fails
|
||||
}
|
||||
}
|
||||
|
||||
const action = {
|
||||
channel: {
|
||||
nest: `chat/${hostShip}/${channelName}`,
|
||||
action: replyToId
|
||||
action: formattedReplyId
|
||||
? {
|
||||
reply: {
|
||||
id: replyToId,
|
||||
delta: {
|
||||
add: {
|
||||
memo: {
|
||||
// Thread reply - needs post wrapper around reply action
|
||||
// ReplyActionAdd takes Memo: {content, author, sent} - no kind/blob/meta
|
||||
post: {
|
||||
reply: {
|
||||
id: formattedReplyId,
|
||||
action: {
|
||||
add: {
|
||||
content: story,
|
||||
author: fromShip,
|
||||
sent: sentAt,
|
||||
@ -82,6 +94,7 @@ export async function sendGroupMessage({
|
||||
},
|
||||
}
|
||||
: {
|
||||
// Regular post
|
||||
post: {
|
||||
add: {
|
||||
content: story,
|
||||
|
||||
@ -12,7 +12,10 @@
|
||||
"manmal",
|
||||
"thesash",
|
||||
"rhjoh",
|
||||
"ysqander"
|
||||
"ysqander",
|
||||
"atalovesyou",
|
||||
"0xJonHoldsCrypto",
|
||||
"hougangdev"
|
||||
],
|
||||
"seedCommit": "d6863f87",
|
||||
"placeholderAvatar": "assets/avatar-placeholder.svg",
|
||||
|
||||
@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||
.option(
|
||||
"--auth-choice <choice>",
|
||||
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
|
||||
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
|
||||
)
|
||||
.option(
|
||||
"--token-provider <id>",
|
||||
@ -74,6 +74,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
.option("--zai-api-key <key>", "Z.AI API key")
|
||||
.option("--minimax-api-key <key>", "MiniMax API key")
|
||||
.option("--synthetic-api-key <key>", "Synthetic API key")
|
||||
.option("--venice-api-key <key>", "Venice API key")
|
||||
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
|
||||
.option("--gateway-port <port>", "Gateway port")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
|
||||
@ -123,6 +124,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
zaiApiKey: opts.zaiApiKey as string | undefined,
|
||||
minimaxApiKey: opts.minimaxApiKey as string | undefined,
|
||||
syntheticApiKey: opts.syntheticApiKey as string | undefined,
|
||||
veniceApiKey: opts.veniceApiKey as string | undefined,
|
||||
opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined,
|
||||
gatewayPort:
|
||||
typeof gatewayPort === "number" && Number.isFinite(gatewayPort)
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
applyOpencodeZenConfig,
|
||||
applyOpenrouterConfig,
|
||||
applySyntheticConfig,
|
||||
applyVeniceConfig,
|
||||
applyVercelAiGatewayConfig,
|
||||
applyZaiConfig,
|
||||
setAnthropicApiKey,
|
||||
@ -30,6 +31,7 @@ import {
|
||||
setOpencodeZenApiKey,
|
||||
setOpenrouterApiKey,
|
||||
setSyntheticApiKey,
|
||||
setVeniceApiKey,
|
||||
setVercelAiGatewayApiKey,
|
||||
setZaiApiKey,
|
||||
} from "../../onboard-auth.js";
|
||||
@ -272,6 +274,25 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
return applySyntheticConfig(nextConfig);
|
||||
}
|
||||
|
||||
if (authChoice === "venice-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "venice",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.veniceApiKey,
|
||||
flagName: "--venice-api-key",
|
||||
envVar: "VENICE_API_KEY",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return null;
|
||||
if (resolved.source !== "profile") await setVeniceApiKey(resolved.key);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "venice:default",
|
||||
provider: "venice",
|
||||
mode: "api_key",
|
||||
});
|
||||
return applyVeniceConfig(nextConfig);
|
||||
}
|
||||
|
||||
if (
|
||||
authChoice === "minimax-cloud" ||
|
||||
authChoice === "minimax-api" ||
|
||||
|
||||
@ -35,7 +35,7 @@ export const ChatHistoryParamsSchema = Type.Object(
|
||||
export const ChatSendParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: NonEmptyString,
|
||||
message: NonEmptyString,
|
||||
message: Type.String(),
|
||||
thinking: Type.Optional(Type.String()),
|
||||
deliver: Type.Optional(Type.Boolean()),
|
||||
attachments: Type.Optional(Type.Array(Type.Unknown())),
|
||||
|
||||
@ -338,6 +338,15 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
: undefined,
|
||||
}))
|
||||
.filter((a) => a.content) ?? [];
|
||||
const rawMessage = p.message.trim();
|
||||
if (!rawMessage && normalizedAttachments.length === 0) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "message or attachment required"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let parsedMessage = p.message;
|
||||
let parsedImages: ChatImageContent[] = [];
|
||||
if (normalizedAttachments.length > 0) {
|
||||
|
||||
@ -208,6 +208,39 @@ describe("gateway server chat", () => {
|
||||
| undefined;
|
||||
expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
||||
|
||||
const callsBeforeImageOnly = spy.mock.calls.length;
|
||||
const reqIdOnly = "chat-img-only";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: reqIdOnly,
|
||||
method: "chat.send",
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
message: "",
|
||||
idempotencyKey: "idem-img-only",
|
||||
attachments: [
|
||||
{
|
||||
type: "image",
|
||||
mimeType: "image/png",
|
||||
fileName: "dot.png",
|
||||
content: `data:image/png;base64,${pngB64}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const imgOnlyRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqIdOnly, 8000);
|
||||
expect(imgOnlyRes.ok).toBe(true);
|
||||
expect(imgOnlyRes.payload?.runId).toBeDefined();
|
||||
|
||||
await waitFor(() => spy.mock.calls.length > callsBeforeImageOnly, 8000);
|
||||
const imgOnlyOpts = spy.mock.calls.at(-1)?.[1] as
|
||||
| { images?: Array<{ type: string; data: string; mimeType: string }> }
|
||||
| undefined;
|
||||
expect(imgOnlyOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
||||
|
||||
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
tempDirs.push(historyDir);
|
||||
testState.sessionStorePath = path.join(historyDir, "sessions.json");
|
||||
|
||||
@ -381,6 +381,31 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: ClawdbotConfig;
|
||||
};
|
||||
}
|
||||
|
||||
// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data.
|
||||
function mergeSessionEntryIntoCombined(params: {
|
||||
combined: Record<string, SessionEntry>;
|
||||
entry: SessionEntry;
|
||||
agentId: string;
|
||||
canonicalKey: string;
|
||||
}) {
|
||||
const { combined, entry, agentId, canonicalKey } = params;
|
||||
const existing = combined[canonicalKey];
|
||||
|
||||
if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
|
||||
combined[canonicalKey] = {
|
||||
...entry,
|
||||
...existing,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, existing.spawnedBy ?? entry.spawnedBy),
|
||||
};
|
||||
} else {
|
||||
combined[canonicalKey] = {
|
||||
...existing,
|
||||
...entry,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
|
||||
storePath: string;
|
||||
store: Record<string, SessionEntry>;
|
||||
@ -393,10 +418,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
|
||||
const combined: Record<string, SessionEntry> = {};
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
|
||||
combined[canonicalKey] = {
|
||||
...entry,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(defaultAgentId, entry.spawnedBy),
|
||||
};
|
||||
mergeSessionEntryIntoCombined({
|
||||
combined,
|
||||
entry,
|
||||
agentId: defaultAgentId,
|
||||
canonicalKey,
|
||||
});
|
||||
}
|
||||
return { storePath, store: combined };
|
||||
}
|
||||
@ -408,13 +435,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
|
||||
const store = loadSessionStore(storePath);
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
|
||||
// Merge with existing entry if present (avoid overwriting with less complete data)
|
||||
const existing = combined[canonicalKey];
|
||||
combined[canonicalKey] = {
|
||||
...existing,
|
||||
...entry,
|
||||
spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
|
||||
};
|
||||
mergeSessionEntryIntoCombined({
|
||||
combined,
|
||||
entry,
|
||||
agentId,
|
||||
canonicalKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -103,7 +103,7 @@
|
||||
bottom: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: auto; /* Push to bottom of flex container */
|
||||
padding: 12px 4px 4px;
|
||||
@ -111,6 +111,121 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Image attachments preview */
|
||||
.chat-attachments {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-self: flex-start; /* Don't stretch in flex column parent */
|
||||
}
|
||||
|
||||
.chat-attachment {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.chat-attachment__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.chat-attachment__remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-attachment:hover .chat-attachment__remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-attachment__remove:hover {
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
}
|
||||
|
||||
.chat-attachment__remove svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
/* Light theme attachment overrides */
|
||||
:root[data-theme="light"] .chat-attachments {
|
||||
background: #f8fafc;
|
||||
border-color: rgba(16, 24, 40, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-attachment {
|
||||
border-color: rgba(16, 24, 40, 0.15);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-attachment__remove {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Message images (sent images displayed in chat) */
|
||||
.chat-message-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-message-image {
|
||||
max-width: 300px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-message-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* User message images align right */
|
||||
.chat-group.user .chat-message-images {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Compose input row - horizontal layout */
|
||||
.chat-compose__row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-compose {
|
||||
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
|
||||
}
|
||||
|
||||
@ -1303,9 +1303,8 @@
|
||||
/* Chat compose */
|
||||
.chat-compose {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
@ -8,11 +8,13 @@ import { normalizeBasePath } from "./navigation";
|
||||
import type { GatewayHelloOk } from "./gateway";
|
||||
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
|
||||
import type { ClawdbotApp } from "./app";
|
||||
import type { ChatAttachment, ChatQueueItem } from "./ui-types";
|
||||
|
||||
type ChatHost = {
|
||||
connected: boolean;
|
||||
chatMessage: string;
|
||||
chatQueue: Array<{ id: string; text: string; createdAt: number }>;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatQueue: ChatQueueItem[];
|
||||
chatRunId: string | null;
|
||||
chatSending: boolean;
|
||||
sessionKey: string;
|
||||
@ -45,15 +47,17 @@ export async function handleAbortChat(host: ChatHost) {
|
||||
await abortChatRun(host as unknown as ClawdbotApp);
|
||||
}
|
||||
|
||||
function enqueueChatMessage(host: ChatHost, text: string) {
|
||||
function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAttachment[]) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
if (!trimmed && !hasAttachments) return;
|
||||
host.chatQueue = [
|
||||
...host.chatQueue,
|
||||
{
|
||||
id: generateUUID(),
|
||||
text: trimmed,
|
||||
createdAt: Date.now(),
|
||||
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -61,19 +65,31 @@ function enqueueChatMessage(host: ChatHost, text: string) {
|
||||
async function sendChatMessageNow(
|
||||
host: ChatHost,
|
||||
message: string,
|
||||
opts?: { previousDraft?: string; restoreDraft?: boolean },
|
||||
opts?: {
|
||||
previousDraft?: string;
|
||||
restoreDraft?: boolean;
|
||||
attachments?: ChatAttachment[];
|
||||
previousAttachments?: ChatAttachment[];
|
||||
restoreAttachments?: boolean;
|
||||
},
|
||||
) {
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
const ok = await sendChatMessage(host as unknown as ClawdbotApp, message);
|
||||
const ok = await sendChatMessage(host as unknown as ClawdbotApp, message, opts?.attachments);
|
||||
if (!ok && opts?.previousDraft != null) {
|
||||
host.chatMessage = opts.previousDraft;
|
||||
}
|
||||
if (!ok && opts?.previousAttachments) {
|
||||
host.chatAttachments = opts.previousAttachments;
|
||||
}
|
||||
if (ok) {
|
||||
setLastActiveSessionKey(host as unknown as Parameters<typeof setLastActiveSessionKey>[0], host.sessionKey);
|
||||
}
|
||||
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
|
||||
host.chatMessage = opts.previousDraft;
|
||||
}
|
||||
if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) {
|
||||
host.chatAttachments = opts.previousAttachments;
|
||||
}
|
||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
|
||||
if (ok && !host.chatRunId) {
|
||||
void flushChatQueue(host);
|
||||
@ -86,7 +102,7 @@ async function flushChatQueue(host: ChatHost) {
|
||||
const [next, ...rest] = host.chatQueue;
|
||||
if (!next) return;
|
||||
host.chatQueue = rest;
|
||||
const ok = await sendChatMessageNow(host, next.text);
|
||||
const ok = await sendChatMessageNow(host, next.text, { attachments: next.attachments });
|
||||
if (!ok) {
|
||||
host.chatQueue = [next, ...host.chatQueue];
|
||||
}
|
||||
@ -104,7 +120,12 @@ export async function handleSendChat(
|
||||
if (!host.connected) return;
|
||||
const previousDraft = host.chatMessage;
|
||||
const message = (messageOverride ?? host.chatMessage).trim();
|
||||
if (!message) return;
|
||||
const attachments = host.chatAttachments ?? [];
|
||||
const attachmentsToSend = messageOverride == null ? attachments : [];
|
||||
const hasAttachments = attachmentsToSend.length > 0;
|
||||
|
||||
// Allow sending with just attachments (no message text required)
|
||||
if (!message && !hasAttachments) return;
|
||||
|
||||
if (isChatStopCommand(message)) {
|
||||
await handleAbortChat(host);
|
||||
@ -113,16 +134,21 @@ export async function handleSendChat(
|
||||
|
||||
if (messageOverride == null) {
|
||||
host.chatMessage = "";
|
||||
// Clear attachments when sending
|
||||
host.chatAttachments = [];
|
||||
}
|
||||
|
||||
if (isChatBusy(host)) {
|
||||
enqueueChatMessage(host, message);
|
||||
enqueueChatMessage(host, message, attachmentsToSend);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendChatMessageNow(host, message, {
|
||||
previousDraft: messageOverride == null ? previousDraft : undefined,
|
||||
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
|
||||
attachments: hasAttachments ? attachmentsToSend : undefined,
|
||||
previousAttachments: messageOverride == null ? attachments : undefined,
|
||||
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -431,6 +431,7 @@ export function renderApp(state: AppViewState) {
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
@ -477,6 +478,8 @@ export function renderApp(state: AppViewState) {
|
||||
},
|
||||
onChatScroll: (event) => state.handleChatScroll(event),
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
attachments: state.chatAttachments,
|
||||
onAttachmentsChange: (next) => (state.chatAttachments = next),
|
||||
onSend: () => state.handleSendChat(),
|
||||
canAbort: Boolean(state.chatRunId),
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
|
||||
@ -19,7 +19,7 @@ import type {
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import type { ChatQueueItem, CronFormState } from "./ui-types";
|
||||
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
|
||||
import type { EventLogEntry } from "./app-events";
|
||||
import type { SkillMessage } from "./controllers/skills";
|
||||
import type {
|
||||
@ -49,6 +49,7 @@ export type AppViewState = {
|
||||
chatLoading: boolean;
|
||||
chatSending: boolean;
|
||||
chatMessage: string;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatMessages: unknown[];
|
||||
chatToolMessages: unknown[];
|
||||
chatStream: string | null;
|
||||
|
||||
@ -24,7 +24,7 @@ import type {
|
||||
StatusSummary,
|
||||
NostrProfile,
|
||||
} from "./types";
|
||||
import { type ChatQueueItem, type CronFormState } from "./ui-types";
|
||||
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
|
||||
import type { EventLogEntry } from "./app-events";
|
||||
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
|
||||
import type {
|
||||
@ -129,6 +129,7 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() chatAvatarUrl: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
@state() chatAttachments: ChatAttachment[] = [];
|
||||
// Sidebar state for tool output viewing
|
||||
@state() sidebarOpen = false;
|
||||
@state() sidebarContent: string | null = null;
|
||||
|
||||
@ -13,6 +13,48 @@ import {
|
||||
} from "./message-extract";
|
||||
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
|
||||
|
||||
type ImageBlock = {
|
||||
url: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
function extractImages(message: unknown): ImageBlock[] {
|
||||
const m = message as Record<string, unknown>;
|
||||
const content = m.content;
|
||||
const images: ImageBlock[] = [];
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (typeof block !== "object" || block === null) continue;
|
||||
const b = block as Record<string, unknown>;
|
||||
|
||||
if (b.type === "image") {
|
||||
// Handle source object format (from sendChatMessage)
|
||||
const source = b.source as Record<string, unknown> | undefined;
|
||||
if (source?.type === "base64" && typeof source.data === "string") {
|
||||
const data = source.data as string;
|
||||
const mediaType = (source.media_type as string) || "image/png";
|
||||
// If data is already a data URL, use it directly
|
||||
const url = data.startsWith("data:")
|
||||
? data
|
||||
: `data:${mediaType};base64,${data}`;
|
||||
images.push({ url });
|
||||
} else if (typeof b.url === "string") {
|
||||
images.push({ url: b.url });
|
||||
}
|
||||
} else if (b.type === "image_url") {
|
||||
// OpenAI format
|
||||
const imageUrl = b.image_url as Record<string, unknown> | undefined;
|
||||
if (typeof imageUrl?.url === "string") {
|
||||
images.push({ url: imageUrl.url });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
@ -163,6 +205,25 @@ function isAvatarUrl(value: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function renderMessageImages(images: ImageBlock[]) {
|
||||
if (images.length === 0) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="chat-message-images">
|
||||
${images.map(
|
||||
(img) => html`
|
||||
<img
|
||||
src=${img.url}
|
||||
alt=${img.alt ?? "Attached image"}
|
||||
class="chat-message-image"
|
||||
@click=${() => window.open(img.url, "_blank")}
|
||||
/>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGroupedMessage(
|
||||
message: unknown,
|
||||
opts: { isStreaming: boolean; showReasoning: boolean },
|
||||
@ -179,6 +240,8 @@ function renderGroupedMessage(
|
||||
|
||||
const toolCards = extractToolCards(message);
|
||||
const hasToolCards = toolCards.length > 0;
|
||||
const images = extractImages(message);
|
||||
const hasImages = images.length > 0;
|
||||
|
||||
const extractedText = extractTextCached(message);
|
||||
const extractedThinking =
|
||||
@ -207,11 +270,12 @@ function renderGroupedMessage(
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (!markdown && !hasToolCards) return nothing;
|
||||
if (!markdown && !hasToolCards && !hasImages) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="${bubbleClasses}">
|
||||
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
||||
${renderMessageImages(images)}
|
||||
${reasoningMarkdown
|
||||
? html`<div class="chat-thinking">${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||
|
||||
99
ui/src/ui/controllers/chat.test.ts
Normal file
99
ui/src/ui/controllers/chat.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
handleChatEvent,
|
||||
type ChatEventPayload,
|
||||
type ChatState,
|
||||
} from "./chat";
|
||||
|
||||
function createState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
client: null,
|
||||
connected: true,
|
||||
sessionKey: "main",
|
||||
chatLoading: false,
|
||||
chatMessages: [],
|
||||
chatThinkingLevel: null,
|
||||
chatSending: false,
|
||||
chatMessage: "",
|
||||
chatRunId: null,
|
||||
chatStream: null,
|
||||
chatStreamStartedAt: null,
|
||||
lastError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleChatEvent", () => {
|
||||
it("returns null when payload is missing", () => {
|
||||
const state = createState();
|
||||
expect(handleChatEvent(state, undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when sessionKey does not match", () => {
|
||||
const state = createState({ sessionKey: "main" });
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "other",
|
||||
state: "final",
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for delta from another run", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Hello",
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Done" }] },
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe(null);
|
||||
expect(state.chatRunId).toBe("run-user");
|
||||
expect(state.chatStream).toBe("Hello");
|
||||
});
|
||||
|
||||
it("returns 'final' for final from another run (e.g. sub-agent announce) without clearing state", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Sub-agent findings" }],
|
||||
},
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatRunId).toBe("run-user");
|
||||
expect(state.chatStream).toBe("Working...");
|
||||
expect(state.chatStreamStartedAt).toBe(123);
|
||||
});
|
||||
|
||||
it("processes final from own run and clears state", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Reply",
|
||||
chatStreamStartedAt: 100,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
};
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { extractText } from "../chat/message-extract";
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { generateUUID } from "../uuid";
|
||||
import type { ChatAttachment } from "../ui-types";
|
||||
|
||||
export type ChatState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@ -11,6 +12,7 @@ export type ChatState = {
|
||||
chatThinkingLevel: string | null;
|
||||
chatSending: boolean;
|
||||
chatMessage: string;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatRunId: string | null;
|
||||
chatStream: string | null;
|
||||
chatStreamStartedAt: number | null;
|
||||
@ -43,17 +45,44 @@ export async function loadChatHistory(state: ChatState) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendChatMessage(state: ChatState, message: string): Promise<boolean> {
|
||||
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
|
||||
if (!match) return null;
|
||||
return { mimeType: match[1], content: match[2] };
|
||||
}
|
||||
|
||||
export async function sendChatMessage(
|
||||
state: ChatState,
|
||||
message: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): Promise<boolean> {
|
||||
if (!state.client || !state.connected) return false;
|
||||
const msg = message.trim();
|
||||
if (!msg) return false;
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
if (!msg && !hasAttachments) return false;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Build user message content blocks
|
||||
const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
|
||||
if (msg) {
|
||||
contentBlocks.push({ type: "text", text: msg });
|
||||
}
|
||||
// Add image previews to the message for display
|
||||
if (hasAttachments) {
|
||||
for (const att of attachments) {
|
||||
contentBlocks.push({
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: msg }],
|
||||
content: contentBlocks,
|
||||
timestamp: now,
|
||||
},
|
||||
];
|
||||
@ -64,12 +93,29 @@ export async function sendChatMessage(state: ChatState, message: string): Promis
|
||||
state.chatRunId = runId;
|
||||
state.chatStream = "";
|
||||
state.chatStreamStartedAt = now;
|
||||
|
||||
// Convert attachments to API format
|
||||
const apiAttachments = hasAttachments
|
||||
? attachments
|
||||
.map((att) => {
|
||||
const parsed = dataUrlToBase64(att.dataUrl);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
type: "image",
|
||||
mimeType: parsed.mimeType,
|
||||
content: parsed.content,
|
||||
};
|
||||
})
|
||||
.filter((a): a is NonNullable<typeof a> => a !== null)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
await state.client.request("chat.send", {
|
||||
sessionKey: state.sessionKey,
|
||||
message: msg,
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
attachments: apiAttachments,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
@ -115,8 +161,17 @@ export function handleChatEvent(
|
||||
) {
|
||||
if (!payload) return null;
|
||||
if (payload.sessionKey !== state.sessionKey) return null;
|
||||
if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId)
|
||||
|
||||
// Final from another run (e.g. sub-agent announce): refresh history to show new message.
|
||||
// See https://github.com/clawdbot/clawdbot/issues/1909
|
||||
if (
|
||||
payload.runId &&
|
||||
state.chatRunId &&
|
||||
payload.runId !== state.chatRunId
|
||||
) {
|
||||
if (payload.state === "final") return "final";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.state === "delta") {
|
||||
const next = extractText(payload.message);
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
export type ChatAttachment = {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type ChatQueueItem = {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
attachments?: ChatAttachment[];
|
||||
};
|
||||
|
||||
export const CRON_CHANNEL_LAST = "last";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import type { SessionsListResult } from "../types";
|
||||
import type { ChatQueueItem } from "../ui-types";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../ui-types";
|
||||
import type { ChatItem, MessageGroup } from "../types/chat-types";
|
||||
import { icons } from "../icons";
|
||||
import {
|
||||
@ -52,6 +52,9 @@ export type ChatProps = {
|
||||
splitRatio?: number;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
// Image attachments
|
||||
attachments?: ChatAttachment[];
|
||||
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
|
||||
// Event handlers
|
||||
onRefresh: () => void;
|
||||
onToggleFocusMode: () => void;
|
||||
@ -95,6 +98,82 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
|
||||
return nothing;
|
||||
}
|
||||
|
||||
function generateAttachmentId(): string {
|
||||
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function handlePaste(
|
||||
e: ClipboardEvent,
|
||||
props: ChatProps,
|
||||
) {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items || !props.onAttachmentsChange) return;
|
||||
|
||||
const imageItems: DataTransferItem[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith("image/")) {
|
||||
imageItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageItems.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
for (const item of imageItems) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const newAttachment: ChatAttachment = {
|
||||
id: generateAttachmentId(),
|
||||
dataUrl,
|
||||
mimeType: file.type,
|
||||
};
|
||||
const current = props.attachments ?? [];
|
||||
props.onAttachmentsChange?.([...current, newAttachment]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAttachmentPreview(props: ChatProps) {
|
||||
const attachments = props.attachments ?? [];
|
||||
if (attachments.length === 0) return nothing;
|
||||
|
||||
return html`
|
||||
<div class="chat-attachments">
|
||||
${attachments.map(
|
||||
(att) => html`
|
||||
<div class="chat-attachment">
|
||||
<img
|
||||
src=${att.dataUrl}
|
||||
alt="Attachment preview"
|
||||
class="chat-attachment__img"
|
||||
/>
|
||||
<button
|
||||
class="chat-attachment__remove"
|
||||
type="button"
|
||||
aria-label="Remove attachment"
|
||||
@click=${() => {
|
||||
const next = (props.attachments ?? []).filter(
|
||||
(a) => a.id !== att.id,
|
||||
);
|
||||
props.onAttachmentsChange?.(next);
|
||||
}}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChat(props: ChatProps) {
|
||||
const canCompose = props.connected;
|
||||
const isBusy = props.sending || props.stream !== null;
|
||||
@ -109,8 +188,11 @@ export function renderChat(props: ChatProps) {
|
||||
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
|
||||
};
|
||||
|
||||
const hasAttachments = (props.attachments?.length ?? 0) > 0;
|
||||
const composePlaceholder = props.connected
|
||||
? "Message (↩ to send, Shift+↩ for line breaks)"
|
||||
? hasAttachments
|
||||
? "Add a message or paste more images..."
|
||||
: "Message (↩ to send, Shift+↩ for line breaks, paste images)"
|
||||
: "Connect to the gateway to start chatting…";
|
||||
|
||||
const splitRatio = props.splitRatio ?? 0.6;
|
||||
@ -217,7 +299,12 @@ export function renderChat(props: ChatProps) {
|
||||
${props.queue.map(
|
||||
(item) => html`
|
||||
<div class="chat-queue__item">
|
||||
<div class="chat-queue__text">${item.text}</div>
|
||||
<div class="chat-queue__text">
|
||||
${item.text ||
|
||||
(item.attachments?.length
|
||||
? `Image (${item.attachments.length})`
|
||||
: "")}
|
||||
</div>
|
||||
<button
|
||||
class="btn chat-queue__remove"
|
||||
type="button"
|
||||
@ -235,39 +322,43 @@ export function renderChat(props: ChatProps) {
|
||||
: nothing}
|
||||
|
||||
<div class="chat-compose">
|
||||
<label class="field chat-compose__field">
|
||||
<span>Message</span>
|
||||
<textarea
|
||||
.value=${props.draft}
|
||||
?disabled=${!props.connected}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter") return;
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
|
||||
if (!props.connected) return;
|
||||
e.preventDefault();
|
||||
if (canCompose) props.onSend();
|
||||
}}
|
||||
@input=${(e: Event) =>
|
||||
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder=${composePlaceholder}
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="chat-compose__actions">
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${!props.connected || (!canAbort && props.sending)}
|
||||
@click=${canAbort ? props.onAbort : props.onNewSession}
|
||||
>
|
||||
${canAbort ? "Stop" : "New session"}
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${!props.connected}
|
||||
@click=${props.onSend}
|
||||
>
|
||||
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd">↵</kbd>
|
||||
</button>
|
||||
${renderAttachmentPreview(props)}
|
||||
<div class="chat-compose__row">
|
||||
<label class="field chat-compose__field">
|
||||
<span>Message</span>
|
||||
<textarea
|
||||
.value=${props.draft}
|
||||
?disabled=${!props.connected}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key !== "Enter") return;
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
if (e.shiftKey) return; // Allow Shift+Enter for line breaks
|
||||
if (!props.connected) return;
|
||||
e.preventDefault();
|
||||
if (canCompose) props.onSend();
|
||||
}}
|
||||
@input=${(e: Event) =>
|
||||
props.onDraftChange((e.target as HTMLTextAreaElement).value)}
|
||||
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
|
||||
placeholder=${composePlaceholder}
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="chat-compose__actions">
|
||||
<button
|
||||
class="btn"
|
||||
?disabled=${!props.connected || (!canAbort && props.sending)}
|
||||
@click=${canAbort ? props.onAbort : props.onNewSession}
|
||||
>
|
||||
${canAbort ? "Stop" : "New session"}
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
?disabled=${!props.connected}
|
||||
@click=${props.onSend}
|
||||
>
|
||||
${isBusy ? "Queue" : "Send"}<kbd class="btn-kbd">↵</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user