diff --git a/.env.local.example b/.env.local.example index 27e1e11312..b4720a8378 100644 --- a/.env.local.example +++ b/.env.local.example @@ -91,3 +91,15 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com # Set this in CI and local development so you can exercise the full # template UI without a real WABA. Leave unset (or "false") in prod. # WHATSAPP_TEMPLATES_DRY_RUN=true + +# AI assistant (Settings → AI Assistant). Anthropic API key used to +# auto-reply to inbound WhatsApp messages grounded in your knowledge +# base. Leave blank to keep the assistant effectively off — without it +# every inbound is handed straight to a human (the feature fails safe). +# Get one at https://console.anthropic.com → API Keys. +# ANTHROPIC_API_KEY=sk-ant-... + +# Optional override of the default model for AI replies. Defaults to +# claude-sonnet-4-6; set claude-haiku-4-5 for a cheaper/faster option. +# The model is also selectable per-account in Settings → AI Assistant. +# AI_DEFAULT_MODEL=claude-sonnet-4-6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b93bb25f44..9cd7d42ffe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,11 @@ jobs: NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-dummy-anon-key ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' META_APP_SECRET: 'ci-dummy-meta-secret' + # The AI assistant reads ANTHROPIC_API_KEY lazily (only when a reply + # is actually generated), so build/typecheck/test don't strictly + # need it — but provide a placeholder for parity with the other + # secrets and to keep any future module-load read satisfied. + ANTHROPIC_API_KEY: 'ci-dummy-anthropic-key' steps: - uses: actions/checkout@v7 diff --git a/docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md b/docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md new file mode 100644 index 0000000000..8b0a9de4f1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-wacrm-ai-assistant-design.md @@ -0,0 +1,378 @@ +# AI Assistant for WhatsApp — Design Spec + +- **Date:** 2026-06-25 +- **Status:** Approved design — ready for implementation plan +- **Author:** brainstormed with the maintainer +- **Feature branch (planned):** `feat/ai-assistant` +- **Migration:** `027_ai_assistant.sql` + +--- + +## 1. Goal + +Add an AI assistant that answers inbound WhatsApp messages, **grounded +only in a per-account knowledge base** ("LLM wiki"). When the model is +confident the knowledge base covers the question, it **replies +autonomously**. When it is unsure, it **escalates to a human** and goes +silent on that conversation. Admins manage the knowledge base and edit +the assistant's prompt from Settings. + +This is a customer-facing, autonomous-send feature on the official +WhatsApp Business API — so the bias everywhere is **fail safe to a +human**: any doubt, any error, any cap hit → escalate, never guess and +never go silent. + +## 2. Locked decisions (from brainstorming) + +| Decision | Choice | Rationale | +|---|---|---| +| Audience / channel | Customer-facing on WhatsApp | Highest value; builds on existing webhook + inbox | +| Engagement model | AI is first responder; autonomous send when confident; clean hand-off + escalate when unsure; goes silent once a human takes over; agent can hand back | Matches "responds immediately; if unsure add a human" | +| Knowledge approach | **LLM wiki (long-context + prompt caching)**, not vector/RAG | One provider, no pgvector/embeddings/chunking, ships faster; fits a typical company KB. RAG deferred with an upgrade path | +| Confidence detection | **LLM self-reported grounding + deterministic guardrails** (both must pass to auto-send) | The retrieval half of the original "hybrid" is N/A since we send the whole KB; guardrails cover the deterministic half | +| Provider | **Anthropic only** (Claude) | One key; LLM-wiki needs no embeddings provider | +| Where logic lives | Dedicated `src/lib/ai/` module wired into the existing WhatsApp webhook | Lowest latency, self-contained, testable; designed so it could later be a Flows node | +| Default model | `claude-sonnet-4-6` (configurable; Haiku 4.5 for cheaper/faster) | Good support-reply quality/cost balance | +| Cost guard | Per-account **daily reply cap** (default 500/day, editable) | Prevents runaway spend; cap hit → escalate | + +## 3. Scope + +**In v1** +- DB migration: AI config table, knowledge base table, conversation AI + columns, AI reply audit log. +- `src/lib/ai/` module: prompt assembly, decision core, Anthropic + client wrapper, escalation logic — pure/mockable where possible. +- Webhook integration: AI auto-reply dispatched from `processMessage`, + gated on `!flowConsumed` and `ai_handling`. +- Settings → "AI Assistant" tab: enable toggle, prompt editor, handoff + message, escalation keywords, business name + logo, **knowledge base + manager** (CRUD + `.txt`/`.md`/PDF upload → text), KB size meter. +- Inbox hand-off UX: "Needs human" badge, AI message tag (`sender_type + = 'bot'`), Take over / Hand back to AI controls. +- API routes for AI config + knowledge base CRUD (account-scoped, + admin+). +- Unit tests for the decision core and prompt assembly. + +**Deferred (not in v1; upgrade paths designed in)** +- Vector/RAG retrieval (turn on when a KB exceeds the context budget). +- Usage/cost analytics dashboard (data is logged in v1 via + `ai_reply_log`; the UI comes later). +- Customer-facing logo surface / web chat widget (logo stored in v1, + used only as persona context). +- Multi-language tuning, voice-note transcription, image understanding. + +## 4. Data model — `supabase/migrations/027_ai_assistant.sql` + +Follows existing conventions: `account_id` tenancy column, RLS enabled, +`is_account_member(account_id, min_role)` for policies (mirror +migrations 017–026). Service-role writes from the webhook bypass RLS as +the other engines already do. + +### 4.1 `ai_assistant_config` — one row per account +| Column | Type | Notes | +|---|---|---| +| `id` | uuid pk | | +| `account_id` | uuid unique not null | FK → accounts, `ON DELETE CASCADE` | +| `enabled` | boolean not null default false | Master switch; off by default (opt-in) | +| `system_prompt` | text not null | Editable prompt; seeded with a strong default (see §7.2) | +| `handoff_message` | text | Sent to the customer on escalation; nullable = send nothing | +| `escalation_keywords` | text[] not null default `'{}'` | Seeded: refund, cancel, complaint, lawyer, legal, human, agent, manager | +| `business_name` | text | Persona context | +| `logo_url` | text | Stored via existing storage bucket; persona context only in v1 | +| `model` | text not null default `'claude-sonnet-4-6'` | Configurable | +| `daily_reply_cap` | integer not null default 500 | Cap hit → escalate | +| `created_at` / `updated_at` | timestamptz default now() | | + +**RLS:** select/insert/update for `is_account_member(account_id, +'admin')`. (Read could be widened to any member if the UI needs it; +keep admin-only in v1.) + +### 4.2 `knowledge_base_entries` — many per account (the "wiki pages") +| Column | Type | Notes | +|---|---|---| +| `id` | uuid pk | | +| `account_id` | uuid not null | FK → accounts, cascade | +| `title` | text not null | | +| `content` | text not null | Markdown/plain text; what gets fed to the model | +| `source_type` | text not null default `'manual'` | CHECK in (`manual`, `file`) | +| `source_filename` | text | Original filename when `source_type='file'` | +| `enabled` | boolean not null default true | Disabled entries are excluded from the prompt | +| `token_estimate` | integer | Cached ~token count for the size meter (chars/4 heuristic) | +| `created_by_user_id` | uuid | Audit | +| `created_at` / `updated_at` | timestamptz default now() | | + +**RLS:** all ops for `is_account_member(account_id, 'admin')`. + +### 4.3 `conversations` — add AI hand-off columns (ALTER) +| Column | Type | Notes | +|---|---|---| +| `ai_handling` | boolean not null default true | Is the AI still driving this conversation? Set false when escalated or when a human replies | +| `ai_escalated_at` | timestamptz | Set on escalation | +| `ai_escalation_reason` | text | `low_confidence` / `keyword` / `error` / `cap_reached` / `human_takeover` | + +(No change to the `status` CHECK. On escalation we also set +`status='pending'`, which is already an allowed value and reads as +"needs attention" in the inbox.) + +### 4.4 `ai_reply_log` — one row per AI decision (audit + future cost view) +| Column | Type | Notes | +|---|---|---| +| `id` | uuid pk | | +| `account_id` | uuid not null | cascade | +| `conversation_id` | uuid | | +| `message_id` | uuid | the inbound `messages.id` that triggered it | +| `decision` | text not null | CHECK in (`replied`, `escalated`, `skipped`, `error`) | +| `confident` | boolean | model's self-report (null when skipped pre-LLM) | +| `reason` | text | escalation reason / error summary | +| `model` | text | | +| `input_tokens` / `output_tokens` / `cache_read_tokens` | integer | from `usage` | +| `latency_ms` | integer | | +| `created_at` | timestamptz default now() | | + +**RLS:** select for `is_account_member(account_id, 'admin')`; inserts +are service-role only. + +## 5. Module structure — `src/lib/ai/` + +| File | Responsibility | I/O | +|---|---|---| +| `admin-client.ts` | Lazy service-role Supabase client | mirrors `src/lib/flows/admin-client.ts` | +| `config.ts` | Load `ai_assistant_config` for an account | DB read | +| `knowledge-base.ts` | Load enabled KB entries; assemble the KB text block; estimate tokens | DB read + pure | +| `prompt.ts` | **Pure.** Build the system block (persona + KB, cache-marked) and the message array from conversation history + inbound | pure | +| `guardrails.ts` | **Pure.** Deterministic escalation rules (keyword match, explicit human request) | pure | +| `decide.ts` | **Pure.** Given the model's structured response + guardrail result + cap state → `{action: 'reply'|'escalate', text?, reason?}` | pure | +| `anthropic.ts` | Thin wrapper around `@anthropic-ai/sdk`: forces the `submit_answer` tool, returns `{answer, confident, reason, usage}`. Injectable for tests | network | +| `reply.ts` | Orchestrator: `maybeReplyToInbound(...)` — loads config/KB, runs guardrails, calls the model, applies `decide`, sends or escalates, writes `ai_reply_log`. Wraps everything in try/catch → escalate on error | composes the above | +| `send.ts` | Send an AI reply via the existing Meta send path and insert the outbound `messages` row with `sender_type='bot'` | network + DB | +| `escalate.ts` | Set `ai_handling=false`, `ai_escalated_at`, `ai_escalation_reason`, `status='pending'`; optionally send `handoff_message` | DB + network | + +The pure modules (`prompt`, `guardrails`, `decide`) hold all the +logic worth testing; `anthropic`/`send`/DB are thin and mocked. + +## 6. Runtime flow + +Integration point: `src/app/api/whatsapp/webhook/route.ts` → +`processMessage()`, **after** the flow dispatch resolves `flowConsumed` +and after the inbound `messages` row is inserted. The webhook already +runs `processWebhook(...)` fire-and-forget before acking Meta 200, so +no change to the ack path is needed (no double-send risk). The AI call +is fire-and-forget with its own try/catch, exactly like the automation +dispatch. + +``` +inbound message stored + │ + ├─ flowConsumed? ── yes ─▶ skip AI (customer is in a bot menu/flow) + │ no + ▼ +maybeReplyToInbound({ accountId, conversationId, contactId, messageId, inboundText }) + │ + load ai_assistant_config + ├─ !enabled ─▶ skip + load conversation + ├─ ai_handling === false ─▶ skip (human owns it) + guardrails(inboundText, keywords) + ├─ keyword / "talk to a human" ─▶ escalate (reason=keyword), no LLM call + daily cap check (count today's `replied` in ai_reply_log) + ├─ cap reached ─▶ escalate (reason=cap_reached) + ▼ + build prompt (persona + cached KB block + recent history + inbound) + call Claude, force submit_answer tool → { answer, confident, reason, usage } + ▼ + decide(modelResult) + ├─ confident && answer ─▶ send (sender_type='bot') + log replied + └─ else ─▶ escalate (reason=low_confidence) + log escalated + ▼ + any throw anywhere ─▶ escalate (reason=error) + log error +``` + +**Human-takeover detection:** when an agent sends a manual reply +(`POST /api/whatsapp/send`), set `ai_handling=false` on that +conversation in the same handler. This guarantees the AI stays silent +the instant a human engages, even mid-stream. + +## 7. Prompt & decision contract + +### 7.1 Structured output +Call Claude with a single forced tool `submit_answer` +(`tool_choice: { type: 'tool', name: 'submit_answer' }`) whose +`input_schema` is: +```json +{ + "answer": "string — the reply to send the customer, empty if not confident", + "confident": "boolean — true ONLY if the knowledge base fully answers the question", + "reason": "string — short rationale, for the audit log" +} +``` +This makes the decision reliably parseable (no JSON-in-text scraping). + +### 7.2 System block (cache-marked) +`system` is an array; the **KB block carries +`cache_control: { type: 'ephemeral' }`** so it bills at ~10% on cache +hits (5-min TTL). Order: stable persona/instructions first, then the +assembled KB (most cacheable last-stable content). + +Seeded default `system_prompt` (editable): +> You are the customer-support assistant for {business_name}. Answer +> ONLY using the information in the KNOWLEDGE BASE below. If the +> knowledge base does not clearly and fully answer the customer's +> question, you MUST set `confident` to false and leave `answer` empty — +> do not guess, do not use outside knowledge, do not make promises about +> pricing, refunds, delivery dates, or policies that aren't written +> here. Be concise, friendly, and match the customer's language. + +The assembled KB block is the concatenation of enabled +`knowledge_base_entries` (`## {title}\n{content}`), wrapped in clear +delimiters. + +### 7.3 Conversation history +Include the last N inbound/outbound messages (default N=10) for context, +oldest→newest, mapped to `user`/`assistant` roles. History is NOT +cached (it changes every turn); only the KB block is. + +### 7.4 Account isolation (critical) +`prompt.ts` receives KB entries already filtered by `account_id`. A unit +test asserts that entries from another account never appear in the +assembled block. No global/shared KB in v1. + +## 8. Escalation & inbox UX + +- **Conversation list:** conversations with `ai_escalated_at` set (and + `status='pending'`) show a **"🙋 Needs human"** badge. Surfaces live + via the existing Supabase realtime subscription. +- **Thread:** messages with `sender_type='bot'` render with a small + **"AI"** tag so agents see what the bot said. +- **Controls:** a per-conversation **"Take over"** (sets + `ai_handling=false`) and **"Hand back to AI"** (sets + `ai_handling=true`, clears escalation fields) toggle. Sending a manual + reply implicitly takes over. +- No new conversation status value introduced; reuse `pending`. + +## 9. Settings → "AI Assistant" tab + +Plugs into the existing settings rail (`settings-sections.ts` + +`settings-rail.tsx`). Gated by `canEditSettings(role)` (admin+), mirror +`whatsapp-config.tsx` / `template-manager.tsx` patterns. + +Sections: +1. **Status & enable** — master toggle; shows whether `ANTHROPIC_API_KEY` + is configured (server-reported boolean, never the key). +2. **Prompt** — textarea for `system_prompt` (seeded default), handoff + message, escalation-keyword chips. +3. **Persona** — business name, logo upload (existing storage helper). +4. **Knowledge base manager** — list entries (title, size, enabled + toggle), add/edit (title + markdown content), delete, and **file + upload** (`.txt`/`.md` read directly; PDF → text extraction). A + **size meter** shows total estimated tokens vs. a soft budget + (e.g. 150k) with a warning band — the signal to enable RAG later. +5. **Model** — dropdown (Sonnet 4.6 / Haiku 4.5), daily reply cap input. + +## 10. API routes (account-scoped, admin+ via `requireRole`) + +| Route | Methods | Purpose | +|---|---|---| +| `src/app/api/ai/config/route.ts` | GET, PUT | Read/update `ai_assistant_config` | +| `src/app/api/ai/knowledge/route.ts` | GET, POST | List / create KB entries | +| `src/app/api/ai/knowledge/[id]/route.ts` | PATCH, DELETE | Edit / delete a KB entry | +| `src/app/api/ai/knowledge/upload/route.ts` | POST | Accept a file, extract text, create an entry | + +All use the user-session Supabase server client (RLS enforces tenancy) +plus an explicit role check. The conversation hand-off toggle reuses / +extends the existing conversation-update path used by the inbox. + +## 11. Environment & config + +Add to `.env.local.example` (and document in the CI dummy-env block): +``` +# AI assistant (Anthropic). Leave blank to disable AI replies. +ANTHROPIC_API_KEY= +# Optional override of the default model. +AI_DEFAULT_MODEL=claude-sonnet-4-6 +``` +`anthropic.ts` reads `ANTHROPIC_API_KEY` lazily (like the Supabase admin +clients) so a missing key never crashes the build — it just means AI is +effectively off (treated as `enabled=false` / escalate). + +## 12. Security & safety + +- API key server-side only; never sent to the client; Settings shows + only a configured/not-configured boolean. +- RLS on every new table; admin+ for config and KB; `ai_reply_log` + inserts are service-role only, reads admin-only. +- **Strict per-account KB isolation** in prompt assembly (unit-tested). +- **Fail safe to human**: missing key, API error, timeout, cap reached, + or low confidence all escalate; the customer is never left silent and + the bot never guesses. +- No CSP change: all Anthropic calls are server-side (mirrors how Meta + calls already work). +- The bot cannot perform destructive actions — it only sends a text + reply or escalates. No tool access to the DB beyond logging. + +## 13. Cost controls + +- **Prompt caching** on the KB block (primary lever). +- **Daily reply cap** per account (escalate when exceeded). +- History window capped (default 10 messages). +- `ai_reply_log` records token usage per call for a future cost view. + +## 14. Testing + +Vitest, matching the `src/lib/**/*.test.ts` convention. + +- `prompt.test.ts` — KB assembly, cache_control placement, account + isolation, history mapping/order, history cap. +- `guardrails.test.ts` — keyword matching (case/word-boundary), explicit + human-request detection, empty-KB behavior. +- `decide.test.ts` — confident+answer → reply; not-confident → escalate; + empty answer despite confident → escalate; reason propagation. +- `reply.test.ts` — orchestration with a mocked `anthropic` + mocked DB: + disabled→skip, human-owned→skip, keyword→escalate (no LLM call), + cap→escalate, error→escalate, happy path→send+log. +- Anthropic SDK and Supabase admin client are injected/mocked; no + network in tests. + +CI (`.github/workflows/ci.yml`) gains a dummy `ANTHROPIC_API_KEY` in the +env block so `next build` and module-load are satisfied (mirrors the +existing `META_APP_SECRET` pattern). + +## 15. Build orchestration (workflow + worktrees) + +Implementation runs as a **Workflow** inside a dedicated **git +worktree** on branch `feat/ai-assistant`, so it never disturbs the main +checkout and lands as one reviewable branch. + +Phased pipeline (dependency-ordered; later phases consume earlier +outputs): + +1. **Schema** — write `027_ai_assistant.sql` + extend `src/types`. +2. **Lib core** (parallelizable once types exist) — the pure modules + (`prompt`, `guardrails`, `decide`) **with their tests written first**, + then `anthropic`, `config`, `knowledge-base`, `send`, `escalate`, + `reply`. +3. **Webhook integration** — wire `maybeReplyToInbound` into + `processMessage`; set `ai_handling=false` in the send route. +4. **API routes** — config + knowledge CRUD + upload. +5. **UI** — Settings "AI Assistant" tab + KB manager; inbox badge, AI + tag, take-over/hand-back controls. +6. **Verify** — `npm run lint && npm run typecheck && npm test && + npm run build`; iterate to green. +7. **Review** — adversarial review pass (security + correctness) before + opening a PR. + +Agents that mutate overlapping files run sequentially; independent files +(e.g. the three pure libs, the API route files) fan out in parallel. +Every agent is told to **read the relevant `node_modules/next/dist/docs/` +guide before writing Next.js code** (per `AGENTS.md` — this is a modified +Next.js 16) and to follow existing patterns in the files referenced +above. + +## 16. Future / upgrade path + +- **RAG:** when the KB size meter trips the budget, add + `knowledge_base_chunks` (pgvector) + an embedding step; `prompt.ts` + swaps "whole KB" for "top-k retrieved chunks". Nothing else in the + flow changes — `decide`, guardrails, escalation, logging are reused. +- **Flows node:** expose `maybeReplyToInbound` as an "AI Reply" node so + it can be composed visually. +- **Cost dashboard:** read `ai_reply_log` into the existing dashboard. diff --git a/package-lock.json b/package-lock.json index 7de68320f7..3458154a33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.2", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.106.0", "@base-ui/react": "^1.5.0", "@dagrejs/dagre": "^3.0.0", "@dnd-kit/core": "^6.3.1", @@ -29,7 +30,8 @@ "shadcn": "^4.11.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "unpdf": "^1.6.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -61,6 +63,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.106.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.106.0.tgz", + "integrity": "sha512-ufwVvYNDBj2dzOGupBCTaNzBLxqcTnGOzI4z8Wouxlt+mT3J3HuOmatgCy1VmwCHOUueqZ41ERhm0O99OUcbWA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2248,6 +2271,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2951,7 +2980,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2961,7 +2990,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -4526,7 +4555,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/d3-array": { @@ -5900,6 +5929,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -7293,6 +7328,19 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -8850,6 +8898,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/react-redux": { @@ -9654,6 +9703,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -10080,6 +10139,12 @@ "node": ">=0.6" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -10253,7 +10318,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10334,6 +10399,20 @@ "node": ">= 10.0.0" } }, + "node_modules/unpdf": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/unpdf/-/unpdf-1.6.2.tgz", + "integrity": "sha512-zQ80ySoPuPHOsvIoRp/nJyQt8TOUoTh1+WBCGcBvlddQNgKDLRwm0AY3x8Q35I7+kIiRSgqMx+Ma2pl9McIp7A==", + "license": "MIT", + "peerDependencies": { + "@napi-rs/canvas": "^0.1.69" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 20d01a2493..2f9a3c8455 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:watch": "vitest" }, "dependencies": { + "@anthropic-ai/sdk": "^0.106.0", "@base-ui/react": "^1.5.0", "@dagrejs/dagre": "^3.0.0", "@dnd-kit/core": "^6.3.1", @@ -59,7 +60,8 @@ "shadcn": "^4.11.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "unpdf": "^1.6.2" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index 7ec389549b..38eea74d17 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -537,6 +537,23 @@ export default function InboxPage() { [activeConversation] ); + // Reflect a Take over / Hand back to AI toggle. Mirrors the status / + // assign handlers: patch the list row + active conversation optimistically + // so the header control and the "Needs human" badge update instantly; the + // realtime conversations.UPDATE the Supabase write emits converges the + // same fields as a no-op. + const handleAiHandlingChange = useCallback( + (conversationId: string, updates: Partial) => { + setConversations((prev) => + prev.map((c) => (c.id === conversationId ? { ...c, ...updates } : c)) + ); + if (activeConversation?.id === conversationId) { + setActiveConversation((prev) => (prev ? { ...prev, ...updates } : prev)); + } + }, + [activeConversation] + ); + // On mobile ( = { @@ -55,7 +57,9 @@ export default function SettingsPage() { profile: , security: , appearance: , + branding: , whatsapp: , + ai: , templates: , fields: , deals: , @@ -66,10 +70,10 @@ export default function SettingsPage() { return (
-

