diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3ab78da..9742150a3 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 47f3a9090..217a4b61c 100644 --- a/README.md +++ b/README.md @@ -484,7 +484,7 @@ Thanks to all clawtributors: vignesh07 mteam88 dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko denysvitali orlyjamie - sircrumpet peschee rafaelreis-r thewilloftheshadow ratulsarna lutr0 danielz1z emanuelst KristijanJovanovski rdev + thewilloftheshadow sircrumpet peschee rafaelreis-r ratulsarna lutr0 danielz1z emanuelst KristijanJovanovski rdev joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter @@ -504,7 +504,7 @@ Thanks to all clawtributors: Lloyd loukotal louzhixian martinpucik Matt mini Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke testingabc321 The Admiral thesash Ubuntu - voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 Zach Knickerbocker aaronn Alphonse-arianee Azade - carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres - rhjoh ronak-guliani William Stock + voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee + atalovesyou Azade carlulsoe ddyo Erik hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik + pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

diff --git a/docs/docs.json b/docs/docs.json index 983585bff..b0f0ee802 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" ] }, diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md new file mode 100644 index 000000000..1b8e1d90d --- /dev/null +++ b/docs/platforms/digitalocean.md @@ -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 +``` + +### 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 +``` + +### 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 diff --git a/docs/platforms/gcp.md b/docs/platforms/gcp.md new file mode 100644 index 000000000..cffa03ace --- /dev/null +++ b/docs/platforms/gcp.md @@ -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) diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 1b5c85129..d53073026 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -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 diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md new file mode 100644 index 000000000..b34e3fcfe --- /dev/null +++ b/docs/platforms/raspberry-pi.md @@ -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 diff --git a/docs/providers/claude-max-api-proxy.md b/docs/providers/claude-max-api-proxy.md new file mode 100644 index 000000000..255be62fc --- /dev/null +++ b/docs/providers/claude-max-api-proxy.md @@ -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' + + + + + Label + com.claude-max-api + RunAtLoad + + KeepAlive + + ProgramArguments + + /usr/local/bin/node + /usr/local/lib/node_modules/claude-max-api-proxy/dist/server/standalone.js + + EnvironmentVariables + + PATH + /usr/local/bin:/opt/homebrew/bin:~/.local/bin:/usr/bin:/bin + + + +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 diff --git a/docs/providers/index.md b/docs/providers/index.md index c4f020192..b4779d201 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -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). diff --git a/docs/vps.md b/docs/vps.md index a6d267513..23e88255b 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -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 diff --git a/extensions/tlon/src/urbit/send.ts b/extensions/tlon/src/urbit/send.ts index 35f7f2d74..621bbd69a 100644 --- a/extensions/tlon/src/urbit/send.ts +++ b/extensions/tlon/src/urbit/send.ts @@ -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, diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json index 8899afc93..d652938a6 100644 --- a/scripts/clawtributors-map.json +++ b/scripts/clawtributors-map.json @@ -12,7 +12,10 @@ "manmal", "thesash", "rhjoh", - "ysqander" + "ysqander", + "atalovesyou", + "0xJonHoldsCrypto", + "hougangdev" ], "seedCommit": "d6863f87", "placeholderAvatar": "assets/avatar-placeholder.svg", diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 281464b6f..ee9d5ccd2 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-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 ", @@ -74,6 +74,7 @@ export function registerOnboardCommand(program: Command) { .option("--zai-api-key ", "Z.AI API key") .option("--minimax-api-key ", "MiniMax API key") .option("--synthetic-api-key ", "Synthetic API key") + .option("--venice-api-key ", "Venice API key") .option("--opencode-zen-api-key ", "OpenCode Zen API key") .option("--gateway-port ", "Gateway port") .option("--gateway-bind ", "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) diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 6762fb7d2..02e0a75b9 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -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" || diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts index 7b684771a..dc04a29d5 100644 --- a/src/gateway/protocol/schema/logs-chat.ts +++ b/src/gateway/protocol/schema/logs-chat.ts @@ -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())), diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 50f441779..9010a6f21 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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) { diff --git a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts index 54f772580..6827b24c4 100644 --- a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts @@ -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"); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index c4046a08e..1cb4cc5c3 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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; + 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; @@ -393,10 +418,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): { const combined: Record = {}; 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, + }); } } diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index e137cb8c8..e11fedb71 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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%); } diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index a78e0ef0a..27dfe62d1 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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; } diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 81aae3c88..c5f883716 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -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[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[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[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), }); } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index db29bd7ec..fe67c86f1 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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(), diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index f589c760c..069465e32 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 0e21d283a..649e76342 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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; diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index ea1c7ffda..4a9ccec14 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -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; + 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; + + if (b.type === "image") { + // Handle source object format (from sendChatMessage) + const source = b.source as Record | 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 | undefined; + if (typeof imageUrl?.url === "string") { + images.push({ url: imageUrl.url }); + } + } + } + } + + return images; +} + export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) { return html`
@@ -163,6 +205,25 @@ function isAvatarUrl(value: string): boolean { ); } +function renderMessageImages(images: ImageBlock[]) { + if (images.length === 0) return nothing; + + return html` +
+ ${images.map( + (img) => html` + ${img.alt window.open(img.url, "_blank")} + /> + `, + )} +
+ `; +} + 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`
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} + ${renderMessageImages(images)} ${reasoningMarkdown ? html`
${unsafeHTML( toSanitizedMarkdownHtml(reasoningMarkdown), diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts new file mode 100644 index 000000000..c75ceefc4 --- /dev/null +++ b/ui/src/ui/controllers/chat.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { + handleChatEvent, + type ChatEventPayload, + type ChatState, +} from "./chat"; + +function createState(overrides: Partial = {}): 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); + }); +}); diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 53027c6ea..518c35fe1 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -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 { +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 { 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 => 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); diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 428c4c381..196d6d114 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -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"; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index dd61ca0ec..a9b4da572 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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` +
+ ${attachments.map( + (att) => html` +
+ Attachment preview + +
+ `, + )} +
+ `; +} + 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`
-
${item.text}
+
+ ${item.text || + (item.attachments?.length + ? `Image (${item.attachments.length})` + : "")} +
- + ${renderAttachmentPreview(props)} +
+ +
+ + +