feat: AI chat conclusion + survey completion & management #95

Merged
chihlasm merged 5 commits from feat/chat-conclusion-survey-management into main 2026-03-06 03:43:02 +00:00
22 changed files with 1844 additions and 96 deletions

View File

@@ -0,0 +1,36 @@
"""Add conclusion fields to assistant_chats.
Revision ID: 048
Revises: 047
Create Date: 2026-03-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "048"
down_revision: str = "047"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"assistant_chats",
sa.Column("conclusion_outcome", sa.String(20), nullable=True),
)
op.add_column(
"assistant_chats",
sa.Column("conclusion_summary", sa.String, nullable=True),
)
op.add_column(
"assistant_chats",
sa.Column("concluded_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("assistant_chats", "concluded_at")
op.drop_column("assistant_chats", "conclusion_summary")
op.drop_column("assistant_chats", "conclusion_outcome")

View File

@@ -0,0 +1,31 @@
"""Add is_read and archived_at to survey_responses.
Revision ID: 049
Revises: 048
Create Date: 2026-03-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "049"
down_revision: str = "048"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"survey_responses",
sa.Column("is_read", sa.Boolean(), nullable=False, server_default="false"),
)
op.add_column(
"survey_responses",
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("survey_responses", "archived_at")
op.drop_column("survey_responses", "is_read")

View File

@@ -4,10 +4,11 @@ import io
import logging
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
@@ -96,6 +97,7 @@ async def list_survey_invites(
async def list_survey_responses(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
include_archived: bool = False,
):
"""List all survey responses with summary stats."""
stmt = (
@@ -103,11 +105,15 @@ async def list_survey_responses(
.outerjoin(SurveyInvite, SurveyResponse.invite_id == SurveyInvite.id)
.order_by(SurveyResponse.created_at.desc())
)
if not include_archived:
stmt = stmt.where(SurveyResponse.archived_at.is_(None))
result = await db.execute(stmt)
rows = result.all()
one_week_ago = datetime.now(timezone.utc) - timedelta(days=7)
this_week = 0
unread = 0
responses: list[SurveyResponseDetail] = []
for survey_resp, invite_name in rows:
@@ -119,19 +125,168 @@ async def list_survey_responses(
responses=survey_resp.responses,
source=source,
invite_name=invite_name,
is_read=survey_resp.is_read,
archived_at=survey_resp.archived_at,
created_at=survey_resp.created_at,
)
)
if survey_resp.created_at >= one_week_ago:
this_week += 1
if not survey_resp.is_read:
unread += 1
return SurveyResponseListResponse(
responses=responses,
total=len(responses),
this_week=this_week,
unread=unread,
)
@router.put("/survey-responses/{response_id}/read", status_code=200)
async def mark_response_read(
response_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Mark a survey response as read."""
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == response_id)
)
resp = result.scalar_one_or_none()
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
resp.is_read = True
await db.commit()
return {"id": str(resp.id), "is_read": True}
@router.put("/survey-responses/{response_id}/unread", status_code=200)
async def mark_response_unread(
response_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Mark a survey response as unread."""
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == response_id)
)
resp = result.scalar_one_or_none()
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
resp.is_read = False
await db.commit()
return {"id": str(resp.id), "is_read": False}
@router.put("/survey-responses/{response_id}/archive", status_code=200)
async def archive_response(
response_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Archive a survey response."""
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == response_id)
)
resp = result.scalar_one_or_none()
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
resp.archived_at = datetime.now(timezone.utc)
await db.commit()
return {"id": str(resp.id), "archived_at": resp.archived_at.isoformat()}
@router.put("/survey-responses/{response_id}/unarchive", status_code=200)
async def unarchive_response(
response_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Unarchive a survey response."""
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == response_id)
)
resp = result.scalar_one_or_none()
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
resp.archived_at = None
await db.commit()
return {"id": str(resp.id), "archived_at": None}
@router.delete("/survey-responses/{response_id}", status_code=204)
async def delete_response(
response_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Permanently delete a survey response."""
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == response_id)
)
resp = result.scalar_one_or_none()
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
await db.delete(resp)
await db.commit()
@router.post("/survey-responses/bulk", status_code=200)
async def bulk_action_responses(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
action: str = Body(...),
ids: list[str] = Body(...),
):
"""Bulk action on survey responses. Actions: mark_read, mark_unread, archive, delete."""
from uuid import UUID as _UUID
uuids = []
for id_str in ids:
try:
uuids.append(_UUID(id_str))
except ValueError:
continue
if not uuids:
raise HTTPException(status_code=400, detail="No valid IDs provided")
if action == "mark_read":
await db.execute(
update(SurveyResponse)
.where(SurveyResponse.id.in_(uuids))
.values(is_read=True)
)
elif action == "mark_unread":
await db.execute(
update(SurveyResponse)
.where(SurveyResponse.id.in_(uuids))
.values(is_read=False)
)
elif action == "archive":
await db.execute(
update(SurveyResponse)
.where(SurveyResponse.id.in_(uuids))
.values(archived_at=datetime.now(timezone.utc))
)
elif action == "delete":
await db.execute(
delete(SurveyResponse)
.where(SurveyResponse.id.in_(uuids))
)
else:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
await db.commit()
return {"action": action, "count": len(uuids)}
# Question IDs in survey order, used for CSV export columns.
QUESTION_IDS = [
"prereqs",

View File

@@ -35,6 +35,8 @@ from app.schemas.assistant_chat import (
ChatUpdateRequest,
RetentionSettingsResponse,
RetentionSettingsUpdate,
ConcludeChatRequest,
ConcludeChatResponse,
)
from app.schemas.copilot import SuggestedFlow
from app.services import assistant_chat_service
@@ -203,6 +205,67 @@ async def post_message(
)
@router.post("/chats/{chat_id}/conclude", response_model=ConcludeChatResponse)
@limiter.limit("10/minute")
async def conclude_chat(
request: Request,
chat_id: UUID,
data: ConcludeChatRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Conclude a chat session and generate ticket-ready summary."""
_require_ai_enabled()
result = await db.execute(
select(AssistantChat).where(
AssistantChat.id == chat_id,
AssistantChat.user_id == current_user.id,
)
)
chat = result.scalar_one_or_none()
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if chat.concluded_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Chat already concluded",
)
if chat.message_count < 2:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Chat must have at least one exchange before concluding",
)
try:
summary = await assistant_chat_service.generate_conclusion_summary(
chat=chat,
outcome=data.outcome,
notes=data.notes,
)
except Exception as e:
logger.exception("Failed to generate conclusion summary: %s", e)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to generate summary. Please try again.",
)
now = datetime.now(timezone.utc)
chat.conclusion_outcome = data.outcome
chat.conclusion_summary = summary
chat.concluded_at = now
await db.commit()
return ConcludeChatResponse(
summary=summary,
outcome=data.outcome,
concluded_at=now,
)
@router.patch("/chats/{chat_id}", response_model=ChatDetailResponse)
async def update_chat(
chat_id: UUID,

View File

@@ -14,7 +14,7 @@ from app.core.email import EmailService
from app.core.rate_limit import limiter
from app.models.survey_invite import SurveyInvite
from app.models.survey_response import SurveyResponse
from app.schemas.survey import SurveyInviteStatus, SurveySubmission, SurveySubmissionResponse
from app.schemas.survey import SurveyEmailCopyRequest, SurveyInviteStatus, SurveySubmission, SurveySubmissionResponse
logger = logging.getLogger(__name__)
@@ -88,3 +88,79 @@ async def submit_survey(
await db.commit()
return SurveySubmissionResponse(id=str(response.id))
# Question metadata for formatting email copy
_QUESTION_LABELS = {
"prereqs": "Q1. Before you start troubleshooting, what info do you need?",
"verify_fix": "Q2. After you apply a fix, how do you verify it actually worked?",
"steps_at_a_time": "Q3. How many steps do you prefer to see at once?",
"first_step": "Q4. \"Internet is down.\" What's your FIRST move?",
"junior_mistake": "Q5. Most common mistake junior engineers make?",
"pivot": "Q6. When do you stop pursuing one theory and pivot?",
"scenario_approach": "Q7. First 3 diagnostic steps for this ticket.",
"scenario_deeper": "Q8. Server pings fine, you can RDP in. What next?",
"doc_pct": "Q9. Percentage of steps you actually document?",
"go_to_commands": "Q10. Top 3 go-to PowerShell commands?",
"secret_weapon": "Q11. Secret weapon command/tool/technique?",
"gotcha": "Q12. Issue where the obvious diagnosis was WRONG?",
"hard_rules": "Q13. Which rules do you follow?",
"prioritization": "Q14. Rank factors by diagnostic priority.",
"detail_level": "Q15. How specific should AI suggestions be?",
"ai_personality": "Q16. What makes an AI feel like a useful colleague?",
}
_QUESTION_ORDER = [
"prereqs", "verify_fix", "steps_at_a_time", "first_step", "junior_mistake",
"pivot", "scenario_approach", "scenario_deeper", "doc_pct", "go_to_commands",
"secret_weapon", "gotcha", "hard_rules", "prioritization", "detail_level", "ai_personality",
]
@router.post("/survey/email-copy")
@limiter.limit("5/hour")
async def email_survey_copy(
request: Request,
data: SurveyEmailCopyRequest,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Email a copy of survey responses to the respondent."""
from uuid import UUID as _UUID
try:
resp_id = _UUID(data.response_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid response ID")
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == resp_id)
)
response = result.scalar_one_or_none()
if not response:
raise HTTPException(status_code=404, detail="Response not found")
# Build formatted responses for the email
answers = response.responses or {}
formatted_lines = []
for qid in _QUESTION_ORDER:
label = _QUESTION_LABELS.get(qid, qid)
val = answers.get(qid)
if isinstance(val, list):
answer_str = ", ".join(str(v) for v in val)
elif val is not None:
answer_str = str(val)
else:
answer_str = "(no answer)"
formatted_lines.append(f"{label}\n{answer_str}")
try:
await EmailService.send_survey_copy_email(
to_email=data.email,
respondent_name=response.respondent_name,
formatted_responses="\n\n".join(formatted_lines),
)
except Exception:
logger.exception("Failed to send survey copy email to %s", data.email)
raise HTTPException(status_code=502, detail="Failed to send email. Please try again.")
return {"message": "Email sent"}

View File

@@ -84,6 +84,9 @@ class Settings(BaseSettings):
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
AI_MODEL_ANTHROPIC: str = "claude-haiku-4-5-20251001"
# MCP (Model Context Protocol) integrations
ENABLE_MCP_MICROSOFT_LEARN: bool = True
# Embedding / RAG
VOYAGE_API_KEY: Optional[str] = None
EMBEDDING_MODEL: str = "voyage-3.5"

View File

@@ -354,6 +354,70 @@ class EmailService:
logger.exception("Failed to send survey notification email")
return False
@staticmethod
async def send_survey_copy_email(
to_email: str,
respondent_name: str | None,
formatted_responses: str,
) -> bool:
"""Send a copy of survey responses to the respondent."""
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
import html as html_mod
resend.api_key = settings.RESEND_API_KEY
safe_name = html_mod.escape(respondent_name or "there")
safe_responses = html_mod.escape(formatted_responses).replace("\n", "<br>")
subject = "Your FlowPilot Survey Responses"
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">Your Survey Responses</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
Hi {safe_name}, here's a copy of your FlowPilot survey responses for your records.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<div style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;padding:24px;">
<p style="margin:0;color:#e0e0e0;font-size:13px;line-height:1.8;">{safe_responses}</p>
</div>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Thank you for your contribution to FlowPilot research.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": email_html,
})
logger.info("Survey copy email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send survey copy email to %s", to_email)
return False
@staticmethod
async def send_survey_invite_email(
to_email: str,

View File

@@ -49,6 +49,15 @@ class AssistantChat(Base):
pinned: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
conclusion_outcome: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True
)
conclusion_summary: Mapped[Optional[str]] = mapped_column(
String, nullable=True
)
concluded_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -2,7 +2,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from app.core.database import Base
@@ -17,4 +17,6 @@ class SurveyResponse(Base):
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
invite_id = Column(UUID(as_uuid=True), ForeignKey("survey_invites.id"), nullable=True)
is_read = Column(Boolean, nullable=False, default=False)
archived_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)

View File

@@ -1,5 +1,5 @@
"""Pydantic schemas for standalone AI assistant chat."""
from typing import Optional, Any
from typing import Optional, Any, Literal
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel, Field
@@ -57,3 +57,14 @@ class RetentionSettingsResponse(BaseModel):
class RetentionSettingsUpdate(BaseModel):
chat_retention_days: Optional[int] = Field(None, ge=1, le=365)
chat_retention_max_count: Optional[int] = Field(None, ge=10, le=10000)
class ConcludeChatRequest(BaseModel):
outcome: Literal["resolved", "escalated", "paused"]
notes: Optional[str] = Field(None, max_length=2000)
class ConcludeChatResponse(BaseModel):
summary: str
outcome: str
concluded_at: datetime

View File

@@ -46,6 +46,12 @@ class SurveyInviteStatus(BaseModel):
status: str
class SurveyEmailCopyRequest(BaseModel):
"""Request to email a copy of responses to the respondent."""
email: str = Field(..., max_length=255)
response_id: str
class SurveyResponseDetail(BaseModel):
"""Full survey response returned to admin."""
id: str
@@ -53,6 +59,8 @@ class SurveyResponseDetail(BaseModel):
responses: dict[str, Any]
source: str
invite_name: Optional[str]
is_read: bool = False
archived_at: Optional[datetime] = None
created_at: datetime
@@ -61,3 +69,4 @@ class SurveyResponseListResponse(BaseModel):
responses: list[SurveyResponseDetail]
total: int
this_week: int
unread: int

View File

@@ -2,37 +2,224 @@
Provides persistent conversation history for general IT questions
with semantic search over the team's flow library.
Uses Anthropic prompt caching to reduce cost on multi-turn conversations:
- The static system prompt is cached (ephemeral, 5-min TTL)
- The conversation history prefix is cached via a breakpoint on the
last existing message before the new user input
Optionally connects to Microsoft Learn via Anthropic's MCP connector
for real-time documentation lookups (controlled by ENABLE_MCP_MICROSOFT_LEARN).
"""
import logging
from typing import Optional, Any
from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.models.assistant_chat import AssistantChat
from app.services.rag_service import search as rag_search, build_rag_context, extract_suggested_flows
logger = logging.getLogger(__name__)
ASSISTANT_SYSTEM_PROMPT = """You are a Senior Systems and Network Engineer with 15+ years of experience working in Managed Service Provider (MSP) environments. You specialize in:
- Windows Server, Active Directory, Group Policy, and Hybrid Identity (Entra ID)
- Networking (TCP/IP, DNS, DHCP, VPN, firewall troubleshooting, Cisco/Fortinet)
- Virtualization (VMware, Hyper-V) and cloud platforms (Azure, AWS, M365)
- Endpoint management, RMM tools, and PSA platforms (ConnectWise, Datto, Kaseya)
- PowerShell scripting and automation
ASSISTANT_SYSTEM_PROMPT = """\
You are ResolutionFlow Assistant — an expert IT systems engineer embedded in a \
troubleshooting platform built for Managed Service Provider (MSP) teams.
When answering:
- Be direct and actionable — MSP engineers need fast, practical answers
- Include specific commands, paths, and config values when relevant
- Mention potential risks or gotchas before suggesting changes
- If a relevant troubleshooting flow exists in the team's library, reference it
- Keep responses concise but thorough — prefer bullet points and code blocks
- Format code with proper markdown code blocks
## Your Role
You are a senior peer helping fellow MSP engineers solve problems fast. You have \
deep expertise across the MSP technology stack:
- Windows Server, Active Directory, Group Policy, Hybrid Identity (Entra ID / Azure AD)
- Networking: TCP/IP, DNS, DHCP, VPN, firewalls (Cisco, Fortinet, Meraki, SonicWall)
- Virtualization: VMware vSphere, Hyper-V, Proxmox
- Cloud platforms: Microsoft 365, Azure, AWS
- Endpoint management, RMM tools, and PSA platforms (ConnectWise, Datto, Kaseya, NinjaRMM)
- PowerShell scripting and automation
- Security: MFA, Conditional Access, EDR, backup/DR
## How to Answer
- **Be direct and actionable.** Engineers are mid-ticket — give them the answer, \
not a lecture. Lead with the fix, then explain why.
- **Include specifics.** Exact commands, registry paths, config values, port numbers. \
Vague advice wastes time.
- **Warn before you wreck.** If a step could cause downtime, data loss, or a lockout, \
say so upfront — before the command.
- **Use structured formatting.** Bullet points for steps, code blocks for commands, \
bold for key terms. Engineers scan, they don't read essays.
- **Say when you're unsure.** If you don't know the exact answer, say so. Suggest \
where to verify (vendor docs, a specific KB article) rather than guessing.
## Using the Team's Flow Library
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
appear in the context below, reference them by name so the engineer can launch them \
directly. Prefer the team's proven flows over ad-hoc instructions when they exist.
## Using Microsoft Learn Documentation
You have access to Microsoft's official documentation via Microsoft Learn. Use it when:
- The question involves exact cmdlet syntax, API parameters, or configuration steps
- You need to verify current Microsoft/Azure behavior or requirements
- No team flow covers the topic and vendor-specific detail would help
Do NOT use Microsoft Learn for every question — only when official docs add real value.
## Boundaries
- Stay focused on IT infrastructure, systems administration, and MSP operations.
- If a question is clearly outside your domain, say so briefly and redirect.
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
"""
async def _call_ai(
system_base: str,
rag_context: str,
history: list[dict[str, Any]],
new_message: str,
max_tokens: int = 4096,
) -> tuple[str, int, int]:
"""Call the AI with prompt caching when using Anthropic.
Caching strategy:
- System prompt base: cached (stable across all turns)
- RAG context: NOT cached (changes per query)
- Conversation history prefix: cached via breakpoint on last
existing message (stable — only new user message is uncached)
"""
if settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY:
return await _call_anthropic_cached(
system_base, rag_context, history, new_message, max_tokens
)
# Fallback: generic provider (Gemini, etc.)
from app.core.ai_provider import get_ai_provider
system_prompt = system_base + rag_context
messages = history + [{"role": "user", "content": new_message}]
provider = get_ai_provider()
return await provider.generate_text(
system_prompt=system_prompt,
messages=messages,
max_tokens=max_tokens,
)
async def _call_anthropic_cached(
system_base: str,
rag_context: str,
history: list[dict[str, Any]],
new_message: str,
max_tokens: int,
) -> tuple[str, int, int]:
"""Call Anthropic with prompt caching on system prompt and history.
Uses structured system blocks so the static base prompt is cached
independently from the per-query RAG context. Optionally connects
to Microsoft Learn via MCP for real-time documentation lookups.
"""
import anthropic
client = anthropic.AsyncAnthropic(
api_key=settings.ANTHROPIC_API_KEY,
timeout=settings.AI_REQUEST_TIMEOUT_SECONDS,
)
# System prompt as structured blocks:
# Block 1: static base prompt (cached)
# Block 2: RAG context (changes per query, not cached)
system_blocks: list[dict[str, Any]] = [
{
"type": "text",
"text": system_base,
"cache_control": {"type": "ephemeral"},
},
]
if rag_context:
system_blocks.append({"type": "text", "text": rag_context})
# Build messages with cache breakpoint on conversation history
messages: list[dict[str, Any]] = []
for msg in history:
messages.append({"role": msg["role"], "content": msg["content"]})
# Place cache breakpoint on the last history message so the entire
# conversation prefix is cached across turns
if messages:
last = messages[-1]
messages[-1] = {
"role": last["role"],
"content": [
{
"type": "text",
"text": last["content"],
"cache_control": {"type": "ephemeral"},
}
],
}
# Add the new user message (uncached — it's new each turn)
messages.append({"role": "user", "content": new_message})
# MCP server config (optional — controlled by settings)
mcp_servers = anthropic.NOT_GIVEN
tools = anthropic.NOT_GIVEN
if settings.ENABLE_MCP_MICROSOFT_LEARN:
mcp_servers = [
{
"type": "url",
"url": "https://learn.microsoft.com/api/mcp",
"name": "microsoft-learn",
}
]
tools = [
{
"type": "mcp_toolset",
"mcp_server_name": "microsoft-learn",
}
]
response = await client.beta.messages.create(
model=settings.AI_MODEL_ANTHROPIC,
max_tokens=max_tokens,
system=system_blocks,
messages=messages,
mcp_servers=mcp_servers,
tools=tools,
betas=["mcp-client-2025-11-20"],
)
# Extract text from response — MCP responses can have multiple block
# types (text, mcp_tool_use, mcp_tool_result). We join all text blocks.
text_parts = []
mcp_tools_used = []
for block in response.content:
if hasattr(block, "text"):
text_parts.append(block.text)
if getattr(block, "type", None) == "mcp_tool_use":
mcp_tools_used.append(getattr(block, "name", "unknown"))
text = "\n".join(text_parts) if text_parts else ""
usage = response.usage
input_tokens = usage.input_tokens
output_tokens = usage.output_tokens
# Log MCP tool usage
if mcp_tools_used:
logger.info("MCP tools used: %s", ", ".join(mcp_tools_used))
# Log cache performance
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
cache_creation = getattr(usage, "cache_creation_input_tokens", 0) or 0
if cache_read or cache_creation:
logger.info(
"Anthropic cache: read=%d creation=%d input=%d output=%d",
cache_read, cache_creation, input_tokens, output_tokens,
)
return text, input_tokens, output_tokens
def _auto_title(message: str) -> str:
"""Generate a short title from the first user message."""
title = message.strip()[:100]
@@ -41,6 +228,117 @@ def _auto_title(message: str) -> str:
return title
CONCLUSION_SYSTEM_PROMPT = """\
You are a ticket documentation specialist for MSP (Managed Service Provider) teams. \
Your job is to transform an AI troubleshooting conversation into clean, professional \
ticket notes that can be pasted directly into a PSA/ticketing system (ConnectWise, \
Autotask, HaloPSA, etc.).
## Output Format
Generate a structured summary using this exact format:
**Subject:** [One-line summary of the issue]
**Outcome:** {outcome_label}
**Problem Description:**
[2-3 sentence summary of the original problem]
**Steps Taken:**
1. [Step] — [Result/finding]
2. [Step] — [Result/finding]
(list all troubleshooting steps from the conversation)
**Current Status:**
[Where things stand now — what was resolved, what remains]
{notes_section}
**Key Findings:**
- [Important discovery or configuration detail]
- [Any relevant error codes, settings, or values identified]
{resume_section}
## Rules
- Be concise but thorough — these notes will be read by another engineer
- Include specific technical details (commands run, error messages, config values)
- Use plain text formatting (no HTML) — bold with ** is fine
- Do NOT include conversational filler, greetings, or meta-commentary
- Extract ALL actionable steps from the conversation, in chronological order
- If the conversation identified root cause, state it clearly
"""
async def generate_conclusion_summary(
chat: "AssistantChat",
outcome: str,
notes: str | None = None,
) -> str:
"""Generate a ticket-ready summary from a concluded chat conversation."""
outcome_labels = {
"resolved": "Resolved",
"escalated": "Escalated",
"paused": "Paused — To Be Continued",
}
outcome_label = outcome_labels.get(outcome, outcome)
notes_section = ""
if notes:
notes_section = f"\n**Engineer Notes:**\n{notes}\n"
resume_section = ""
if outcome == "paused":
resume_section = (
"\n**Next Steps (for resumption):**\n"
"- [What needs to happen next]\n"
"- [Any pending actions or follow-ups]\n"
)
elif outcome == "escalated":
resume_section = (
"\n**Escalation Details:**\n"
"- [Reason for escalation]\n"
"- [Recommended next steps for receiving team/tier]\n"
)
# Build the conversation transcript for the AI
transcript_lines = []
for msg in chat.messages:
role_label = "ENGINEER" if msg["role"] == "user" else "AI ASSISTANT"
transcript_lines.append(f"[{role_label}]: {msg['content']}")
transcript = "\n\n".join(transcript_lines)
prompt = (
f"Outcome: {outcome_label}\n\n"
f"{'Engineer Notes: ' + notes if notes else '(No additional notes)'}\n\n"
f"--- CONVERSATION TRANSCRIPT ---\n\n{transcript}\n\n"
f"--- END TRANSCRIPT ---\n\n"
f"Generate the ticket notes now. Replace all placeholder brackets with actual content from the conversation. "
f"The notes_section placeholder should be: {notes_section or '(omit this section)'}\n"
f"The resume_section placeholder should be filled based on the conversation context."
)
system_with_vars = CONCLUSION_SYSTEM_PROMPT.replace(
"{outcome_label}", outcome_label
).replace(
"{notes_section}", notes_section or ""
).replace(
"{resume_section}", resume_section
)
content, _, _ = await _call_ai(
system_base=system_with_vars,
rag_context="",
history=[],
new_message=prompt,
max_tokens=2048,
)
return content
async def create_chat(
user_id: UUID,
account_id: UUID,
@@ -90,22 +388,20 @@ async def send_message(
limit=8,
)
# Build system prompt
system_prompt = ASSISTANT_SYSTEM_PROMPT + build_rag_context(rag_results)
rag_context = build_rag_context(rag_results)
# Build messages for AI
ai_messages = []
ai_messages: list[dict[str, Any]] = []
for msg in chat.messages:
if msg["role"] in ("user", "assistant"):
ai_messages.append({"role": msg["role"], "content": msg["content"]})
ai_messages.append({"role": "user", "content": message})
# Call AI
provider = get_ai_provider()
ai_content, input_tokens, output_tokens = await provider.generate_text(
system_prompt=system_prompt,
messages=ai_messages,
max_tokens=4096,
# Call AI with prompt caching (Anthropic) or generic provider
ai_content, input_tokens, output_tokens = await _call_ai(
system_base=ASSISTANT_SYSTEM_PROMPT,
rag_context=rag_context,
history=ai_messages,
new_message=message,
)
# Update chat

View File

@@ -38,6 +38,8 @@ export interface SurveyResponseDetail {
responses: Record<string, string | string[]>
source: 'invite' | 'direct'
invite_name: string | null
is_read: boolean
archived_at: string | null
created_at: string
}
@@ -45,6 +47,7 @@ export interface SurveyResponseListResponse {
responses: SurveyResponseDetail[]
total: number
this_week: number
unread: number
}
export const adminApi = {
@@ -175,10 +178,22 @@ export const adminApi = {
api.post<SurveyInviteResponse>('/admin/survey-invites', data).then(r => r.data),
// Survey Responses
listSurveyResponses: () =>
api.get<SurveyResponseListResponse>('/admin/survey-responses').then(r => r.data),
listSurveyResponses: (includeArchived = false) =>
api.get<SurveyResponseListResponse>('/admin/survey-responses', { params: { include_archived: includeArchived } }).then(r => r.data),
exportSurveyResponsesCsv: () =>
api.get('/admin/survey-responses/export', { responseType: 'blob' }).then(r => r.data),
markResponseRead: (id: string) =>
api.put(`/admin/survey-responses/${id}/read`).then(r => r.data),
markResponseUnread: (id: string) =>
api.put(`/admin/survey-responses/${id}/unread`).then(r => r.data),
archiveResponse: (id: string) =>
api.put(`/admin/survey-responses/${id}/archive`).then(r => r.data),
unarchiveResponse: (id: string) =>
api.put(`/admin/survey-responses/${id}/unarchive`).then(r => r.data),
deleteResponse: (id: string) =>
api.delete(`/admin/survey-responses/${id}`),
bulkActionResponses: (action: string, ids: string[]) =>
api.post('/admin/survey-responses/bulk', { action, ids }).then(r => r.data),
}
export default adminApi

View File

@@ -4,6 +4,8 @@ import type {
ChatListItem,
ChatMessageResponse,
RetentionSettings,
ConcludeChatRequest,
ConcludeChatResponse,
} from '@/types/assistant-chat'
export const assistantChatApi = {
@@ -54,6 +56,14 @@ export const assistantChatApi = {
const response = await apiClient.patch<RetentionSettings>('/assistant/retention', data)
return response.data
},
async concludeChat(chatId: string, data: ConcludeChatRequest): Promise<ConcludeChatResponse> {
const response = await apiClient.post<ConcludeChatResponse>(
`/assistant/chats/${chatId}/conclude`,
data
)
return response.data
},
}
export default assistantChatApi

View File

@@ -0,0 +1,421 @@
import { useState, useEffect } from 'react'
import {
X,
CheckCircle2,
ArrowUpRight,
Pause,
Loader2,
Copy,
Check,
RefreshCw,
ClipboardList,
Sparkles,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
interface ConcludeSessionModalProps {
isOpen: boolean
onClose: () => void
onConclude: (outcome: ConclusionOutcome, notes: string) => Promise<string>
onResumeNew: (summary: string) => void
chatTitle: string
}
const OUTCOMES: { value: ConclusionOutcome; label: string; description: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }[] = [
{
value: 'resolved',
label: 'Resolved',
description: 'Issue has been fixed or answered',
icon: CheckCircle2,
color: 'text-emerald-400',
bg: 'bg-emerald-400/10',
border: 'border-emerald-400/30',
},
{
value: 'escalated',
label: 'Escalate',
description: 'Needs to be handed off or escalated',
icon: ArrowUpRight,
color: 'text-amber-400',
bg: 'bg-amber-400/10',
border: 'border-amber-400/30',
},
{
value: 'paused',
label: 'Paused',
description: 'Continuing later — saving progress',
icon: Pause,
color: 'text-blue-400',
bg: 'bg-blue-400/10',
border: 'border-blue-400/30',
},
]
type ModalStep = 'select-outcome' | 'add-notes' | 'summary'
export function ConcludeSessionModal({
isOpen,
onClose,
onConclude,
onResumeNew,
chatTitle,
}: ConcludeSessionModalProps) {
const [step, setStep] = useState<ModalStep>('select-outcome')
const [outcome, setOutcome] = useState<ConclusionOutcome | null>(null)
const [notes, setNotes] = useState('')
const [summary, setSummary] = useState('')
const [generating, setGenerating] = useState(false)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep('select-outcome')
setOutcome(null)
setNotes('')
setSummary('')
setGenerating(false)
setCopied(false)
setError(null)
}
}, [isOpen])
const handleOutcomeSelect = (selected: ConclusionOutcome) => {
setOutcome(selected)
setStep('add-notes')
}
const handleGenerate = async () => {
if (!outcome) return
setGenerating(true)
setError(null)
try {
const result = await onConclude(outcome, notes)
setSummary(result)
setStep('summary')
} catch {
setError('Failed to generate summary. Please try again.')
} finally {
setGenerating(false)
}
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(summary)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback
const textarea = document.createElement('textarea')
textarea.value = summary
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const handleResumeNew = () => {
onResumeNew(summary)
onClose()
}
if (!isOpen) return null
const selectedOutcome = OUTCOMES.find(o => o.value === outcome)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div
className="relative w-full max-w-2xl mx-4 glass-card-static overflow-hidden animate-in fade-in zoom-in-95 duration-200"
style={{
maxHeight: 'calc(100vh - 4rem)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-6 py-4 border-b shrink-0"
style={{ borderColor: 'var(--glass-border)' }}
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<ClipboardList size={18} className="text-primary" />
</div>
<div>
<h2 className="text-base font-heading font-semibold text-foreground">
Conclude Session
</h2>
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
{chatTitle}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
>
<X size={18} />
</button>
</div>
{/* Step indicator */}
<div
className="px-6 py-3 border-b shrink-0 flex items-center gap-2"
style={{ borderColor: 'var(--glass-border)' }}
>
{(['select-outcome', 'add-notes', 'summary'] as ModalStep[]).map((s, i) => (
<div key={s} className="flex items-center gap-2">
{i > 0 && (
<div
className={cn(
'w-8 h-px',
step === s || (i === 1 && step === 'summary') || (i === 2 && step === 'summary')
? 'bg-primary/40'
: 'bg-[rgba(255,255,255,0.06)]'
)}
/>
)}
<div
className={cn(
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-label font-medium transition-colors',
step === s
? 'bg-gradient-brand text-[#101114]'
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
? 'bg-primary/20 text-primary'
: 'bg-[rgba(255,255,255,0.06)] text-muted-foreground'
)}
>
{i + 1}
</div>
<span
className={cn(
'text-xs font-label',
step === s ? 'text-foreground' : 'text-muted-foreground'
)}
>
{s === 'select-outcome' ? 'Outcome' : s === 'add-notes' ? 'Notes' : 'Summary'}
</span>
</div>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Step 1: Select Outcome */}
{step === 'select-outcome' && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground mb-4">
How did this session end?
</p>
{OUTCOMES.map(o => {
const Icon = o.icon
return (
<button
key={o.value}
onClick={() => handleOutcomeSelect(o.value)}
className={cn(
'w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left',
'hover:scale-[1.01] active:scale-[0.99]',
'bg-[rgba(255,255,255,0.02)] border-[rgba(255,255,255,0.06)]',
'hover:border-[rgba(255,255,255,0.12)] hover:bg-[rgba(255,255,255,0.04)]'
)}
>
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center', o.bg)}>
<Icon size={20} className={o.color} />
</div>
<div>
<span className="text-sm font-semibold text-foreground block">{o.label}</span>
<span className="text-xs text-muted-foreground">{o.description}</span>
</div>
</button>
)
})}
</div>
)}
{/* Step 2: Add Notes */}
{step === 'add-notes' && selectedOutcome && (
<div className="space-y-4">
{/* Selected outcome badge */}
<div className="flex items-center gap-2">
<div className={cn('px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs font-label', selectedOutcome.bg, selectedOutcome.border, 'border')}>
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div>
<button
onClick={() => setStep('select-outcome')}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Change
</button>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground block mb-2">
Additional Notes (optional)
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder={
outcome === 'resolved'
? 'Any additional context about the resolution...'
: outcome === 'escalated'
? 'Reason for escalation, who to assign to...'
: 'What still needs to be done, where you left off...'
}
rows={4}
className="w-full resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
/>
</div>
{error && (
<div className="text-sm text-rose-400 bg-rose-400/10 border border-rose-400/20 rounded-lg px-4 py-2">
{error}
</div>
)}
</div>
)}
{/* Step 3: Summary */}
{step === 'summary' && (
<div className="space-y-4">
{/* Outcome badge */}
{selectedOutcome && (
<div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs font-label', selectedOutcome.bg, selectedOutcome.border, 'border')}>
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div>
)}
{/* Generated summary */}
<div
className="rounded-xl border p-5 bg-[rgba(255,255,255,0.02)]"
style={{ borderColor: 'var(--glass-border)' }}
>
<div className="flex items-center justify-between mb-3">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground flex items-center gap-1.5">
<Sparkles size={10} className="text-primary" />
Generated Ticket Notes
</span>
</div>
<div className="prose-sm text-foreground">
<MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
</div>
</div>
</div>
)}
</div>
{/* Footer actions */}
<div
className="px-6 py-4 border-t shrink-0 flex items-center justify-between gap-3"
style={{ borderColor: 'var(--glass-border)' }}
>
{step === 'select-outcome' && (
<>
<div />
<button
onClick={onClose}
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Cancel
</button>
</>
)}
{step === 'add-notes' && (
<>
<button
onClick={() => setStep('select-outcome')}
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Back
</button>
<button
onClick={handleGenerate}
disabled={generating}
className="flex items-center gap-2 bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-5 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-50"
>
{generating ? (
<>
<Loader2 size={15} className="animate-spin" />
Generating...
</>
) : (
<>
<Sparkles size={15} />
Generate Summary
</>
)}
</button>
</>
)}
{step === 'summary' && (
<>
<div className="flex items-center gap-2">
{outcome === 'paused' && (
<button
onClick={handleResumeNew}
className="flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-sm font-medium text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/15 transition-all"
>
<RefreshCw size={14} />
Resume in New Chat
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-sm font-semibold transition-all',
copied
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
: 'bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]'
)}
>
{copied ? (
<>
<Check size={15} />
Copied!
</>
) : (
<>
<Copy size={15} />
Copy to Clipboard
</>
)}
</button>
<button
onClick={onClose}
className="px-4 py-2.5 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Done
</button>
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Sparkles, Send, Loader2 } from 'lucide-react'
import { Sparkles, Send, Loader2, Flag } from 'lucide-react'
import { assistantChatApi } from '@/api/assistantChat'
import { toast } from '@/lib/toast'
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
import { ChatMessage } from '@/components/assistant/ChatMessage'
import type { ChatListItem, AssistantChatMessage as ChatMessageType } from '@/types/assistant-chat'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import type { ChatListItem, AssistantChatMessage as ChatMessageType, ConclusionOutcome } from '@/types/assistant-chat'
import type { SuggestedFlow } from '@/types/copilot'
interface MessageWithMeta extends ChatMessageType {
@@ -17,6 +18,7 @@ export default function AssistantChatPage() {
const [messages, setMessages] = useState<MessageWithMeta[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [showConclude, setShowConclude] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
@@ -120,6 +122,55 @@ export default function AssistantChatPage() {
}
}
const handleConclude = async (outcome: ConclusionOutcome, notes: string): Promise<string> => {
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
}
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('')
setMessages([{ role: 'user', content: resumePrompt }])
setLoading(true)
const response = await assistantChatApi.sendMessage(chat.id, resumePrompt)
setMessages(prev => [
...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
])
setChats(prev =>
prev.map(c =>
c.id === chat.id
? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
: c
)
)
} catch {
toast.error('Failed to create resume chat')
} finally {
setLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
@@ -189,18 +240,32 @@ export default function AssistantChatPage() {
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about IT, networking, troubleshooting..."
rows={1}
rows={3}
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
disabled={loading}
/>
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
>
<Send size={18} />
</button>
<div className="flex flex-col gap-2">
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
title="Send message"
>
<Send size={18} />
</button>
{messages.length >= 2 && (
<button
onClick={() => setShowConclude(true)}
disabled={loading}
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
style={{ borderColor: 'var(--glass-border)' }}
title="Conclude session"
>
<Flag size={18} />
</button>
)}
</div>
</div>
</div>
</>
@@ -225,6 +290,15 @@ export default function AssistantChatPage() {
</div>
)}
</div>
{/* Conclude Session Modal */}
<ConcludeSessionModal
isOpen={showConclude}
onClose={() => setShowConclude(false)}
onConclude={handleConclude}
onResumeNew={handleResumeNew}
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
/>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/common/BrandLogo'
// ── Survey Data Types ──
@@ -147,6 +147,12 @@ export default function SurveyPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [isComplete, setIsComplete] = useState(false)
const [submitError, setSubmitError] = useState('')
const [emailInput, setEmailInput] = useState('')
const [emailSending, setEmailSending] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const [emailError, setEmailError] = useState('')
const [responseId, setResponseId] = useState<string | null>(null)
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const token = searchParams.get('t')
@@ -203,6 +209,8 @@ export default function SurveyPage() {
const errData = await res.json().catch(() => null)
throw new Error(errData?.detail || `Submission failed (${res.status})`)
}
const data = await res.json()
setResponseId(data.id)
setIsComplete(true)
window.scrollTo({ top: 0, behavior: 'smooth' })
} catch (err) {
@@ -257,9 +265,20 @@ export default function SurveyPage() {
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 className="font-heading text-2xl font-bold mb-2.5">Already Submitted</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed">
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed mb-3">
{inviteName ? `Thanks ${inviteName} — y` : 'Y'}our response has already been recorded. We appreciate your time!
</p>
<p className="text-sm text-muted-foreground/70 max-w-[400px] mx-auto leading-relaxed mb-8">
You can safely close this browser window now.
</p>
<div className="glass-card-static p-5 max-w-[400px] mx-auto text-center">
<p className="text-xs text-muted-foreground leading-relaxed">
Have feedback unrelated to the survey?{' '}
<a href="mailto:feedback@resolutionflow.com" className="text-primary hover:underline font-medium">
feedback@resolutionflow.com
</a>
</p>
</div>
</div>
</div>
</div>
@@ -385,13 +404,86 @@ export default function SurveyPage() {
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 className="font-heading text-2xl font-bold mb-2.5">Done — Thank You!</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-7 leading-relaxed">
Your answers will directly shape how FlowPilot troubleshoots. We truly appreciate your time and expertise.
<h2 className="font-heading text-2xl font-bold mb-2.5">Response Submitted!</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-8 leading-relaxed">
Your answers will directly shape how FlowPilot troubleshoots. Would you like a copy of your responses?
</p>
{/* Email a copy */}
<div className="glass-card-static p-6 max-w-[420px] mx-auto mb-5">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">
Email a copy to yourself
</p>
{!emailSent ? (
<div className="flex gap-2">
<input
type="email"
value={emailInput}
onChange={e => setEmailInput(e.target.value)}
placeholder="your@email.com"
className="flex-1 rounded-[9px] px-3.5 py-2.5 text-sm text-foreground placeholder:text-[#5a6170] focus:outline-none"
style={{ background: 'rgba(16, 17, 20, 0.6)', border: '1px solid var(--glass-border)' }}
onFocus={e => { e.currentTarget.style.borderColor = 'rgba(6, 182, 212, 0.4)' }}
onBlur={e => { e.currentTarget.style.borderColor = 'var(--glass-border)' }}
disabled={emailSending}
/>
<button
onClick={async () => {
if (!emailInput.trim() || !responseId) return
setEmailSending(true)
setEmailError('')
try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const res = await fetch(`${apiUrl}/api/v1/survey/email-copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailInput.trim(), response_id: responseId }),
})
if (!res.ok) {
const err = await res.json().catch(() => null)
throw new Error(err?.detail || 'Failed to send')
}
setEmailSent(true)
} catch (err) {
setEmailError(err instanceof Error ? err.message : 'Failed to send email')
} finally {
setEmailSending(false)
}
}}
disabled={!emailInput.trim() || emailSending}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-[9px] text-sm font-semibold bg-gradient-brand text-[#101114] transition-all duration-150 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
>
{emailSending ? (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Sending...
</>
) : 'Send'}
</button>
</div>
) : (
<div className="flex items-center justify-center gap-2 py-2 text-sm text-emerald-400">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6L9 17l-5-5"/></svg>
Email sent! Check your inbox.
</div>
)}
{emailError && (
<p className="text-xs text-rose-400 mt-2">{emailError}</p>
)}
</div>
{/* Copy + Finish buttons */}
<div className="flex gap-2.5 justify-center flex-wrap">
<button onClick={copyAll} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]">
Copy Responses to Clipboard
<button onClick={copyAll} className="inline-flex items-center gap-2 px-5 py-2.5 rounded-[10px] text-sm font-semibold transition-all duration-150 text-muted-foreground hover:text-foreground" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy to Clipboard
</button>
<button
onClick={() => navigate('/survey/thank-you')}
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]"
>
Finish
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>

View File

@@ -0,0 +1,76 @@
import { BrandLogo } from '@/components/common/BrandLogo'
export default function SurveyThankYouPage() {
return (
<div className="min-h-screen bg-background text-foreground">
{/* Atmosphere orbs */}
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden="true">
<div
className="absolute"
style={{ top: '-200px', left: '-100px', width: '600px', height: '600px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(6, 182, 212, 0.12) 0%, rgba(6, 182, 212, 0.03) 40%, transparent 70%)', filter: 'blur(80px)' }}
/>
<div
className="absolute"
style={{ bottom: '-150px', right: '-100px', width: '500px', height: '500px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(52, 211, 153, 0.08) 0%, rgba(52, 211, 153, 0.02) 40%, transparent 70%)', filter: 'blur(80px)' }}
/>
</div>
{/* Top bar */}
<div className="sticky top-0 z-50" style={{ backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', background: 'rgba(16, 17, 20, 0.85)', borderBottom: '1px solid var(--glass-border)' }}>
<div className="mx-auto flex max-w-[680px] items-center justify-between gap-3 px-5 py-3.5">
<a href="https://resolutionflow.com" target="_blank" rel="noreferrer" className="flex items-center gap-2.5 text-sm font-heading font-bold text-muted-foreground no-underline">
<BrandLogo size="sm" />
<span>Resolution<span className="text-gradient-brand">Flow</span></span>
</a>
</div>
</div>
<div className="relative z-10 mx-auto max-w-[680px] px-5">
<div className="text-center pt-[120px] animate-fade-in-up">
{/* Success icon */}
<div className="w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)', border: '1px solid rgba(52, 211, 153, 0.15)' }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
</div>
<h1 className="font-heading text-[clamp(24px,4vw,32px)] font-extrabold leading-tight mb-3">
Thank You!
</h1>
<p className="text-[15px] text-muted-foreground max-w-[460px] mx-auto leading-relaxed mb-3">
Your response has been recorded. Your expertise will directly shape how FlowPilot thinks about troubleshooting.
</p>
<p className="text-sm text-muted-foreground/70 max-w-[400px] mx-auto leading-relaxed mb-10">
You can safely close this browser window now.
</p>
{/* Divider */}
<div className="mx-auto w-12 h-px mb-10" style={{ background: 'var(--glass-border)' }} />
{/* Feedback callout */}
<div
className="glass-card-static p-6 text-center max-w-[480px] mx-auto"
>
<div className="flex items-center justify-center gap-2 mb-3">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary font-semibold">
Have Feedback?
</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
If you have any feedback unrelated to the survey, we'd love to hear from you at{' '}
<a
href="mailto:feedback@resolutionflow.com"
className="text-primary hover:underline font-medium"
>
feedback@resolutionflow.com
</a>
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -7,15 +7,11 @@ import { cn } from '@/lib/utils'
export function VerifyEmailPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [errorMessage, setErrorMessage] = useState('')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(token ? 'loading' : 'error')
const [errorMessage, setErrorMessage] = useState(token ? '' : 'No verification token provided')
useEffect(() => {
if (!token) {
setStatus('error')
setErrorMessage('No verification token provided')
return
}
if (!token) return
authApi.verifyEmail(token)
.then(() => setStatus('success'))

View File

@@ -1,8 +1,24 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { adminApi, type SurveyResponseDetail, type SurveyResponseListResponse } from '@/api/admin'
import { PageHeader } from '@/components/admin'
import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react'
import {
ChevronDown,
Download,
User,
Link2,
Loader2,
Eye,
EyeOff,
Archive,
ArchiveRestore,
Trash2,
CheckSquare,
Square,
MoreHorizontal,
Circle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
const QUESTIONS: { id: string; num: string; text: string; type: 'mc' | 'mc-multi' | 'range' | 'text' | 'rank' }[] = [
{ id: 'prereqs', num: '1', text: 'Before you start troubleshooting, what info do you need?', type: 'mc-multi' },
@@ -70,7 +86,7 @@ function AnswerDisplay({ value, type }: { value: string | string[] | undefined;
function ExpandedDetail({ response }: { response: SurveyResponseDetail }) {
return (
<tr>
<td colSpan={6} className="p-0">
<td colSpan={8} className="p-0">
<div
className="px-6 py-5"
style={{
@@ -106,25 +122,57 @@ function ResponseRow({
response,
index,
isExpanded,
isSelected,
onToggle,
onSelect,
onMarkRead,
onArchive,
onDelete,
}: {
response: SurveyResponseDetail
index: number
isExpanded: boolean
isSelected: boolean
onToggle: () => void
onSelect: () => void
onMarkRead: () => void
onArchive: () => void
onDelete: () => void
}) {
const answeredCount = QUESTIONS.filter((q) => {
const val = response.responses[q.id]
return val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)
}).length
const [showMenu, setShowMenu] = useState(false)
return (
<>
<tr
className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors cursor-pointer"
onClick={onToggle}
className={cn(
'border-b border-border/50 transition-colors cursor-pointer',
!response.is_read && 'bg-primary/[0.03]',
'hover:bg-[rgba(255,255,255,0.02)]'
)}
>
<td className="px-4 py-3 w-8">
{/* Checkbox */}
<td className="px-2 py-3 w-8" onClick={e => { e.stopPropagation(); onSelect() }}>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-primary cursor-pointer" />
) : (
<Square className="h-4 w-4 text-muted-foreground/40 cursor-pointer hover:text-muted-foreground" />
)}
</td>
{/* Unread dot */}
<td className="px-1 py-3 w-6" onClick={onToggle}>
{!response.is_read && (
<Circle className="h-2.5 w-2.5 fill-primary text-primary" />
)}
</td>
{/* Expand chevron */}
<td className="px-2 py-3 w-8" onClick={onToggle}>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
@@ -132,11 +180,11 @@ function ResponseRow({
)}
/>
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">{index + 1}</td>
<td className="px-4 py-3 text-sm text-foreground">
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>{index + 1}</td>
<td className={cn('px-4 py-3 text-sm', !response.is_read ? 'text-foreground font-medium' : 'text-foreground')} onClick={onToggle}>
{response.respondent_name || <span className="text-muted-foreground italic">Anonymous</span>}
</td>
<td className="px-4 py-3">
<td className="px-4 py-3" onClick={onToggle}>
{response.source === 'invite' ? (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wider bg-primary/10 text-primary">
<User className="h-3 w-3" />
@@ -152,16 +200,57 @@ function ResponseRow({
</span>
)}
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>
{new Date(response.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
<td className="px-4 py-3 text-sm text-muted-foreground" onClick={onToggle}>
{answeredCount} / {QUESTIONS.length}
</td>
{/* Actions */}
<td className="px-3 py-3 w-10 relative">
<button
onClick={e => { e.stopPropagation(); setShowMenu(!showMenu) }}
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
>
<MoreHorizontal className="h-4 w-4" />
</button>
{showMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
<div
className="absolute right-3 top-full z-50 mt-1 w-44 rounded-xl py-1 shadow-xl"
style={{ background: 'rgba(24, 26, 31, 0.95)', border: '1px solid var(--glass-border)', backdropFilter: 'blur(16px)' }}
>
<button
onClick={() => { onMarkRead(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
>
{response.is_read ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{response.is_read ? 'Mark Unread' : 'Mark Read'}
</button>
<button
onClick={() => { onArchive(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
>
{response.archived_at ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
{response.archived_at ? 'Unarchive' : 'Archive'}
</button>
<div className="my-1 border-t" style={{ borderColor: 'var(--glass-border)' }} />
<button
onClick={() => { onDelete(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-rose-400 hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
</>
)}
</td>
</tr>
{isExpanded && <ExpandedDetail response={response} />}
</>
@@ -174,20 +263,24 @@ export default function SurveyResponsesPage() {
const [error, setError] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showArchived, setShowArchived] = useState(false)
const fetchData = useCallback(async () => {
try {
const result = await adminApi.listSurveyResponses(showArchived)
setData(result)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}, [showArchived])
useEffect(() => {
const fetchData = async () => {
try {
const result = await adminApi.listSurveyResponses()
setData(result)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}
setLoading(true)
fetchData()
}, [])
}, [fetchData])
const handleExport = async () => {
setExporting(true)
@@ -206,6 +299,112 @@ export default function SurveyResponsesPage() {
}
}
const handleMarkRead = async (id: string, currentlyRead: boolean) => {
try {
if (currentlyRead) {
await adminApi.markResponseUnread(id)
} else {
await adminApi.markResponseRead(id)
}
setData(prev => prev ? {
...prev,
unread: prev.unread + (currentlyRead ? 1 : -1),
responses: prev.responses.map(r => r.id === id ? { ...r, is_read: !currentlyRead } : r),
} : prev)
} catch {
toast.error('Failed to update read status')
}
}
const handleArchive = async (id: string, currentlyArchived: boolean) => {
try {
if (currentlyArchived) {
await adminApi.unarchiveResponse(id)
setData(prev => prev ? {
...prev,
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: null } : r),
} : prev)
} else {
await adminApi.archiveResponse(id)
if (!showArchived) {
setData(prev => prev ? {
...prev,
total: prev.total - 1,
responses: prev.responses.filter(r => r.id !== id),
} : prev)
} else {
setData(prev => prev ? {
...prev,
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: new Date().toISOString() } : r),
} : prev)
}
}
toast.success(currentlyArchived ? 'Response unarchived' : 'Response archived')
} catch {
toast.error('Failed to update archive status')
}
}
const handleDelete = async (id: string) => {
if (!confirm('Permanently delete this response? This cannot be undone.')) return
try {
await adminApi.deleteResponse(id)
setData(prev => prev ? {
...prev,
total: prev.total - 1,
responses: prev.responses.filter(r => r.id !== id),
} : prev)
setSelectedIds(prev => { const next = new Set(prev); next.delete(id); return next })
toast.success('Response deleted')
} catch {
toast.error('Failed to delete response')
}
}
const handleBulkAction = async (action: string) => {
if (selectedIds.size === 0) return
if (action === 'delete' && !confirm(`Permanently delete ${selectedIds.size} response(s)?`)) return
try {
await adminApi.bulkActionResponses(action, Array.from(selectedIds))
setSelectedIds(new Set())
fetchData()
toast.success(`${action.replace('_', ' ')} applied to ${selectedIds.size} response(s)`)
} catch {
toast.error('Bulk action failed')
}
}
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
const responses = data?.responses ?? []
if (selectedIds.size === responses.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(responses.map(r => r.id)))
}
}
// Auto-mark as read when expanding
const handleExpand = (id: string) => {
const newId = expandedId === id ? null : id
setExpandedId(newId)
if (newId) {
const resp = data?.responses.find(r => r.id === newId)
if (resp && !resp.is_read) {
handleMarkRead(newId, false)
}
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -230,18 +429,33 @@ export default function SurveyResponsesPage() {
title="Survey Responses"
description={`${data?.total ?? 0} total responses collected`}
action={
<button
onClick={handleExport}
disabled={exporting || responses.length === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
>
{exporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export CSV
</button>
<div className="flex items-center gap-2">
{/* Archive toggle */}
<button
onClick={() => setShowArchived(!showArchived)}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-3 py-2 text-xs font-medium transition-colors border',
showArchived
? 'bg-primary/10 text-primary border-primary/20'
: 'bg-[rgba(255,255,255,0.04)] text-muted-foreground border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)]'
)}
>
<Archive className="h-3.5 w-3.5" />
{showArchived ? 'Showing Archived' : 'Show Archived'}
</button>
<button
onClick={handleExport}
disabled={exporting || responses.length === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
>
{exporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export CSV
</button>
</div>
}
/>
@@ -269,14 +483,82 @@ export default function SurveyResponsesPage() {
{data?.this_week ?? 0}
</p>
</div>
<div className="glass-card-static px-5 py-4 flex-1">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">
Unread
</p>
<p className={cn(
'text-2xl font-heading font-bold',
(data?.unread ?? 0) > 0 ? 'text-primary' : 'text-foreground'
)}>
{data?.unread ?? 0}
</p>
</div>
</div>
{/* Bulk actions bar */}
{selectedIds.size > 0 && (
<div
className="flex items-center gap-3 rounded-xl px-4 py-2.5"
style={{ background: 'rgba(6, 182, 212, 0.08)', border: '1px solid rgba(6, 182, 212, 0.15)' }}
>
<span className="text-sm text-primary font-medium">
{selectedIds.size} selected
</span>
<div className="flex-1" />
<button
onClick={() => handleBulkAction('mark_read')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Eye className="h-3.5 w-3.5" />
Mark Read
</button>
<button
onClick={() => handleBulkAction('mark_unread')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<EyeOff className="h-3.5 w-3.5" />
Mark Unread
</button>
<button
onClick={() => handleBulkAction('archive')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Archive className="h-3.5 w-3.5" />
Archive
</button>
<button
onClick={() => handleBulkAction('delete')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-rose-400 hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
Clear
</button>
</div>
)}
{/* Table */}
<div className="glass-card-static overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border/50">
<th className="px-4 py-3 w-8" />
<th className="px-2 py-3 w-8">
<button onClick={toggleSelectAll} className="text-muted-foreground/40 hover:text-muted-foreground">
{selectedIds.size > 0 && selectedIds.size === responses.length ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square className="h-4 w-4" />
)}
</button>
</th>
<th className="px-1 py-3 w-6" />
<th className="px-2 py-3 w-8" />
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
#
</th>
@@ -292,13 +574,14 @@ export default function SurveyResponsesPage() {
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Answered
</th>
<th className="px-3 py-3 w-10" />
</tr>
</thead>
<tbody>
{responses.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-sm text-muted-foreground">
No survey responses yet.
<td colSpan={9} className="px-4 py-12 text-center text-sm text-muted-foreground">
{showArchived ? 'No archived responses.' : 'No survey responses yet.'}
</td>
</tr>
) : (
@@ -308,9 +591,12 @@ export default function SurveyResponsesPage() {
response={response}
index={index}
isExpanded={expandedId === response.id}
onToggle={() =>
setExpandedId(expandedId === response.id ? null : response.id)
}
isSelected={selectedIds.has(response.id)}
onToggle={() => handleExpand(response.id)}
onSelect={() => toggleSelect(response.id)}
onMarkRead={() => handleMarkRead(response.id, response.is_read)}
onArchive={() => handleArchive(response.id, !!response.archived_at)}
onDelete={() => handleDelete(response.id)}
/>
))
)}

View File

@@ -11,6 +11,7 @@ import {
// Public pages
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
const SurveyPage = lazy(() => import('@/pages/SurveyPage'))
const SurveyThankYouPage = lazy(() => import('@/pages/SurveyThankYouPage'))
// Standalone auth pages
const VerifyEmailPage = lazy(() => import('@/pages/VerifyEmailPage'))
@@ -108,6 +109,15 @@ export const router = createBrowserRouter([
),
errorElement: <RouteError />,
},
{
path: '/survey/thank-you',
element: (
<Suspense fallback={<PageLoader />}>
<SurveyThankYouPage />
</Suspense>
),
errorElement: <RouteError />,
},
{
path: '/share/:shareToken',
element: (

View File

@@ -34,4 +34,17 @@ export interface RetentionSettings {
chat_retention_max_count: number | null
}
export type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
export interface ConcludeChatRequest {
outcome: ConclusionOutcome
notes?: string
}
export interface ConcludeChatResponse {
summary: string
outcome: ConclusionOutcome
concluded_at: string
}
export type { SuggestedFlow }