+

Settings

-

+

Everything in one place — your account and your workspace. Pick a section to manage it.

diff --git a/src/app/api/account/route.ts b/src/app/api/account/route.ts index 2f4cf81bb6..8cae35f967 100644 --- a/src/app/api/account/route.ts +++ b/src/app/api/account/route.ts @@ -11,18 +11,18 @@ // without buying anything. // ============================================================ -import { NextResponse } from "next/server"; +import { NextResponse } from 'next/server'; import { requireRole, getCurrentAccount, toErrorResponse, -} from "@/lib/auth/account"; +} from '@/lib/auth/account'; import { checkRateLimit, rateLimitResponse, RATE_LIMITS, -} from "@/lib/rate-limit"; +} from '@/lib/rate-limit'; export async function GET() { try { @@ -40,7 +40,7 @@ const MAX_NAME_LEN = 80; export async function PATCH(request: Request) { try { - const ctx = await requireRole("admin"); + const ctx = await requireRole('admin'); // Per-user limit on admin-class mutations. Bounds accidental // abuse (script run in a loop) and a compromised admin session @@ -48,51 +48,107 @@ export async function PATCH(request: Request) { // one route doesn't starve another. const limit = checkRateLimit( `admin:rename:${ctx.userId}`, - RATE_LIMITS.adminAction, + RATE_LIMITS.adminAction ); if (!limit.success) return rateLimitResponse(limit); - const body = (await request.json().catch(() => null)) as - | { name?: unknown } - | null; - const rawName = body?.name; - - if (typeof rawName !== "string") { + const body = (await request.json().catch(() => null)) as { + name?: unknown; + logo_url?: unknown; + } | null; + if (!body || typeof body !== 'object') { return NextResponse.json( - { error: "'name' must be a string" }, - { status: 400 }, + { error: 'Request body must be a JSON object' }, + { status: 400 } ); } - const name = rawName.trim(); - if (name.length === 0) { - return NextResponse.json( - { error: "Account name cannot be empty" }, - { status: 400 }, - ); + // Update only the fields supplied — the Branding tab may save the + // name, the logo, or both. + const updates: { name?: string; logo_url?: string | null } = {}; + + if (body.name !== undefined) { + if (typeof body.name !== 'string') { + return NextResponse.json( + { error: "'name' must be a string" }, + { status: 400 } + ); + } + const name = body.name.trim(); + if (name.length === 0) { + return NextResponse.json( + { error: 'Account name cannot be empty' }, + { status: 400 } + ); + } + if (name.length > MAX_NAME_LEN) { + return NextResponse.json( + { error: `Account name must be ${MAX_NAME_LEN} characters or fewer` }, + { status: 400 } + ); + } + updates.name = name; } - if (name.length > MAX_NAME_LEN) { - return NextResponse.json( - { error: `Account name must be ${MAX_NAME_LEN} characters or fewer` }, - { status: 400 }, - ); + + if (body.logo_url !== undefined) { + if (body.logo_url === null) { + updates.logo_url = null; + } else if (typeof body.logo_url === 'string') { + const url = body.logo_url.trim(); + if (url.length === 0) { + updates.logo_url = null; + } else if (url.length > 2000) { + return NextResponse.json( + { error: "'logo_url' is too long" }, + { status: 400 } + ); + } else { + // http(s) only — reject javascript:/data: so a stored logo + // can never become an injection vector where it's rendered. + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return NextResponse.json( + { error: "'logo_url' must be a valid URL" }, + { status: 400 } + ); + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return NextResponse.json( + { error: "'logo_url' must be an http(s) URL" }, + { status: 400 } + ); + } + updates.logo_url = url; + } + } else { + return NextResponse.json( + { error: "'logo_url' must be a string or null" }, + { status: 400 } + ); + } + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: 'Nothing to update' }, { status: 400 }); } // RLS allows this UPDATE because accounts_update requires // `is_account_member(id, 'admin')`, and requireRole already // guaranteed the caller is admin+. const { data, error } = await ctx.supabase - .from("accounts") - .update({ name }) - .eq("id", ctx.accountId) - .select("id, name") + .from('accounts') + .update(updates) + .eq('id', ctx.accountId) + .select('id, name, logo_url') .single(); if (error) { - console.error("[PATCH /api/account] update error:", error); + console.error('[PATCH /api/account] update error:', error); return NextResponse.json( - { error: "Failed to update account" }, - { status: 500 }, + { error: 'Failed to update account' }, + { status: 500 } ); } diff --git a/src/app/api/ai/config/route.ts b/src/app/api/ai/config/route.ts new file mode 100644 index 0000000000..61d196c52b --- /dev/null +++ b/src/app/api/ai/config/route.ts @@ -0,0 +1,418 @@ +// ============================================================ +// /api/ai/config +// +// GET — read this account's AI assistant config (spec §4.1, §9, +// §10). Seeds a default row the first time the AI Settings +// tab is opened (mirroring how other settings rows are +// lazily created), so the UI always gets a config to bind. +// Adds a server-computed `apiKeyConfigured` boolean — +// whether `ANTHROPIC_API_KEY` is set in the environment. +// The key ITSELF is never returned (spec §9.1, §12). +// PUT — update the editable fields. Admin+ only. +// +// Both verbs go through the cookie-session Supabase server client, +// so RLS enforces tenancy. On top of that the `ai_assistant_config` +// RLS policies are admin-only (migration 027), but we ALSO gate the +// route with `requireRole('admin')` — the same belt-and-braces +// pattern as `/api/account/api-keys` (explicit role check + RLS), +// so an under-privileged caller gets a clean 403 instead of an +// opaque empty result from RLS. +// ============================================================ + +import { NextResponse } from 'next/server'; + +import { requireRole, toErrorResponse } from '@/lib/auth/account'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; +import type { AiAssistantConfig } from '@/types'; + +// Columns the client may read. Mirrors `AiAssistantConfig`; there is +// no secret column on this table (the Anthropic key lives in env, not +// the DB), so we select everything the row holds. +const CONFIG_COLUMNS = + 'id, account_id, enabled, system_prompt, handoff_message, escalation_keywords, business_name, logo_url, model, daily_reply_cap, created_at, updated_at'; + +// Bounds on caller-supplied free text, so a runaway paste can't bloat +// the row (and, downstream, the prompt). Generous — these are admin +// settings, not user input. +const MAX_PROMPT_LEN = 20_000; +const MAX_HANDOFF_LEN = 2_000; +const MAX_BUSINESS_NAME_LEN = 200; +const MAX_LOGO_URL_LEN = 2_000; +const MAX_MODEL_LEN = 100; +const MAX_KEYWORDS = 100; +const MAX_KEYWORD_LEN = 80; +// Daily reply cap is a positive integer; clamp the ceiling so a typo +// can't disable the cost guard entirely. +const MAX_DAILY_REPLY_CAP = 100_000; + +/** + * Whether the Anthropic key is present in the server environment. + * Computed per-request and surfaced as a plain boolean so Settings can + * show a "key configured / not configured" status WITHOUT the key ever + * crossing to the client (spec §9.1, §12). + */ +function apiKeyConfigured(): boolean { + return !!process.env.ANTHROPIC_API_KEY; +} + +export async function GET() { + try { + // Settings-class read: admin+ only, matching the table's RLS. + const ctx = await requireRole('admin'); + + // Try to read the (at most one) config row for this account. + const { data: existing, error: selectError } = await ctx.supabase + .from('ai_assistant_config') + .select(CONFIG_COLUMNS) + .eq('account_id', ctx.accountId) + .maybeSingle(); + + if (selectError) { + console.error('[GET /api/ai/config] fetch error:', selectError); + return NextResponse.json( + { error: 'Failed to load AI configuration' }, + { status: 500 } + ); + } + + if (existing) { + return NextResponse.json({ + config: existing as AiAssistantConfig, + apiKeyConfigured: apiKeyConfigured(), + }); + } + + // No row yet — the account has never opened this tab. Seed a + // default row so the UI always has something to bind to. Every + // column has a DB default (migration 027), so we only supply + // `account_id`; Postgres fills the seeded prompt, keywords, + // model, and cap. RLS `ai_assistant_config_insert` permits this + // because `requireRole('admin')` already proved the caller is + // admin+ for this account. + const { data: seeded, error: insertError } = await ctx.supabase + .from('ai_assistant_config') + .insert({ account_id: ctx.accountId }) + .select(CONFIG_COLUMNS) + .single(); + + if (insertError || !seeded) { + console.error('[GET /api/ai/config] seed error:', insertError); + return NextResponse.json( + { error: 'Failed to initialize AI configuration' }, + { status: 500 } + ); + } + + return NextResponse.json({ + config: seeded as AiAssistantConfig, + apiKeyConfigured: apiKeyConfigured(), + }); + } catch (err) { + return toErrorResponse(err); + } +} + +export async function PUT(request: Request) { + try { + const ctx = await requireRole('admin'); + + // Per-admin limit on settings mutations — bounds accidental abuse + // (a loop) and a compromised session, with its own bucket so it + // doesn't starve other admin endpoints. + const limit = checkRateLimit( + `admin:aiConfigUpdate:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const body = (await request.json().catch(() => null)) as { + enabled?: unknown; + system_prompt?: unknown; + handoff_message?: unknown; + escalation_keywords?: unknown; + business_name?: unknown; + logo_url?: unknown; + model?: unknown; + daily_reply_cap?: unknown; + } | null; + + if (!body || typeof body !== 'object') { + return NextResponse.json( + { error: 'Request body must be a JSON object' }, + { status: 400 } + ); + } + + // Build the update from only the editable fields that were + // supplied. Validate each; reject the whole request on any bad + // field rather than silently dropping it. `id`, `account_id`, and + // the timestamps are never writable here. + const updates: Record = {}; + + if (body.enabled !== undefined) { + if (typeof body.enabled !== 'boolean') { + return NextResponse.json( + { error: "'enabled' must be a boolean" }, + { status: 400 } + ); + } + updates.enabled = body.enabled; + } + + if (body.system_prompt !== undefined) { + if (typeof body.system_prompt !== 'string') { + return NextResponse.json( + { error: "'system_prompt' must be a string" }, + { status: 400 } + ); + } + const prompt = body.system_prompt.trim(); + // NOT NULL in the DB — an empty prompt would strip the safety + // instructions, so require non-empty. + if (prompt.length === 0) { + return NextResponse.json( + { error: "'system_prompt' cannot be empty" }, + { status: 400 } + ); + } + if (prompt.length > MAX_PROMPT_LEN) { + return NextResponse.json( + { + error: `'system_prompt' must be ${MAX_PROMPT_LEN} characters or fewer`, + }, + { status: 400 } + ); + } + updates.system_prompt = prompt; + } + + if (body.handoff_message !== undefined) { + // Nullable column — null/empty means "send nothing on escalation". + if (body.handoff_message === null) { + updates.handoff_message = null; + } else if (typeof body.handoff_message === 'string') { + const msg = body.handoff_message.trim(); + if (msg.length > MAX_HANDOFF_LEN) { + return NextResponse.json( + { + error: `'handoff_message' must be ${MAX_HANDOFF_LEN} characters or fewer`, + }, + { status: 400 } + ); + } + updates.handoff_message = msg.length === 0 ? null : msg; + } else { + return NextResponse.json( + { error: "'handoff_message' must be a string or null" }, + { status: 400 } + ); + } + } + + if (body.escalation_keywords !== undefined) { + if (!Array.isArray(body.escalation_keywords)) { + return NextResponse.json( + { error: "'escalation_keywords' must be an array of strings" }, + { status: 400 } + ); + } + if (body.escalation_keywords.length > MAX_KEYWORDS) { + return NextResponse.json( + { error: `At most ${MAX_KEYWORDS} escalation keywords are allowed` }, + { status: 400 } + ); + } + const keywords: string[] = []; + for (const raw of body.escalation_keywords) { + if (typeof raw !== 'string') { + return NextResponse.json( + { error: "'escalation_keywords' must be an array of strings" }, + { status: 400 } + ); + } + const kw = raw.trim().toLowerCase(); + if (kw.length === 0) continue; // drop blanks + if (kw.length > MAX_KEYWORD_LEN) { + return NextResponse.json( + { + error: `Each escalation keyword must be ${MAX_KEYWORD_LEN} characters or fewer`, + }, + { status: 400 } + ); + } + if (!keywords.includes(kw)) keywords.push(kw); // de-dupe + } + updates.escalation_keywords = keywords; + } + + if (body.business_name !== undefined) { + if (body.business_name === null) { + updates.business_name = null; + } else if (typeof body.business_name === 'string') { + const name = body.business_name.trim(); + if (name.length > MAX_BUSINESS_NAME_LEN) { + return NextResponse.json( + { + error: `'business_name' must be ${MAX_BUSINESS_NAME_LEN} characters or fewer`, + }, + { status: 400 } + ); + } + updates.business_name = name.length === 0 ? null : name; + } else { + return NextResponse.json( + { error: "'business_name' must be a string or null" }, + { status: 400 } + ); + } + } + + if (body.logo_url !== undefined) { + if (body.logo_url === null) { + updates.logo_url = null; + } else if (typeof body.logo_url === 'string') { + const url = body.logo_url.trim(); + if (url.length > MAX_LOGO_URL_LEN) { + return NextResponse.json( + { + error: `'logo_url' must be ${MAX_LOGO_URL_LEN} characters or fewer`, + }, + { status: 400 } + ); + } + if (url.length === 0) { + updates.logo_url = null; + } else { + // Only accept http(s) URLs — reject `javascript:` / `data:` and + // other schemes so a stored logo can never become an injection + // vector wherever it's later rendered as an . + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return NextResponse.json( + { error: "'logo_url' must be a valid URL" }, + { status: 400 } + ); + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return NextResponse.json( + { error: "'logo_url' must be an http(s) URL" }, + { status: 400 } + ); + } + updates.logo_url = url; + } + } else { + return NextResponse.json( + { error: "'logo_url' must be a string or null" }, + { status: 400 } + ); + } + } + + if (body.model !== undefined) { + if (typeof body.model !== 'string') { + return NextResponse.json( + { error: "'model' must be a string" }, + { status: 400 } + ); + } + const model = body.model.trim(); + // NOT NULL with a DB default — never allow it to be blanked. + if (model.length === 0) { + return NextResponse.json( + { error: "'model' cannot be empty" }, + { status: 400 } + ); + } + if (model.length > MAX_MODEL_LEN) { + return NextResponse.json( + { error: `'model' must be ${MAX_MODEL_LEN} characters or fewer` }, + { status: 400 } + ); + } + updates.model = model; + } + + if (body.daily_reply_cap !== undefined) { + const cap = body.daily_reply_cap; + if ( + typeof cap !== 'number' || + !Number.isInteger(cap) || + cap < 1 || + cap > MAX_DAILY_REPLY_CAP + ) { + return NextResponse.json( + { + error: `'daily_reply_cap' must be an integer between 1 and ${MAX_DAILY_REPLY_CAP}`, + }, + { status: 400 } + ); + } + updates.daily_reply_cap = cap; + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { error: 'No editable fields supplied' }, + { status: 400 } + ); + } + + // Update the account's row. RLS `ai_assistant_config_update` allows + // this because `requireRole('admin')` already proved the caller is + // admin+ for this account; `.eq('account_id')` keeps it scoped even + // so. If the row doesn't exist yet (the tab was never opened), seed + // it from the supplied values merged over the column defaults, then + // re-run as an insert so PUT is usable without a prior GET. + const { data: updated, error: updateError } = await ctx.supabase + .from('ai_assistant_config') + .update(updates) + .eq('account_id', ctx.accountId) + .select(CONFIG_COLUMNS) + .maybeSingle(); + + if (updateError) { + console.error('[PUT /api/ai/config] update error:', updateError); + return NextResponse.json( + { error: 'Failed to update AI configuration' }, + { status: 500 } + ); + } + + if (updated) { + return NextResponse.json({ + config: updated as AiAssistantConfig, + apiKeyConfigured: apiKeyConfigured(), + }); + } + + // No existing row matched — seed one with the supplied values laid + // over the DB column defaults. + const { data: inserted, error: insertError } = await ctx.supabase + .from('ai_assistant_config') + .insert({ account_id: ctx.accountId, ...updates }) + .select(CONFIG_COLUMNS) + .single(); + + if (insertError || !inserted) { + console.error('[PUT /api/ai/config] seed error:', insertError); + return NextResponse.json( + { error: 'Failed to update AI configuration' }, + { status: 500 } + ); + } + + return NextResponse.json({ + config: inserted as AiAssistantConfig, + apiKeyConfigured: apiKeyConfigured(), + }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/ai/knowledge/[id]/route.ts b/src/app/api/ai/knowledge/[id]/route.ts new file mode 100644 index 0000000000..2f53e51c39 --- /dev/null +++ b/src/app/api/ai/knowledge/[id]/route.ts @@ -0,0 +1,228 @@ +// ============================================================ +// /api/ai/knowledge/[id] +// +// PATCH — update a single knowledge_base_entry (title, content, +// and/or enabled). Recomputes `token_estimate` whenever +// `content` changes. +// DELETE — remove a single entry. +// +// These are the *dashboard* endpoints for editing one KB page +// (spec §10), so they authenticate the normal way (cookie session) +// and go through the RLS client. Both methods are admin+ only: the +// KB is account-settings-class data (spec §4.2 RLS: all ops for +// `is_account_member(account_id, 'admin')`), so we enforce the same +// admin floor on the wire that the table's RLS policies enforce in +// the database — `requireRole('admin')` plus the `canEditSettings` +// capability predicate (defence in depth alongside RLS). +// +// Tenancy: every query is scoped by BOTH `id` and `account_id`, so an +// admin can never touch another account's entry by guessing a UUID. +// RLS already enforces this; the explicit `.eq('account_id', …)` +// filter is belt-and-braces and makes the "0 rows → 404" path precise. +// +// `token_estimate` is recomputed server-side from the new content via +// the shared chars/4 heuristic (`estimateTokens`, spec §4.2 / §9), so +// the Settings size meter stays consistent regardless of caller and +// matches the value the collection route's POST writes. +// ============================================================ + +import { NextResponse } from 'next/server'; + +import { + ForbiddenError, + requireRole, + toErrorResponse, +} from '@/lib/auth/account'; +import { canEditSettings } from '@/lib/auth/roles'; +import { estimateTokens } from '@/lib/ai/knowledge-base'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; + +const MAX_TITLE_LEN = 200; +// Hard ceiling on a single entry's content. A KB page far longer +// than this is almost certainly a misuse (paste a whole site) and +// would blow the prompt budget on its own — reject early rather than +// silently truncate. Mirrors the collection route's POST. +const MAX_CONTENT_LEN = 200_000; + +// Columns safe to expose. Mirrors the `KnowledgeBaseEntry` shape and +// the collection route's `SAFE_COLUMNS`. +const SAFE_COLUMNS = + 'id, account_id, title, content, source_type, source_filename, enabled, token_estimate, created_by_user_id, created_at, updated_at'; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const ctx = await requireRole('admin'); + // Defence in depth: the capability predicate is the single source + // of truth for "can edit account settings" (the KB is settings- + // class), alongside the role floor above and the table RLS policy. + if (!canEditSettings(ctx.role)) { + throw new ForbiddenError('This action requires the admin role or higher'); + } + + const limit = checkRateLimit( + `admin:knowledgeUpdate:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const { id } = await params; + + const body = (await request.json().catch(() => null)) as { + title?: unknown; + content?: unknown; + enabled?: unknown; + } | null; + if (!body) { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + // Build the patch from only the fields the caller actually sent, so + // a partial update (e.g. just toggling `enabled`) leaves the rest + // untouched. Each field is validated as it's added. + const update: Record = {}; + + if ('title' in body) { + const rawTitle = typeof body.title === 'string' ? body.title.trim() : ''; + if (!rawTitle) { + return NextResponse.json( + { error: "'title' must be a non-empty string" }, + { status: 400 } + ); + } + if (rawTitle.length > MAX_TITLE_LEN) { + return NextResponse.json( + { error: `Title must be ${MAX_TITLE_LEN} characters or fewer` }, + { status: 400 } + ); + } + update.title = rawTitle; + } + + if ('content' in body) { + const rawContent = + typeof body.content === 'string' ? body.content.trim() : ''; + if (!rawContent) { + return NextResponse.json( + { error: "'content' must be a non-empty string" }, + { status: 400 } + ); + } + if (rawContent.length > MAX_CONTENT_LEN) { + return NextResponse.json( + { error: `Content must be ${MAX_CONTENT_LEN} characters or fewer` }, + { status: 400 } + ); + } + update.content = rawContent; + // Recompute the cached estimate whenever content changes so the + // size meter never drifts from the stored text (spec §4.2 / §9). + update.token_estimate = estimateTokens(rawContent); + } + + if ('enabled' in body) { + if (typeof body.enabled !== 'boolean') { + return NextResponse.json( + { error: "'enabled' must be a boolean" }, + { status: 400 } + ); + } + update.enabled = body.enabled; + } + + if (Object.keys(update).length === 0) { + return NextResponse.json( + { + error: + "Nothing to update — provide at least one of 'title', 'content', or 'enabled'", + }, + { status: 400 } + ); + } + + // Scope the update by account_id as well as id so an admin can never + // edit another account's entry by guessing a UUID. (RLS already + // enforces this; the explicit filter makes the "0 rows → 404" path + // precise.) + const { data, error } = await ctx.supabase + .from('knowledge_base_entries') + .update(update) + .eq('id', id) + .eq('account_id', ctx.accountId) + .select(SAFE_COLUMNS) + .maybeSingle(); + + if (error) { + console.error('[PATCH /api/ai/knowledge/[id]] update error:', error); + return NextResponse.json( + { error: 'Failed to update knowledge base entry' }, + { status: 500 } + ); + } + if (!data) { + return NextResponse.json( + { error: 'Knowledge base entry not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ entry: data }); + } catch (err) { + return toErrorResponse(err); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const ctx = await requireRole('admin'); + if (!canEditSettings(ctx.role)) { + throw new ForbiddenError('This action requires the admin role or higher'); + } + + const limit = checkRateLimit( + `admin:knowledgeDelete:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const { id } = await params; + + // Account-scoped delete (id + account_id) for the same tenancy + // reason as the PATCH above. `.select('id').maybeSingle()` lets us + // tell "deleted" from "no such entry in this account" → 404. + const { data, error } = await ctx.supabase + .from('knowledge_base_entries') + .delete() + .eq('id', id) + .eq('account_id', ctx.accountId) + .select('id') + .maybeSingle(); + + if (error) { + console.error('[DELETE /api/ai/knowledge/[id]] delete error:', error); + return NextResponse.json( + { error: 'Failed to delete knowledge base entry' }, + { status: 500 } + ); + } + if (!data) { + return NextResponse.json( + { error: 'Knowledge base entry not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/ai/knowledge/route.ts b/src/app/api/ai/knowledge/route.ts new file mode 100644 index 0000000000..67d7b97036 --- /dev/null +++ b/src/app/api/ai/knowledge/route.ts @@ -0,0 +1,150 @@ +// ============================================================ +// /api/ai/knowledge +// +// GET — list this account's knowledge_base_entries. +// POST — create a manual entry (title, content). +// +// These are the *dashboard* endpoints for managing the AI +// assistant's knowledge base (spec §10), so they authenticate the +// normal way (cookie session) and go through the RLS client. Both +// methods are admin+ only: the KB is account-settings-class data +// (spec §4.2 RLS: all ops for `is_account_member(account_id, +// 'admin')`), so we enforce the same admin floor on the wire that +// the table's RLS policies enforce in the database. +// +// `token_estimate` is computed server-side from the content via the +// shared chars/4 heuristic (`estimateTokens`, spec §4.2 / §9) so the +// Settings size meter has a consistent value regardless of caller. +// `source_type` is fixed to 'manual' here; file-sourced entries come +// in through the dedicated upload route. +// ============================================================ + +import { NextResponse } from 'next/server'; + +import { + ForbiddenError, + requireRole, + toErrorResponse, +} from '@/lib/auth/account'; +import { canEditSettings } from '@/lib/auth/roles'; +import { estimateTokens } from '@/lib/ai/knowledge-base'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; + +const MAX_TITLE_LEN = 200; +// Hard ceiling on a single entry's content. A KB page far longer +// than this is almost certainly a misuse (paste a whole site) and +// would blow the prompt budget on its own — reject early rather than +// silently truncate. +const MAX_CONTENT_LEN = 200_000; + +// Columns safe to expose. Mirrors the `KnowledgeBaseEntry` shape. +const SAFE_COLUMNS = + 'id, account_id, title, content, source_type, source_filename, enabled, token_estimate, created_by_user_id, created_at, updated_at'; + +export async function GET() { + try { + const ctx = await requireRole('admin'); + // Defence in depth: the capability predicate is the single source + // of truth for "can edit account settings" (the KB is settings- + // class), alongside the role floor above and the table RLS policy. + if (!canEditSettings(ctx.role)) { + throw new ForbiddenError('This action requires the admin role or higher'); + } + + const { data, error } = await ctx.supabase + .from('knowledge_base_entries') + .select(SAFE_COLUMNS) + .eq('account_id', ctx.accountId) + .order('created_at', { ascending: false }); + + if (error) { + console.error('[GET /api/ai/knowledge] fetch error:', error); + return NextResponse.json( + { error: 'Failed to load knowledge base entries' }, + { status: 500 } + ); + } + + return NextResponse.json({ entries: data ?? [] }); + } catch (err) { + return toErrorResponse(err); + } +} + +export async function POST(request: Request) { + try { + const ctx = await requireRole('admin'); + if (!canEditSettings(ctx.role)) { + throw new ForbiddenError('This action requires the admin role or higher'); + } + + const limit = checkRateLimit( + `admin:knowledgeCreate:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const body = (await request.json().catch(() => null)) as { + title?: unknown; + content?: unknown; + } | null; + + const rawTitle = typeof body?.title === 'string' ? body.title.trim() : ''; + if (!rawTitle) { + return NextResponse.json( + { error: "'title' is required" }, + { status: 400 } + ); + } + if (rawTitle.length > MAX_TITLE_LEN) { + return NextResponse.json( + { error: `Title must be ${MAX_TITLE_LEN} characters or fewer` }, + { status: 400 } + ); + } + + const rawContent = + typeof body?.content === 'string' ? body.content.trim() : ''; + if (!rawContent) { + return NextResponse.json( + { error: "'content' is required" }, + { status: 400 } + ); + } + if (rawContent.length > MAX_CONTENT_LEN) { + return NextResponse.json( + { error: `Content must be ${MAX_CONTENT_LEN} characters or fewer` }, + { status: 400 } + ); + } + + const { data, error } = await ctx.supabase + .from('knowledge_base_entries') + .insert({ + account_id: ctx.accountId, + title: rawTitle, + content: rawContent, + source_type: 'manual', + token_estimate: estimateTokens(rawContent), + created_by_user_id: ctx.userId, + }) + .select(SAFE_COLUMNS) + .single(); + + if (error || !data) { + console.error('[POST /api/ai/knowledge] insert error:', error); + return NextResponse.json( + { error: 'Failed to create knowledge base entry' }, + { status: 500 } + ); + } + + return NextResponse.json({ entry: data }, { status: 201 }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/ai/knowledge/upload/route.ts b/src/app/api/ai/knowledge/upload/route.ts new file mode 100644 index 0000000000..cbd1919ac3 --- /dev/null +++ b/src/app/api/ai/knowledge/upload/route.ts @@ -0,0 +1,235 @@ +// ============================================================ +// /api/ai/knowledge/upload +// +// POST — accept an uploaded file, extract its text, and create a +// `source_type='file'` knowledge_base_entries row. +// +// This is the file-import counterpart of the manual-create endpoint +// (`POST /api/ai/knowledge`, spec §9.4 / §10). It authenticates the +// dashboard way (cookie session → RLS client) and is admin+ only: +// the KB is account-settings-class data (spec §4.2 RLS: all ops for +// `is_account_member(account_id, 'admin')`), so we enforce the same +// admin floor on the wire that the table's RLS policies enforce in +// the database — the exact guard the sibling `route.ts` uses. +// +// Supported inputs (spec §9.4): +// .txt / .md — read directly as UTF-8 text. +// .pdf — text extracted server-side with `unpdf` (a serverless +// PDF.js build). On any extraction failure we return a +// clear 422 telling the user to paste the text manually, +// rather than persisting an empty/garbage entry. +// +// `token_estimate` is computed from the extracted text via the shared +// chars/4 heuristic (`estimateTokens`, spec §4.2 / §9), identical to +// the manual route, so the Settings size meter stays consistent +// regardless of how an entry was created. +// +// Runtime note: PDF extraction runs Mozilla's PDF.js, which needs the +// Node.js runtime — pin it explicitly so this route never gets pushed +// onto the Edge runtime where those APIs are unavailable. +// ============================================================ + +import { NextResponse } from 'next/server'; + +import { + ForbiddenError, + requireRole, + toErrorResponse, +} from '@/lib/auth/account'; +import { canEditSettings } from '@/lib/auth/roles'; +import { estimateTokens } from '@/lib/ai/knowledge-base'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; + +export const runtime = 'nodejs'; + +const MAX_TITLE_LEN = 200; +// Hard ceiling on a single entry's content, mirroring the manual +// create route. Extracted text longer than this is almost certainly a +// misuse (a whole manual / site dump) and would blow the prompt budget +// on its own — reject rather than silently truncate. +const MAX_CONTENT_LEN = 200_000; + +// Cap the raw upload so a single request can't pull a huge file into +// memory before we even look at it. 10 MB comfortably covers a text +// KB page or a reasonable PDF while bounding the PDF.js workload. +const MAX_FILE_BYTES = 10 * 1024 * 1024; + +// Columns safe to expose. Mirrors the `KnowledgeBaseEntry` shape and +// the SAFE_COLUMNS list in the sibling `route.ts`. +const SAFE_COLUMNS = + 'id, account_id, title, content, source_type, source_filename, enabled, token_estimate, created_by_user_id, created_at, updated_at'; + +/** Classify an upload by its filename extension. */ +type FileKind = 'text' | 'pdf' | 'unsupported'; + +function classify(filename: string): FileKind { + const lower = filename.toLowerCase(); + if (lower.endsWith('.txt') || lower.endsWith('.md')) return 'text'; + if (lower.endsWith('.pdf')) return 'pdf'; + return 'unsupported'; +} + +/** + * Extract text from a PDF buffer using `unpdf` (serverless PDF.js). + * Returns the merged text, or `null` on any failure / empty result so + * the caller can surface a clean 422 rather than persisting nothing. + */ +async function extractPdfText(bytes: Uint8Array): Promise { + try { + const { extractText, getDocumentProxy } = await import('unpdf'); + const pdf = await getDocumentProxy(bytes); + const { text } = await extractText(pdf, { mergePages: true }); + const trimmed = text.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch (err) { + console.error('[POST /api/ai/knowledge/upload] PDF extract error:', err); + return null; + } +} + +export async function POST(request: Request) { + try { + const ctx = await requireRole('admin'); + // Defence in depth: the capability predicate is the single source + // of truth for "can edit account settings" (the KB is settings- + // class), alongside the role floor above and the table RLS policy. + if (!canEditSettings(ctx.role)) { + throw new ForbiddenError('This action requires the admin role or higher'); + } + + const limit = checkRateLimit( + `admin:knowledgeUpload:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + // Reject oversized uploads before buffering the whole multipart body + // into memory. Content-Length can be understated by a hostile client + // but not inflated, so this is an early filter; the authoritative + // bound is the file.size check below after the body is parsed. + const declaredLength = Number(request.headers.get('content-length') ?? '0'); + if (Number.isFinite(declaredLength) && declaredLength > MAX_FILE_BYTES) { + return NextResponse.json( + { + error: `File is too large. Maximum size is ${Math.floor( + MAX_FILE_BYTES / (1024 * 1024) + )} MB.`, + }, + { status: 413 } + ); + } + + const form = await request.formData().catch(() => null); + const file = form?.get('file'); + if (!(file instanceof File)) { + return NextResponse.json( + { error: "A 'file' field is required" }, + { status: 400 } + ); + } + + const filename = file.name?.trim() || 'upload'; + const kind = classify(filename); + if (kind === 'unsupported') { + return NextResponse.json( + { error: 'Unsupported file type. Upload a .txt, .md, or .pdf file.' }, + { status: 400 } + ); + } + + if (file.size > MAX_FILE_BYTES) { + return NextResponse.json( + { + error: `File is too large. Maximum size is ${Math.floor( + MAX_FILE_BYTES / (1024 * 1024) + )} MB.`, + }, + { status: 400 } + ); + } + + const bytes = new Uint8Array(await file.arrayBuffer()); + if (bytes.byteLength === 0) { + return NextResponse.json( + { error: 'The uploaded file is empty.' }, + { status: 400 } + ); + } + + let content: string; + if (kind === 'pdf') { + const extracted = await extractPdfText(bytes); + if (extracted === null) { + // Fail safe: don't persist an empty/garbage entry. Tell the + // user exactly what to do instead (spec §1 fail-safe bias). + return NextResponse.json( + { + error: + "Couldn't extract text from this PDF. It may be scanned, " + + 'image-only, or encrypted. Copy the text and paste it in ' + + 'manually instead.', + }, + { status: 422 } + ); + } + content = extracted; + } else { + content = new TextDecoder('utf-8').decode(bytes).trim(); + } + + if (!content) { + return NextResponse.json( + { + error: + "Couldn't read any text from this file. Copy the text and " + + 'paste it in manually instead.', + }, + { status: 422 } + ); + } + if (content.length > MAX_CONTENT_LEN) { + return NextResponse.json( + { + error: `Extracted text is too long (${content.length} characters). Maximum is ${MAX_CONTENT_LEN}. Split it into smaller entries.`, + }, + { status: 400 } + ); + } + + // Derive a title from the filename (strip the extension), clamped + // to the same ceiling the manual route enforces. Falls back to the + // raw filename if stripping leaves nothing. + const baseTitle = filename.replace(/\.[^.]+$/, '').trim() || filename; + const title = baseTitle.slice(0, MAX_TITLE_LEN); + + const { data, error } = await ctx.supabase + .from('knowledge_base_entries') + .insert({ + account_id: ctx.accountId, + title, + content, + source_type: 'file', + source_filename: filename.slice(0, MAX_TITLE_LEN), + token_estimate: estimateTokens(content), + created_by_user_id: ctx.userId, + }) + .select(SAFE_COLUMNS) + .single(); + + if (error || !data) { + console.error('[POST /api/ai/knowledge/upload] insert error:', error); + return NextResponse.json( + { error: 'Failed to create knowledge base entry' }, + { status: 500 } + ); + } + + return NextResponse.json({ entry: data }, { status: 201 }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index 107c493257..f1e3481162 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -388,13 +388,24 @@ export async function POST(request: Request) { ) } - // Update conversation + // Update conversation. Setting `ai_handling=false` is the human- + // takeover signal: an agent replying by hand owns the conversation + // from here on, so the AI assistant goes silent on it immediately + // (mirrors the flow pause below — the strongest "human is here" + // signal). "Hand back to AI" in the inbox flips this back to true. + // + // Also clear any AI escalation markers: if the AI had escalated this + // thread (status='pending' + ai_escalated_at), a human now replying + // resolves it, so the "Needs human" badge drops instead of sticking. await supabase .from('conversations') .update({ last_message_text: content_text || `[${message_type}]`, last_message_at: new Date().toISOString(), updated_at: new Date().toISOString(), + ai_handling: false, + ai_escalated_at: null, + ai_escalation_reason: null, }) .eq('id', conversation_id) diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index ba404f83e7..b5ecddc30e 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -7,6 +7,7 @@ import { findExistingContact, isUniqueViolation } from '@/lib/contacts/dedupe' import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' import { runAutomationsForTrigger } from '@/lib/automations/engine' import { dispatchInboundToFlows } from '@/lib/flows/engine' +import { maybeReplyToInbound } from '@/lib/ai/reply' import { handleTemplateWebhookChange, isTemplateWebhookField, @@ -275,7 +276,10 @@ async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) { // inserts that need it for NOT NULL FK compliance. Always // the admin who saved the WhatsApp config. config.user_id, - decryptedAccessToken + decryptedAccessToken, + // Meta phone-number id — the AI assistant needs it to send an + // autonomous reply via the same Meta send path. + config.phone_number_id ) } } @@ -509,7 +513,11 @@ async function processMessage( // (contacts, conversations). Always the admin who saved the // WhatsApp config; the choice is arbitrary post-017 but stable. configOwnerUserId: string, - accessToken: string + accessToken: string, + // Meta phone-number id from the matched whatsapp_config row. Passed + // through to the AI assistant so an autonomous reply goes out on the + // same number the inbound arrived on. + phoneNumberId: string ) { const senderPhone = normalizePhone(message.from) const contactName = contact.profile.name @@ -595,21 +603,28 @@ async function processMessage( .eq('sender_type', 'customer') const isFirstInboundMessage = (priorCustomerMsgCount ?? 0) === 0 - const { error: msgError } = await supabaseAdmin().from('messages').insert({ - conversation_id: conversation.id, - sender_type: 'customer', - content_type: contentType, - content_text: contentText, - media_url: mediaUrl, - message_id: message.id, - status: 'delivered', - created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(), - reply_to_message_id: replyToInternalId, - // Only populated for content_type='interactive'. Migration 010 added - // the column; null for every other content_type so existing inserts - // behave identically. - interactive_reply_id: interactiveReplyId, - }) + const { data: insertedMessage, error: msgError } = await supabaseAdmin() + .from('messages') + .insert({ + conversation_id: conversation.id, + sender_type: 'customer', + content_type: contentType, + content_text: contentText, + media_url: mediaUrl, + message_id: message.id, + status: 'delivered', + created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(), + reply_to_message_id: replyToInternalId, + // Only populated for content_type='interactive'. Migration 010 added + // the column; null for every other content_type so existing inserts + // behave identically. + interactive_reply_id: interactiveReplyId, + }) + // `.select().single()` so the inserted UUID is available for the AI + // assistant's `ai_reply_log` audit row (the inbound message that + // triggered the decision — see maybeReplyToInbound dispatch below). + .select('id') + .single() if (msgError) { console.error('Error inserting message:', msgError) @@ -713,6 +728,26 @@ async function processMessage( }, }).catch((err) => console.error('[automations] dispatch failed:', err)) } + + // AI assistant dispatch. Suppressed when a flow consumed the message — + // the customer is navigating a bot menu/flow, so the assistant must not + // also reply (same `!flowConsumed` gate as the content-level automation + // triggers above). Fire-and-forget like the automation dispatch: + // maybeReplyToInbound has its own try/catch, never throws, and runs off + // the webhook's ack path so a slow or failing AI call can't delay Meta's + // 200. Its own internal gates (enabled / ai_handling / guardrails / cap) + // decide whether it actually replies or escalates. + if (!flowConsumed) { + maybeReplyToInbound({ + accountId, + conversationId: conversation.id, + contactId: contactRecord.id, + messageId: insertedMessage.id, + inboundText, + accessToken, + phoneNumberId, + }).catch(console.error) + } } async function parseMessageContent( diff --git a/src/components/inbox/conversation-list.tsx b/src/components/inbox/conversation-list.tsx index 69f51dd6fe..8068d8458d 100644 --- a/src/components/inbox/conversation-list.tsx +++ b/src/components/inbox/conversation-list.tsx @@ -283,6 +283,16 @@ function ConversationItem({ {conversation.last_message_text || "No messages yet"}

