feat: unified sessions — merge assistant chat into ai_sessions table
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||||
@@ -45,8 +45,12 @@ from app.schemas.ai_session import (
|
|||||||
AISessionStepResponse,
|
AISessionStepResponse,
|
||||||
AISessionSearchResult,
|
AISessionSearchResult,
|
||||||
StepOptionSchema,
|
StepOptionSchema,
|
||||||
|
ChatSessionCreateResponse,
|
||||||
|
ChatMessageRequest,
|
||||||
|
ChatMessageResponse,
|
||||||
)
|
)
|
||||||
from app.services import flowpilot_engine
|
from app.services import flowpilot_engine
|
||||||
|
from app.services import unified_chat_service
|
||||||
from app.services.psa_documentation_service import retry_failed_push
|
from app.services.psa_documentation_service import retry_failed_push
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -89,6 +93,8 @@ def _build_session_detail(session: AISession) -> AISessionDetail:
|
|||||||
|
|
||||||
return AISessionDetail(
|
return AISessionDetail(
|
||||||
id=session.id,
|
id=session.id,
|
||||||
|
session_type=getattr(session, 'session_type', 'guided'),
|
||||||
|
title=getattr(session, 'title', None),
|
||||||
status=session.status,
|
status=session.status,
|
||||||
intake_type=session.intake_type,
|
intake_type=session.intake_type,
|
||||||
intake_content=session.intake_content or {},
|
intake_content=session.intake_content or {},
|
||||||
@@ -109,6 +115,7 @@ def _build_session_detail(session: AISession) -> AISessionDetail:
|
|||||||
created_at=session.created_at,
|
created_at=session.created_at,
|
||||||
resolved_at=session.resolved_at,
|
resolved_at=session.resolved_at,
|
||||||
steps=step_responses,
|
steps=step_responses,
|
||||||
|
conversation_messages=session.conversation_messages or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -176,7 +183,7 @@ async def _record_usage(
|
|||||||
|
|
||||||
# ── Create session ──
|
# ── Create session ──
|
||||||
|
|
||||||
@router.post("", response_model=AISessionCreateResponse, status_code=201)
|
@router.post("", status_code=201)
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def create_session(
|
async def create_session(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -185,10 +192,35 @@ async def create_session(
|
|||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
_: None = Depends(require_engineer_or_admin),
|
_: None = Depends(require_engineer_or_admin),
|
||||||
):
|
):
|
||||||
"""Start a new FlowPilot troubleshooting session."""
|
"""Start a new FlowPilot or chat session."""
|
||||||
_require_ai_enabled()
|
_require_ai_enabled()
|
||||||
await _check_quota(current_user, db)
|
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:
|
try:
|
||||||
result = await flowpilot_engine.start_session(
|
result = await flowpilot_engine.start_session(
|
||||||
request=data,
|
request=data,
|
||||||
@@ -229,6 +261,70 @@ async def create_session(
|
|||||||
return result
|
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 ──
|
# ── Respond to step ──
|
||||||
|
|
||||||
@router.post("/{session_id}/respond", response_model=StepResponseResponse)
|
@router.post("/{session_id}/respond", response_model=StepResponseResponse)
|
||||||
@@ -426,6 +522,29 @@ async def abandon_session(
|
|||||||
await db.commit()
|
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 ──
|
# ── Escalation Queue ──
|
||||||
|
|
||||||
@router.get("/escalation-queue", response_model=list[AISessionSummary])
|
@router.get("/escalation-queue", response_model=list[AISessionSummary])
|
||||||
@@ -638,6 +757,7 @@ async def list_sessions(
|
|||||||
matched_flow_id: Optional[UUID] = Query(None),
|
matched_flow_id: Optional[UUID] = Query(None),
|
||||||
confidence_tier: Optional[str] = Query(None, pattern="^(guided|exploring|discovery)$"),
|
confidence_tier: Optional[str] = Query(None, pattern="^(guided|exploring|discovery)$"),
|
||||||
ticket_id: Optional[str] = Query(None),
|
ticket_id: Optional[str] = Query(None),
|
||||||
|
session_type: Optional[str] = Query(None, pattern="^(guided|chat)$"),
|
||||||
date_from: Optional[datetime] = Query(None),
|
date_from: Optional[datetime] = Query(None),
|
||||||
date_to: Optional[datetime] = Query(None),
|
date_to: Optional[datetime] = Query(None),
|
||||||
q: Optional[str] = Query(None, min_length=2, max_length=200),
|
q: Optional[str] = Query(None, min_length=2, max_length=200),
|
||||||
@@ -657,6 +777,8 @@ async def list_sessions(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if session_type:
|
||||||
|
query = query.where(AISession.session_type == session_type)
|
||||||
if session_status:
|
if session_status:
|
||||||
query = query.where(AISession.status == session_status)
|
query = query.where(AISession.status == session_status)
|
||||||
if problem_domain:
|
if problem_domain:
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ class AISession(Base):
|
|||||||
index=True,
|
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 ──
|
||||||
intake_type: Mapped[str] = mapped_column(
|
intake_type: Mapped[str] = mapped_column(
|
||||||
String(20), nullable=False, default="free_text"
|
String(20), nullable=False, default="free_text"
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ from pydantic import BaseModel, Field
|
|||||||
# ── Intake ──
|
# ── Intake ──
|
||||||
|
|
||||||
class AISessionCreateRequest(BaseModel):
|
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(
|
intake_type: str = Field(
|
||||||
"free_text",
|
"free_text",
|
||||||
pattern="^(free_text|psa_ticket|screenshot|log_paste|combined)$",
|
pattern="^(free_text|psa_ticket|screenshot|log_paste|combined)$",
|
||||||
@@ -192,6 +197,8 @@ class LinkTicketRequest(BaseModel):
|
|||||||
class AISessionSummary(BaseModel):
|
class AISessionSummary(BaseModel):
|
||||||
"""Compact session for list views."""
|
"""Compact session for list views."""
|
||||||
id: UUID
|
id: UUID
|
||||||
|
session_type: str = "guided"
|
||||||
|
title: str | None = None
|
||||||
status: str
|
status: str
|
||||||
intake_type: str
|
intake_type: str
|
||||||
problem_summary: str | None = None
|
problem_summary: str | None = None
|
||||||
@@ -208,7 +215,7 @@ class AISessionSummary(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AISessionDetail(AISessionSummary):
|
class AISessionDetail(AISessionSummary):
|
||||||
"""Full session detail with steps."""
|
"""Full session detail with steps (guided) or messages (chat)."""
|
||||||
intake_content: dict[str, Any]
|
intake_content: dict[str, Any]
|
||||||
matched_flow_id: UUID | None = None
|
matched_flow_id: UUID | None = None
|
||||||
match_score: float | None = None
|
match_score: float | None = None
|
||||||
@@ -220,10 +227,32 @@ class AISessionDetail(AISessionSummary):
|
|||||||
psa_connection_id: UUID | None = None
|
psa_connection_id: UUID | None = None
|
||||||
ticket_data: dict[str, Any] | None = None
|
ticket_data: dict[str, Any] | None = None
|
||||||
steps: list[AISessionStepResponse] = []
|
steps: list[AISessionStepResponse] = []
|
||||||
|
conversation_messages: list[dict[str, Any]] = [] # Chat sessions store messages here
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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):
|
class AISessionSearchResult(BaseModel):
|
||||||
"""Lightweight session result for Command Palette / autocomplete."""
|
"""Lightweight session result for Command Palette / autocomplete."""
|
||||||
id: UUID
|
id: UUID
|
||||||
|
|||||||
125
backend/app/services/unified_chat_service.py
Normal file
125
backend/app/services/unified_chat_service.py
Normal file
@@ -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
|
||||||
130
docs/plans/2026-03-23-unified-sessions.md
Normal file
130
docs/plans/2026-03-23-unified-sessions.md
Normal file
@@ -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) | `<Route size={14} />` | cyan | "Guided" |
|
||||||
|
| Chat (Assistant) | `<MessageCircle size={14} />` | 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
|
||||||
@@ -15,6 +15,9 @@ import type {
|
|||||||
PickupSessionRequest,
|
PickupSessionRequest,
|
||||||
StatusUpdateRequest,
|
StatusUpdateRequest,
|
||||||
StatusUpdateResponse,
|
StatusUpdateResponse,
|
||||||
|
ChatSessionCreateResponse,
|
||||||
|
ChatMessageRequest,
|
||||||
|
ChatMessageResponse,
|
||||||
} from '@/types/ai-session'
|
} from '@/types/ai-session'
|
||||||
|
|
||||||
export const aiSessionsApi = {
|
export const aiSessionsApi = {
|
||||||
@@ -23,6 +26,22 @@ export const aiSessionsApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createChatSession(data: AISessionCreateRequest): Promise<ChatSessionCreateResponse> {
|
||||||
|
const response = await apiClient.post<ChatSessionCreateResponse>('/ai-sessions', {
|
||||||
|
...data,
|
||||||
|
session_type: 'chat',
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendChatMessage(sessionId: string, data: ChatMessageRequest): Promise<ChatMessageResponse> {
|
||||||
|
const response = await apiClient.post<ChatMessageResponse>(
|
||||||
|
`/ai-sessions/${sessionId}/chat`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
async respondToStep(sessionId: string, data: StepResponseRequest): Promise<StepResponseResponse> {
|
async respondToStep(sessionId: string, data: StepResponseRequest): Promise<StepResponseResponse> {
|
||||||
const response = await apiClient.post<StepResponseResponse>(
|
const response = await apiClient.post<StepResponseResponse>(
|
||||||
`/ai-sessions/${sessionId}/respond`,
|
`/ai-sessions/${sessionId}/respond`,
|
||||||
@@ -49,6 +68,7 @@ export const aiSessionsApi = {
|
|||||||
|
|
||||||
async listSessions(params?: {
|
async listSessions(params?: {
|
||||||
status?: string
|
status?: string
|
||||||
|
session_type?: string
|
||||||
skip?: number
|
skip?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
problem_domain?: string
|
problem_domain?: string
|
||||||
@@ -104,6 +124,10 @@ export const aiSessionsApi = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteSession(sessionId: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/ai-sessions/${sessionId}`)
|
||||||
|
},
|
||||||
|
|
||||||
async pickupSession(sessionId: string, data: PickupSessionRequest): Promise<StepResponseResponse> {
|
async pickupSession(sessionId: string, data: PickupSessionRequest): Promise<StepResponseResponse> {
|
||||||
const response = await apiClient.post<StepResponseResponse>(
|
const response = await apiClient.post<StepResponseResponse>(
|
||||||
`/ai-sessions/${sessionId}/pickup`,
|
`/ai-sessions/${sessionId}/pickup`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
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 { aiSessionsApi } from '@/api/aiSessions'
|
||||||
import type { AISessionSummary } from '@/types/ai-session'
|
import type { AISessionSummary } from '@/types/ai-session'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -30,7 +30,7 @@ export function ActiveFlowPilotSessions() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="card-flat">
|
<div className="card-flat">
|
||||||
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
||||||
<h3 className="font-heading text-sm font-bold text-foreground">Active Sessions</h3>
|
<h3 className="font-heading text-sm font-bold text-foreground">Active Sessions</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
|
||||||
@@ -46,7 +46,7 @@ export function ActiveFlowPilotSessions() {
|
|||||||
<div className="card-flat">
|
<div className="card-flat">
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between px-5 py-3"
|
className="flex items-center justify-between px-5 py-3"
|
||||||
style={{ borderBottom: '1px solid var(--glass-border)' }}
|
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-heading text-sm font-bold text-foreground">Active Sessions</h3>
|
<h3 className="font-heading text-sm font-bold text-foreground">Active Sessions</h3>
|
||||||
@@ -74,11 +74,15 @@ export function ActiveFlowPilotSessions() {
|
|||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<button
|
<button
|
||||||
key={session.id}
|
key={session.id}
|
||||||
onClick={() => navigate(`/pilot/${session.id}`)}
|
onClick={() => navigate(session.session_type === 'chat' ? `/assistant/${session.id}` : `/pilot/${session.id}`)}
|
||||||
className="card-interactive p-4 text-left"
|
className="card-interactive p-4 text-left"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 mb-2">
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
<Sparkles size={14} className="shrink-0 text-primary mt-0.5" />
|
{session.session_type === 'chat' ? (
|
||||||
|
<MessageCircle size={14} className="shrink-0 text-violet-400 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<Route size={14} className="shrink-0 text-primary mt-0.5" />
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-sans text-xs text-[0.5625rem] uppercase px-1.5 py-0.5 rounded',
|
'font-sans text-xs text-[0.5625rem] uppercase px-1.5 py-0.5 rounded',
|
||||||
@@ -92,7 +96,9 @@ export function ActiveFlowPilotSessions() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-foreground truncate">
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
{session.problem_summary || 'Session in progress'}
|
{session.session_type === 'chat'
|
||||||
|
? (session.title || session.problem_summary || 'Chat in progress')
|
||||||
|
: (session.problem_summary || 'Session in progress')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex items-center gap-2 text-[0.625rem] text-muted-foreground">
|
<div className="mt-2 flex items-center gap-2 text-[0.625rem] text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
@@ -100,7 +106,7 @@ export function ActiveFlowPilotSessions() {
|
|||||||
{timeAgo(session.created_at)}
|
{timeAgo(session.created_at)}
|
||||||
</span>
|
</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{session.step_count} steps</span>
|
<span>{session.step_count} {session.session_type === 'chat' ? 'messages' : 'steps'}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
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 { aiSessionsApi } from '@/api/aiSessions'
|
||||||
import type { AISessionSummary } from '@/types/ai-session'
|
import type { AISessionSummary } from '@/types/ai-session'
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export function RecentFlowPilotSessions() {
|
|||||||
<div className="card-flat">
|
<div className="card-flat">
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between px-5 py-3"
|
className="flex items-center justify-between px-5 py-3"
|
||||||
style={{ borderBottom: '1px solid var(--glass-border)' }}
|
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||||
>
|
>
|
||||||
<h3 className="font-heading text-sm font-bold text-foreground">Recent Sessions</h3>
|
<h3 className="font-heading text-sm font-bold text-foreground">Recent Sessions</h3>
|
||||||
<Link
|
<Link
|
||||||
@@ -61,16 +61,22 @@ export function RecentFlowPilotSessions() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.id}
|
key={session.id}
|
||||||
onClick={() => 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"
|
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={{
|
style={{
|
||||||
borderBottom: i < sessions.length - 1 ? '1px solid var(--glass-border)' : undefined,
|
borderBottom: i < sessions.length - 1 ? '1px solid var(--color-border-default)' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StatusIcon size={14} style={{ color: config.color }} className="shrink-0" />
|
{session.session_type === 'chat' ? (
|
||||||
|
<MessageCircle size={14} className="shrink-0 text-violet-400" />
|
||||||
|
) : (
|
||||||
|
<StatusIcon size={14} style={{ color: config.color }} className="shrink-0" />
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-foreground truncate">
|
<p className="text-sm text-foreground truncate">
|
||||||
{session.problem_summary || 'Session'}
|
{session.session_type === 'chat'
|
||||||
|
? (session.title || session.problem_summary || 'Chat')
|
||||||
|
: (session.problem_summary || 'Session')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 font-sans text-xs text-muted-foreground">
|
<span className="shrink-0 font-sans text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom'
|
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 { cn } from '@/lib/utils'
|
||||||
import type { AISessionSummary } from '@/types/ai-session'
|
import type { AISessionSummary } from '@/types/ai-session'
|
||||||
|
|
||||||
@@ -18,17 +18,31 @@ const STATUS_CONFIG = {
|
|||||||
export function AISessionListItem({ session }: AISessionListItemProps) {
|
export function AISessionListItem({ session }: AISessionListItemProps) {
|
||||||
const config = STATUS_CONFIG[session.status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.active
|
const config = STATUS_CONFIG[session.status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.active
|
||||||
const StatusIcon = config.icon
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/pilot/${session.id}`}
|
to={linkTo}
|
||||||
className="card-interactive block p-4 transition-all"
|
className="card-interactive block p-4 transition-all"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-foreground truncate">
|
<div className="flex items-center gap-2">
|
||||||
{session.problem_summary || 'Untitled session'}
|
<span className={cn(
|
||||||
</p>
|
'flex items-center justify-center w-5 h-5 rounded',
|
||||||
|
isChat ? 'text-violet-400' : 'text-primary'
|
||||||
|
)}>
|
||||||
|
<TypeIcon size={14} />
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{displayTitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="mt-1.5 flex items-center gap-3 flex-wrap">
|
<div className="mt-1.5 flex items-center gap-3 flex-wrap">
|
||||||
{session.problem_domain && (
|
{session.problem_domain && (
|
||||||
<span className="font-sans text-xs rounded-md bg-accent-dim px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-primary">
|
<span className="font-sans text-xs rounded-md bg-accent-dim px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-primary">
|
||||||
@@ -40,7 +54,7 @@ export function AISessionListItem({ session }: AISessionListItemProps) {
|
|||||||
{config.label}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{session.step_count} steps
|
{session.step_count} {isChat ? 'messages' : 'steps'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-text-muted">
|
<span className="text-xs text-text-muted">
|
||||||
{new Date(session.created_at).toLocaleDateString(undefined, {
|
{new Date(session.created_at).toLocaleDateString(undefined, {
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
|||||||
|
|
||||||
setSession({
|
setSession({
|
||||||
id: result.session_id,
|
id: result.session_id,
|
||||||
|
session_type: 'guided',
|
||||||
|
title: null,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
intake_type: intake.intake_type,
|
intake_type: intake.intake_type,
|
||||||
intake_content: intake.intake_content,
|
intake_content: intake.intake_content,
|
||||||
@@ -89,6 +91,7 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
|||||||
psa_connection_id: intake.psa_connection_id ?? null,
|
psa_connection_id: intake.psa_connection_id ?? null,
|
||||||
ticket_data: null,
|
ticket_data: null,
|
||||||
steps: [firstStep],
|
steps: [firstStep],
|
||||||
|
conversation_messages: [],
|
||||||
})
|
})
|
||||||
setAllSteps([firstStep])
|
setAllSteps([firstStep])
|
||||||
setCurrentStep(firstStep)
|
setCurrentStep(firstStep)
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
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 { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { uploadsApi } from '@/api/uploads'
|
import { uploadsApi } from '@/api/uploads'
|
||||||
import type { PendingUpload } from '@/types/upload'
|
import type { PendingUpload } from '@/types/upload'
|
||||||
import { PageMeta } from '@/components/common/PageMeta'
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
import { assistantChatApi } from '@/api/assistantChat'
|
import { aiSessionsApi } from '@/api/aiSessions'
|
||||||
import { analytics } from '@/lib/analytics'
|
import { analytics } from '@/lib/analytics'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
|
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
|
||||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
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'
|
import type { SuggestedFlow } from '@/types/copilot'
|
||||||
|
|
||||||
interface MessageWithMeta extends ChatMessageType {
|
interface MessageWithMeta {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
suggestedFlows?: SuggestedFlow[]
|
suggestedFlows?: SuggestedFlow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AssistantChatPage() {
|
export default function AssistantChatPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>()
|
||||||
const [chats, setChats] = useState<ChatListItem[]>([])
|
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||||
const [activeChatId, setActiveChatId] = useState<string | null>(null)
|
const [activeChatId, setActiveChatId] = useState<string | null>(urlSessionId || null)
|
||||||
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -38,39 +41,53 @@ export default function AssistantChatPage() {
|
|||||||
const dragCounterRef = useRef(0)
|
const dragCounterRef = useRef(0)
|
||||||
const prefillHandledRef = useRef(false)
|
const prefillHandledRef = useRef(false)
|
||||||
|
|
||||||
// Load chat list
|
// Load chat list from ai_sessions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadChats()
|
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(() => {
|
useEffect(() => {
|
||||||
const prefill = (location.state as { prefill?: string } | null)?.prefill
|
const prefill = (location.state as { prefill?: string } | null)?.prefill
|
||||||
if (!prefill || prefillHandledRef.current) return
|
if (!prefill || prefillHandledRef.current) return
|
||||||
prefillHandledRef.current = true
|
prefillHandledRef.current = true
|
||||||
|
|
||||||
// Clear the location state so back-navigation doesn't retrigger
|
|
||||||
navigate(location.pathname, { replace: true, state: {} })
|
navigate(location.pathname, { replace: true, state: {} })
|
||||||
|
|
||||||
const sendPrefill = async () => {
|
const sendPrefill = async () => {
|
||||||
try {
|
try {
|
||||||
const chat = await assistantChatApi.createChat()
|
const session = await aiSessionsApi.createChatSession({
|
||||||
setChats(prev => [
|
intake_type: 'free_text',
|
||||||
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
|
intake_content: { text: prefill },
|
||||||
...prev,
|
})
|
||||||
])
|
const chatItem: ChatListItem = {
|
||||||
setActiveChatId(chat.id)
|
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 }])
|
setMessages([{ role: 'user', content: prefill }])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const response = await assistantChatApi.sendMessage(chat.id, prefill)
|
const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: prefill })
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
||||||
])
|
])
|
||||||
setChats(prev =>
|
setChats(prev =>
|
||||||
prev.map(c =>
|
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, message_count: 2, title: prefill.slice(0, 100), updated_at: new Date().toISOString() }
|
||||||
: c
|
: c
|
||||||
)
|
)
|
||||||
@@ -93,8 +110,15 @@ export default function AssistantChatPage() {
|
|||||||
|
|
||||||
const loadChats = async () => {
|
const loadChats = async () => {
|
||||||
try {
|
try {
|
||||||
const list = await assistantChatApi.listChats(1, 100)
|
const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 })
|
||||||
setChats(list)
|
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 {
|
} catch {
|
||||||
// silently handle
|
// silently handle
|
||||||
}
|
}
|
||||||
@@ -103,8 +127,13 @@ export default function AssistantChatPage() {
|
|||||||
const selectChat = useCallback(async (chatId: string) => {
|
const selectChat = useCallback(async (chatId: string) => {
|
||||||
setActiveChatId(chatId)
|
setActiveChatId(chatId)
|
||||||
try {
|
try {
|
||||||
const chat = await assistantChatApi.getChat(chatId)
|
const detail = await aiSessionsApi.getSession(chatId)
|
||||||
setMessages(chat.messages.map(m => ({ ...m })))
|
setMessages(
|
||||||
|
(detail.conversation_messages || []).map(m => ({
|
||||||
|
role: m.role as 'user' | 'assistant',
|
||||||
|
content: m.content,
|
||||||
|
}))
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
setMessages([])
|
setMessages([])
|
||||||
}
|
}
|
||||||
@@ -112,12 +141,20 @@ export default function AssistantChatPage() {
|
|||||||
|
|
||||||
const handleNewChat = async () => {
|
const handleNewChat = async () => {
|
||||||
try {
|
try {
|
||||||
const chat = await assistantChatApi.createChat()
|
const session = await aiSessionsApi.createChatSession({
|
||||||
setChats(prev => [
|
intake_type: 'free_text',
|
||||||
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
|
intake_content: { text: '' },
|
||||||
...prev,
|
})
|
||||||
])
|
const chatItem: ChatListItem = {
|
||||||
setActiveChatId(chat.id)
|
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([])
|
setMessages([])
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to create chat')
|
toast.error('Failed to create chat')
|
||||||
@@ -126,7 +163,7 @@ export default function AssistantChatPage() {
|
|||||||
|
|
||||||
const handleDeleteChat = async (chatId: string) => {
|
const handleDeleteChat = async (chatId: string) => {
|
||||||
try {
|
try {
|
||||||
await assistantChatApi.deleteChat(chatId)
|
await aiSessionsApi.deleteSession(chatId)
|
||||||
setChats(prev => prev.filter(c => c.id !== chatId))
|
setChats(prev => prev.filter(c => c.id !== chatId))
|
||||||
if (activeChatId === chatId) {
|
if (activeChatId === chatId) {
|
||||||
setActiveChatId(null)
|
setActiveChatId(null)
|
||||||
@@ -137,15 +174,9 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTogglePin = async (chatId: string, pinned: boolean) => {
|
const handleTogglePin = async (_chatId: string, _pinned: boolean) => {
|
||||||
try {
|
// Pin/unpin not yet supported on unified sessions — no-op for now
|
||||||
await assistantChatApi.updateChat(chatId, { pinned })
|
toast.info('Pin feature coming soon')
|
||||||
setChats(prev =>
|
|
||||||
prev.map(c => c.id === chatId ? { ...c, pinned } : c)
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to update chat')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
@@ -157,13 +188,12 @@ export default function AssistantChatPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await assistantChatApi.sendMessage(activeChatId, userMessage)
|
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
|
||||||
analytics.aiFeatureUsed({ feature: 'assistant_chat' })
|
analytics.aiFeatureUsed({ feature: 'assistant_chat' })
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
||||||
])
|
])
|
||||||
// Update chat list title if it was the first message
|
|
||||||
setChats(prev =>
|
setChats(prev =>
|
||||||
prev.map(c =>
|
prev.map(c =>
|
||||||
c.id === activeChatId
|
c.id === activeChatId
|
||||||
@@ -182,44 +212,55 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConclude = async (outcome: ConclusionOutcome, notes: string): Promise<string> => {
|
const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise<string> => {
|
||||||
if (!activeChatId) throw new Error('No active chat')
|
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
|
// Map conclusion outcomes to ai_sessions actions
|
||||||
setChats(prev =>
|
if (outcome === 'resolved') {
|
||||||
prev.map(c =>
|
const result = await aiSessionsApi.resolveSession(activeChatId, {
|
||||||
c.id === activeChatId
|
resolution_summary: _notes || 'Resolved via assistant chat',
|
||||||
? { ...c, concluded_at: response.concluded_at, conclusion_outcome: outcome }
|
})
|
||||||
: c
|
return result.documentation?.problem_summary || 'Session resolved'
|
||||||
)
|
} else if (outcome === 'escalated') {
|
||||||
)
|
const result = await aiSessionsApi.escalateSession(activeChatId, {
|
||||||
return response.summary
|
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) => {
|
const handleResumeNew = async (summary: string) => {
|
||||||
try {
|
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.`
|
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 }])
|
setMessages([{ role: 'user', content: resumePrompt }])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const response = await assistantChatApi.sendMessage(chat.id, resumePrompt)
|
const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt })
|
||||||
setMessages(prev => [
|
setMessages(prev => [
|
||||||
...prev,
|
...prev,
|
||||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
||||||
])
|
])
|
||||||
setChats(prev =>
|
setChats(prev =>
|
||||||
prev.map(c =>
|
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, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
|
||||||
: c
|
: c
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function SessionHistoryPage() {
|
|||||||
const aiSearchTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
const aiSearchTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
const [aiFilters, setAiFilters] = useState({
|
const [aiFilters, setAiFilters] = useState({
|
||||||
q: '',
|
q: '',
|
||||||
|
session_type: '',
|
||||||
problem_domain: '',
|
problem_domain: '',
|
||||||
confidence_tier: '',
|
confidence_tier: '',
|
||||||
date_from: '',
|
date_from: '',
|
||||||
@@ -176,6 +177,7 @@ export function SessionHistoryPage() {
|
|||||||
const data = await aiSessionsApi.listSessions({
|
const data = await aiSessionsApi.listSessions({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
q: aiFilters.q || undefined,
|
q: aiFilters.q || undefined,
|
||||||
|
session_type: aiFilters.session_type || undefined,
|
||||||
problem_domain: aiFilters.problem_domain || undefined,
|
problem_domain: aiFilters.problem_domain || undefined,
|
||||||
confidence_tier: aiFilters.confidence_tier || undefined,
|
confidence_tier: aiFilters.confidence_tier || undefined,
|
||||||
date_from: aiFilters.date_from || undefined,
|
date_from: aiFilters.date_from || undefined,
|
||||||
@@ -267,7 +269,7 @@ export function SessionHistoryPage() {
|
|||||||
return labels[outcome] ?? outcome
|
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)
|
const hasFlowFiltersActive = !!(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from)
|
||||||
|
|
||||||
// Determine section visibility
|
// Determine section visibility
|
||||||
@@ -314,7 +316,7 @@ export function SessionHistoryPage() {
|
|||||||
{/* FlowPilot Sessions Section */}
|
{/* FlowPilot Sessions Section */}
|
||||||
{showAiSection && (
|
{showAiSection && (
|
||||||
<>
|
<>
|
||||||
<h2 className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">FlowPilot Sessions</h2>
|
<h2 className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">AI Sessions</h2>
|
||||||
|
|
||||||
{/* AI Session Filter Bar */}
|
{/* AI Session Filter Bar */}
|
||||||
<div className="card-flat p-3 mb-4">
|
<div className="card-flat p-3 mb-4">
|
||||||
@@ -331,6 +333,24 @@ export function SessionHistoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Session type pills */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['', 'guided', 'chat'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setAiFilters((f) => ({ ...f, session_type: t }))}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border px-3 py-1 text-xs font-sans transition-colors',
|
||||||
|
aiFilters.session_type === t
|
||||||
|
? 'bg-accent-dim text-foreground border-primary/30'
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:text-foreground hover:border-[rgba(255,255,255,0.12)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t === '' ? 'All' : t === 'guided' ? 'Guided' : 'Chat'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Problem domain dropdown */}
|
{/* Problem domain dropdown */}
|
||||||
<select
|
<select
|
||||||
value={aiFilters.problem_domain}
|
value={aiFilters.problem_domain}
|
||||||
@@ -393,7 +413,7 @@ export function SessionHistoryPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAiSearchInput('')
|
setAiSearchInput('')
|
||||||
setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
|
setAiFilters({ q: '', session_type: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
|
||||||
}}
|
}}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
@@ -415,7 +435,7 @@ export function SessionHistoryPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAiSearchInput('')
|
setAiSearchInput('')
|
||||||
setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
|
setAiFilters({ q: '', session_type: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
|
||||||
}}
|
}}
|
||||||
className="text-foreground hover:underline text-sm"
|
className="text-foreground hover:underline text-sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
{ path: 'script-builder', element: page(ScriptBuilderPage) },
|
{ path: 'script-builder', element: page(ScriptBuilderPage) },
|
||||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||||
|
{ path: 'assistant/:sessionId', element: page(AssistantChatPage) },
|
||||||
{ path: 'flow-assist', element: page(FlowAssistPage) },
|
{ path: 'flow-assist', element: page(FlowAssistPage) },
|
||||||
{ path: 'pilot', element: page(FlowPilotSessionPage) },
|
{ path: 'pilot', element: page(FlowPilotSessionPage) },
|
||||||
{ path: 'pilot/:sessionId', element: page(FlowPilotSessionPage) },
|
{ path: 'pilot/:sessionId', element: page(FlowPilotSessionPage) },
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// ── Intake ──
|
// ── Intake ──
|
||||||
|
|
||||||
|
export type SessionType = 'guided' | 'chat'
|
||||||
|
|
||||||
export interface AISessionCreateRequest {
|
export interface AISessionCreateRequest {
|
||||||
|
session_type?: SessionType
|
||||||
intake_type: 'free_text' | 'psa_ticket' | 'screenshot' | 'log_paste' | 'combined'
|
intake_type: 'free_text' | 'psa_ticket' | 'screenshot' | 'log_paste' | 'combined'
|
||||||
intake_content: Record<string, unknown>
|
intake_content: Record<string, unknown>
|
||||||
psa_ticket_id?: string
|
psa_ticket_id?: string
|
||||||
@@ -159,6 +162,8 @@ export interface RateSessionRequest {
|
|||||||
|
|
||||||
export interface AISessionSummary {
|
export interface AISessionSummary {
|
||||||
id: string
|
id: string
|
||||||
|
session_type: SessionType
|
||||||
|
title: string | null
|
||||||
status: string
|
status: string
|
||||||
intake_type: string
|
intake_type: string
|
||||||
problem_summary: string | null
|
problem_summary: string | null
|
||||||
@@ -189,6 +194,7 @@ export interface AISessionDetail extends AISessionSummary {
|
|||||||
psa_connection_id: string | null
|
psa_connection_id: string | null
|
||||||
ticket_data: Record<string, unknown> | null
|
ticket_data: Record<string, unknown> | null
|
||||||
steps: AISessionStepResponse[]
|
steps: AISessionStepResponse[]
|
||||||
|
conversation_messages: Array<{ role: string; content: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AISessionSearchResult {
|
export interface AISessionSearchResult {
|
||||||
@@ -199,6 +205,24 @@ export interface AISessionSearchResult {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Chat session ──
|
||||||
|
|
||||||
|
export interface ChatSessionCreateResponse {
|
||||||
|
session_id: string
|
||||||
|
session_type: 'chat'
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessageRequest {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessageResponse {
|
||||||
|
content: string
|
||||||
|
suggested_flows: Array<{ tree_id: string; tree_name: string; tree_type: string; relevance_snippet: string }>
|
||||||
|
}
|
||||||
|
|
||||||
export interface SimilarSession {
|
export interface SimilarSession {
|
||||||
id: string
|
id: string
|
||||||
problem_summary: string | null
|
problem_summary: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user