# AI Conversations (Main · User Portal)

**Portal:** Main · **Routes:** `main.portal.conversations.*` at `/property/ai-advisor` (URI moved 2026-08-24; old `/ai-conversations` 301s, route names unchanged) · **Nav:** sidebar → **AI Coach** → the **AI Chatbot** tab · **Gated by:** `['auth','main']`

## What it does
A ChatGPT-style chat where a member talks to an AI provider about property, financing and their
portfolio. Each member keeps multiple **conversations**. The AI **provider + model are a single
admin-set default** (configured on the Manage AI Providers page — members can't choose); each
conversation has two **grounding toggles** that decide whether the AI is given the
member's own **Wealth Plans** and/or **Property Analyses** as context. Replies **stream token-by-token**.

> Conversations are owned by the **lead** (lead == portal user — see
> [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md)), so
> everything keys on `lead_id`. Charging follows the AI module's policy: **free credits first** (on the
> company key), **then the member's own key** (added via the **AI Providers** modal on this page), else the chat is blocked — see the
> [AI Integration](/docs/modules_handbook/shared/ai/readMe.md) doc. This feature is the AI module's
> canonical consumer of `AiClient::stream()` + `AiCreditService`.

## How it works
- **Storage (lead-keyed, emulates the WhatsApp inbox).** `ai_conversations` holds the thread
  (title, `is_wealth_context`, `is_analysis_context`, `last_message_preview`,
  `last_activity_at`); `ai_conversation_messages` holds each message (`role` user/assistant, `body`,
  per-assistant `provider`/`model`/token counts, `status`/`error`). The `ai_requests` log links back to
  the thread via its polymorphic `subject` (the `AiConversation`), so each call is traceable.
  Both extend `SoftDeleteModel` + `HasUuid` + `RecordsBlame`; deleting a conversation soft-cascades its
  messages. Writes go through `AiConversationRepository` (each in `DB::transaction`, per GUIDELINES).
- **CRUD (Inertia).** `index` renders the chat shell — the member's conversations + the active one
  (opened via `?c={uuid}`) + the member's BYO-key catalog (`AiKeyService::present(SCOPE_LEAD)`, for the
  keys modal) + the resolved **admin default** provider/model (`aiDefault()`, read-only) + grounding
  availability + credit balance. `store` creates an empty conversation and opens it; `update` saves its
  settings (rename + grounding toggles only); `destroy` deletes it. Every action is scoped to the
  authenticated lead (`ownedConversation()`), and the lead is lazily created on first write
  (`LeadRepository::firstOrCreateForUser`).
- **Admin-set provider/model.** Members never choose the provider/model; `stream()` resolves them live
  from `aiDefault()` — the pin on the `ai_conversation` prompt key (`AiPrompt::pinFor()`, set in the
  Model card on the Manage AI Prompts page), falling back to `config('ai.default_provider')` + that
  provider's catalog default model. Resolved up-front (not left to AiClient) because the provider
  decides the billing key before the call.
- **Send + stream (the one non-Inertia endpoint).** `POST {id}/stream` returns a **`text/event-stream`**
  `StreamedResponse` consumed by the `useAiStream` composable with `fetch` + a ReadableStream (Inertia
  would try to parse a page). The controller: picks the funding key
  (`AiCreditService::planConversation($lead, $provider)` → `credit` | `own` | blocked-422), persists the
  member message, builds the replayed history (capped) + the system prompt (the `ai_conversation` prompt
  key persona **plus** the grounding context when toggled on, from `ConversationContextBuilder`), then
  calls `AiClient::stream(... , $onDelta)` — emitting each delta as an SSE `delta` event. On success it
  persists the assistant message, **spends a credit** when the source was `credit`, auto-titles a new
  thread, and emits `done` (with the new credit balance); on failure it records a failed assistant
  message and emits `error`. The session lock is released (`session()->save()`) before streaming so the
  member's other requests aren't blocked.