+ {/* AI handed this thread off to a human — surfaces live via the + existing conversations realtime UPDATE (page bumps the row's + ai_* fields into state). Tied to status==='pending' so a + taken-over/handed-back thread (escalation cleared) drops it. */} + {conversation.ai_escalated_at && + conversation.status === "pending" && ( + + 🙋 Needs human + + )} {conversation.unread_count > 0 && ( {conversation.unread_count} diff --git a/src/components/inbox/message-bubble.tsx b/src/components/inbox/message-bubble.tsx index 510f25c4cd..d3b2f2ffed 100644 --- a/src/components/inbox/message-bubble.tsx +++ b/src/components/inbox/message-bubble.tsx @@ -249,6 +249,7 @@ export function MessageBubble({ onToggleReaction, }: MessageBubbleProps) { const isAgent = message.sender_type === "agent" || message.sender_type === "bot"; + const isBot = message.sender_type === "bot"; const time = format(new Date(message.created_at), "HH:mm"); // Row alignment + width cap are owned by so its hover @@ -282,6 +283,13 @@ export function MessageBubble({ isAgent ? "justify-end" : "justify-start", )} > + {/* Mark assistant-sent replies so agents can tell at a glance + which messages the AI sent (sender_type==='bot'). */} + {isBot && ( + + AI + + )} void; + /** + * Reflect an AI hand-off toggle (Take over / Hand back to AI). Patches + * the affected conversation's `ai_*` fields in the page's state so the + * header + list badge update immediately; the realtime UPDATE that the + * Supabase write emits converges the same fields as a no-op. + */ + onAiHandlingChange: ( + conversationId: string, + updates: Partial, + ) => void; /** * On mobile, the thread is shown full-screen with the conversation list * hidden. This callback lets the page deselect the active conversation @@ -159,6 +171,7 @@ export function MessageThread({ onUpdateMessage, onStatusChange, onAssignChange, + onAiHandlingChange, onBack, resyncToken = 0, onRefresh, @@ -778,6 +791,55 @@ export function MessageThread({ [conversation, onAssignChange], ); + // Take over (human owns the thread; AI goes silent) / Hand back to AI + // (re-arm the bot and clear the escalation). Reuses the same + // conversations.update() path the status/assign controls use; the + // backend send route already flips ai_handling=false on a manual reply, + // so this just covers the explicit toggle. Issue: spec §8. + const handleAiHandlingChange = useCallback( + async (handling: boolean) => { + if (!conversation) return; + + const updates: Partial = handling + ? { + // Hand back: re-arm the bot and clear the escalation markers so + // the "Needs human" badge drops and the thread is bot-eligible. + ai_handling: true, + ai_escalated_at: undefined, + ai_escalation_reason: undefined, + } + : { ai_handling: false }; + + const supabase = createClient(); + const { error } = await supabase + .from("conversations") + // null clears the columns in the DB; the optimistic `updates` + // patch above uses `undefined` to drop them from the client row. + .update( + handling + ? { + ai_handling: true, + ai_escalated_at: null, + ai_escalation_reason: null, + } + : { ai_handling: false }, + ) + .eq("id", conversation.id); + + if (error) { + console.error("Failed to update AI handling:", error); + toast.error( + handling ? "Failed to hand back to AI" : "Failed to take over", + ); + return; + } + + onAiHandlingChange(conversation.id, updates); + toast.success(handling ? "Handed back to AI" : "You've taken over"); + }, + [conversation, onAiHandlingChange], + ); + // Empty state — same WhatsApp-style doodle background as the active // thread below, so swapping between empty/selected doesn't change the // pattern under the user's eye. @@ -808,6 +870,14 @@ export function MessageThread({ ? (currentAssignee?.full_name ?? "Assigned") : "Assign"; + // AI hand-off state for the header controls. `ai_handling` defaults to + // true at the DB level, so an undefined value reads as "AI is driving". + // The bot is considered active only while it's handling AND hasn't + // escalated; once escalated or taken over we offer "Hand back to AI". + const aiHandling = conversation.ai_handling !== false; + const aiEscalated = !!conversation.ai_escalated_at; + const aiActive = aiHandling && !aiEscalated; + return ( // `min-w-0` is load-bearing: the page already puts min-w-0 on the // thread's flex *wrapper* (issue #165), but this root keeps the @@ -905,6 +975,33 @@ export function MessageThread({ )} + {/* AI hand-off control — matches the other header pills. While + the AI is driving (handling && not escalated) we offer "Take + over"; once escalated or a human has taken over we offer + "Hand back to AI". Sending a manual reply also takes over + (handled in the backend send route). Spec §8. */} + {aiActive ? ( + + ) : ( + + )} + {/* Status dropdown */} = { owner: { icon: Crown, - label: "Owner", + label: 'Owner', // Amber: scarce, immutable, "the boss" — gets visual emphasis. - className: - "border-amber-500/40 bg-amber-500/10 text-amber-300", + className: 'border-amber-500/40 bg-amber-500/10 text-amber-300', }, admin: { icon: Shield, - label: "Admin", + label: 'Admin', // Primary-tinted: significant but not as scarce as owner. - className: - "border-primary/40 bg-primary/10 text-primary", + className: 'border-primary/40 bg-primary/10 text-primary', }, agent: { icon: UserCog, - label: "Agent", + label: 'Agent', // Neutral slate: the operational default. - className: - "border-border bg-muted text-foreground", + className: 'border-border bg-muted text-foreground', }, viewer: { icon: User, - label: "Viewer", + label: 'Viewer', // Muted slate: read-only role; visually quieter than agent. - className: - "border-border bg-card text-muted-foreground", + className: 'border-border bg-card text-muted-foreground', }, }; -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@/components/ui/avatar"; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +} from '@/components/ui/dropdown-menu'; interface NavItem { href: string; @@ -87,17 +79,17 @@ interface NavItem { } const navItems: NavItem[] = [ - { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, - { href: "/inbox", label: "Inbox", icon: MessageSquare }, - { href: "/contacts", label: "Contacts", icon: Users }, - { href: "/pipelines", label: "Pipelines", icon: GitBranch }, - { href: "/broadcasts", label: "Broadcasts", icon: Radio }, - { href: "/automations", label: "Automations", icon: Zap }, - { href: "/flows", label: "Flows", icon: Workflow, beta: true }, + { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/inbox', label: 'Inbox', icon: MessageSquare }, + { href: '/contacts', label: 'Contacts', icon: Users }, + { href: '/pipelines', label: 'Pipelines', icon: GitBranch }, + { href: '/broadcasts', label: 'Broadcasts', icon: Radio }, + { href: '/automations', label: 'Automations', icon: Zap }, + { href: '/flows', label: 'Flows', icon: Workflow, beta: true }, ]; const bottomNavItems = [ - { href: "/settings", label: "Settings", icon: Settings }, + { href: '/settings', label: 'Settings', icon: Settings }, ]; interface SidebarProps { @@ -119,9 +111,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) { // we gate on. Wait for the profile fetch to settle first, otherwise // the strip flashes in once the row resolves (a layout jump). const showAccountStrip = - !profileLoading && - !!account?.name && - account.name !== profile?.full_name; + !profileLoading && !!account?.name && account.name !== profile?.full_name; // Close the drawer when route changes — users opened it to navigate, // so once they pick a destination the drawer should get out of the way. @@ -136,14 +126,14 @@ export function Sidebar({ open = false, onClose }: SidebarProps) { useEffect(() => { if (!open) return; const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; + document.body.style.overflow = 'hidden'; const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose?.(); + if (e.key === 'Escape') onClose?.(); }; - window.addEventListener("keydown", onKey); + window.addEventListener('keydown', onKey); return () => { document.body.style.overflow = prev; - window.removeEventListener("keydown", onKey); + window.removeEventListener('keydown', onKey); }; }, [open, onClose]); @@ -157,40 +147,49 @@ export function Sidebar({ open = false, onClose }: SidebarProps) { aria-label="Close menu" onClick={onClose} className={cn( - "fixed inset-0 z-30 bg-background/70 backdrop-blur-sm transition-opacity lg:hidden", + 'bg-background/70 fixed inset-0 z-30 backdrop-blur-sm transition-opacity lg:hidden', open - ? "pointer-events-auto opacity-100" - : "pointer-events-none opacity-0", + ? 'pointer-events-auto opacity-100' + : 'pointer-events-none opacity-0' )} />