# amanat-assist — AI Request Assistant **Status:** Live at `assist.amn.gg` (v1.1.1) **Repo:** `/amanat-assist` (separate repo, no Amanat DB or internal-service access) **Owner:** Amanat Platform **PRD:** [PRD — AI Request Assistant Mini App](../PRD%20-%20AI%20Request%20Assistant%20Mini%20App.md) --- ## 1. Overview `amanat-assist` is a Telegram Mini App **and** standalone web app that guides buyers through creating a purchase request on the Amanat escrow marketplace using a conversational LLM interface. The user describes what they want in plain language; the assistant asks clarifying questions, suggests price and delivery windows, then with one tap posts the structured request to the Amanat backend. The user never sees a form. The LLM handles categorisation, field normalisation, and the API call. --- ## 2. Architecture ``` ┌─────────────────────────────────────────────────────────┐ │ Telegram / Browser │ │ assist.amn.gg (nginx, static React/Vite bundle) │ │ → auth (Telegram SSO or web redirect to dev.amn.gg) │ │ → UI: multi-turn chat, photo upload, review card │ └──────────────────────┬──────────────────────────────────┘ │ POST /api/llm ▼ ┌─────────────────────────────────────────────────────────┐ │ amanat-llm-proxy (Node.js 18+, port 3001) │ │ Providers: Mistral → fallback DeepSeek on 429 │ │ Also: Kimi, OpenCode proxy │ │ API keys server-side only, never in browser │ └──────────────────────┬──────────────────────────────────┘ │ Bearer JWT ▼ ┌─────────────────────────────────────────────────────────┐ │ Amanat Backend (api.amn.gg / dev.amn.gg) │ │ /api/auth/telegram /api/categories /api/requests │ └─────────────────────────────────────────────────────────┘ ``` ### Docker Compose | Service | Image | Container | Notes | |---|---|---|---| | `frontend` | `nginx:alpine` | `amanat-frontend` | Serves `dist/` — static bundle | | `llm-proxy` | Built from `./llm-proxy/` | `amanat-llm-proxy` | Port 3001 | Both services join the external `escrow-dev_default` docker network (alias `escrow_net`). --- ## 3. Tech Stack | Layer | Tech | |---|---| | Frontend | React 18, TypeScript, Vite 5 | | Styling | CSS variables + Telegram theme tokens | | LLM Proxy | Plain Node.js 18+ (`http` module, native `fetch`) — zero npm deps | | State | React state machine + `useSlotFilling` hook | | Persistence | `localStorage` via `useChatSessions` hook | | Auth (Telegram) | `window.Telegram.WebApp.initData` → `/api/auth/telegram` | | Auth (Web) | Redirect to `dev.amn.gg` → `?access_token=...` callback | | CI | Woodpecker CI on ARM64 agent co-located with `assist.amn.gg` | --- ## 4. State Machine ```mermaid stateDiagram-v2 [*] --> INIT INIT --> AUTH : Telegram initData present INIT --> GREETING : Dev mode (skip auth) INIT --> GREETING : Web — stored session valid INIT --> GREETING : Web — OAuth callback received AUTH --> GREETING : silentSSO success AUTH --> ERROR : silentSSO failure GREETING --> COLLECT : user sends first message COLLECT --> COLLECT : LLM asks follow-up COLLECT --> REVIEW : all required slots filled REVIEW --> SUBMITTING : user taps Submit REVIEW --> COLLECT : user taps Edit SUBMITTING --> DONE : POST /api/requests 200 SUBMITTING --> ERROR : submit failed ERROR --> AUTH : retry (Telegram) ERROR --> GREETING : retry (dev/web) COLLECT --> HISTORY : user taps History HISTORY --> COLLECT : user loads session HISTORY --> GREETING : new chat ``` --- ## 5. Auth ### 5.1 Telegram Mini App (primary) ``` User opens bot → window.Telegram.WebApp.initData (injected by Telegram) → POST https://dev.amn.gg/api/auth/telegram { initData: "", role: "buyer" } ← { data: { tokens: { accessToken, refreshToken }, user, isNewUser } } → Store accessToken in memory (not localStorage — ephemeral session) ``` On any `401`, the app transparently POSTs `/api/auth/refresh-token` and retries. ### 5.2 Web Browser 1. Check for `?access_token=...` in URL (OAuth callback redirect from `dev.amn.gg`) 2. Check `localStorage` for a stored valid session (calls `/api/auth/me` to verify) 3. If no session → redirect to `dev.amn.gg?redirect_uri=` for login ### 5.3 Development Mode Skips all auth, uses mock tokens + mock user. --- ## 6. LLM Service ### 6.1 Providers | Provider | Model | Key env var | Notes | |---|---|---|---| | `mistral` | `mistral-large-latest` | `MISTRAL_API_KEY` | Primary | | `mistral` (vision) | `pixtral-12b-2409` | `MISTRAL_API_KEY` | Image analysis | | `kimi` | `moonshot-v1-8k` | `KIMI_API_KEY` | Optional | | `deepseek` | `deepseek-chat` | `DEEPSEEK_API_KEY` | Auto-fallback on 429 | | `opencode` | `claude-3-sonnet` | — | OpenCode local proxy | ### 6.2 Proxy API ``` POST /api/llm Content-Type: application/json { "messages": [{ "role": "user", "content": "..." }, ...], "provider": "mistral", // optional, defaults to mistral "model": "mistral-large-latest" // optional } Response: { "content": "...", "model": "..." } | { "content": "...", "model": "...", "fallback": true } // on auto-failover ``` ### 6.3 Slot Filling The system prompt instructs the LLM to: 1. Extract ALL info from the user's message before asking anything 2. Ask at most **one question** at a time 3. When all required slots are filled, output a fenced JSON block: ```` ```request { "title": "...", "description": "...", "categoryId": "...", ... } ``` ```` **Required fields:** `title`, `description`, `categoryId`, `urgency`, `deliveryInfo.deliveryType` **Optional:** `productLink`, `attachments[]`, `budget{min,max,currency}`, `quantity`, `size`, `color` The app detects the ` ```request ` fence, parses the JSON, and transitions to `REVIEW`. ### 6.4 Vision (Image Upload) Uses `pixtral-12b-2409`. The user uploads a photo; the LLM returns structured JSON with `name`, `category`, `color`, `description`, `quantity`. Result is merged into slots; `categoryId` is never set from vision (names aren't valid ObjectIds). ### 6.5 Price Suggestion If `slots.budget` is unset at REVIEW time, the app calls the LLM with a structured price-suggestion prompt. Result tagged `high`/`medium`/`low` confidence; only `high` and `medium` are auto-applied. --- ## 7. Request Slots Schema ```typescript interface RequestSlots { title?: string; description?: string; categoryId?: string; // must be a valid ObjectId from /api/categories productLink?: string; attachments?: string[]; // image URLs or base64 (base64 stripped before storage) budget?: { min?: number; max?: number; currency: string }; urgency?: 'low' | 'medium' | 'high' | 'urgent'; quantity?: number; size?: string; color?: string; deliveryInfo?: { deliveryType: 'physical' | 'online'; email?: string; }; } ``` --- ## 8. Frontend Components | Component | Description | |---|---| | `App.tsx` | State machine root — renders one screen per state | | `ChatUI` | Scrollable message list + text/photo input + category chips | | `ChatHistory` | localStorage-persisted past sessions list | | `ReviewCard` | Final structured view of filled slots + Submit/Edit buttons | | `AuthScreen` | Loading spinner shown during SSO | | `ErrorScreen` | Error message + Retry button | ### Hooks | Hook | Description | |---|---| | `useSlotFilling` | Manages LLM conversation, slot extraction, greeting, session load | | `useChatSessions` | Read/write/delete chat sessions from `localStorage` | --- ## 9. Amanat API Calls | Method | Endpoint | Auth | Purpose | |---|---|---|---| | `POST` | `/api/auth/telegram` | — | Exchange Telegram initData for JWT | | `POST` | `/api/auth/refresh-token` | — | Refresh expired access token | | `GET` | `/api/auth/me` | Bearer | Validate stored session | | `GET` | `/api/categories` | Bearer | Load category list for slot filling | | `POST` | `/api/requests` | Bearer | Submit completed purchase request | All requests from `src/services/api.ts` use `amanatApi()` from `auth.ts`, which auto-refreshes on 401. Submitted requests include `aiGenerated: true`. --- ## 10. Deployment ### CI Pipeline (`.woodpecker/ci.yml`) ```yaml trigger: push/manual to main agent: linux/arm64 (same host as assist.amn.gg) steps: 1. build-frontend (node:22-alpine): - npm ci + npm run build (Vite) - Bakes VITE_ env vars into the static bundle at build time 2. deploy (docker:27-cli, docker socket volume-mounted — no registry push): - Copy dist/ to /opt/amanat-assist/dist/ (nginx bind-mount) - Sync docker-compose.yml to /opt/amanat-assist/ - Rebuild `amanat-assist-llm-proxy` Docker image in-place (locally, never pushed) - docker compose up -d llm-proxy (recreates llm-proxy container only) 3. notify (node:22-alpine): - Runs scripts/ci/tg-notify.cjs on success or failure - Uses TG_TOKEN + TG_USERS secrets ``` Nginx picks up new static files from the bind-mount without restart. The proxy container is recreated with the new image. ### Environment Variables | Variable | Scope | Description | |---|---|---| | `VITE_AMANAT_API_BASE` | Frontend build-time | Backend URL (e.g. `https://dev.amn.gg`) | | `VITE_LLM_PROVIDER` | Frontend build-time | Default LLM provider (`mistral`) | | `VITE_LLM_API_URL` | Frontend build-time | Proxy URL (e.g. `https://assist.amn.gg/api/llm`) | | `MISTRAL_API_KEY` | llm-proxy runtime | Mistral API key (server-side only) | | `KIMI_API_KEY` | llm-proxy runtime | Optional Kimi API key | | `DEEPSEEK_API_KEY` | llm-proxy runtime | Optional DeepSeek API key (auto-fallback) | | `OPENCODE_PROXY_URL` | llm-proxy runtime | OpenCode local proxy URL (default `http://127.0.0.1:3456`) | | `ALLOWED_ORIGINS` | llm-proxy runtime | CORS whitelist (comma-separated) | | `PORT` | llm-proxy runtime | Port (default 3001) | --- ## 11. Integration with dev.amn.gg Frontend The `dev.amn.gg` frontend (Next.js) includes a native AI Assistant page at `/dashboard/assist` that: - Proxies `/api/llm` calls to `amanat-llm-proxy` via an internal Next.js API route - Uses the existing `dev.amn.gg` session (no re-auth needed) - Allows buyers to start an AI-assisted request flow from within the main dashboard - The "New Request" page includes a button to launch the AI assistant See `src/sections/assist/` in the frontend repo for the implementation. --- ## 12. Known Limitations / Open Items - **No voice input** — text and photo only (MVP) - **Single-item only** — one purchase request per conversation - **No post-submit editing** — requests posted via the assistant cannot be edited through the assistant - **Session storage is local only** — history lives in `localStorage`, not synced to backend - **Vision model not streaming** — responses may feel slow for image analysis - **categoryId from vision disabled** — vision returns category names, not ObjectIds; name→ID matching is left to the LLM in the follow-up turn - **llm-proxy is zero-dependency** — `llm-proxy/index.mjs` uses only Node.js built-ins (`http`, native `fetch`); no npm packages. Logs rotate at 10 MB. - **No registry push** — CI builds the llm-proxy image directly on the host via a docker socket volume mount; `docker pull` will always fail (intentional — image is local-only) - **Telegram theme override is --primary accent only** — applying full Telegram theme tokens causes invisible text on cream backgrounds; only the primary accent colour is overridden from the Telegram theme - **iframe auth handoff** — when embedded via iframe, auth is delivered via `access_token` + `user_json` URL params; the app decodes the JWT client-side as a fallback when the backend `/api/auth/me` call is not possible - **slotsRef stale-closure guard** — review submit uses a ref (`slotsRef`) instead of state directly to avoid a stale-closure bug that could cause the wrong slot values to be submitted