From b414502062e7096d9dac2d1d70aad164ec925b60 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 23 Mar 2026 17:29:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20unified=20sessions=20=E2=80=94=20merge?= =?UTF-8?q?=20assistant=20chat=20into=20ai=5Fsessions=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add session_type ('guided'|'chat') and title columns to ai_sessions, enabling both FlowPilot guided sessions and assistant chat sessions to live in a single table. This is the foundation for a unified session history and consistent UX across both interaction modes. Backend: - Migration 066: session_type + title columns - unified_chat_service: chat sessions on ai_sessions with same AI/RAG - POST /ai-sessions supports session_type='chat' creation - POST /ai-sessions/{id}/chat for chat messages - DELETE /ai-sessions/{id} for session deletion - session_type filter on GET /ai-sessions Frontend: - AssistantChatPage rewired to aiSessionsApi (no more assistantChatApi) - /assistant/:sessionId route for deep-linking - Session history: type filter pills (All/Guided/Chat), type icons - Dashboard: both types shown with correct routing and icons - Fixed glass-border → border-default in dashboard components Co-Authored-By: Claude Opus 4.6 (1M context) --- ...d_session_type_and_title_to_ai_sessions.py | 42 +++++ backend/app/api/endpoints/ai_sessions.py | 126 +++++++++++++- backend/app/models/ai_session.py | 10 ++ backend/app/schemas/ai_session.py | 33 +++- backend/app/services/unified_chat_service.py | 125 ++++++++++++++ docs/plans/2026-03-23-unified-sessions.md | 130 ++++++++++++++ frontend/src/api/aiSessions.ts | 24 +++ .../dashboard/ActiveFlowPilotSessions.tsx | 20 ++- .../dashboard/RecentFlowPilotSessions.tsx | 18 +- .../flowpilot/AISessionListItem.tsx | 26 ++- frontend/src/hooks/useFlowPilotSession.ts | 3 + frontend/src/pages/AssistantChatPage.tsx | 163 +++++++++++------- frontend/src/pages/SessionHistoryPage.tsx | 28 ++- frontend/src/router.tsx | 1 + frontend/src/types/ai-session.ts | 24 +++ 15 files changed, 685 insertions(+), 88 deletions(-) create mode 100644 backend/alembic/versions/066_add_session_type_and_title_to_ai_sessions.py create mode 100644 backend/app/services/unified_chat_service.py create mode 100644 docs/plans/2026-03-23-unified-sessions.md diff --git a/backend/alembic/versions/066_add_session_type_and_title_to_ai_sessions.py b/backend/alembic/versions/066_add_session_type_and_title_to_ai_sessions.py new file mode 100644 index 00000000..94160d2d --- /dev/null +++ b/backend/alembic/versions/066_add_session_type_and_title_to_ai_sessions.py @@ -0,0 +1,42 @@ +"""Add session_type and title columns to ai_sessions for unified sessions. + +Revision ID: 066 +Revises: 065 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "066" +down_revision = "065" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "ai_sessions", + sa.Column( + "session_type", + sa.String(10), + nullable=False, + server_default="guided", + comment="Session type: guided (FlowPilot) or chat (assistant)", + ), + ) + op.add_column( + "ai_sessions", + sa.Column( + "title", + sa.String(255), + nullable=True, + comment="Display title for chat sessions; guided sessions use problem_summary", + ), + ) + op.create_index("ix_ai_sessions_session_type", "ai_sessions", ["session_type"]) + + +def downgrade() -> None: + op.drop_index("ix_ai_sessions_session_type", table_name="ai_sessions") + op.drop_column("ai_sessions", "title") + op.drop_column("ai_sessions", "session_type") diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index c62d60a7..df0be33b 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -45,8 +45,12 @@ from app.schemas.ai_session import ( AISessionStepResponse, AISessionSearchResult, StepOptionSchema, + ChatSessionCreateResponse, + ChatMessageRequest, + ChatMessageResponse, ) from app.services import flowpilot_engine +from app.services import unified_chat_service from app.services.psa_documentation_service import retry_failed_push logger = logging.getLogger(__name__) @@ -89,6 +93,8 @@ def _build_session_detail(session: AISession) -> AISessionDetail: return AISessionDetail( id=session.id, + session_type=getattr(session, 'session_type', 'guided'), + title=getattr(session, 'title', None), status=session.status, intake_type=session.intake_type, intake_content=session.intake_content or {}, @@ -109,6 +115,7 @@ def _build_session_detail(session: AISession) -> AISessionDetail: created_at=session.created_at, resolved_at=session.resolved_at, steps=step_responses, + conversation_messages=session.conversation_messages or [], ) @@ -176,7 +183,7 @@ async def _record_usage( # ── Create session ── -@router.post("", response_model=AISessionCreateResponse, status_code=201) +@router.post("", status_code=201) @limiter.limit("5/minute") async def create_session( request: Request, @@ -185,10 +192,35 @@ async def create_session( db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_engineer_or_admin), ): - """Start a new FlowPilot troubleshooting session.""" + """Start a new FlowPilot or chat session.""" _require_ai_enabled() await _check_quota(current_user, db) + # Chat sessions use a different creation path + if data.session_type == "chat": + try: + session = await unified_chat_service.create_chat_session( + user_id=current_user.id, + account_id=current_user.account_id, + team_id=current_user.team_id, + intake_content=data.intake_content, + db=db, + ) + except Exception as e: + logger.exception("Chat session creation failed: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create chat session", + ) + + await db.commit() + + return ChatSessionCreateResponse( + session_id=session.id, + title=session.title or "New Chat", + status=session.status, + ) + try: result = await flowpilot_engine.start_session( request=data, @@ -229,6 +261,70 @@ async def create_session( return result +# ── Chat message ── + +@router.post("/{session_id}/chat", response_model=ChatMessageResponse) +@limiter.limit("10/minute") +async def send_chat_message( + request: Request, + session_id: UUID, + data: ChatMessageRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +): + """Send a message in a chat session and get AI response.""" + _require_ai_enabled() + await _check_quota(current_user, db) + + user_id = current_user.id + account_id = current_user.account_id + + try: + ai_content, suggested_flows, session = await unified_chat_service.send_chat_message( + session_id=session_id, + user_id=user_id, + account_id=account_id, + message=data.message, + db=db, + ) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.exception("Chat message failed: %s", e) + await db.rollback() + try: + await _record_usage( + current_user, db, + generation_type="chat_message", + input_tokens=0, output_tokens=0, + succeeded=False, + session_id=session_id, + error_code=type(e).__name__, + ) + await db.commit() + except Exception: + logger.warning("Failed to record usage after chat failure", exc_info=True) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"AI provider error ({type(e).__name__}). Please try again.", + ) + + await _record_usage( + current_user, db, + generation_type="chat_message", + input_tokens=0, output_tokens=0, + succeeded=True, + session_id=session_id, + ) + await db.commit() + + return ChatMessageResponse( + content=ai_content, + suggested_flows=suggested_flows, + ) + + # ── Respond to step ── @router.post("/{session_id}/respond", response_model=StepResponseResponse) @@ -426,6 +522,29 @@ async def abandon_session( await db.commit() +# ── Delete ── + +@router.delete("/{session_id}", status_code=204) +async def delete_session( + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Delete a session (owner only).""" + result = await db.execute( + select(AISession).where( + AISession.id == session_id, + AISession.user_id == current_user.id, + ) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + + await db.delete(session) + await db.commit() + + # ── Escalation Queue ── @router.get("/escalation-queue", response_model=list[AISessionSummary]) @@ -638,6 +757,7 @@ async def list_sessions( matched_flow_id: Optional[UUID] = Query(None), confidence_tier: Optional[str] = Query(None, pattern="^(guided|exploring|discovery)$"), ticket_id: Optional[str] = Query(None), + session_type: Optional[str] = Query(None, pattern="^(guided|chat)$"), date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None), q: Optional[str] = Query(None, min_length=2, max_length=200), @@ -657,6 +777,8 @@ async def list_sessions( .limit(limit) ) + if session_type: + query = query.where(AISession.session_type == session_type) if session_status: query = query.where(AISession.status == session_status) if problem_domain: diff --git a/backend/app/models/ai_session.py b/backend/app/models/ai_session.py index c2722df0..721924ad 100644 --- a/backend/app/models/ai_session.py +++ b/backend/app/models/ai_session.py @@ -66,6 +66,16 @@ class AISession(Base): index=True, ) + # ── Session type ── + session_type: Mapped[str] = mapped_column( + String(10), nullable=False, default="guided", index=True, + comment="Session type: guided (FlowPilot) or chat (assistant)", + ) + title: Mapped[Optional[str]] = mapped_column( + String(255), nullable=True, + comment="Display title for chat sessions; guided sessions use problem_summary", + ) + # ── Intake ── intake_type: Mapped[str] = mapped_column( String(20), nullable=False, default="free_text" diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index bedf26da..3def117a 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -11,7 +11,12 @@ from pydantic import BaseModel, Field # ── Intake ── class AISessionCreateRequest(BaseModel): - """Start a new FlowPilot session.""" + """Start a new FlowPilot or chat session.""" + session_type: str = Field( + "guided", + pattern="^(guided|chat)$", + description="Session type: guided (FlowPilot) or chat (assistant)", + ) intake_type: str = Field( "free_text", pattern="^(free_text|psa_ticket|screenshot|log_paste|combined)$", @@ -192,6 +197,8 @@ class LinkTicketRequest(BaseModel): class AISessionSummary(BaseModel): """Compact session for list views.""" id: UUID + session_type: str = "guided" + title: str | None = None status: str intake_type: str problem_summary: str | None = None @@ -208,7 +215,7 @@ class AISessionSummary(BaseModel): class AISessionDetail(AISessionSummary): - """Full session detail with steps.""" + """Full session detail with steps (guided) or messages (chat).""" intake_content: dict[str, Any] matched_flow_id: UUID | None = None match_score: float | None = None @@ -220,10 +227,32 @@ class AISessionDetail(AISessionSummary): psa_connection_id: UUID | None = None ticket_data: dict[str, Any] | None = None steps: list[AISessionStepResponse] = [] + conversation_messages: list[dict[str, Any]] = [] # Chat sessions store messages here model_config = {"from_attributes": True} +# ── Chat session ── + +class ChatSessionCreateResponse(BaseModel): + """Response after creating a chat session on ai_sessions.""" + session_id: UUID + session_type: str = "chat" + title: str + status: str = "active" + + +class ChatMessageRequest(BaseModel): + """Send a message in a chat session.""" + message: str = Field(..., min_length=1, max_length=8000) + + +class ChatMessageResponse(BaseModel): + """AI response to a chat message.""" + content: str + suggested_flows: list[dict[str, Any]] = [] + + class AISessionSearchResult(BaseModel): """Lightweight session result for Command Palette / autocomplete.""" id: UUID diff --git a/backend/app/services/unified_chat_service.py b/backend/app/services/unified_chat_service.py new file mode 100644 index 00000000..83aca3e5 --- /dev/null +++ b/backend/app/services/unified_chat_service.py @@ -0,0 +1,125 @@ +"""Unified chat service — chat sessions on ai_sessions table. + +Replaces assistant_chat_service for new chat sessions. Messages are stored +in ai_sessions.conversation_messages JSONB. Reuses the same AI calling +infrastructure and system prompt from assistant_chat_service. +""" +import logging +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.ai_session import AISession +from app.services.assistant_chat_service import ( + ASSISTANT_SYSTEM_PROMPT, + _call_ai, + _auto_title, +) +from app.services.rag_service import search as rag_search, build_rag_context, extract_suggested_flows + +logger = logging.getLogger(__name__) + + +async def create_chat_session( + user_id: UUID, + account_id: UUID, + team_id: UUID | None, + intake_content: dict[str, Any], + db: AsyncSession, +) -> AISession: + """Create a new chat session on ai_sessions.""" + first_message = intake_content.get("text", "") + title = _auto_title(first_message) if first_message else "New Chat" + + session = AISession( + user_id=user_id, + account_id=account_id, + team_id=team_id, + session_type="chat", + title=title, + intake_type="free_text", + intake_content=intake_content, + status="active", + confidence_tier="discovery", + confidence_score=0.0, + conversation_messages=[], + ) + db.add(session) + await db.flush() + return session + + +async def send_chat_message( + session_id: UUID, + user_id: UUID, + account_id: UUID, + message: str, + db: AsyncSession, +) -> tuple[str, list[dict[str, Any]], AISession]: + """Send a message in a chat session and get AI response. + + Returns (ai_content, suggested_flows, session). + """ + result = await db.execute( + select(AISession).where( + AISession.id == session_id, + AISession.user_id == user_id, + AISession.session_type == "chat", + ) + ) + session = result.scalar_one_or_none() + if not session: + raise ValueError("Chat session not found") + + if session.status not in ("active", "paused"): + raise ValueError(f"Cannot send messages to a {session.status} session") + + # Auto-title from first message if still default + if session.step_count == 0 and message.strip(): + session.title = _auto_title(message) + + # Auto-detect problem domain from first message + if not session.problem_summary and message.strip(): + session.problem_summary = _auto_title(message) + + # RAG search for relevant flows + rag_results = await rag_search( + query=message, + account_id=account_id, + db=db, + limit=8, + ) + rag_context = build_rag_context(rag_results) + + # Build message history for AI + ai_messages: list[dict[str, Any]] = [] + for msg in (session.conversation_messages or []): + if msg.get("role") in ("user", "assistant"): + ai_messages.append({"role": msg["role"], "content": msg["content"]}) + + # Call AI + ai_content, input_tokens, output_tokens = await _call_ai( + system_base=ASSISTANT_SYSTEM_PROMPT, + rag_context=rag_context, + history=ai_messages, + new_message=message, + ) + + # Append messages to conversation_messages + msgs = list(session.conversation_messages or []) + msgs.append({"role": "user", "content": message}) + msgs.append({"role": "assistant", "content": ai_content}) + session.conversation_messages = msgs + session.step_count += 2 # message count for display + session.total_input_tokens += input_tokens + session.total_output_tokens += output_tokens + + # Resume if paused + if session.status == "paused": + session.status = "active" + + suggested_flows = extract_suggested_flows(rag_results) + + return ai_content, suggested_flows, session diff --git a/docs/plans/2026-03-23-unified-sessions.md b/docs/plans/2026-03-23-unified-sessions.md new file mode 100644 index 00000000..a48108d7 --- /dev/null +++ b/docs/plans/2026-03-23-unified-sessions.md @@ -0,0 +1,130 @@ +# Unified Sessions — Migration Plan + +> **Date:** 2026-03-23 +> **Status:** Implementation ready +> **Goal:** Merge assistant chat into the ai_sessions system so both guided (FlowPilot) and free-form (chat) sessions share the same data model, history, and action system. + +## Problem + +Three separate conversation systems exist: +1. `assistant_chats` — free-form chat, JSONB messages inline, no steps +2. `ai_sessions` + `ai_session_steps` — guided FlowPilot, separate steps table +3. `ai_chat_sessions` — flow builder (unrelated, leave alone) + +This causes: +- No unified session history +- Assistant chats missing Resolve/Escalate/Update actions +- Dashboard can't show assistant chats in active/recent +- Two separate API surfaces to maintain + +## Solution + +Add `session_type` to `ai_sessions`. Chat sessions use `conversation_messages` JSONB for message history (already exists). Both types share the same status, PSA, escalation, and documentation features. + +## Data Model Changes + +### Migration: Add `session_type` to `ai_sessions` + +```sql +ALTER TABLE ai_sessions ADD COLUMN session_type VARCHAR(10) NOT NULL DEFAULT 'guided'; +-- Values: 'guided' (FlowPilot), 'chat' (assistant) +``` + +### How chat sessions use ai_sessions + +| ai_sessions column | Chat usage | +|---|---| +| `session_type` | `'chat'` | +| `intake_type` | `'free_text'` | +| `intake_content` | `{text: "first message"}` | +| `conversation_messages` | Full chat history as JSONB array `[{role, content}]` | +| `status` | Same: active/resolved/escalated/paused/abandoned | +| `problem_summary` | AI-generated from first few messages | +| `problem_domain` | AI-detected domain | +| `step_count` | Message count (for display) | +| `resolution_summary` | Set on resolve | +| `escalation_reason` | Set on escalate | +| All PSA fields | Same — can link tickets, push notes | +| All timestamps | Same | + +### What chat sessions DON'T use + +- `ai_session_steps` table — no steps, messages are in `conversation_messages` +- `matched_flow_id` / `match_score` — no flow matching +- `confidence_tier` / `confidence_score` — no structured confidence +- `system_prompt_snapshot` — could store chat system prompt + +### New field on ai_sessions + +- `title` (String 255, nullable) — chat sessions need a title for the sidebar. Guided sessions can use `problem_summary`. + +## Implementation Phases + +### Phase 1: Backend — Model & Migration +- [ ] Alembic migration: add `session_type` VARCHAR(10) default 'guided', add `title` VARCHAR(255) nullable +- [ ] Update AISession model with new columns +- [ ] Update schemas: add `session_type` and `title` to response schemas +- [ ] Add session_type filter to GET /ai-sessions endpoint + +### Phase 2: Backend — Chat API on ai_sessions +- [ ] Create new endpoints or extend existing: + - `POST /ai-sessions` with `session_type: 'chat'` — creates a chat session + - `POST /ai-sessions/{id}/chat` — send message, get AI response (appends to conversation_messages) + - Reuse existing: resolve, escalate, pause, abandon, status-update +- [ ] Chat AI service: takes conversation_messages, calls Anthropic, appends response +- [ ] Auto-generate title from first message (like current assistant chat does) +- [ ] Auto-detect problem_domain from conversation + +### Phase 3: Frontend — Unified Session History +- [ ] Update SessionHistoryPage to show both types +- [ ] Add type icon: compass/route for guided, message-circle for chat +- [ ] Session detail page routes correctly based on type +- [ ] Add session_type filter option + +### Phase 4: Frontend — Assistant Chat on ai_sessions +- [ ] Update AssistantChatPage to use ai_sessions API instead of assistant_chats +- [ ] Chat sidebar queries ai_sessions with `session_type=chat` +- [ ] Messages read from / write to `conversation_messages` +- [ ] Add header actions: Resolve / Escalate / Share Update / Pause / Close +- [ ] Status update modal works the same as FlowPilot + +### Phase 5: Frontend — Dashboard Integration +- [ ] ActiveFlowPilotSessions includes chat sessions (both types) +- [ ] RecentFlowPilotSessions includes resolved chats +- [ ] Type icon on each card so users see the difference at a glance + +### Phase 6: Cleanup +- [ ] Migrate existing assistant_chat data to ai_sessions (optional — could just start fresh for pilot) +- [ ] Deprecate /assistant/* API endpoints +- [ ] Remove assistant_chats model (post-pilot) + +## Visual Differentiators + +| Type | Icon | Badge color | Label | +|------|------|------------|-------| +| Guided (FlowPilot) | `` | cyan | "Guided" | +| Chat (Assistant) | `` | purple/violet | "Chat" | + +## API Surface (after migration) + +All under `/ai-sessions`: + +| Endpoint | Both types? | Notes | +|----------|------------|-------| +| `POST /ai-sessions` | Yes | `session_type` field determines behavior | +| `GET /ai-sessions` | Yes | Filter by `session_type` optional | +| `GET /ai-sessions/{id}` | Yes | Returns full session with messages or steps | +| `POST /ai-sessions/{id}/chat` | Chat only | Send/receive messages | +| `POST /ai-sessions/{id}/respond` | Guided only | Step response | +| `POST /ai-sessions/{id}/resolve` | Both | Same resolve flow | +| `POST /ai-sessions/{id}/escalate` | Both | Same escalation | +| `POST /ai-sessions/{id}/pause` | Both | Same pause | +| `POST /ai-sessions/{id}/abandon` | Both | Same abandon | +| `POST /ai-sessions/{id}/status-update` | Both | Same status updates | + +## Risk Assessment + +- **Low risk:** Adding columns to ai_sessions is additive, no existing data changes +- **Medium risk:** Frontend routing — need to route to correct page based on session_type +- **Data migration:** Can skip for pilot — start with fresh chat sessions on new system. Old assistant_chats remain accessible via old API until removed. +- **Rollback:** session_type column is additive, old assistant_chat endpoints can stay as fallback diff --git a/frontend/src/api/aiSessions.ts b/frontend/src/api/aiSessions.ts index 656ad483..39259c1b 100644 --- a/frontend/src/api/aiSessions.ts +++ b/frontend/src/api/aiSessions.ts @@ -15,6 +15,9 @@ import type { PickupSessionRequest, StatusUpdateRequest, StatusUpdateResponse, + ChatSessionCreateResponse, + ChatMessageRequest, + ChatMessageResponse, } from '@/types/ai-session' export const aiSessionsApi = { @@ -23,6 +26,22 @@ export const aiSessionsApi = { return response.data }, + async createChatSession(data: AISessionCreateRequest): Promise { + const response = await apiClient.post('/ai-sessions', { + ...data, + session_type: 'chat', + }) + return response.data + }, + + async sendChatMessage(sessionId: string, data: ChatMessageRequest): Promise { + const response = await apiClient.post( + `/ai-sessions/${sessionId}/chat`, + data + ) + return response.data + }, + async respondToStep(sessionId: string, data: StepResponseRequest): Promise { const response = await apiClient.post( `/ai-sessions/${sessionId}/respond`, @@ -49,6 +68,7 @@ export const aiSessionsApi = { async listSessions(params?: { status?: string + session_type?: string skip?: number limit?: number problem_domain?: string @@ -104,6 +124,10 @@ export const aiSessionsApi = { }) }, + async deleteSession(sessionId: string): Promise { + await apiClient.delete(`/ai-sessions/${sessionId}`) + }, + async pickupSession(sessionId: string, data: PickupSessionRequest): Promise { const response = await apiClient.post( `/ai-sessions/${sessionId}/pickup`, diff --git a/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx b/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx index e7bcd31d..9c232919 100644 --- a/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx +++ b/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Link, useNavigate } from 'react-router-dom' -import { Sparkles, Clock, ArrowRight } from 'lucide-react' +import { Clock, ArrowRight, Route, MessageCircle } from 'lucide-react' import { aiSessionsApi } from '@/api/aiSessions' import type { AISessionSummary } from '@/types/ai-session' import { cn } from '@/lib/utils' @@ -30,7 +30,7 @@ export function ActiveFlowPilotSessions() { if (loading) { return (
-
+

Active Sessions

@@ -46,7 +46,7 @@ export function ActiveFlowPilotSessions() {

Active Sessions

@@ -74,11 +74,15 @@ export function ActiveFlowPilotSessions() { {sessions.map((session) => ( ))} diff --git a/frontend/src/components/dashboard/RecentFlowPilotSessions.tsx b/frontend/src/components/dashboard/RecentFlowPilotSessions.tsx index 4ab12e51..5d7da215 100644 --- a/frontend/src/components/dashboard/RecentFlowPilotSessions.tsx +++ b/frontend/src/components/dashboard/RecentFlowPilotSessions.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Link, useNavigate } from 'react-router-dom' -import { CheckCircle, AlertTriangle, XCircle, ArrowRight } from 'lucide-react' +import { CheckCircle, AlertTriangle, XCircle, ArrowRight, MessageCircle } from 'lucide-react' import { aiSessionsApi } from '@/api/aiSessions' import type { AISessionSummary } from '@/types/ai-session' @@ -44,7 +44,7 @@ export function RecentFlowPilotSessions() {

Recent Sessions

navigate(`/pilot/${session.id}`)} + onClick={() => navigate(session.session_type === 'chat' ? `/assistant/${session.id}` : `/pilot/${session.id}`)} className="flex w-full items-center gap-3 px-5 py-3 text-left hover:bg-[rgba(255,255,255,0.02)] transition-colors" style={{ - borderBottom: i < sessions.length - 1 ? '1px solid var(--glass-border)' : undefined, + borderBottom: i < sessions.length - 1 ? '1px solid var(--color-border-default)' : undefined, }} > - + {session.session_type === 'chat' ? ( + + ) : ( + + )}

- {session.problem_summary || 'Session'} + {session.session_type === 'chat' + ? (session.title || session.problem_summary || 'Chat') + : (session.problem_summary || 'Session')}

diff --git a/frontend/src/components/flowpilot/AISessionListItem.tsx b/frontend/src/components/flowpilot/AISessionListItem.tsx index 8186c10e..c936c844 100644 --- a/frontend/src/components/flowpilot/AISessionListItem.tsx +++ b/frontend/src/components/flowpilot/AISessionListItem.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom' -import { Clock, CheckCircle2, ArrowUpRight, AlertCircle, Pause } from 'lucide-react' +import { Clock, CheckCircle2, ArrowUpRight, AlertCircle, Pause, Route, MessageCircle } from 'lucide-react' import { cn } from '@/lib/utils' import type { AISessionSummary } from '@/types/ai-session' @@ -18,17 +18,31 @@ const STATUS_CONFIG = { export function AISessionListItem({ session }: AISessionListItemProps) { const config = STATUS_CONFIG[session.status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.active const StatusIcon = config.icon + const isChat = session.session_type === 'chat' + const TypeIcon = isChat ? MessageCircle : Route + const linkTo = isChat ? `/assistant/${session.id}` : `/pilot/${session.id}` + const displayTitle = isChat + ? (session.title || session.problem_summary || 'Untitled chat') + : (session.problem_summary || 'Untitled session') return (
-

- {session.problem_summary || 'Untitled session'} -

+
+ + + +

+ {displayTitle} +

+
{session.problem_domain && ( @@ -40,7 +54,7 @@ export function AISessionListItem({ session }: AISessionListItemProps) { {config.label} - {session.step_count} steps + {session.step_count} {isChat ? 'messages' : 'steps'} {new Date(session.created_at).toLocaleDateString(undefined, { diff --git a/frontend/src/hooks/useFlowPilotSession.ts b/frontend/src/hooks/useFlowPilotSession.ts index 66ae535c..3875109b 100644 --- a/frontend/src/hooks/useFlowPilotSession.ts +++ b/frontend/src/hooks/useFlowPilotSession.ts @@ -69,6 +69,8 @@ export function useFlowPilotSession(): UseFlowPilotSession { setSession({ id: result.session_id, + session_type: 'guided', + title: null, status: result.status, intake_type: intake.intake_type, intake_content: intake.intake_content, @@ -89,6 +91,7 @@ export function useFlowPilotSession(): UseFlowPilotSession { psa_connection_id: intake.psa_connection_id ?? null, ticket_data: null, steps: [firstStep], + conversation_messages: [], }) setAllSteps([firstStep]) setCurrentStep(firstStep) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 157ebab2..a1f69b3c 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -1,28 +1,31 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus } from 'lucide-react' import { cn } from '@/lib/utils' import { uploadsApi } from '@/api/uploads' import type { PendingUpload } from '@/types/upload' import { PageMeta } from '@/components/common/PageMeta' -import { assistantChatApi } from '@/api/assistantChat' +import { aiSessionsApi } from '@/api/aiSessions' import { analytics } from '@/lib/analytics' import { toast } from '@/lib/toast' import { ChatSidebar } from '@/components/assistant/ChatSidebar' import { ChatMessage } from '@/components/assistant/ChatMessage' import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal' -import type { ChatListItem, AssistantChatMessage as ChatMessageType, ConclusionOutcome } from '@/types/assistant-chat' +import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' import type { SuggestedFlow } from '@/types/copilot' -interface MessageWithMeta extends ChatMessageType { +interface MessageWithMeta { + role: 'user' | 'assistant' + content: string suggestedFlows?: SuggestedFlow[] } export default function AssistantChatPage() { const location = useLocation() const navigate = useNavigate() + const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>() const [chats, setChats] = useState([]) - const [activeChatId, setActiveChatId] = useState(null) + const [activeChatId, setActiveChatId] = useState(urlSessionId || null) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) @@ -38,39 +41,53 @@ export default function AssistantChatPage() { const dragCounterRef = useRef(0) const prefillHandledRef = useRef(false) - // Load chat list + // Load chat list from ai_sessions useEffect(() => { loadChats() }, []) - // Handle prefill from command palette handoff + // If URL has a session ID, load it + useEffect(() => { + if (urlSessionId && urlSessionId !== activeChatId) { + selectChat(urlSessionId) + } + }, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Handle prefill from command palette / dashboard handoff useEffect(() => { const prefill = (location.state as { prefill?: string } | null)?.prefill if (!prefill || prefillHandledRef.current) return prefillHandledRef.current = true - // Clear the location state so back-navigation doesn't retrigger navigate(location.pathname, { replace: true, state: {} }) const sendPrefill = async () => { try { - const chat = await assistantChatApi.createChat() - setChats(prev => [ - { id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at }, - ...prev, - ]) - setActiveChatId(chat.id) + const session = await aiSessionsApi.createChatSession({ + intake_type: 'free_text', + intake_content: { text: prefill }, + }) + const chatItem: ChatListItem = { + id: session.session_id, + title: session.title, + message_count: 0, + pinned: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + setChats(prev => [chatItem, ...prev]) + setActiveChatId(session.session_id) setMessages([{ role: 'user', content: prefill }]) setLoading(true) - const response = await assistantChatApi.sendMessage(chat.id, prefill) + const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: prefill }) setMessages(prev => [ ...prev, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows }, ]) setChats(prev => prev.map(c => - c.id === chat.id + c.id === session.session_id ? { ...c, message_count: 2, title: prefill.slice(0, 100), updated_at: new Date().toISOString() } : c ) @@ -93,8 +110,15 @@ export default function AssistantChatPage() { const loadChats = async () => { try { - const list = await assistantChatApi.listChats(1, 100) - setChats(list) + const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 }) + setChats(sessions.map(s => ({ + id: s.id, + title: s.title || s.problem_summary || 'New Chat', + message_count: s.step_count, + pinned: false, + created_at: s.created_at, + updated_at: s.created_at, + }))) } catch { // silently handle } @@ -103,8 +127,13 @@ export default function AssistantChatPage() { const selectChat = useCallback(async (chatId: string) => { setActiveChatId(chatId) try { - const chat = await assistantChatApi.getChat(chatId) - setMessages(chat.messages.map(m => ({ ...m }))) + const detail = await aiSessionsApi.getSession(chatId) + setMessages( + (detail.conversation_messages || []).map(m => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })) + ) } catch { setMessages([]) } @@ -112,12 +141,20 @@ export default function AssistantChatPage() { const handleNewChat = async () => { try { - const chat = await assistantChatApi.createChat() - setChats(prev => [ - { id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at }, - ...prev, - ]) - setActiveChatId(chat.id) + const session = await aiSessionsApi.createChatSession({ + intake_type: 'free_text', + intake_content: { text: '' }, + }) + const chatItem: ChatListItem = { + id: session.session_id, + title: session.title, + message_count: 0, + pinned: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + setChats(prev => [chatItem, ...prev]) + setActiveChatId(session.session_id) setMessages([]) } catch { toast.error('Failed to create chat') @@ -126,7 +163,7 @@ export default function AssistantChatPage() { const handleDeleteChat = async (chatId: string) => { try { - await assistantChatApi.deleteChat(chatId) + await aiSessionsApi.deleteSession(chatId) setChats(prev => prev.filter(c => c.id !== chatId)) if (activeChatId === chatId) { setActiveChatId(null) @@ -137,15 +174,9 @@ export default function AssistantChatPage() { } } - const handleTogglePin = async (chatId: string, pinned: boolean) => { - try { - await assistantChatApi.updateChat(chatId, { pinned }) - setChats(prev => - prev.map(c => c.id === chatId ? { ...c, pinned } : c) - ) - } catch { - toast.error('Failed to update chat') - } + const handleTogglePin = async (_chatId: string, _pinned: boolean) => { + // Pin/unpin not yet supported on unified sessions — no-op for now + toast.info('Pin feature coming soon') } const handleSend = async () => { @@ -157,13 +188,12 @@ export default function AssistantChatPage() { setLoading(true) try { - const response = await assistantChatApi.sendMessage(activeChatId, userMessage) + const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage }) analytics.aiFeatureUsed({ feature: 'assistant_chat' }) setMessages(prev => [ ...prev, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows }, ]) - // Update chat list title if it was the first message setChats(prev => prev.map(c => c.id === activeChatId @@ -182,44 +212,55 @@ export default function AssistantChatPage() { } } - const handleConclude = async (outcome: ConclusionOutcome, notes: string): Promise => { + const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise => { if (!activeChatId) throw new Error('No active chat') - const response = await assistantChatApi.concludeChat(activeChatId, { outcome, notes: notes || undefined }) - // Update chat in sidebar to show concluded status - setChats(prev => - prev.map(c => - c.id === activeChatId - ? { ...c, concluded_at: response.concluded_at, conclusion_outcome: outcome } - : c - ) - ) - return response.summary + + // Map conclusion outcomes to ai_sessions actions + if (outcome === 'resolved') { + const result = await aiSessionsApi.resolveSession(activeChatId, { + resolution_summary: _notes || 'Resolved via assistant chat', + }) + return result.documentation?.problem_summary || 'Session resolved' + } else if (outcome === 'escalated') { + const result = await aiSessionsApi.escalateSession(activeChatId, { + escalation_reason: _notes || 'Escalated from assistant chat', + }) + return result.documentation?.problem_summary || 'Session escalated' + } else { + // paused + await aiSessionsApi.pauseSession(activeChatId) + return 'Session paused' + } } const handleResumeNew = async (summary: string) => { try { - const chat = await assistantChatApi.createChat() - setChats(prev => [ - { id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at }, - ...prev, - ]) - setActiveChatId(chat.id) - setMessages([]) - - // Send the summary as the first message to prime the new chat const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.` - setInput('') + const session = await aiSessionsApi.createChatSession({ + intake_type: 'free_text', + intake_content: { text: resumePrompt }, + }) + const chatItem: ChatListItem = { + id: session.session_id, + title: session.title, + message_count: 0, + pinned: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + setChats(prev => [chatItem, ...prev]) + setActiveChatId(session.session_id) setMessages([{ role: 'user', content: resumePrompt }]) setLoading(true) - const response = await assistantChatApi.sendMessage(chat.id, resumePrompt) + const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt }) setMessages(prev => [ ...prev, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows }, ]) setChats(prev => prev.map(c => - c.id === chat.id + c.id === session.session_id ? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() } : c ) diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx index 88694d76..2a7d2e6c 100644 --- a/frontend/src/pages/SessionHistoryPage.tsx +++ b/frontend/src/pages/SessionHistoryPage.tsx @@ -27,6 +27,7 @@ export function SessionHistoryPage() { const aiSearchTimeout = useRef | undefined>(undefined) const [aiFilters, setAiFilters] = useState({ q: '', + session_type: '', problem_domain: '', confidence_tier: '', date_from: '', @@ -176,6 +177,7 @@ export function SessionHistoryPage() { const data = await aiSessionsApi.listSessions({ limit: 50, q: aiFilters.q || undefined, + session_type: aiFilters.session_type || undefined, problem_domain: aiFilters.problem_domain || undefined, confidence_tier: aiFilters.confidence_tier || undefined, date_from: aiFilters.date_from || undefined, @@ -267,7 +269,7 @@ export function SessionHistoryPage() { return labels[outcome] ?? outcome } - const hasAiFiltersActive = !!(aiSearchInput || aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) + const hasAiFiltersActive = !!(aiSearchInput || aiFilters.q || aiFilters.session_type || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) const hasFlowFiltersActive = !!(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) // Determine section visibility @@ -314,7 +316,7 @@ export function SessionHistoryPage() { {/* FlowPilot Sessions Section */} {showAiSection && ( <> -

FlowPilot Sessions

+

AI Sessions

{/* AI Session Filter Bar */}
@@ -331,6 +333,24 @@ export function SessionHistoryPage() { />
+ {/* Session type pills */} +
+ {(['', 'guided', 'chat'] as const).map((t) => ( + + ))} +
+ {/* Problem domain dropdown */}