feat: add AI assistant with in-session copilot and standalone chat with RAG
Implements three-phase AI assistant feature: - Phase 0: RAG infrastructure with pgvector embeddings, Voyage AI integration, tree chunking service, and semantic search over team's flow library - Phase 1: In-session copilot panel during flow navigation with contextual AI help, current step awareness, and suggested related flows - Phase 2: Standalone AI chat page with persistent conversation history, pin/delete, and configurable retention policies (account-level) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,10 @@ from alembic import context
|
|||||||
# Import your models
|
# Import your models
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
from app.models import User, Team, Tree, Session, Attachment, InviteCode
|
from app.models import User, Team, Tree, Session, Attachment, InviteCode
|
||||||
|
from app.models.email_verification_token import EmailVerificationToken
|
||||||
|
from app.models.tree_embedding import TreeEmbedding
|
||||||
|
from app.models.copilot_conversation import CopilotConversation
|
||||||
|
from app.models.assistant_chat import AssistantChat
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
# this is the Alembic Config object
|
# this is the Alembic Config object
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Add pgvector extension and tree_embeddings table.
|
||||||
|
|
||||||
|
Revision ID: 042
|
||||||
|
Revises: 041
|
||||||
|
Create Date: 2026-03-04
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision: str = "042"
|
||||||
|
down_revision: str = "041"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Enable pgvector extension
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"tree_embeddings",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tree_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("chunk_type", sa.String(30), nullable=False),
|
||||||
|
sa.Column("node_type", sa.String(30), nullable=True),
|
||||||
|
sa.Column("node_id", sa.String(100), nullable=True),
|
||||||
|
sa.Column("chunk_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("embedding_model", sa.String(50), nullable=False, server_default="voyage-3.5"),
|
||||||
|
sa.Column("embedding", sa.Column.__class__, nullable=True), # placeholder, replaced below
|
||||||
|
sa.Column("meta", postgresql.JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop the placeholder embedding column and add the vector column
|
||||||
|
op.drop_column("tree_embeddings", "embedding")
|
||||||
|
op.execute("ALTER TABLE tree_embeddings ADD COLUMN embedding vector(1024)")
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
op.create_index("ix_tree_embeddings_account_id", "tree_embeddings", ["account_id"])
|
||||||
|
op.create_index("ix_tree_embeddings_tree_id", "tree_embeddings", ["tree_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("tree_embeddings")
|
||||||
|
op.execute("DROP EXTENSION IF EXISTS vector")
|
||||||
39
backend/alembic/versions/043_add_copilot_conversations.py
Normal file
39
backend/alembic/versions/043_add_copilot_conversations.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Add copilot_conversations table.
|
||||||
|
|
||||||
|
Revision ID: 043
|
||||||
|
Revises: 042
|
||||||
|
Create Date: 2026-03-04
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "043"
|
||||||
|
down_revision: str = "042"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"copilot_conversations",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("session_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("tree_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("messages", postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||||
|
sa.Column("current_node_id", sa.String(100), nullable=True),
|
||||||
|
sa.Column("message_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("total_input_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("total_output_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("copilot_conversations")
|
||||||
37
backend/alembic/versions/044_add_assistant_chats.py
Normal file
37
backend/alembic/versions/044_add_assistant_chats.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Add assistant_chats table.
|
||||||
|
|
||||||
|
Revision ID: 044
|
||||||
|
Revises: 043
|
||||||
|
Create Date: 2026-03-04
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "044"
|
||||||
|
down_revision: str = "043"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"assistant_chats",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False, server_default="New Chat"),
|
||||||
|
sa.Column("messages", postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||||
|
sa.Column("message_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("total_input_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("total_output_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("assistant_chats")
|
||||||
31
backend/alembic/versions/045_add_chat_retention_settings.py
Normal file
31
backend/alembic/versions/045_add_chat_retention_settings.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Add chat retention settings to accounts.
|
||||||
|
|
||||||
|
Revision ID: 045
|
||||||
|
Revises: 044
|
||||||
|
Create Date: 2026-03-04
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "045"
|
||||||
|
down_revision: str = "044"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"accounts",
|
||||||
|
sa.Column("chat_retention_days", sa.Integer(), nullable=True, server_default=sa.text("90")),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"accounts",
|
||||||
|
sa.Column("chat_retention_max_count", sa.Integer(), nullable=True, server_default=sa.text("100")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("accounts", "chat_retention_max_count")
|
||||||
|
op.drop_column("accounts", "chat_retention_days")
|
||||||
320
backend/app/api/endpoints/assistant_chat.py
Normal file
320
backend/app/api/endpoints/assistant_chat.py
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
"""Standalone AI assistant chat endpoints.
|
||||||
|
|
||||||
|
POST /assistant/chats — Create new chat
|
||||||
|
GET /assistant/chats — List chats (paginated, newest first)
|
||||||
|
GET /assistant/chats/{id} — Get chat with messages
|
||||||
|
POST /assistant/chats/{id}/messages — Send message
|
||||||
|
PATCH /assistant/chats/{id} — Update title, pin/unpin
|
||||||
|
DELETE /assistant/chats/{id} — Delete single chat
|
||||||
|
DELETE /assistant/chats — Bulk delete (older_than_days query param)
|
||||||
|
GET /assistant/retention — Get account retention settings
|
||||||
|
PATCH /assistant/retention — Update retention settings (owner only)
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
from sqlalchemy import select, delete, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.ai_quota_service import check_ai_quota, record_ai_usage, get_user_plan
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.account import Account
|
||||||
|
from app.models.assistant_chat import AssistantChat
|
||||||
|
from app.schemas.assistant_chat import (
|
||||||
|
ChatCreateRequest,
|
||||||
|
ChatMessageRequest,
|
||||||
|
ChatMessageResponse,
|
||||||
|
ChatListResponse,
|
||||||
|
ChatDetailResponse,
|
||||||
|
ChatUpdateRequest,
|
||||||
|
RetentionSettingsResponse,
|
||||||
|
RetentionSettingsUpdate,
|
||||||
|
)
|
||||||
|
from app.schemas.copilot import SuggestedFlow
|
||||||
|
from app.services import assistant_chat_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/assistant", tags=["assistant-chat"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ai_enabled() -> None:
|
||||||
|
if not settings.ai_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="AI is not configured. Set GOOGLE_AI_API_KEY or ANTHROPIC_API_KEY.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chats", response_model=ChatDetailResponse, status_code=201)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def create_chat(
|
||||||
|
request: Request,
|
||||||
|
data: ChatCreateRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
):
|
||||||
|
"""Create a new empty chat conversation."""
|
||||||
|
chat = await assistant_chat_service.create_chat(
|
||||||
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return ChatDetailResponse.model_validate(chat)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chats", response_model=list[ChatListResponse])
|
||||||
|
async def list_chats(
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
|
):
|
||||||
|
"""List user's chat conversations (newest first, pinned on top)."""
|
||||||
|
offset = (page - 1) * size
|
||||||
|
result = await db.execute(
|
||||||
|
select(AssistantChat)
|
||||||
|
.where(AssistantChat.user_id == current_user.id)
|
||||||
|
.order_by(AssistantChat.pinned.desc(), AssistantChat.updated_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(size)
|
||||||
|
)
|
||||||
|
chats = result.scalars().all()
|
||||||
|
return [ChatListResponse.model_validate(c) for c in chats]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chats/{chat_id}", response_model=ChatDetailResponse)
|
||||||
|
async def get_chat(
|
||||||
|
chat_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Get a chat with full message history."""
|
||||||
|
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")
|
||||||
|
return ChatDetailResponse.model_validate(chat)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chats/{chat_id}/messages", response_model=ChatMessageResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def post_message(
|
||||||
|
request: Request,
|
||||||
|
chat_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 and get AI response."""
|
||||||
|
_require_ai_enabled()
|
||||||
|
|
||||||
|
allowed, quota_status = await check_ai_quota(
|
||||||
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
db=db,
|
||||||
|
billing_anchor=current_user.ai_billing_cycle_anchor_at,
|
||||||
|
is_super_admin=current_user.is_super_admin,
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
reset_key = "daily_reset_at" if quota_status.get("deny_reason") == "daily" else "monthly_reset_at"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail={
|
||||||
|
"message": f"AI limit exceeded ({quota_status['deny_reason']})",
|
||||||
|
"reset_at": quota_status.get(reset_key),
|
||||||
|
"quota": quota_status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = await get_user_plan(current_user.account_id, db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ai_content, suggested_flows, chat = await assistant_chat_service.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
message=data.message,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Assistant chat message failed: %s", e)
|
||||||
|
await record_ai_usage(
|
||||||
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
conversation_id=None,
|
||||||
|
generation_type="assistant_message",
|
||||||
|
tier=plan,
|
||||||
|
input_tokens=0,
|
||||||
|
output_tokens=0,
|
||||||
|
estimated_cost=0,
|
||||||
|
succeeded=False,
|
||||||
|
counts_toward_quota=False,
|
||||||
|
error_code=type(e).__name__,
|
||||||
|
extra_data={"assistant_chat_id": str(chat_id)},
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"AI provider error ({type(e).__name__}). Please try again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
await record_ai_usage(
|
||||||
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
conversation_id=None,
|
||||||
|
generation_type="assistant_message",
|
||||||
|
tier=plan,
|
||||||
|
input_tokens=chat.total_input_tokens,
|
||||||
|
output_tokens=chat.total_output_tokens,
|
||||||
|
estimated_cost=(
|
||||||
|
chat.total_input_tokens * 1.0 / 1_000_000
|
||||||
|
+ chat.total_output_tokens * 5.0 / 1_000_000
|
||||||
|
),
|
||||||
|
succeeded=True,
|
||||||
|
counts_toward_quota=False,
|
||||||
|
error_code=None,
|
||||||
|
extra_data={"assistant_chat_id": str(chat_id)},
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ChatMessageResponse(
|
||||||
|
content=ai_content,
|
||||||
|
suggested_flows=[SuggestedFlow(**sf) for sf in suggested_flows],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/chats/{chat_id}", response_model=ChatDetailResponse)
|
||||||
|
async def update_chat(
|
||||||
|
chat_id: UUID,
|
||||||
|
data: ChatUpdateRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Update chat title or pin/unpin."""
|
||||||
|
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 data.title is not None:
|
||||||
|
chat.title = data.title
|
||||||
|
if data.pinned is not None:
|
||||||
|
chat.pinned = data.pinned
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return ChatDetailResponse.model_validate(chat)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/chats/{chat_id}", status_code=204)
|
||||||
|
async def delete_chat(
|
||||||
|
chat_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Delete a single chat."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
await db.delete(chat)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/chats", status_code=204)
|
||||||
|
async def bulk_delete_chats(
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
older_than_days: int = Query(..., ge=1),
|
||||||
|
):
|
||||||
|
"""Bulk delete chats older than N days (skips pinned)."""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
||||||
|
await db.execute(
|
||||||
|
delete(AssistantChat).where(
|
||||||
|
AssistantChat.user_id == current_user.id,
|
||||||
|
AssistantChat.pinned == False, # noqa: E712
|
||||||
|
AssistantChat.updated_at < cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/retention", response_model=RetentionSettingsResponse)
|
||||||
|
async def get_retention_settings(
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Get account chat retention settings."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Account).where(Account.id == current_user.account_id)
|
||||||
|
)
|
||||||
|
account = result.scalar_one_or_none()
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||||
|
|
||||||
|
return RetentionSettingsResponse(
|
||||||
|
chat_retention_days=account.chat_retention_days,
|
||||||
|
chat_retention_max_count=account.chat_retention_max_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/retention", response_model=RetentionSettingsResponse)
|
||||||
|
async def update_retention_settings(
|
||||||
|
data: RetentionSettingsUpdate,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Update account chat retention settings (account owner only)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Account).where(Account.id == current_user.account_id)
|
||||||
|
)
|
||||||
|
account = result.scalar_one_or_none()
|
||||||
|
if not account:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||||
|
|
||||||
|
if account.owner_id != current_user.id and not current_user.is_super_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only the account owner can update retention settings",
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.chat_retention_days is not None:
|
||||||
|
account.chat_retention_days = data.chat_retention_days
|
||||||
|
if data.chat_retention_max_count is not None:
|
||||||
|
account.chat_retention_max_count = data.chat_retention_max_count
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return RetentionSettingsResponse(
|
||||||
|
chat_retention_days=account.chat_retention_days,
|
||||||
|
chat_retention_max_count=account.chat_retention_max_count,
|
||||||
|
)
|
||||||
190
backend/app/api/endpoints/copilot.py
Normal file
190
backend/app/api/endpoints/copilot.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""In-session copilot endpoints.
|
||||||
|
|
||||||
|
Contextual AI assistant during flow navigation:
|
||||||
|
POST /copilot/conversations — Start conversation (requires tree_id)
|
||||||
|
POST /copilot/conversations/{id}/messages — Send message, get response + suggestions
|
||||||
|
GET /copilot/conversations/{id} — Get conversation history
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.ai_quota_service import check_ai_quota, record_ai_usage, get_user_plan
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.copilot import (
|
||||||
|
CopilotStartRequest,
|
||||||
|
CopilotStartResponse,
|
||||||
|
CopilotMessageRequest,
|
||||||
|
CopilotMessageResponse,
|
||||||
|
CopilotConversationResponse,
|
||||||
|
SuggestedFlow,
|
||||||
|
)
|
||||||
|
from app.services import copilot_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/copilot", tags=["copilot"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ai_enabled() -> None:
|
||||||
|
if not settings.ai_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="AI is not configured. Set GOOGLE_AI_API_KEY or ANTHROPIC_API_KEY.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conversations", response_model=CopilotStartResponse, status_code=201)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def start_conversation(
|
||||||
|
request: Request,
|
||||||
|
data: CopilotStartRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
):
|
||||||
|
"""Start a new copilot conversation for a flow."""
|
||||||
|
_require_ai_enabled()
|
||||||
|
|
||||||
|
allowed, quota_status = await check_ai_quota(
|
||||||
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
db=db,
|
||||||
|
billing_anchor=current_user.ai_billing_cycle_anchor_at,
|
||||||
|
is_super_admin=current_user.is_super_admin,
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
reset_key = "daily_reset_at" if quota_status.get("deny_reason") == "daily" else "monthly_reset_at"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail={
|
||||||
|
"message": f"AI limit exceeded ({quota_status['deny_reason']})",
|
||||||
|
"reset_at": quota_status.get(reset_key),
|
||||||
|
"quota": quota_status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conversation, greeting = await copilot_service.start_conversation(
|
||||||
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
tree_id=data.tree_id,
|
||||||
|
session_id=data.session_id,
|
||||||
|
current_node_id=data.current_node_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Copilot conversation start failed: %s", e)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"AI provider error ({type(e).__name__}). Please try again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return CopilotStartResponse(
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
greeting=greeting,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conversations/{conversation_id}/messages", response_model=CopilotMessageResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def post_message(
|
||||||
|
request: Request,
|
||||||
|
conversation_id: UUID,
|
||||||
|
data: CopilotMessageRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
):
|
||||||
|
"""Send a message and get AI response with flow suggestions."""
|
||||||
|
_require_ai_enabled()
|
||||||
|
|
||||||
|
plan = await get_user_plan(current_user.account_id, db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ai_content, suggested_flows = await copilot_service.send_message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
message=data.message,
|
||||||
|
current_node_id=data.current_node_id,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Copilot message failed: %s", e)
|
||||||
|
await record_ai_usage(
|
||||||
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
conversation_id=None,
|
||||||
|
generation_type="copilot_message",
|
||||||
|
tier=plan,
|
||||||
|
input_tokens=0,
|
||||||
|
output_tokens=0,
|
||||||
|
estimated_cost=0,
|
||||||
|
succeeded=False,
|
||||||
|
counts_toward_quota=False,
|
||||||
|
error_code=type(e).__name__,
|
||||||
|
extra_data={"copilot_conversation_id": str(conversation_id)},
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"AI provider error ({type(e).__name__}). Please try again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
await record_ai_usage(
|
||||||
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
conversation_id=None,
|
||||||
|
generation_type="copilot_message",
|
||||||
|
tier=plan,
|
||||||
|
input_tokens=0,
|
||||||
|
output_tokens=0,
|
||||||
|
estimated_cost=0,
|
||||||
|
succeeded=True,
|
||||||
|
counts_toward_quota=False,
|
||||||
|
error_code=None,
|
||||||
|
extra_data={"copilot_conversation_id": str(conversation_id)},
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return CopilotMessageResponse(
|
||||||
|
content=ai_content,
|
||||||
|
suggested_flows=[SuggestedFlow(**sf) for sf in suggested_flows],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversations/{conversation_id}", response_model=CopilotConversationResponse)
|
||||||
|
async def get_conversation(
|
||||||
|
conversation_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Get copilot conversation history."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.copilot_conversation import CopilotConversation
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(CopilotConversation).where(
|
||||||
|
CopilotConversation.id == conversation_id,
|
||||||
|
CopilotConversation.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||||
|
|
||||||
|
return CopilotConversationResponse.model_validate(conversation)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -29,6 +30,7 @@ from app.core.audit import log_audit
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.tree_validation import can_publish_tree
|
from app.core.tree_validation import can_publish_tree
|
||||||
from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree
|
from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree
|
||||||
|
from app.services.rag_service import index_tree as rag_index_tree
|
||||||
|
|
||||||
router = APIRouter(prefix="/trees", tags=["trees"])
|
router = APIRouter(prefix="/trees", tags=["trees"])
|
||||||
|
|
||||||
@@ -542,6 +544,13 @@ async def create_tree(
|
|||||||
)
|
)
|
||||||
tree = result.scalar_one()
|
tree = result.scalar_one()
|
||||||
|
|
||||||
|
# Index tree for RAG (best-effort, don't fail the request)
|
||||||
|
try:
|
||||||
|
await rag_index_tree(tree.id, db)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning("RAG indexing failed for tree %s", tree.id)
|
||||||
|
|
||||||
return build_full_tree_response(tree)
|
return build_full_tree_response(tree)
|
||||||
|
|
||||||
|
|
||||||
@@ -725,6 +734,13 @@ async def update_tree(
|
|||||||
)
|
)
|
||||||
tree = result.scalar_one()
|
tree = result.scalar_one()
|
||||||
|
|
||||||
|
# Re-index tree for RAG (best-effort)
|
||||||
|
try:
|
||||||
|
await rag_index_tree(tree.id, db)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning("RAG re-indexing failed for tree %s", tree.id)
|
||||||
|
|
||||||
return build_full_tree_response(tree)
|
return build_full_tree_response(tree)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from app.api.endpoints import feedback
|
|||||||
from app.api.endpoints import ai_builder
|
from app.api.endpoints import ai_builder
|
||||||
from app.api.endpoints import ai_fix
|
from app.api.endpoints import ai_fix
|
||||||
from app.api.endpoints import ai_chat
|
from app.api.endpoints import ai_chat
|
||||||
|
from app.api.endpoints import copilot
|
||||||
|
from app.api.endpoints import assistant_chat
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -40,3 +42,5 @@ api_router.include_router(feedback.router)
|
|||||||
api_router.include_router(ai_builder.router)
|
api_router.include_router(ai_builder.router)
|
||||||
api_router.include_router(ai_fix.router)
|
api_router.include_router(ai_fix.router)
|
||||||
api_router.include_router(ai_chat.router)
|
api_router.include_router(ai_chat.router)
|
||||||
|
api_router.include_router(copilot.router)
|
||||||
|
api_router.include_router(assistant_chat.router)
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ async def check_ai_quota(
|
|||||||
select(func.count(AIUsage.id)).where(
|
select(func.count(AIUsage.id)).where(
|
||||||
AIUsage.user_id == user_id,
|
AIUsage.user_id == user_id,
|
||||||
AIUsage.succeeded == True, # noqa: E712
|
AIUsage.succeeded == True, # noqa: E712
|
||||||
AIUsage.generation_type.in_(["scaffold", "branch_detail", "chat_message", "chat_generate"]),
|
AIUsage.generation_type.in_(["scaffold", "branch_detail", "chat_message", "chat_generate", "copilot_message", "assistant_message"]),
|
||||||
AIUsage.created_at >= day_start,
|
AIUsage.created_at >= day_start,
|
||||||
)
|
)
|
||||||
) or 0
|
) or 0
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ class Settings(BaseSettings):
|
|||||||
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
||||||
AI_MODEL_ANTHROPIC: str = "claude-haiku-4-5-20251001"
|
AI_MODEL_ANTHROPIC: str = "claude-haiku-4-5-20251001"
|
||||||
|
|
||||||
|
# Embedding / RAG
|
||||||
|
VOYAGE_API_KEY: Optional[str] = None
|
||||||
|
EMBEDDING_MODEL: str = "voyage-3.5"
|
||||||
|
EMBEDDING_DIMENSIONS: int = 1024
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ai_enabled(self) -> bool:
|
def ai_enabled(self) -> bool:
|
||||||
"""Check if any AI provider is configured."""
|
"""Check if any AI provider is configured."""
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
|
|||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations
|
from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations
|
||||||
|
from app.services.retention_cleanup import cleanup_expired_chats
|
||||||
from app.core.service_account import ensure_service_account
|
from app.core.service_account import ensure_service_account
|
||||||
|
|
||||||
# Initialize logging configuration
|
# Initialize logging configuration
|
||||||
@@ -122,6 +123,15 @@ async def lifespan(app: FastAPI):
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Chat retention cleanup (daily)
|
||||||
|
scheduler.add_job(
|
||||||
|
cleanup_expired_chats,
|
||||||
|
trigger="interval",
|
||||||
|
hours=24,
|
||||||
|
id="cleanup_expired_chats",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Auto-seed trees in background on PR environments
|
# Auto-seed trees in background on PR environments
|
||||||
seed_task = None
|
seed_task = None
|
||||||
if settings.SEED_ON_DEPLOY:
|
if settings.SEED_ON_DEPLOY:
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ from .feedback import Feedback
|
|||||||
from .ai_conversation import AIConversation
|
from .ai_conversation import AIConversation
|
||||||
from .ai_usage import AIUsage
|
from .ai_usage import AIUsage
|
||||||
from .ai_chat_session import AIChatSession
|
from .ai_chat_session import AIChatSession
|
||||||
|
from .tree_embedding import TreeEmbedding
|
||||||
|
from .copilot_conversation import CopilotConversation
|
||||||
|
from .assistant_chat import AssistantChat
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -69,4 +72,7 @@ __all__ = [
|
|||||||
"AIConversation",
|
"AIConversation",
|
||||||
"AIUsage",
|
"AIUsage",
|
||||||
"AIChatSession",
|
"AIChatSession",
|
||||||
|
"TreeEmbedding",
|
||||||
|
"CopilotConversation",
|
||||||
|
"AssistantChat",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean
|
from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -35,6 +35,14 @@ class Account(Base):
|
|||||||
comment="Policy: engineers can create public shares. Only affects NEW shares (grandfathered)."
|
comment="Policy: engineers can create public shares. Only affects NEW shares (grandfathered)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Chat retention settings
|
||||||
|
chat_retention_days: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True, default=90, server_default="90"
|
||||||
|
)
|
||||||
|
chat_retention_max_count: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True, default=100, server_default="100"
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account")
|
owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account")
|
||||||
users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account")
|
users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account")
|
||||||
|
|||||||
59
backend/app/models/assistant_chat.py
Normal file
59
backend/app/models/assistant_chat.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Standalone AI assistant chat model.
|
||||||
|
|
||||||
|
Persistent conversation history for general IT questions with RAG context.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantChat(Base):
|
||||||
|
__tablename__ = "assistant_chats"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, default="New Chat"
|
||||||
|
)
|
||||||
|
messages: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||||
|
JSONB, nullable=False, default=list
|
||||||
|
)
|
||||||
|
message_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0
|
||||||
|
)
|
||||||
|
total_input_tokens: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0
|
||||||
|
)
|
||||||
|
total_output_tokens: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0
|
||||||
|
)
|
||||||
|
pinned: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
69
backend/app/models/copilot_conversation.py
Normal file
69
backend/app/models/copilot_conversation.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Copilot conversation model for in-session AI assistant.
|
||||||
|
|
||||||
|
Tracks conversation state during flow navigation with contextual AI help.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotConversation(Base):
|
||||||
|
__tablename__ = "copilot_conversations"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("sessions.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
tree_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("trees.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
messages: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||||
|
JSONB, nullable=False, default=list
|
||||||
|
)
|
||||||
|
current_node_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100), nullable=True
|
||||||
|
)
|
||||||
|
message_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0
|
||||||
|
)
|
||||||
|
total_input_tokens: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0
|
||||||
|
)
|
||||||
|
total_output_tokens: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False
|
||||||
|
)
|
||||||
72
backend/app/models/tree_embedding.py
Normal file
72
backend/app/models/tree_embedding.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Tree embedding storage for RAG-powered AI assistant.
|
||||||
|
|
||||||
|
Stores vector embeddings of tree content chunks for semantic search.
|
||||||
|
Each tree is split into multiple chunks (node, solution, tree_summary)
|
||||||
|
and embedded via Voyage AI for cosine similarity retrieval.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
# pgvector column type — imported at runtime to avoid import errors
|
||||||
|
# when pgvector is not installed locally
|
||||||
|
try:
|
||||||
|
from pgvector.sqlalchemy import Vector
|
||||||
|
except ImportError:
|
||||||
|
Vector = None
|
||||||
|
|
||||||
|
|
||||||
|
class TreeEmbedding(Base):
|
||||||
|
__tablename__ = "tree_embeddings"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_tree_embeddings_account_id", "account_id"),
|
||||||
|
Index("ix_tree_embeddings_tree_id", "tree_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
tree_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("trees.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
chunk_type: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
nullable=False,
|
||||||
|
comment="node | solution | tree_summary",
|
||||||
|
)
|
||||||
|
node_type: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(30), nullable=True
|
||||||
|
)
|
||||||
|
node_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100), nullable=True
|
||||||
|
)
|
||||||
|
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
embedding_model: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False, default="voyage-3.5"
|
||||||
|
)
|
||||||
|
# The embedding column is created via migration with vector(1024) type
|
||||||
|
# We store it as a generic column here and handle it in queries
|
||||||
|
meta: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSONB, nullable=False, default=dict
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
59
backend/app/schemas/assistant_chat.py
Normal file
59
backend/app/schemas/assistant_chat.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Pydantic schemas for standalone AI assistant chat."""
|
||||||
|
from typing import Optional, Any
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.copilot import SuggestedFlow
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCreateRequest(BaseModel):
|
||||||
|
"""Empty body — creates a new blank conversation."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageRequest(BaseModel):
|
||||||
|
message: str = Field(..., min_length=1, max_length=8000)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageResponse(BaseModel):
|
||||||
|
content: str
|
||||||
|
suggested_flows: list[SuggestedFlow] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ChatListResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
title: str
|
||||||
|
message_count: int
|
||||||
|
pinned: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ChatDetailResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
title: str
|
||||||
|
messages: list[dict[str, Any]]
|
||||||
|
message_count: int
|
||||||
|
pinned: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ChatUpdateRequest(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
pinned: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RetentionSettingsResponse(BaseModel):
|
||||||
|
chat_retention_days: Optional[int]
|
||||||
|
chat_retention_max_count: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
44
backend/app/schemas/copilot.py
Normal file
44
backend/app/schemas/copilot.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Pydantic schemas for the in-session copilot."""
|
||||||
|
from typing import Optional, Any
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestedFlow(BaseModel):
|
||||||
|
tree_id: UUID
|
||||||
|
tree_name: str
|
||||||
|
tree_type: str
|
||||||
|
relevance_snippet: str
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotStartRequest(BaseModel):
|
||||||
|
tree_id: UUID
|
||||||
|
session_id: Optional[UUID] = None
|
||||||
|
current_node_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotStartResponse(BaseModel):
|
||||||
|
conversation_id: UUID
|
||||||
|
greeting: str
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotMessageRequest(BaseModel):
|
||||||
|
message: str = Field(..., min_length=1, max_length=4000)
|
||||||
|
current_node_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotMessageResponse(BaseModel):
|
||||||
|
content: str
|
||||||
|
suggested_flows: list[SuggestedFlow] = []
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotConversationResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
tree_id: UUID
|
||||||
|
messages: list[dict[str, Any]]
|
||||||
|
current_node_id: Optional[str] = None
|
||||||
|
message_count: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
152
backend/app/services/assistant_chat_service.py
Normal file
152
backend/app/services/assistant_chat_service.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""Standalone AI assistant chat service with RAG context.
|
||||||
|
|
||||||
|
Provides persistent conversation history for general IT questions
|
||||||
|
with semantic search over the team's flow library.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional, 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.models.assistant_chat import AssistantChat
|
||||||
|
from app.services import rag_service
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rag_context(rag_results: list[dict[str, Any]]) -> str:
|
||||||
|
"""Format RAG results into a system prompt section."""
|
||||||
|
if not rag_results:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
parts = ["\n--- RELEVANT FLOWS FROM TEAM LIBRARY ---"]
|
||||||
|
for r in rag_results[:5]:
|
||||||
|
parts.append(f"- [{r['tree_type']}] {r['tree_name']}: {r['chunk_text'][:200]}")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_suggested_flows(rag_results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Extract unique suggested flows from RAG results."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
suggestions = []
|
||||||
|
for r in rag_results:
|
||||||
|
tid = r["tree_id"]
|
||||||
|
if tid in seen or r["similarity"] < 0.3:
|
||||||
|
continue
|
||||||
|
seen.add(tid)
|
||||||
|
suggestions.append({
|
||||||
|
"tree_id": tid,
|
||||||
|
"tree_name": r["tree_name"],
|
||||||
|
"tree_type": r["tree_type"],
|
||||||
|
"relevance_snippet": r["chunk_text"][:150],
|
||||||
|
})
|
||||||
|
return suggestions[:3]
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_title(message: str) -> str:
|
||||||
|
"""Generate a short title from the first user message."""
|
||||||
|
title = message.strip()[:100]
|
||||||
|
if len(message) > 100:
|
||||||
|
title = title.rsplit(" ", 1)[0] + "..."
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
async def create_chat(
|
||||||
|
user_id: UUID,
|
||||||
|
account_id: UUID,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> AssistantChat:
|
||||||
|
"""Create a new empty chat."""
|
||||||
|
chat = AssistantChat(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
messages=[],
|
||||||
|
)
|
||||||
|
db.add(chat)
|
||||||
|
await db.flush()
|
||||||
|
return chat
|
||||||
|
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
chat_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
account_id: UUID,
|
||||||
|
message: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> tuple[str, list[dict[str, Any]], AssistantChat]:
|
||||||
|
"""Send a user message and get AI response.
|
||||||
|
|
||||||
|
Returns (ai_content, suggested_flows, chat).
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AssistantChat).where(
|
||||||
|
AssistantChat.id == chat_id,
|
||||||
|
AssistantChat.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chat = result.scalar_one_or_none()
|
||||||
|
if not chat:
|
||||||
|
raise ValueError("Chat not found")
|
||||||
|
|
||||||
|
# Auto-title from first message
|
||||||
|
if chat.message_count == 0:
|
||||||
|
chat.title = _auto_title(message)
|
||||||
|
|
||||||
|
# RAG search
|
||||||
|
rag_results = await rag_service.search(
|
||||||
|
query=message,
|
||||||
|
account_id=account_id,
|
||||||
|
db=db,
|
||||||
|
limit=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build system prompt
|
||||||
|
system_prompt = ASSISTANT_SYSTEM_PROMPT + _build_rag_context(rag_results)
|
||||||
|
|
||||||
|
# Build messages for AI
|
||||||
|
ai_messages = []
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update chat
|
||||||
|
msgs = list(chat.messages)
|
||||||
|
msgs.append({"role": "user", "content": message})
|
||||||
|
msgs.append({"role": "assistant", "content": ai_content})
|
||||||
|
chat.messages = msgs
|
||||||
|
chat.message_count += 2
|
||||||
|
chat.total_input_tokens += input_tokens
|
||||||
|
chat.total_output_tokens += output_tokens
|
||||||
|
|
||||||
|
suggested_flows = _extract_suggested_flows(rag_results)
|
||||||
|
|
||||||
|
return ai_content, suggested_flows, chat
|
||||||
241
backend/app/services/copilot_service.py
Normal file
241
backend/app/services/copilot_service.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""Copilot service — in-session AI assistant with RAG context.
|
||||||
|
|
||||||
|
Builds system prompts with current flow context and RAG results,
|
||||||
|
manages conversation state, and returns AI responses with flow suggestions.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional, Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.core.ai_provider import get_ai_provider
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.copilot_conversation import CopilotConversation
|
||||||
|
from app.services import rag_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
COPILOT_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
|
||||||
|
|
||||||
|
You are acting as an in-session copilot while the user navigates a troubleshooting or procedural flow. You can see the flow context and their current position.
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_flow_context(tree: Tree, current_node_id: Optional[str]) -> str:
|
||||||
|
"""Build flow context string for the system prompt."""
|
||||||
|
parts = [
|
||||||
|
f"\n--- CURRENT FLOW CONTEXT ---",
|
||||||
|
f"Flow: {tree.name}",
|
||||||
|
f"Type: {tree.tree_type}",
|
||||||
|
]
|
||||||
|
if tree.description:
|
||||||
|
parts.append(f"Description: {tree.description}")
|
||||||
|
|
||||||
|
if current_node_id and tree.tree_structure:
|
||||||
|
node = _find_node(tree.tree_structure, current_node_id)
|
||||||
|
if node:
|
||||||
|
parts.append(f"Current node type: {node.get('type', 'unknown')}")
|
||||||
|
parts.append(f"Current node: {node.get('content', node.get('label', 'Unknown'))}")
|
||||||
|
# Add options if it's a question/decision node
|
||||||
|
children = node.get("children", [])
|
||||||
|
if children and isinstance(children, list):
|
||||||
|
option_labels = [
|
||||||
|
c.get("label", c.get("content", ""))
|
||||||
|
for c in children if isinstance(c, dict)
|
||||||
|
]
|
||||||
|
if option_labels:
|
||||||
|
parts.append(f"Available options: {', '.join(option_labels)}")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_node(structure: dict, node_id: str) -> Optional[dict]:
|
||||||
|
"""Recursively find a node by ID in tree structure."""
|
||||||
|
if structure.get("id") == node_id:
|
||||||
|
return structure
|
||||||
|
for child in structure.get("children", []):
|
||||||
|
if isinstance(child, dict):
|
||||||
|
found = _find_node(child, node_id)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
# Check steps array for procedural flows
|
||||||
|
for step in structure.get("steps", []):
|
||||||
|
if isinstance(step, dict):
|
||||||
|
found = _find_node(step, node_id)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rag_context(rag_results: list[dict[str, Any]]) -> str:
|
||||||
|
"""Format RAG results into a system prompt section."""
|
||||||
|
if not rag_results:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
parts = ["\n--- RELEVANT FLOWS FROM TEAM LIBRARY ---"]
|
||||||
|
for r in rag_results[:5]: # Cap at 5 for prompt size
|
||||||
|
parts.append(f"- [{r['tree_type']}] {r['tree_name']}: {r['chunk_text'][:200]}")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_suggested_flows(
|
||||||
|
rag_results: list[dict[str, Any]],
|
||||||
|
exclude_tree_id: Optional[UUID] = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Extract unique suggested flows from RAG results."""
|
||||||
|
seen_tree_ids: set[str] = set()
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
for r in rag_results:
|
||||||
|
tid = r["tree_id"]
|
||||||
|
if exclude_tree_id and tid == str(exclude_tree_id):
|
||||||
|
continue
|
||||||
|
if tid in seen_tree_ids:
|
||||||
|
continue
|
||||||
|
if r["similarity"] < 0.3:
|
||||||
|
continue
|
||||||
|
seen_tree_ids.add(tid)
|
||||||
|
suggestions.append({
|
||||||
|
"tree_id": tid,
|
||||||
|
"tree_name": r["tree_name"],
|
||||||
|
"tree_type": r["tree_type"],
|
||||||
|
"relevance_snippet": r["chunk_text"][:150],
|
||||||
|
})
|
||||||
|
|
||||||
|
return suggestions[:3]
|
||||||
|
|
||||||
|
|
||||||
|
async def start_conversation(
|
||||||
|
user_id: UUID,
|
||||||
|
account_id: UUID,
|
||||||
|
tree_id: UUID,
|
||||||
|
session_id: Optional[UUID],
|
||||||
|
current_node_id: Optional[str],
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> tuple[CopilotConversation, str]:
|
||||||
|
"""Start a new copilot conversation.
|
||||||
|
|
||||||
|
Returns (conversation, greeting_message).
|
||||||
|
"""
|
||||||
|
# Load tree
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tree).options(selectinload(Tree.tags)).where(Tree.id == tree_id)
|
||||||
|
)
|
||||||
|
tree = result.scalar_one_or_none()
|
||||||
|
if not tree:
|
||||||
|
raise ValueError(f"Tree {tree_id} not found")
|
||||||
|
|
||||||
|
conversation = CopilotConversation(
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
tree_id=tree_id,
|
||||||
|
session_id=session_id,
|
||||||
|
current_node_id=current_node_id,
|
||||||
|
messages=[],
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=24),
|
||||||
|
)
|
||||||
|
db.add(conversation)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
greeting = f"I'm your copilot for this **{tree.tree_type}** flow: **{tree.name}**. Ask me anything about the current step, alternative approaches, or related troubleshooting tips."
|
||||||
|
|
||||||
|
conversation.messages = [{"role": "assistant", "content": greeting}]
|
||||||
|
conversation.message_count = 1
|
||||||
|
|
||||||
|
return conversation, greeting
|
||||||
|
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
conversation_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
message: str,
|
||||||
|
current_node_id: Optional[str],
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> tuple[str, list[dict[str, Any]]]:
|
||||||
|
"""Send a user message and get AI response.
|
||||||
|
|
||||||
|
Returns (ai_content, suggested_flows).
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(CopilotConversation).where(
|
||||||
|
CopilotConversation.id == conversation_id,
|
||||||
|
CopilotConversation.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
if not conversation:
|
||||||
|
raise ValueError("Conversation not found")
|
||||||
|
|
||||||
|
if conversation.expires_at < datetime.now(timezone.utc):
|
||||||
|
raise ValueError("Conversation has expired")
|
||||||
|
|
||||||
|
# Load tree for context
|
||||||
|
tree_result = await db.execute(
|
||||||
|
select(Tree).options(selectinload(Tree.tags)).where(Tree.id == conversation.tree_id)
|
||||||
|
)
|
||||||
|
tree = tree_result.scalar_one_or_none()
|
||||||
|
if not tree:
|
||||||
|
raise ValueError("Associated flow not found")
|
||||||
|
|
||||||
|
# Update current node
|
||||||
|
if current_node_id:
|
||||||
|
conversation.current_node_id = current_node_id
|
||||||
|
|
||||||
|
# RAG search
|
||||||
|
rag_results = await rag_service.search(
|
||||||
|
query=message,
|
||||||
|
account_id=conversation.account_id,
|
||||||
|
db=db,
|
||||||
|
limit=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build system prompt
|
||||||
|
system_prompt = COPILOT_SYSTEM_PROMPT
|
||||||
|
system_prompt += _build_flow_context(tree, conversation.current_node_id)
|
||||||
|
system_prompt += _build_rag_context(rag_results)
|
||||||
|
|
||||||
|
# Build messages for AI
|
||||||
|
ai_messages = []
|
||||||
|
for msg in conversation.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=2048,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update conversation
|
||||||
|
msgs = list(conversation.messages)
|
||||||
|
msgs.append({"role": "user", "content": message})
|
||||||
|
msgs.append({"role": "assistant", "content": ai_content})
|
||||||
|
conversation.messages = msgs
|
||||||
|
conversation.message_count += 2
|
||||||
|
conversation.total_input_tokens += input_tokens
|
||||||
|
conversation.total_output_tokens += output_tokens
|
||||||
|
|
||||||
|
# Extract suggested flows
|
||||||
|
suggested_flows = _extract_suggested_flows(rag_results, exclude_tree_id=tree.id)
|
||||||
|
|
||||||
|
return ai_content, suggested_flows
|
||||||
78
backend/app/services/embedding_service.py
Normal file
78
backend/app/services/embedding_service.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Embedding provider abstraction for RAG.
|
||||||
|
|
||||||
|
Uses Voyage AI (voyage-3.5, 1024 dims) as the embedding provider.
|
||||||
|
Supports document and query input types for asymmetric search.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_embedding(
|
||||||
|
text: str,
|
||||||
|
input_type: str = "document",
|
||||||
|
) -> Optional[list[float]]:
|
||||||
|
"""Get embedding vector for text using Voyage AI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to embed.
|
||||||
|
input_type: "document" for indexing, "query" for search queries.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of floats (1024 dims) or None if embedding service unavailable.
|
||||||
|
"""
|
||||||
|
if not settings.VOYAGE_API_KEY:
|
||||||
|
logger.warning("VOYAGE_API_KEY not set — embedding service unavailable")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import voyageai
|
||||||
|
|
||||||
|
client = voyageai.AsyncClient(api_key=settings.VOYAGE_API_KEY)
|
||||||
|
result = await client.embed(
|
||||||
|
texts=[text],
|
||||||
|
model=settings.EMBEDDING_MODEL,
|
||||||
|
input_type=input_type,
|
||||||
|
)
|
||||||
|
return result.embeddings[0]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Embedding failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_embeddings_batch(
|
||||||
|
texts: list[str],
|
||||||
|
input_type: str = "document",
|
||||||
|
) -> Optional[list[list[float]]]:
|
||||||
|
"""Get embedding vectors for multiple texts in a single API call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
texts: List of texts to embed.
|
||||||
|
input_type: "document" for indexing, "query" for search queries.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of embedding vectors or None if service unavailable.
|
||||||
|
"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not settings.VOYAGE_API_KEY:
|
||||||
|
logger.warning("VOYAGE_API_KEY not set — embedding service unavailable")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import voyageai
|
||||||
|
|
||||||
|
client = voyageai.AsyncClient(api_key=settings.VOYAGE_API_KEY)
|
||||||
|
result = await client.embed(
|
||||||
|
texts=texts,
|
||||||
|
model=settings.EMBEDDING_MODEL,
|
||||||
|
input_type=input_type,
|
||||||
|
)
|
||||||
|
return result.embeddings
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Batch embedding failed: %s", e)
|
||||||
|
return None
|
||||||
170
backend/app/services/rag_service.py
Normal file
170
backend/app/services/rag_service.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""RAG service — index trees and search embeddings for AI context.
|
||||||
|
|
||||||
|
Orchestrates tree chunking, embedding, and semantic search over the
|
||||||
|
team's flow library via pgvector cosine similarity.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import text, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.tree_embedding import TreeEmbedding
|
||||||
|
from app.services.embedding_service import get_embedding, get_embeddings_batch
|
||||||
|
from app.services.tree_chunker import chunk_tree
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def index_tree(tree_id: UUID, db: AsyncSession) -> int:
|
||||||
|
"""Chunk and embed a tree, storing results in tree_embeddings.
|
||||||
|
|
||||||
|
Deletes existing embeddings for this tree before re-indexing.
|
||||||
|
Returns the number of chunks indexed.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tree)
|
||||||
|
.options(selectinload(Tree.tags))
|
||||||
|
.where(Tree.id == tree_id)
|
||||||
|
)
|
||||||
|
tree = result.scalar_one_or_none()
|
||||||
|
if not tree:
|
||||||
|
logger.warning("index_tree: tree %s not found", tree_id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Delete existing embeddings
|
||||||
|
await db.execute(
|
||||||
|
delete(TreeEmbedding).where(TreeEmbedding.tree_id == tree_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chunk the tree
|
||||||
|
tag_names = [t.name for t in tree.tags] if tree.tags else []
|
||||||
|
chunks = chunk_tree(
|
||||||
|
tree_name=tree.name,
|
||||||
|
tree_type=tree.tree_type,
|
||||||
|
description=tree.description,
|
||||||
|
tags=tag_names,
|
||||||
|
tree_structure=tree.tree_structure,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not chunks:
|
||||||
|
logger.info("index_tree: no chunks for tree %s", tree_id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Get embeddings for all chunks in batch
|
||||||
|
texts = [c["chunk_text"] for c in chunks]
|
||||||
|
embeddings = await get_embeddings_batch(texts, input_type="document")
|
||||||
|
|
||||||
|
if embeddings is None:
|
||||||
|
logger.warning("index_tree: embedding service unavailable for tree %s", tree_id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Insert embeddings
|
||||||
|
for chunk, embedding in zip(chunks, embeddings):
|
||||||
|
embedding_str = "[" + ",".join(str(v) for v in embedding) + "]"
|
||||||
|
await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO tree_embeddings
|
||||||
|
(tree_id, account_id, chunk_type, node_type, node_id, chunk_text, embedding_model, embedding, meta)
|
||||||
|
VALUES
|
||||||
|
(:tree_id, :account_id, :chunk_type, :node_type, :node_id, :chunk_text, :embedding_model, :embedding::vector, :meta::jsonb)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"tree_id": str(tree_id),
|
||||||
|
"account_id": str(tree.account_id) if tree.account_id else None,
|
||||||
|
"chunk_type": chunk["chunk_type"],
|
||||||
|
"node_type": chunk.get("node_type"),
|
||||||
|
"node_id": chunk.get("node_id"),
|
||||||
|
"chunk_text": chunk["chunk_text"],
|
||||||
|
"embedding_model": "voyage-3.5",
|
||||||
|
"embedding": embedding_str,
|
||||||
|
"meta": "{}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("index_tree: indexed %d chunks for tree %s", len(chunks), tree_id)
|
||||||
|
return len(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_tree_embeddings(tree_id: UUID, db: AsyncSession) -> None:
|
||||||
|
"""Delete all embeddings for a tree."""
|
||||||
|
await db.execute(
|
||||||
|
delete(TreeEmbedding).where(TreeEmbedding.tree_id == tree_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
query: str,
|
||||||
|
account_id: UUID,
|
||||||
|
db: AsyncSession,
|
||||||
|
limit: int = 8,
|
||||||
|
exclude_tree_id: Optional[UUID] = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Semantic search over team's flow library.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Natural language search query.
|
||||||
|
account_id: Scope search to team's flows.
|
||||||
|
db: Database session.
|
||||||
|
limit: Max results to return.
|
||||||
|
exclude_tree_id: Exclude chunks from this tree (for copilot context).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with tree_id, tree_name, tree_type, chunk_text, chunk_type, similarity.
|
||||||
|
"""
|
||||||
|
query_embedding = await get_embedding(query, input_type="query")
|
||||||
|
if query_embedding is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
embedding_str = "[" + ",".join(str(v) for v in query_embedding) + "]"
|
||||||
|
|
||||||
|
exclude_clause = ""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"embedding": embedding_str,
|
||||||
|
"account_id": str(account_id),
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if exclude_tree_id:
|
||||||
|
exclude_clause = "AND te.tree_id != :exclude_tree_id"
|
||||||
|
params["exclude_tree_id"] = str(exclude_tree_id)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
text(f"""
|
||||||
|
SELECT
|
||||||
|
te.tree_id,
|
||||||
|
t.name as tree_name,
|
||||||
|
t.tree_type,
|
||||||
|
te.chunk_text,
|
||||||
|
te.chunk_type,
|
||||||
|
te.node_id,
|
||||||
|
1 - (te.embedding <=> :embedding::vector) as similarity
|
||||||
|
FROM tree_embeddings te
|
||||||
|
JOIN trees t ON t.id = te.tree_id
|
||||||
|
WHERE te.account_id = :account_id
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
{exclude_clause}
|
||||||
|
ORDER BY te.embedding <=> :embedding::vector
|
||||||
|
LIMIT :limit
|
||||||
|
"""),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = result.mappings().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"tree_id": str(row["tree_id"]),
|
||||||
|
"tree_name": row["tree_name"],
|
||||||
|
"tree_type": row["tree_type"],
|
||||||
|
"chunk_text": row["chunk_text"],
|
||||||
|
"chunk_type": row["chunk_type"],
|
||||||
|
"node_id": row["node_id"],
|
||||||
|
"similarity": float(row["similarity"]),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
84
backend/app/services/retention_cleanup.py
Normal file
84
backend/app/services/retention_cleanup.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Chat retention cleanup job.
|
||||||
|
|
||||||
|
Runs daily via APScheduler to enforce account-level retention settings:
|
||||||
|
- Delete non-pinned chats older than chat_retention_days
|
||||||
|
- Delete oldest non-pinned chats when count exceeds chat_retention_max_count
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select, delete, func
|
||||||
|
|
||||||
|
from app.core.database import async_session_maker
|
||||||
|
from app.models.account import Account
|
||||||
|
from app.models.assistant_chat import AssistantChat
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_expired_chats() -> None:
|
||||||
|
"""Enforce chat retention policies for all accounts."""
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
try:
|
||||||
|
result = await db.execute(select(Account))
|
||||||
|
accounts = result.scalars().all()
|
||||||
|
|
||||||
|
total_deleted = 0
|
||||||
|
for account in accounts:
|
||||||
|
deleted = await _cleanup_account_chats(account, db)
|
||||||
|
total_deleted += deleted
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
if total_deleted > 0:
|
||||||
|
logger.info("[retention] Cleaned up %d expired chats", total_deleted)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[retention] Chat cleanup failed: %s", e)
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup_account_chats(account: Account, db) -> int:
|
||||||
|
"""Enforce retention for a single account. Returns count deleted."""
|
||||||
|
deleted = 0
|
||||||
|
|
||||||
|
# Age-based retention
|
||||||
|
if account.chat_retention_days:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=account.chat_retention_days)
|
||||||
|
result = await db.execute(
|
||||||
|
delete(AssistantChat)
|
||||||
|
.where(
|
||||||
|
AssistantChat.account_id == account.id,
|
||||||
|
AssistantChat.pinned == False, # noqa: E712
|
||||||
|
AssistantChat.updated_at < cutoff,
|
||||||
|
)
|
||||||
|
.returning(AssistantChat.id)
|
||||||
|
)
|
||||||
|
deleted += len(result.all())
|
||||||
|
|
||||||
|
# Count-based retention
|
||||||
|
if account.chat_retention_max_count:
|
||||||
|
total = await db.scalar(
|
||||||
|
select(func.count(AssistantChat.id)).where(
|
||||||
|
AssistantChat.account_id == account.id,
|
||||||
|
)
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
if total > account.chat_retention_max_count:
|
||||||
|
excess = total - account.chat_retention_max_count
|
||||||
|
# Get oldest non-pinned chat IDs
|
||||||
|
oldest = await db.execute(
|
||||||
|
select(AssistantChat.id)
|
||||||
|
.where(
|
||||||
|
AssistantChat.account_id == account.id,
|
||||||
|
AssistantChat.pinned == False, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(AssistantChat.updated_at.asc())
|
||||||
|
.limit(excess)
|
||||||
|
)
|
||||||
|
ids_to_delete = [row[0] for row in oldest.all()]
|
||||||
|
if ids_to_delete:
|
||||||
|
await db.execute(
|
||||||
|
delete(AssistantChat).where(AssistantChat.id.in_(ids_to_delete))
|
||||||
|
)
|
||||||
|
deleted += len(ids_to_delete)
|
||||||
|
|
||||||
|
return deleted
|
||||||
165
backend/app/services/tree_chunker.py
Normal file
165
backend/app/services/tree_chunker.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""Tree chunker — converts tree_structure JSON into embeddable text chunks.
|
||||||
|
|
||||||
|
Produces three chunk types:
|
||||||
|
- tree_summary: Name + description + tags + type overview
|
||||||
|
- node: Individual node content with breadcrumb path context
|
||||||
|
- solution: Full solution/action text with path context
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_breadcrumb(node: dict, parent_path: str = "") -> str:
|
||||||
|
"""Build a breadcrumb path string for a node."""
|
||||||
|
content = node.get("content", node.get("label", ""))[:80]
|
||||||
|
if parent_path:
|
||||||
|
return f"{parent_path} > {content}"
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_node(
|
||||||
|
node: dict,
|
||||||
|
tree_name: str,
|
||||||
|
tree_type: str,
|
||||||
|
tags: list[str],
|
||||||
|
parent_path: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Recursively chunk a node and its children."""
|
||||||
|
chunks = []
|
||||||
|
node_type = node.get("type", "unknown")
|
||||||
|
node_id = node.get("id", "")
|
||||||
|
content = node.get("content", node.get("label", ""))
|
||||||
|
breadcrumb = _get_breadcrumb(node, parent_path)
|
||||||
|
|
||||||
|
# Build chunk text based on node type
|
||||||
|
if node_type in ("question", "decision"):
|
||||||
|
options = node.get("children", [])
|
||||||
|
option_labels = [
|
||||||
|
child.get("label", child.get("content", ""))[:100]
|
||||||
|
for child in options
|
||||||
|
if isinstance(child, dict)
|
||||||
|
]
|
||||||
|
text_parts = [
|
||||||
|
f"[{node_type}] {content}",
|
||||||
|
]
|
||||||
|
if option_labels:
|
||||||
|
text_parts.append(f"Options: {', '.join(option_labels)}")
|
||||||
|
text_parts.append(f"Path: {breadcrumb}")
|
||||||
|
text_parts.append(f"Flow: {tree_name} | Type: {tree_type}")
|
||||||
|
if tags:
|
||||||
|
text_parts.append(f"Tags: {', '.join(tags)}")
|
||||||
|
|
||||||
|
chunks.append({
|
||||||
|
"chunk_type": "node",
|
||||||
|
"node_type": node_type,
|
||||||
|
"node_id": node_id,
|
||||||
|
"chunk_text": "\n".join(text_parts),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif node_type in ("action", "solution", "info", "warning"):
|
||||||
|
text_parts = [
|
||||||
|
f"[{node_type}] {content}",
|
||||||
|
f"Path: {breadcrumb}",
|
||||||
|
f"Flow: {tree_name} | Type: {tree_type}",
|
||||||
|
]
|
||||||
|
if tags:
|
||||||
|
text_parts.append(f"Tags: {', '.join(tags)}")
|
||||||
|
|
||||||
|
chunk_type = "solution" if node_type == "solution" else "node"
|
||||||
|
chunks.append({
|
||||||
|
"chunk_type": chunk_type,
|
||||||
|
"node_type": node_type,
|
||||||
|
"node_id": node_id,
|
||||||
|
"chunk_text": "\n".join(text_parts),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif node_type in ("step", "section_header"):
|
||||||
|
text_parts = [
|
||||||
|
f"[{node_type}] {content}",
|
||||||
|
f"Path: {breadcrumb}",
|
||||||
|
f"Flow: {tree_name} | Type: {tree_type}",
|
||||||
|
]
|
||||||
|
if node.get("description"):
|
||||||
|
text_parts.insert(1, node["description"])
|
||||||
|
if tags:
|
||||||
|
text_parts.append(f"Tags: {', '.join(tags)}")
|
||||||
|
|
||||||
|
chunks.append({
|
||||||
|
"chunk_type": "node",
|
||||||
|
"node_type": node_type,
|
||||||
|
"node_id": node_id,
|
||||||
|
"chunk_text": "\n".join(text_parts),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Recurse into children
|
||||||
|
children = node.get("children", [])
|
||||||
|
if isinstance(children, list):
|
||||||
|
for child in children:
|
||||||
|
if isinstance(child, dict):
|
||||||
|
chunks.extend(
|
||||||
|
_chunk_node(child, tree_name, tree_type, tags, breadcrumb)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Follow next_node_id linked nodes (action nodes)
|
||||||
|
# These are handled at the tree level, not recursively
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_tree(
|
||||||
|
tree_name: str,
|
||||||
|
tree_type: str,
|
||||||
|
description: str | None,
|
||||||
|
tags: list[str],
|
||||||
|
tree_structure: dict[str, Any],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Convert a tree into embeddable text chunks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tree_name: Name of the flow.
|
||||||
|
tree_type: troubleshooting | procedural | maintenance.
|
||||||
|
description: Flow description.
|
||||||
|
tags: List of tag names.
|
||||||
|
tree_structure: The tree_structure JSONB content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of chunk dicts with keys: chunk_type, node_type, node_id, chunk_text.
|
||||||
|
"""
|
||||||
|
chunks = []
|
||||||
|
|
||||||
|
# Tree summary chunk
|
||||||
|
summary_parts = [
|
||||||
|
f"Flow: {tree_name}",
|
||||||
|
f"Type: {tree_type}",
|
||||||
|
]
|
||||||
|
if description:
|
||||||
|
summary_parts.append(f"Description: {description}")
|
||||||
|
if tags:
|
||||||
|
summary_parts.append(f"Tags: {', '.join(tags)}")
|
||||||
|
|
||||||
|
chunks.append({
|
||||||
|
"chunk_type": "tree_summary",
|
||||||
|
"node_type": None,
|
||||||
|
"node_id": None,
|
||||||
|
"chunk_text": "\n".join(summary_parts),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Chunk the tree structure nodes
|
||||||
|
root = tree_structure
|
||||||
|
if isinstance(root, dict):
|
||||||
|
# Handle both flat structure and nested
|
||||||
|
if "children" in root or "type" in root:
|
||||||
|
chunks.extend(
|
||||||
|
_chunk_node(root, tree_name, tree_type, tags)
|
||||||
|
)
|
||||||
|
# Handle steps array (procedural flows)
|
||||||
|
if "steps" in root and isinstance(root["steps"], list):
|
||||||
|
for step in root["steps"]:
|
||||||
|
if isinstance(step, dict):
|
||||||
|
chunks.extend(
|
||||||
|
_chunk_node(step, tree_name, tree_type, tags)
|
||||||
|
)
|
||||||
|
|
||||||
|
return chunks
|
||||||
@@ -35,6 +35,10 @@ httpx>=0.27.0
|
|||||||
anthropic>=0.40.0
|
anthropic>=0.40.0
|
||||||
google-genai>=1.0.0
|
google-genai>=1.0.0
|
||||||
|
|
||||||
|
# RAG / Embeddings
|
||||||
|
pgvector>=0.3.6
|
||||||
|
voyageai>=0.3.0
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
croniter>=2.0.0
|
croniter>=2.0.0
|
||||||
|
|||||||
59
frontend/src/api/assistantChat.ts
Normal file
59
frontend/src/api/assistantChat.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type {
|
||||||
|
AssistantChat,
|
||||||
|
ChatListItem,
|
||||||
|
ChatMessageResponse,
|
||||||
|
RetentionSettings,
|
||||||
|
} from '@/types/assistant-chat'
|
||||||
|
|
||||||
|
export const assistantChatApi = {
|
||||||
|
async createChat(): Promise<AssistantChat> {
|
||||||
|
const response = await apiClient.post<AssistantChat>('/assistant/chats', {})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listChats(page = 1, size = 20): Promise<ChatListItem[]> {
|
||||||
|
const response = await apiClient.get<ChatListItem[]>('/assistant/chats', {
|
||||||
|
params: { page, size },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getChat(chatId: string): Promise<AssistantChat> {
|
||||||
|
const response = await apiClient.get<AssistantChat>(`/assistant/chats/${chatId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendMessage(chatId: string, message: string): Promise<ChatMessageResponse> {
|
||||||
|
const response = await apiClient.post<ChatMessageResponse>(
|
||||||
|
`/assistant/chats/${chatId}/messages`,
|
||||||
|
{ message }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateChat(chatId: string, data: { title?: string; pinned?: boolean }): Promise<AssistantChat> {
|
||||||
|
const response = await apiClient.patch<AssistantChat>(`/assistant/chats/${chatId}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteChat(chatId: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/assistant/chats/${chatId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkDeleteChats(olderThanDays: number): Promise<void> {
|
||||||
|
await apiClient.delete('/assistant/chats', { params: { older_than_days: olderThanDays } })
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRetentionSettings(): Promise<RetentionSettings> {
|
||||||
|
const response = await apiClient.get<RetentionSettings>('/assistant/retention')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateRetentionSettings(data: Partial<RetentionSettings>): Promise<RetentionSettings> {
|
||||||
|
const response = await apiClient.patch<RetentionSettings>('/assistant/retention', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default assistantChatApi
|
||||||
30
frontend/src/api/copilot.ts
Normal file
30
frontend/src/api/copilot.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type {
|
||||||
|
CopilotStartRequest,
|
||||||
|
CopilotStartResponse,
|
||||||
|
CopilotMessageRequest,
|
||||||
|
CopilotMessageResponse,
|
||||||
|
CopilotConversation,
|
||||||
|
} from '@/types/copilot'
|
||||||
|
|
||||||
|
export const copilotApi = {
|
||||||
|
async startConversation(data: CopilotStartRequest): Promise<CopilotStartResponse> {
|
||||||
|
const response = await apiClient.post<CopilotStartResponse>('/copilot/conversations', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendMessage(conversationId: string, data: CopilotMessageRequest): Promise<CopilotMessageResponse> {
|
||||||
|
const response = await apiClient.post<CopilotMessageResponse>(
|
||||||
|
`/copilot/conversations/${conversationId}/messages`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getConversation(conversationId: string): Promise<CopilotConversation> {
|
||||||
|
const response = await apiClient.get<CopilotConversation>(`/copilot/conversations/${conversationId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default copilotApi
|
||||||
@@ -18,3 +18,5 @@ export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
|
|||||||
export { default as feedbackApi } from './feedback'
|
export { default as feedbackApi } from './feedback'
|
||||||
export { default as aiBuilderApi } from './aiBuilder'
|
export { default as aiBuilderApi } from './aiBuilder'
|
||||||
export { default as aiChatApi } from './aiChat'
|
export { default as aiChatApi } from './aiChat'
|
||||||
|
export { copilotApi } from './copilot'
|
||||||
|
export { assistantChatApi } from './assistantChat'
|
||||||
|
|||||||
51
frontend/src/components/assistant/ChatMessage.tsx
Normal file
51
frontend/src/components/assistant/ChatMessage.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Sparkles, User } from 'lucide-react'
|
||||||
|
import { SuggestedFlowCard } from './SuggestedFlowCard'
|
||||||
|
import type { SuggestedFlow } from '@/types/copilot'
|
||||||
|
|
||||||
|
interface ChatMessageProps {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
suggestedFlows?: SuggestedFlow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
|
role === 'assistant'
|
||||||
|
? 'bg-primary/15 text-primary'
|
||||||
|
: 'bg-[rgba(255,255,255,0.08)] text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{role === 'assistant' ? <Sparkles size={14} /> : <User size={14} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={`max-w-[80%] space-y-2 ${role === 'user' ? 'text-right' : ''}`}>
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl px-4 py-3 text-[0.875rem] leading-relaxed ${
|
||||||
|
role === 'user'
|
||||||
|
? 'bg-primary/15 text-foreground'
|
||||||
|
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="whitespace-pre-wrap">{content}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggested flows (assistant only) */}
|
||||||
|
{role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
Related Flows
|
||||||
|
</span>
|
||||||
|
{suggestedFlows.map(flow => (
|
||||||
|
<SuggestedFlowCard key={flow.tree_id} flow={flow} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
134
frontend/src/components/assistant/ChatSidebar.tsx
Normal file
134
frontend/src/components/assistant/ChatSidebar.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Plus, Pin, Trash2, MessageSquare } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { ChatListItem } from '@/types/assistant-chat'
|
||||||
|
|
||||||
|
interface ChatSidebarProps {
|
||||||
|
chats: ChatListItem[]
|
||||||
|
activeChatId: string | null
|
||||||
|
onSelectChat: (id: string) => void
|
||||||
|
onNewChat: () => void
|
||||||
|
onDeleteChat: (id: string) => void
|
||||||
|
onTogglePin: (id: string, pinned: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatSidebar({
|
||||||
|
chats,
|
||||||
|
activeChatId,
|
||||||
|
onSelectChat,
|
||||||
|
onNewChat,
|
||||||
|
onDeleteChat,
|
||||||
|
onTogglePin,
|
||||||
|
}: ChatSidebarProps) {
|
||||||
|
const pinnedChats = chats.filter(c => c.pinned)
|
||||||
|
const unpinnedChats = chats.filter(c => !c.pinned)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-72 shrink-0 flex flex-col border-r h-full"
|
||||||
|
style={{ borderColor: 'var(--glass-border)' }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 py-3 border-b shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
|
<button
|
||||||
|
onClick={onNewChat}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-4 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat list */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
|
{pinnedChats.length > 0 && (
|
||||||
|
<div className="px-3 mb-1">
|
||||||
|
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
Pinned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pinnedChats.map(chat => (
|
||||||
|
<ChatItem
|
||||||
|
key={chat.id}
|
||||||
|
chat={chat}
|
||||||
|
isActive={chat.id === activeChatId}
|
||||||
|
onSelect={() => onSelectChat(chat.id)}
|
||||||
|
onDelete={() => onDeleteChat(chat.id)}
|
||||||
|
onTogglePin={() => onTogglePin(chat.id, !chat.pinned)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pinnedChats.length > 0 && unpinnedChats.length > 0 && (
|
||||||
|
<div className="mx-3 my-2 border-b" style={{ borderColor: 'var(--glass-border)' }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unpinnedChats.map(chat => (
|
||||||
|
<ChatItem
|
||||||
|
key={chat.id}
|
||||||
|
chat={chat}
|
||||||
|
isActive={chat.id === activeChatId}
|
||||||
|
onSelect={() => onSelectChat(chat.id)}
|
||||||
|
onDelete={() => onDeleteChat(chat.id)}
|
||||||
|
onTogglePin={() => onTogglePin(chat.id, !chat.pinned)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{chats.length === 0 && (
|
||||||
|
<div className="px-4 py-8 text-center text-muted-foreground text-sm">
|
||||||
|
No conversations yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatItem({
|
||||||
|
chat,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
onTogglePin,
|
||||||
|
}: {
|
||||||
|
chat: ChatListItem
|
||||||
|
isActive: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onTogglePin: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-[rgba(255,255,255,0.04)] hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageSquare size={14} className="shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
|
||||||
|
<div className="text-[0.6875rem] text-muted-foreground">
|
||||||
|
{chat.message_count} messages
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onTogglePin() }}
|
||||||
|
className="p-1 rounded hover:bg-[rgba(255,255,255,0.08)]"
|
||||||
|
title={chat.pinned ? 'Unpin' : 'Pin'}
|
||||||
|
>
|
||||||
|
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete() }}
|
||||||
|
className="p-1 rounded hover:bg-[rgba(255,255,255,0.08)] text-muted-foreground hover:text-rose-400"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
frontend/src/components/assistant/SuggestedFlowCard.tsx
Normal file
42
frontend/src/components/assistant/SuggestedFlowCard.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Box, ArrowRight } from 'lucide-react'
|
||||||
|
import { getTreeNavigatePath } from '@/lib/routing'
|
||||||
|
import type { SuggestedFlow } from '@/types/copilot'
|
||||||
|
|
||||||
|
interface SuggestedFlowCardProps {
|
||||||
|
flow: SuggestedFlow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestedFlowCard({ flow }: SuggestedFlowCardProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const path = getTreeNavigatePath(flow.tree_id, flow.tree_type)
|
||||||
|
navigate(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="w-full text-left glass-card-static p-3 rounded-xl hover:border-[rgba(255,255,255,0.12)] transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Box size={14} className="text-primary mt-0.5 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[0.8125rem] font-medium text-foreground truncate">
|
||||||
|
{flow.tree_name}
|
||||||
|
</span>
|
||||||
|
<span className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||||
|
{flow.tree_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[0.75rem] text-muted-foreground mt-0.5 line-clamp-2">
|
||||||
|
{flow.relevance_snippet}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={14} className="text-muted-foreground group-hover:text-primary transition-colors shrink-0 mt-0.5" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
frontend/src/components/copilot/CopilotPanel.tsx
Normal file
177
frontend/src/components/copilot/CopilotPanel.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { X, Send, Sparkles, Loader2 } from 'lucide-react'
|
||||||
|
import { copilotApi } from '@/api/copilot'
|
||||||
|
import { SuggestedFlowCard } from '@/components/assistant/SuggestedFlowCard'
|
||||||
|
import type { CopilotMessage, SuggestedFlow } from '@/types/copilot'
|
||||||
|
|
||||||
|
interface CopilotPanelProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
treeId: string
|
||||||
|
sessionId?: string
|
||||||
|
currentNodeId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId }: CopilotPanelProps) {
|
||||||
|
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||||
|
const [messages, setMessages] = useState<CopilotMessage[]>([])
|
||||||
|
const [suggestedFlows, setSuggestedFlows] = useState<SuggestedFlow[]>([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [initializing, setInitializing] = useState(false)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Start conversation when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !conversationId && !initializing) {
|
||||||
|
startConversation()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
// Auto-scroll to bottom
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const startConversation = async () => {
|
||||||
|
setInitializing(true)
|
||||||
|
try {
|
||||||
|
const response = await copilotApi.startConversation({
|
||||||
|
tree_id: treeId,
|
||||||
|
session_id: sessionId,
|
||||||
|
current_node_id: currentNodeId,
|
||||||
|
})
|
||||||
|
setConversationId(response.conversation_id)
|
||||||
|
setMessages([{ role: 'assistant', content: response.greeting }])
|
||||||
|
} catch {
|
||||||
|
setMessages([{ role: 'assistant', content: 'Failed to start copilot. Please try again.' }])
|
||||||
|
} finally {
|
||||||
|
setInitializing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.trim() || !conversationId || loading) return
|
||||||
|
|
||||||
|
const userMessage = input.trim()
|
||||||
|
setInput('')
|
||||||
|
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await copilotApi.sendMessage(conversationId, {
|
||||||
|
message: userMessage,
|
||||||
|
current_node_id: currentNodeId,
|
||||||
|
})
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: response.content }])
|
||||||
|
if (response.suggested_flows.length > 0) {
|
||||||
|
setSuggestedFlows(response.suggested_flows)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed right-0 top-0 bottom-0 z-50 flex flex-col border-l"
|
||||||
|
style={{
|
||||||
|
width: '400px',
|
||||||
|
background: 'rgba(16, 17, 20, 0.95)',
|
||||||
|
backdropFilter: 'var(--glass-blur)',
|
||||||
|
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||||
|
borderColor: 'var(--glass-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
|
||||||
|
style={{ borderColor: 'var(--glass-border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles size={16} className="text-primary" />
|
||||||
|
<span className="text-sm font-semibold text-foreground">AI Copilot</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-xl px-3.5 py-2.5 text-[0.8125rem] leading-relaxed ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-primary/15 text-foreground'
|
||||||
|
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-xl px-3.5 py-2.5">
|
||||||
|
<Loader2 size={16} className="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggested flows */}
|
||||||
|
{suggestedFlows.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
Related Flows
|
||||||
|
</span>
|
||||||
|
{suggestedFlows.map(flow => (
|
||||||
|
<SuggestedFlowCard key={flow.tree_id} flow={flow} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask about this step..."
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||||
|
style={{ borderColor: 'var(--glass-border)' }}
|
||||||
|
disabled={loading || initializing}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim() || loading || initializing}
|
||||||
|
className="bg-gradient-brand text-[#101114] p-2.5 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
frontend/src/components/copilot/CopilotToggle.tsx
Normal file
20
frontend/src/components/copilot/CopilotToggle.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { MessageCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface CopilotToggleProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopilotToggle({ isOpen, onToggle }: CopilotToggleProps) {
|
||||||
|
if (isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="fixed bottom-6 right-6 z-40 bg-gradient-brand text-[#101114] p-3.5 rounded-full shadow-lg shadow-primary/30 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||||
|
title="Open AI Copilot"
|
||||||
|
>
|
||||||
|
<MessageCircle size={22} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles } from 'lucide-react'
|
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles, BotMessageSquare } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||||
@@ -82,6 +82,7 @@ export function Sidebar() {
|
|||||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
||||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||||
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" collapsed />
|
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" collapsed />
|
||||||
|
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
|
||||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
||||||
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" collapsed />
|
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" collapsed />
|
||||||
@@ -113,6 +114,7 @@ export function Sidebar() {
|
|||||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
||||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||||
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" />
|
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" />
|
||||||
|
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
|
||||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
||||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText } from 'lucide-react'
|
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock } from 'lucide-react'
|
||||||
import { accountsApi } from '@/api/accounts'
|
import { accountsApi } from '@/api/accounts'
|
||||||
import type { Account, AccountMember, AccountInvite } from '@/types'
|
import type { Account, AccountMember, AccountInvite } from '@/types'
|
||||||
|
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
|
||||||
|
import { LeaveAccountModal } from '@/components/account/LeaveAccountModal'
|
||||||
|
import { DeleteAccountModal } from '@/components/account/DeleteAccountModal'
|
||||||
import { Spinner } from '@/components/common/Spinner'
|
import { Spinner } from '@/components/common/Spinner'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
@@ -29,6 +32,11 @@ export function AccountSettingsPage() {
|
|||||||
const [editedName, setEditedName] = useState('')
|
const [editedName, setEditedName] = useState('')
|
||||||
const [isSavingName, setIsSavingName] = useState(false)
|
const [isSavingName, setIsSavingName] = useState(false)
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [showTransferModal, setShowTransferModal] = useState(false)
|
||||||
|
const [showLeaveModal, setShowLeaveModal] = useState(false)
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
|
|
||||||
// Invite form
|
// Invite form
|
||||||
const [inviteEmail, setInviteEmail] = useState('')
|
const [inviteEmail, setInviteEmail] = useState('')
|
||||||
const [inviteRole, setInviteRole] = useState('engineer')
|
const [inviteRole, setInviteRole] = useState('engineer')
|
||||||
@@ -341,16 +349,31 @@ export function AccountSettingsPage() {
|
|||||||
<p className="text-xs text-muted-foreground">{member.email}</p>
|
<p className="text-xs text-muted-foreground">{member.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
{member.account_role === 'owner' ? (
|
||||||
className={cn(
|
<span className="rounded-full px-2.5 py-0.5 text-xs font-medium bg-accent text-foreground">
|
||||||
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
owner
|
||||||
member.account_role === 'owner' && 'bg-accent text-foreground',
|
</span>
|
||||||
member.account_role === 'engineer' && 'bg-accent text-muted-foreground',
|
) : (
|
||||||
member.account_role === 'viewer' && 'bg-accent text-muted-foreground'
|
<select
|
||||||
)}
|
value={member.account_role}
|
||||||
>
|
onChange={async (e) => {
|
||||||
{member.account_role}
|
try {
|
||||||
</span>
|
const updated = await accountsApi.updateMemberRole(member.id, e.target.value)
|
||||||
|
setMembers(members.map((m) => m.id === member.id ? { ...m, account_role: updated.account_role } : m))
|
||||||
|
toast.success(`Role updated to ${updated.account_role}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update role')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-border bg-card px-2 py-0.5 text-xs',
|
||||||
|
'text-foreground focus:border-primary focus:outline-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="engineer">engineer</option>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
{!member.is_active && (
|
{!member.is_active && (
|
||||||
<span className="rounded-full bg-red-400/10 px-2 py-0.5 text-xs text-red-400">
|
<span className="rounded-full bg-red-400/10 px-2 py-0.5 text-xs text-red-400">
|
||||||
Inactive
|
Inactive
|
||||||
@@ -478,6 +501,21 @@ export function AccountSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Profile Settings Link */}
|
||||||
|
<Link
|
||||||
|
to="/account/profile"
|
||||||
|
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserCog className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Profile Settings</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Update your name, email, and personal details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground group-hover:text-foreground transition-colors">→</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Team Categories Link (owners only) */}
|
{/* Team Categories Link (owners only) */}
|
||||||
{isAccountOwner && (
|
{isAccountOwner && (
|
||||||
<Link
|
<Link
|
||||||
@@ -512,6 +550,23 @@ export function AccountSettingsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Chat Retention Link (owners only) */}
|
||||||
|
{isAccountOwner && (
|
||||||
|
<Link
|
||||||
|
to="/account/chat-retention"
|
||||||
|
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Chat Retention</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Configure AI assistant conversation retention policies</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground group-hover:text-foreground transition-colors">→</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Feedback Link (all users) */}
|
{/* Feedback Link (all users) */}
|
||||||
<Link
|
<Link
|
||||||
to="/feedback"
|
to="/feedback"
|
||||||
@@ -563,7 +618,85 @@ export function AccountSettingsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="rounded-xl border border-rose-500/20 p-4 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-rose-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isAccountOwner ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Transfer Ownership</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Make another member the account owner</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTransferModal(true)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
|
||||||
|
'border border-amber-500/30 text-amber-400 hover:bg-amber-500/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Delete Account</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Permanently delete your account and all data</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(true)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
|
||||||
|
'border border-rose-500/30 text-rose-400 hover:bg-rose-500/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Leave Account</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Leave this account and create a personal one</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLeaveModal(true)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
|
||||||
|
'border border-rose-500/30 text-rose-400 hover:bg-rose-500/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showTransferModal && (
|
||||||
|
<TransferOwnershipModal
|
||||||
|
members={members}
|
||||||
|
onClose={() => setShowTransferModal(false)}
|
||||||
|
onTransferred={() => { setShowTransferModal(false); loadData() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showLeaveModal && account && (
|
||||||
|
<LeaveAccountModal
|
||||||
|
accountName={account.name}
|
||||||
|
onClose={() => setShowLeaveModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showDeleteModal && (
|
||||||
|
<DeleteAccountModal onClose={() => setShowDeleteModal(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
226
frontend/src/pages/AssistantChatPage.tsx
Normal file
226
frontend/src/pages/AssistantChatPage.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { Sparkles, Send, Loader2 } from 'lucide-react'
|
||||||
|
import { assistantChatApi } from '@/api/assistantChat'
|
||||||
|
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
|
||||||
|
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||||
|
import type { ChatListItem, ChatMessage as ChatMessageType } from '@/types/assistant-chat'
|
||||||
|
import type { SuggestedFlow } from '@/types/copilot'
|
||||||
|
|
||||||
|
interface MessageWithMeta extends ChatMessageType {
|
||||||
|
suggestedFlows?: SuggestedFlow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssistantChatPage() {
|
||||||
|
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||||
|
const [activeChatId, setActiveChatId] = useState<string | null>(null)
|
||||||
|
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Load chat list
|
||||||
|
useEffect(() => {
|
||||||
|
loadChats()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const loadChats = async () => {
|
||||||
|
try {
|
||||||
|
const list = await assistantChatApi.listChats(1, 100)
|
||||||
|
setChats(list)
|
||||||
|
} catch {
|
||||||
|
// silently handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectChat = useCallback(async (chatId: string) => {
|
||||||
|
setActiveChatId(chatId)
|
||||||
|
try {
|
||||||
|
const chat = await assistantChatApi.getChat(chatId)
|
||||||
|
setMessages(chat.messages.map(m => ({ ...m })))
|
||||||
|
} catch {
|
||||||
|
setMessages([])
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNewChat = async () => {
|
||||||
|
try {
|
||||||
|
const chat = await assistantChatApi.createChat()
|
||||||
|
setChats(prev => [
|
||||||
|
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
|
||||||
|
...prev,
|
||||||
|
])
|
||||||
|
setActiveChatId(chat.id)
|
||||||
|
setMessages([])
|
||||||
|
} catch {
|
||||||
|
// silently handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteChat = async (chatId: string) => {
|
||||||
|
try {
|
||||||
|
await assistantChatApi.deleteChat(chatId)
|
||||||
|
setChats(prev => prev.filter(c => c.id !== chatId))
|
||||||
|
if (activeChatId === chatId) {
|
||||||
|
setActiveChatId(null)
|
||||||
|
setMessages([])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTogglePin = async (chatId: string, pinned: boolean) => {
|
||||||
|
try {
|
||||||
|
await assistantChatApi.updateChat(chatId, { pinned })
|
||||||
|
setChats(prev =>
|
||||||
|
prev.map(c => c.id === chatId ? { ...c, pinned } : c)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// silently handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.trim() || !activeChatId || loading) return
|
||||||
|
|
||||||
|
const userMessage = input.trim()
|
||||||
|
setInput('')
|
||||||
|
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await assistantChatApi.sendMessage(activeChatId, userMessage)
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
||||||
|
])
|
||||||
|
// Update chat list title if it was the first message
|
||||||
|
setChats(prev =>
|
||||||
|
prev.map(c =>
|
||||||
|
c.id === activeChatId
|
||||||
|
? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again.' },
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<ChatSidebar
|
||||||
|
chats={chats}
|
||||||
|
activeChatId={activeChatId}
|
||||||
|
onSelectChat={selectChat}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
onDeleteChat={handleDeleteChat}
|
||||||
|
onTogglePin={handleTogglePin}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main chat area */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{activeChatId ? (
|
||||||
|
<>
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||||
|
{messages.length === 0 && !loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<Sparkles size={28} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">
|
||||||
|
AI Assistant
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md">
|
||||||
|
Ask me anything about IT infrastructure, networking, Active Directory,
|
||||||
|
cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<ChatMessage
|
||||||
|
key={i}
|
||||||
|
role={msg.role}
|
||||||
|
content={msg.content}
|
||||||
|
suggestedFlows={msg.suggestedFlows}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
||||||
|
<Sparkles size={14} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-2xl px-4 py-3">
|
||||||
|
<Loader2 size={16} className="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="px-6 py-4 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||||
|
<div className="flex items-end gap-3 max-w-3xl mx-auto">
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask about IT, networking, troubleshooting..."
|
||||||
|
rows={1}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||||
|
<Sparkles size={32} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
|
||||||
|
AI Assistant
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||||
|
Your Senior Systems & Network Engineer. Ask anything about IT infrastructure,
|
||||||
|
or start a new chat to get personalized help with your team's flows.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleNewChat}
|
||||||
|
className="bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-6 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||||
|
>
|
||||||
|
Start a Conversation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import { MaintenanceContextStrip } from '@/components/maintenance/MaintenanceCon
|
|||||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||||
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
||||||
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
|
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
|
||||||
|
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
|
||||||
|
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
|
||||||
|
|
||||||
interface StepState {
|
interface StepState {
|
||||||
notes: string
|
notes: string
|
||||||
@@ -84,6 +86,7 @@ export function ProceduralNavigationPage() {
|
|||||||
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
|
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
|
||||||
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
|
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
|
||||||
const [isSavingStep, setIsSavingStep] = useState(false)
|
const [isSavingStep, setIsSavingStep] = useState(false)
|
||||||
|
const [copilotOpen, setCopilotOpen] = useState(false)
|
||||||
|
|
||||||
// Get procedural steps from tree
|
// Get procedural steps from tree
|
||||||
const getSteps = (): ProceduralStep[] => {
|
const getSteps = (): ProceduralStep[] => {
|
||||||
@@ -704,6 +707,20 @@ export function ProceduralNavigationPage() {
|
|||||||
isSaving={isSavingStep}
|
isSaving={isSavingStep}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Copilot */}
|
||||||
|
{treeId && (
|
||||||
|
<>
|
||||||
|
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||||
|
<CopilotPanel
|
||||||
|
isOpen={copilotOpen}
|
||||||
|
onClose={() => setCopilotOpen(false)}
|
||||||
|
treeId={treeId}
|
||||||
|
sessionId={session?.id}
|
||||||
|
currentNodeId={runtimeSteps[currentStepIndex]?.id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { CSATModal } from '@/components/session/CSATModal'
|
|||||||
import { hasBeenRated } from '@/components/session/csatUtils'
|
import { hasBeenRated } from '@/components/session/csatUtils'
|
||||||
import { StepFeedback } from '@/components/session/StepFeedback'
|
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||||
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
||||||
|
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
|
||||||
|
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
@@ -60,6 +62,7 @@ export function TreeNavigationPage() {
|
|||||||
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
||||||
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
||||||
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [copilotOpen, setCopilotOpen] = useState(false)
|
||||||
|
|
||||||
const handleCopyCommand = (text: string) => {
|
const handleCopyCommand = (text: string) => {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
@@ -1270,6 +1273,20 @@ export function TreeNavigationPage() {
|
|||||||
onOpenChange={setScratchpadOpen}
|
onOpenChange={setScratchpadOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Copilot */}
|
||||||
|
{treeId && (
|
||||||
|
<>
|
||||||
|
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||||
|
<CopilotPanel
|
||||||
|
isOpen={copilotOpen}
|
||||||
|
onClose={() => setCopilotOpen(false)}
|
||||||
|
treeId={treeId}
|
||||||
|
sessionId={session?.id}
|
||||||
|
currentNodeId={currentNodeId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
119
frontend/src/pages/account/ChatRetentionSettingsPage.tsx
Normal file
119
frontend/src/pages/account/ChatRetentionSettingsPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Save, Loader2, Clock } from 'lucide-react'
|
||||||
|
import { assistantChatApi } from '@/api/assistantChat'
|
||||||
|
|
||||||
|
export default function ChatRetentionSettingsPage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [retentionDays, setRetentionDays] = useState('')
|
||||||
|
const [maxCount, setMaxCount] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const data = await assistantChatApi.getRetentionSettings()
|
||||||
|
setRetentionDays(data.chat_retention_days?.toString() ?? '90')
|
||||||
|
setMaxCount(data.chat_retention_max_count?.toString() ?? '100')
|
||||||
|
} catch {
|
||||||
|
// silently handle
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setSuccess(false)
|
||||||
|
try {
|
||||||
|
await assistantChatApi.updateRetentionSettings({
|
||||||
|
chat_retention_days: parseInt(retentionDays) || null,
|
||||||
|
chat_retention_max_count: parseInt(maxCount) || null,
|
||||||
|
})
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => setSuccess(false), 3000)
|
||||||
|
} catch {
|
||||||
|
// silently handle
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="animate-spin text-primary" size={24} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8 px-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Clock size={20} className="text-primary" />
|
||||||
|
<h1 className="text-xl font-heading font-bold text-foreground">Chat Retention</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card-static rounded-2xl p-6 space-y-6">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure how long AI assistant conversations are retained. Pinned chats are never automatically deleted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground block mb-1.5">
|
||||||
|
Retention Period (days)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={retentionDays}
|
||||||
|
onChange={e => setRetentionDays(e.target.value)}
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
className="w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||||
|
style={{ borderColor: 'var(--glass-border)' }}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Chats older than this will be automatically deleted (1-365 days)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground block mb-1.5">
|
||||||
|
Max Conversations
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maxCount}
|
||||||
|
onChange={e => setMaxCount(e.target.value)}
|
||||||
|
min={10}
|
||||||
|
max={10000}
|
||||||
|
className="w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||||
|
style={{ borderColor: 'var(--glass-border)' }}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
When this limit is exceeded, oldest unpinned chats are deleted
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="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-40 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
{success && (
|
||||||
|
<span className="text-sm text-emerald-400">Settings saved</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
|
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
|
||||||
|
|
||||||
// Standalone auth pages
|
// Standalone auth pages
|
||||||
|
const VerifyEmailPage = lazy(() => import('@/pages/VerifyEmailPage'))
|
||||||
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
|
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
|
||||||
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
|
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
|
||||||
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'))
|
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'))
|
||||||
@@ -34,6 +35,7 @@ const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
|||||||
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
||||||
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
|
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
|
||||||
const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage'))
|
const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage'))
|
||||||
|
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
||||||
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
||||||
// Admin pages
|
// Admin pages
|
||||||
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
||||||
@@ -49,8 +51,10 @@ const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategor
|
|||||||
|
|
||||||
// Account pages
|
// Account pages
|
||||||
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
|
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
|
||||||
|
const ProfileSettingsPage = lazy(() => import('@/pages/account/ProfileSettingsPage'))
|
||||||
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
|
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
|
||||||
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
|
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
|
||||||
|
const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage'))
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -81,6 +85,15 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
errorElement: <RouteError />,
|
errorElement: <RouteError />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/verify-email',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<VerifyEmailPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
errorElement: <RouteError />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/share/:shareToken',
|
path: '/share/:shareToken',
|
||||||
element: (
|
element: (
|
||||||
@@ -262,6 +275,14 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'assistant',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<AssistantChatPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
// Admin routes
|
// Admin routes
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
@@ -364,6 +385,14 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<ProfileSettingsPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'categories',
|
path: 'categories',
|
||||||
element: (
|
element: (
|
||||||
@@ -374,6 +403,16 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'chat-retention',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<ProtectedRoute requiredRole="owner">
|
||||||
|
<ChatRetentionSettingsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'target-lists',
|
path: 'target-lists',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
37
frontend/src/types/assistant-chat.ts
Normal file
37
frontend/src/types/assistant-chat.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { SuggestedFlow } from './copilot'
|
||||||
|
|
||||||
|
export interface AssistantChat {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
messages: ChatMessage[]
|
||||||
|
message_count: number
|
||||||
|
pinned: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatListItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
message_count: number
|
||||||
|
pinned: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessageResponse {
|
||||||
|
content: string
|
||||||
|
suggested_flows: SuggestedFlow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetentionSettings {
|
||||||
|
chat_retention_days: number | null
|
||||||
|
chat_retention_max_count: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SuggestedFlow }
|
||||||
41
frontend/src/types/copilot.ts
Normal file
41
frontend/src/types/copilot.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface SuggestedFlow {
|
||||||
|
tree_id: string
|
||||||
|
tree_name: string
|
||||||
|
tree_type: string
|
||||||
|
relevance_snippet: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopilotStartRequest {
|
||||||
|
tree_id: string
|
||||||
|
session_id?: string
|
||||||
|
current_node_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopilotStartResponse {
|
||||||
|
conversation_id: string
|
||||||
|
greeting: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopilotMessageRequest {
|
||||||
|
message: string
|
||||||
|
current_node_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopilotMessageResponse {
|
||||||
|
content: string
|
||||||
|
suggested_flows: SuggestedFlow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopilotConversation {
|
||||||
|
id: string
|
||||||
|
tree_id: string
|
||||||
|
messages: CopilotMessage[]
|
||||||
|
current_node_id?: string
|
||||||
|
message_count: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopilotMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export * from './step'
|
|||||||
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
|
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
|
||||||
export * from './admin'
|
export * from './admin'
|
||||||
export * from './analytics'
|
export * from './analytics'
|
||||||
|
export * from './copilot'
|
||||||
|
export type { AssistantChat, ChatMessage, ChatListItem, ChatMessageResponse, RetentionSettings } from './assistant-chat'
|
||||||
|
|
||||||
// API response wrapper types
|
// API response wrapper types
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|||||||
Reference in New Issue
Block a user