- **Grounding.** When a toggle is on, `ConversationContextBuilder` reads the lead's own recent
  `WealthPlan` / `PropertyAnalysis` rows, formats compact summaries (via each model's `summarize()`),
  and appends them to the system prompt. Toggles are disabled in the UI when the member has no such data.
- **Frontend.** `Index.vue` is the orchestrator (the shared **PropertyLab AI / AI Debate** tabs —
  `Components/PortalAiTabs.vue`, real Inertia `<Link>`s between `/property/ai-advisor` and the now-built
  [AI Debate](/docs/modules_handbook/main/ai-debate/readMe.md) page — plus conversation state,
  optimistic send, streaming render, credit/blocked banner). The chat surface itself is **not** local to
  this page: `MessageThread` (bubbles + `utils/markdown.js` safe render + streaming cursor + autoscroll)
  and `Composer` (Enter-to-send) live in the shared `Components/Conversation/` folder because the
  Property Analysis **Scout panel** renders the same two components against its own stream — edit them
  there and both surfaces change. Page-local partials are only `ConversationList` (sidebar + new/delete),
  `SettingsModal` (rename + grounding toggles), and `ProvidersModal` — the member's
  BYO-key cards (the shared `Components/Ai/ProviderCard` against `/ai-settings/{provider}`), opened from
  the **Manage keys** button (there is no separate AI Settings page). Destructive delete uses
  `ConfirmModal` (never native `confirm()`).
- **Admin read-only history.** An admin can review a member's whole chat history — the full thread,
  rendered with the same safe `utils/markdown.js` — on the lead's detail page under
  **Manage › Leads › Show › Portal Engagement › AI Conversations**
  (`resources/js/Pages/Manage/Leads/Partials/Tabs/AiConversationsTab.vue`, fed as a plain prop by
  `LeadsController@show`). It is **view-only**: no streaming, no writes, no credits.

> **Threads can also arrive from elsewhere.** A conversation carries an optional
> `property_analysis_id` (PETA Scout, from Analyze Property), an optional `wealth_plan_id`
> (the Wealth Planning result page's "Ask about my plan" assistant — see
> [Wealth Planning](/docs/modules_handbook/main/wealth-planning/readMe.md)), and an optional
> `playbook_key` + inputs/injected-data (an
> [Investment Prompt](/docs/modules_handbook/main/investment-prompt/readMe.md) Playbook run).
> All are created by their own module and simply appear in this list; none spends the member's
> AI credits, which stay this module's own currency.

## Related files

**Backend — Models**
- [src/Ai/AiConversation.php](/src/Ai/AiConversation.php) — the thread (title + grounding toggles; provider/model are the admin default, not stored here); `messages()`; soft-cascade.
- [src/Ai/AiConversationMessage.php](/src/Ai/AiConversationMessage.php) — a message; `ROLE_*` / `STATUS_*` constants.

**Backend — Repository & Services**
- [src/Ai/Repositories/AiConversationRepository.php](/src/Ai/Repositories/AiConversationRepository.php) — `create` / `update` / `delete` / `addMessage` (touches preview + activity).
- [src/Ai/Services/ConversationContextBuilder.php](/src/Ai/Services/ConversationContextBuilder.php) — builds the optional Wealth/Analysis grounding block.
- [src/Ai/Services/AiClient.php](/src/Ai/Services/AiClient.php) — `stream()` (the streaming calling path) · [AiCreditService.php](/src/Ai/Services/AiCreditService.php) — `planConversation()` / `spend()` · [AiKeyService.php](/src/Ai/Services/AiKeyService.php) — `present()` / `models()`.
- [src/Ai/Transports/](/src/Ai/Transports/) — `streamChat()` on `AnthropicTransport` / `OpenAiTransport` (OpenAI + DeepSeek) / `GeminiTransport`, with the shared SSE reader in `AbstractTransport`.

**Backend — Controller & Form Requests**
- [app/Http/Controllers/Main/Portal/AiConversationsController.php](/app/Http/Controllers/Main/Portal/AiConversationsController.php) — index/store/update/destroy + the SSE `stream`; `aiDefault()` resolves the admin provider/model.
- [app/Http/Requests/Main/Portal/AiConversations/StoreRequest.php](/app/Http/Requests/Main/Portal/AiConversations/StoreRequest.php) · [UpdateRequest.php](/app/Http/Requests/Main/Portal/AiConversations/UpdateRequest.php) (title + grounding only) · [StreamRequest.php](/app/Http/Requests/Main/Portal/AiConversations/StreamRequest.php)

**Backend — Admin default (set on the Manage AI Prompts page)**
- [src/Ai/AiPrompt.php](/src/Ai/AiPrompt.php) — `pinFor('ai_conversation')` is the provider/model source (the pin also lives beside the editable, versioned `ai_conversation` prompt body). [src/Setting/Setting.php](/src/Setting/Setting.php) keeps `AI_LEAD_FREE_CREDITS`.
- [app/Http/Controllers/Manage/Integrations/AiPromptsController.php](/app/Http/Controllers/Manage/Integrations/AiPromptsController.php) — `updateModel` saves the pin, `updateSettings` the free credits · [PromptModelRequest.php](/app/Http/Requests/Manage/Integrations/Ai/PromptModelRequest.php) / [SettingsRequest.php](/app/Http/Requests/Manage/Integrations/Ai/SettingsRequest.php) (validation).
- [resources/js/Pages/Manage/Integrations/Ai/Prompts.vue](/resources/js/Pages/Manage/Integrations/Ai/Prompts.vue) — the "PropertyLab AI" prompt's editor + Model card + the free-credit setting.

**Frontend (Vue)**
- [resources/js/Pages/Main/Portal/AiConversations/Index.vue](/resources/js/Pages/Main/Portal/AiConversations/Index.vue) — the chat shell + orchestration.
- Page-local partials: [ConversationList.vue](/resources/js/Pages/Main/Portal/AiConversations/Partials/ConversationList.vue) · [SettingsModal.vue](/resources/js/Pages/Main/Portal/AiConversations/Partials/SettingsModal.vue) · [ProvidersModal.vue](/resources/js/Pages/Main/Portal/AiConversations/Partials/ProvidersModal.vue) (BYO-key cards)
- Shared chat surface (⚠️ **not** under this page's `Partials/` — moved out when Property Analysis's Scout panel started reusing them): [Components/Conversation/MessageThread.vue](/resources/js/Components/Conversation/MessageThread.vue) · [Components/Conversation/Composer.vue](/resources/js/Components/Conversation/Composer.vue) — both also imported by [Components/PropertyAnalysis/ScoutPanel.vue](/resources/js/Components/PropertyAnalysis/ScoutPanel.vue).
- [resources/js/Components/Ai/ProviderCard.vue](/resources/js/Components/Ai/ProviderCard.vue) — the shared provider key card (save verifies then stores; remove), reused from the Manage portal.
- [resources/js/Components/PortalAiTabs.vue](/resources/js/Components/PortalAiTabs.vue) — the shared PropertyLab AI / AI Debate `<Link>` tabs (also used on the AI Debate page).
- [resources/js/composables/useAiStream.js](/resources/js/composables/useAiStream.js) — the SSE `fetch` reader.
- [resources/js/utils/markdown.js](/resources/js/utils/markdown.js) — the small, escape-first Markdown renderer for replies.
- Nav entry: [resources/js/Layouts/AppLayout.vue](/resources/js/Layouts/AppLayout.vue) — the sidebar's single **AI Coach** entry, whose four pages are reached by [`Components/Portal/AiCoachTabs.vue`](/resources/js/Components/Portal/AiCoachTabs.vue) (GUIDELINES §15's "one entry per module, its pages reached by a tab strip"). AI Coach POINTS here, so this is the tab a member lands on. Renamed + moved 2026-08-26: it was **Property Passive → "AI Advisor"** (itself the label since 2026-08-24), and it left that pillar because the chat answers portfolio AND business questions, so filing it under the property engine understated it. It landed briefly in a third pillar ("AI Coach") the same day, which was folded back into AI Active hours later — the member found the menu heavy and a third header earned nothing. **Only the label and the pillar moved** — the URL (`/property/ai-advisor`), the route NAMES and the tables are still `ai_conversation*`, so nothing needs a redirect. The in-page chat tab keeps the product name **"PropertyLab AI"** — the page is AI Chatbot, the agent inside it is PropertyLab AI.

**Prompt registry**
- [config/ai_prompts.php](/config/ai_prompts.php) (`ai_conversation` entry) · [resources/prompts/ai_conversation.md](/resources/prompts/ai_conversation.md) (the assistant persona) · `AiRequest::PROMPT_AI_CONVERSATION`.

**Migrations**
- [database/migrations/2026_06_18_000001_create_ai_conversations_table.php](/database/migrations/2026_06_18_000001_create_ai_conversations_table.php)
- [database/migrations/2026_06_18_000002_create_ai_conversation_messages_table.php](/database/migrations/2026_06_18_000002_create_ai_conversation_messages_table.php)
- [database/migrations/2026_06_18_000003_drop_provider_model_from_ai_conversations.php](/database/migrations/2026_06_18_000003_drop_provider_model_from_ai_conversations.php) — provider/model moved to the admin default.

**Config**
- [config/ai.php](/config/ai.php) — `stream_timeout` (and the provider catalog / `default_provider` this feature reads).

**Routes**
- [routes/main.php](/routes/main.php) — the `main.portal.conversations.*` group (incl. `{id}/stream`).

**See also:** [AI Integration](/docs/modules_handbook/shared/ai/readMe.md) (keys, credits, `AiClient`, the request log — and where the member's BYO key is verified/stored, surfaced here via the AI Providers modal).
