Add conversational brief interface (AC1 chat-style)
Replaces the one-shot form with a Copygen-style chat at /conversations/:id. Each turn is classified as generate / refine / clarify by a lightweight LLM intent router; generate and refine intents enqueue an RQ job that produces a new BannerSet, while clarify returns an inline reply without touching banners. New backend: - Conversation + ConversationMessage models + migration 0005 - intent_router service (chat_structured, 3-intent schema) - chat_turn RQ task with _persist_banner_set helper extracted from _generate_copy_async for reuse - /api/conversations CRUD + POST /messages endpoint - JobType.CHAT_TURN added New frontend: - ChatBrief page: message bubbles, inline BannerPreview cards with checkbox selection and "Open banner editor" CTA (same Medium+Large validation rule as VariantsGrid) - ConversationLanding: /conversations/new creates and redirects - conversationId added to journey store - "New Brief" nav now points to /conversations/new - Default route redirects to /conversations/new Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d9456d80be
commit
7e82a535a9
15 changed files with 1020 additions and 61 deletions
51
backend/alembic/versions/0005_add_conversations.py
Normal file
51
backend/alembic/versions/0005_add_conversations.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Add conversations and conversation_messages tables
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"conversations",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("brief_id", UUID(as_uuid=True), sa.ForeignKey("briefs.id"), nullable=False),
|
||||
sa.Column("title", sa.String(120), nullable=False, server_default=""),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_conversations_user_id", "conversations", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"conversation_messages",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("conversation_id", UUID(as_uuid=True), sa.ForeignKey("conversations.id"), nullable=False),
|
||||
sa.Column("role", sa.String(20), nullable=False),
|
||||
sa.Column("kind", sa.String(20), nullable=False, server_default="text"),
|
||||
sa.Column("content", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("banner_set_id", UUID(as_uuid=True), sa.ForeignKey("banner_sets.id"), nullable=True),
|
||||
sa.Column("job_id", UUID(as_uuid=True), sa.ForeignKey("jobs.id"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_conversation_messages_conv_created",
|
||||
"conversation_messages",
|
||||
["conversation_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_conversation_messages_conv_created", table_name="conversation_messages")
|
||||
op.drop_table("conversation_messages")
|
||||
op.drop_index("ix_conversations_user_id", table_name="conversations")
|
||||
op.drop_table("conversations")
|
||||
223
backend/app/api/conversations.py
Normal file
223
backend/app/api/conversations.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.brief import Brief
|
||||
from app.models.conversation import Conversation, ConversationMessage
|
||||
from app.models.job import Job, JobStatus, JobType
|
||||
from app.models.user import User
|
||||
from app.services.intent_router import classify_turn
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _serialize_message(m: ConversationMessage) -> dict:
|
||||
return {
|
||||
"id": str(m.id),
|
||||
"role": m.role,
|
||||
"kind": m.kind,
|
||||
"content": m.content,
|
||||
"banner_set_id": str(m.banner_set_id) if m.banner_set_id else None,
|
||||
"job_id": str(m.job_id) if m.job_id else None,
|
||||
"created_at": m.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_conversation(c: Conversation) -> dict:
|
||||
return {
|
||||
"id": str(c.id),
|
||||
"brief_id": str(c.brief_id),
|
||||
"title": c.title,
|
||||
"created_at": c.created_at,
|
||||
"updated_at": c.updated_at,
|
||||
"messages": [_serialize_message(m) for m in c.messages],
|
||||
}
|
||||
|
||||
|
||||
class CreateConversationPayload(BaseModel):
|
||||
first_message: str = ""
|
||||
|
||||
|
||||
class SendMessagePayload(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_conversation(
|
||||
payload: CreateConversationPayload,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.workers.tasks import queue
|
||||
|
||||
brief = Brief(user_id=current_user.id, text=payload.first_message or "")
|
||||
db.add(brief)
|
||||
await db.flush()
|
||||
|
||||
title = (payload.first_message or "New conversation")[:60]
|
||||
conversation = Conversation(
|
||||
user_id=current_user.id,
|
||||
brief_id=brief.id,
|
||||
title=title,
|
||||
)
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
|
||||
first_message_id = None
|
||||
job_id = None
|
||||
|
||||
if payload.first_message.strip():
|
||||
user_msg = ConversationMessage(
|
||||
conversation_id=conversation.id,
|
||||
role="user",
|
||||
kind="text",
|
||||
content=payload.first_message,
|
||||
)
|
||||
db.add(user_msg)
|
||||
await db.flush()
|
||||
first_message_id = str(user_msg.id)
|
||||
|
||||
job = Job(
|
||||
type=JobType.CHAT_TURN,
|
||||
status=JobStatus.PENDING,
|
||||
payload={
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(user_msg.id),
|
||||
"message_text": payload.first_message,
|
||||
},
|
||||
)
|
||||
db.add(job)
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
queue.enqueue("app.workers.tasks.chat_turn", str(job.id), str(conversation.id), job_timeout=180)
|
||||
job_id = str(job.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"conversation_id": str(conversation.id),
|
||||
"brief_id": str(brief.id),
|
||||
"message_id": first_message_id,
|
||||
"job_id": job_id,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_conversations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Conversation)
|
||||
.where(Conversation.user_id == current_user.id)
|
||||
.order_by(desc(Conversation.updated_at))
|
||||
.limit(50)
|
||||
)
|
||||
convs = result.scalars().all()
|
||||
return {
|
||||
"conversations": [
|
||||
{"id": str(c.id), "title": c.title, "created_at": c.created_at, "updated_at": c.updated_at}
|
||||
for c in convs
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{conversation_id}")
|
||||
async def get_conversation(
|
||||
conversation_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == conversation_id, Conversation.user_id == current_user.id)
|
||||
.options(selectinload(Conversation.messages))
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
return _serialize_conversation(conv)
|
||||
|
||||
|
||||
@router.post("/{conversation_id}/messages", status_code=202)
|
||||
async def send_message(
|
||||
conversation_id: UUID,
|
||||
payload: SendMessagePayload,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
from app.workers.tasks import queue
|
||||
|
||||
result = await db.execute(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == conversation_id, Conversation.user_id == current_user.id)
|
||||
.options(selectinload(Conversation.messages))
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
brief_result = await db.execute(select(Brief).where(Brief.id == conv.brief_id))
|
||||
brief = brief_result.scalar_one()
|
||||
|
||||
history = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in conv.messages
|
||||
if m.role in ("user", "assistant")
|
||||
]
|
||||
|
||||
intent = await classify_turn(brief.text, history, payload.text)
|
||||
|
||||
user_msg = ConversationMessage(
|
||||
conversation_id=conv.id,
|
||||
role="user",
|
||||
kind="text",
|
||||
content=payload.text,
|
||||
)
|
||||
db.add(user_msg)
|
||||
await db.flush()
|
||||
|
||||
if intent.intent == "clarify":
|
||||
assistant_msg = ConversationMessage(
|
||||
conversation_id=conv.id,
|
||||
role="assistant",
|
||||
kind="text",
|
||||
content=intent.reply_text or "How can I help you with your banners?",
|
||||
)
|
||||
db.add(assistant_msg)
|
||||
conv.updated_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {
|
||||
"message_id": str(user_msg.id),
|
||||
"job_id": None,
|
||||
"reply": intent.reply_text,
|
||||
}
|
||||
|
||||
job = Job(
|
||||
type=JobType.CHAT_TURN,
|
||||
status=JobStatus.PENDING,
|
||||
payload={
|
||||
"conversation_id": str(conv.id),
|
||||
"message_id": str(user_msg.id),
|
||||
"message_text": payload.text,
|
||||
},
|
||||
)
|
||||
db.add(job)
|
||||
conv.updated_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
|
||||
queue.enqueue("app.workers.tasks.chat_turn", str(job.id), str(conv.id), job_timeout=180)
|
||||
|
||||
return {
|
||||
"message_id": str(user_msg.id),
|
||||
"job_id": str(job.id),
|
||||
"reply": None,
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from app.api import health, auth, briefs, jobs, icons, dam, banner_variants, banner_sets, admin
|
||||
from app.api import health, auth, briefs, jobs, icons, dam, banner_variants, banner_sets, admin, conversations
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(briefs.router, prefix="/briefs", tags=["briefs"])
|
||||
api_router.include_router(conversations.router, prefix="/conversations", tags=["conversations"])
|
||||
api_router.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
|
||||
api_router.include_router(icons.router, prefix="/icons", tags=["icons"])
|
||||
api_router.include_router(dam.router, prefix="/dam", tags=["dam"])
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ from app.models.rag import RagChunk
|
|||
from app.models.job import Job
|
||||
from app.models.dam_cache import DamCache
|
||||
from app.models.system_prompt import SystemPrompt
|
||||
from app.models.conversation import Conversation, ConversationMessage
|
||||
|
||||
__all__ = [
|
||||
"User", "Brief", "BannerSet", "BannerVariant",
|
||||
"Icon", "RagChunk", "Job", "DamCache", "SystemPrompt",
|
||||
"Conversation", "ConversationMessage",
|
||||
]
|
||||
|
|
|
|||
54
backend/app/models/conversation.py
Normal file
54
backend/app/models/conversation.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Conversation(Base):
|
||||
__tablename__ = "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"), nullable=False)
|
||||
brief_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("briefs.id"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||
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)
|
||||
)
|
||||
|
||||
messages: Mapped[list["ConversationMessage"]] = relationship(
|
||||
"ConversationMessage", back_populates="conversation", order_by="ConversationMessage.created_at"
|
||||
)
|
||||
|
||||
|
||||
class ConversationMessage(Base):
|
||||
__tablename__ = "conversation_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False) # user | assistant
|
||||
kind: Mapped[str] = mapped_column(String(20), nullable=False, default="text") # text | generation | refinement
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
banner_set_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("banner_sets.id"), nullable=True
|
||||
)
|
||||
job_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("jobs.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
conversation: Mapped["Conversation"] = relationship("Conversation", back_populates="messages")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_conversation_messages_conv_created", "conversation_id", "created_at"),
|
||||
)
|
||||
|
|
@ -18,6 +18,7 @@ class JobStatus(str, enum.Enum):
|
|||
|
||||
class JobType(str, enum.Enum):
|
||||
GENERATE_COPY = "generate_copy"
|
||||
CHAT_TURN = "chat_turn"
|
||||
RENDER_PDF = "render_pdf"
|
||||
INDEX_ICONS = "index_icons"
|
||||
INGEST_RAG = "ingest_rag"
|
||||
|
|
|
|||
97
backend/app/services/intent_router.py
Normal file
97
backend/app/services/intent_router.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Classify a chat turn into one of three intents.
|
||||
|
||||
Intent: generate — produce a fresh BannerSet via generate_copy
|
||||
Intent: refine — tweak existing variants via refine_variant_copy
|
||||
Intent: clarify — answer inline; no banner work
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.llm.openai_client import chat_structured
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INTENT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"intent": {
|
||||
"type": "string",
|
||||
"enum": ["generate", "refine", "clarify"],
|
||||
},
|
||||
"n_variants": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 8,
|
||||
},
|
||||
"refined_brief": {
|
||||
"type": "string",
|
||||
},
|
||||
"feedback": {
|
||||
"type": "string",
|
||||
},
|
||||
"reply_text": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": ["intent", "n_variants", "refined_brief", "feedback", "reply_text"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_CLASSIFIER_SYSTEM = """You are a routing assistant for a Barclays banner-copy generation tool.
|
||||
|
||||
Given the conversation history and the user's latest message, output JSON with:
|
||||
- intent: "generate" if the user wants new banner copy (first message, or "regenerate", "give me N variants", "start over");
|
||||
"refine" if the user wants to tweak existing copy without full regeneration ("make titles more urgent", "change rate to 6%", "shorter", "warmer tone");
|
||||
"clarify" if the user is asking a question, giving context, or the message is off-topic / administrative.
|
||||
- n_variants: number of variants the user wants (default 4; parse from phrases like "give me 6 variants"); only relevant for "generate".
|
||||
- refined_brief: for "generate", a single paragraph that incorporates ALL prior context + the new request, suitable as a standalone brief;
|
||||
for "refine" or "clarify", leave as empty string.
|
||||
- feedback: for "refine", a concise instruction to pass to the copy-refinement model (e.g. "Make titles more urgent and reduce body length");
|
||||
otherwise empty string.
|
||||
- reply_text: for "clarify", a brief helpful reply (1–3 sentences, UK English, Barclays professional tone);
|
||||
for other intents, leave as empty string.
|
||||
|
||||
Always output valid JSON matching the schema. Never generate HTML, CSS, or code."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Intent:
|
||||
intent: str # "generate" | "refine" | "clarify"
|
||||
n_variants: int = 4
|
||||
refined_brief: str = ""
|
||||
feedback: str = ""
|
||||
reply_text: str = ""
|
||||
|
||||
|
||||
async def classify_turn(
|
||||
brief_text: str,
|
||||
history: list[dict],
|
||||
latest_message: str,
|
||||
) -> Intent:
|
||||
"""Classify the latest user message given conversation history.
|
||||
|
||||
Args:
|
||||
brief_text: The original brief text (for context).
|
||||
history: List of {role, content} dicts for prior turns.
|
||||
latest_message: The raw text the user just sent.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": _CLASSIFIER_SYSTEM},
|
||||
*history,
|
||||
{"role": "user", "content": latest_message},
|
||||
]
|
||||
|
||||
try:
|
||||
raw = await chat_structured(messages, _INTENT_SCHEMA, temperature=0.1)
|
||||
return Intent(
|
||||
intent=raw.get("intent", "generate"),
|
||||
n_variants=max(1, min(8, int(raw.get("n_variants") or 4))),
|
||||
refined_brief=raw.get("refined_brief") or "",
|
||||
feedback=raw.get("feedback") or "",
|
||||
reply_text=raw.get("reply_text") or "",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Intent classification failed; defaulting to generate")
|
||||
return Intent(intent="generate", refined_brief=latest_message)
|
||||
|
|
@ -25,23 +25,69 @@ def _run(coro):
|
|||
return asyncio.run(coro)
|
||||
|
||||
|
||||
async def _persist_banner_set(brief_id, copy_variants, db):
|
||||
"""Create a BannerSet with paired Medium+Large variants. Returns the new BannerSet."""
|
||||
import uuid as _uuid
|
||||
from app.models.banner import BannerSet, BannerVariant
|
||||
from app.services.icon_matcher import match_icon
|
||||
from app.services.adobe_dam_client import get_dam_client
|
||||
from app.config import get_settings as _get_settings
|
||||
|
||||
dam = get_dam_client(_get_settings())
|
||||
banner_set = BannerSet(brief_id=brief_id)
|
||||
db.add(banner_set)
|
||||
await db.flush()
|
||||
|
||||
for cv in copy_variants:
|
||||
pair_id = _uuid.uuid4()
|
||||
icon = await match_icon(cv.icon_keyword, db)
|
||||
icon_id = icon.id if icon else None
|
||||
dam_asset = await dam.recommend_for_brief("")
|
||||
dam_ref = dam_asset["id"] if dam_asset else None
|
||||
dam_url = dam_asset["url"] if dam_asset else None
|
||||
|
||||
db.add(BannerVariant(
|
||||
banner_set_id=banner_set.id,
|
||||
aspect_ratio="Medium",
|
||||
pair_id=pair_id,
|
||||
theme=cv.theme,
|
||||
short_title=cv.medium.short_title,
|
||||
long_body=cv.medium.long_body,
|
||||
cta=cv.medium.cta,
|
||||
cta_secondary=None,
|
||||
icon_id=icon_id,
|
||||
dam_asset_ref=dam_ref,
|
||||
dam_asset_url=dam_url,
|
||||
))
|
||||
db.add(BannerVariant(
|
||||
banner_set_id=banner_set.id,
|
||||
aspect_ratio="Large",
|
||||
pair_id=pair_id,
|
||||
theme=cv.theme,
|
||||
short_title=cv.large.short_title,
|
||||
long_body=cv.large.long_body,
|
||||
cta=cv.large.cta,
|
||||
cta_secondary=cv.large.cta_secondary or None,
|
||||
icon_id=icon_id,
|
||||
dam_asset_ref=dam_ref,
|
||||
dam_asset_url=dam_url,
|
||||
))
|
||||
|
||||
return banner_set
|
||||
|
||||
|
||||
def generate_copy(job_id: str, brief_id: str) -> None:
|
||||
"""RQ task: generate copy variants for a brief and persist them."""
|
||||
_run(_generate_copy_async(job_id, brief_id))
|
||||
|
||||
|
||||
async def _generate_copy_async(job_id: str, brief_id: str) -> None:
|
||||
import uuid as _uuid
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.job import Job, JobStatus
|
||||
from app.models.brief import Brief
|
||||
from app.models.banner import BannerSet, BannerVariant
|
||||
from app.services.copy_generation import generate_copy as _gen
|
||||
from app.services.icon_matcher import match_icon
|
||||
from app.services.adobe_dam_client import get_dam_client
|
||||
from app.config import get_settings as _get_settings
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
job_result = await db.execute(select(Job).where(Job.id == UUID(job_id)))
|
||||
|
|
@ -58,59 +104,14 @@ async def _generate_copy_async(job_id: str, brief_id: str) -> None:
|
|||
aspect_ratios = payload.get("aspect_ratios", ["Medium", "Large"])
|
||||
|
||||
copy_variants = await _gen(brief.text, aspect_ratios, n_variants, db)
|
||||
|
||||
dam = get_dam_client(_get_settings())
|
||||
|
||||
banner_set = BannerSet(brief_id=brief.id)
|
||||
db.add(banner_set)
|
||||
await db.flush()
|
||||
|
||||
total_rows = 0
|
||||
for cv in copy_variants:
|
||||
pair_id = _uuid.uuid4()
|
||||
|
||||
# Pre-select icon using embedding similarity on the AI-suggested keyword
|
||||
icon = await match_icon(cv.icon_keyword, db)
|
||||
icon_id = icon.id if icon else None
|
||||
|
||||
# Pre-select a DAM image (shared between Medium and Large)
|
||||
dam_asset = await dam.recommend_for_brief(brief.text)
|
||||
dam_ref = dam_asset["id"] if dam_asset else None
|
||||
dam_url = dam_asset["url"] if dam_asset else None
|
||||
|
||||
db.add(BannerVariant(
|
||||
banner_set_id=banner_set.id,
|
||||
aspect_ratio="Medium",
|
||||
pair_id=pair_id,
|
||||
theme=cv.theme,
|
||||
short_title=cv.medium.short_title,
|
||||
long_body=cv.medium.long_body,
|
||||
cta=cv.medium.cta,
|
||||
cta_secondary=None,
|
||||
icon_id=icon_id,
|
||||
dam_asset_ref=dam_ref,
|
||||
dam_asset_url=dam_url,
|
||||
))
|
||||
db.add(BannerVariant(
|
||||
banner_set_id=banner_set.id,
|
||||
aspect_ratio="Large",
|
||||
pair_id=pair_id,
|
||||
theme=cv.theme,
|
||||
short_title=cv.large.short_title,
|
||||
long_body=cv.large.long_body,
|
||||
cta=cv.large.cta,
|
||||
cta_secondary=cv.large.cta_secondary or None,
|
||||
icon_id=icon_id,
|
||||
dam_asset_ref=dam_ref,
|
||||
dam_asset_url=dam_url,
|
||||
))
|
||||
total_rows += 2
|
||||
banner_set = await _persist_banner_set(brief.id, copy_variants, db)
|
||||
total_rows = len(banner_set.variants) if hasattr(banner_set, 'variants') else n_variants * 2
|
||||
|
||||
job.status = JobStatus.DONE
|
||||
job.result = {"banner_set_id": str(banner_set.id), "variant_count": total_rows}
|
||||
job.result = {"banner_set_id": str(banner_set.id), "variant_count": n_variants * 2}
|
||||
job.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
logger.info("generate_copy done: job=%s banner_set=%s variants=%d", job_id, banner_set.id, total_rows)
|
||||
logger.info("generate_copy done: job=%s banner_set=%s", job_id, banner_set.id)
|
||||
|
||||
except Exception as exc:
|
||||
job.status = JobStatus.FAILED
|
||||
|
|
@ -121,6 +122,159 @@ async def _generate_copy_async(job_id: str, brief_id: str) -> None:
|
|||
raise
|
||||
|
||||
|
||||
def chat_turn(job_id: str, conversation_id: str) -> None:
|
||||
"""RQ task: process one chat turn (generate or refine intent)."""
|
||||
_run(_chat_turn_async(job_id, conversation_id))
|
||||
|
||||
|
||||
async def _chat_turn_async(job_id: str, conversation_id: str) -> None:
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timezone as _tz
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.job import Job, JobStatus
|
||||
from app.models.conversation import Conversation, ConversationMessage
|
||||
from app.models.brief import Brief
|
||||
from app.models.banner import BannerSet, BannerVariant
|
||||
from app.services.copy_generation import generate_copy as _gen, refine_variant_copy
|
||||
from app.services.intent_router import classify_turn
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
job_result = await db.execute(select(Job).where(Job.id == UUID(job_id)))
|
||||
job = job_result.scalar_one()
|
||||
job.status = JobStatus.RUNNING
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
conv_result = await db.execute(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == UUID(conversation_id))
|
||||
.options(selectinload(Conversation.messages))
|
||||
)
|
||||
conversation = conv_result.scalar_one()
|
||||
|
||||
brief_result = await db.execute(select(Brief).where(Brief.id == conversation.brief_id))
|
||||
brief = brief_result.scalar_one()
|
||||
|
||||
payload = job.payload or {}
|
||||
latest_text = payload.get("message_text", "")
|
||||
message_id = UUID(payload["message_id"])
|
||||
|
||||
history = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in conversation.messages
|
||||
if str(m.id) != payload["message_id"] and m.role in ("user", "assistant")
|
||||
]
|
||||
|
||||
intent = await classify_turn(brief.text, history, latest_text)
|
||||
|
||||
if intent.intent == "generate":
|
||||
effective_brief = intent.refined_brief or latest_text or brief.text
|
||||
copy_variants = await _gen(effective_brief, ["Medium", "Large"], intent.n_variants, db)
|
||||
banner_set = await _persist_banner_set(brief.id, copy_variants, db)
|
||||
await db.flush()
|
||||
|
||||
assistant_msg = ConversationMessage(
|
||||
conversation_id=conversation.id,
|
||||
role="assistant",
|
||||
kind="generation",
|
||||
content=f"Generated {intent.n_variants} variants.",
|
||||
banner_set_id=banner_set.id,
|
||||
job_id=UUID(job_id),
|
||||
)
|
||||
db.add(assistant_msg)
|
||||
|
||||
elif intent.intent == "refine":
|
||||
latest_set_result = await db.execute(
|
||||
select(BannerSet)
|
||||
.where(BannerSet.brief_id == brief.id)
|
||||
.order_by(BannerSet.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_set = latest_set_result.scalar_one_or_none()
|
||||
|
||||
if latest_set:
|
||||
variants_result = await db.execute(
|
||||
select(BannerVariant).where(BannerVariant.banner_set_id == latest_set.id)
|
||||
)
|
||||
existing_variants = variants_result.scalars().all()
|
||||
|
||||
new_banner_set = BannerSet(brief_id=brief.id)
|
||||
db.add(new_banner_set)
|
||||
await db.flush()
|
||||
|
||||
feedback = intent.feedback or latest_text
|
||||
for v in existing_variants:
|
||||
from app.services.copy_generation import BannerCopy
|
||||
current_copy = BannerCopy(
|
||||
short_title=v.short_title,
|
||||
long_body=v.long_body,
|
||||
cta=v.cta,
|
||||
cta_secondary=v.cta_secondary or "",
|
||||
)
|
||||
refined = await refine_variant_copy(v.aspect_ratio, current_copy, feedback, db)
|
||||
db.add(BannerVariant(
|
||||
banner_set_id=new_banner_set.id,
|
||||
aspect_ratio=v.aspect_ratio,
|
||||
pair_id=v.pair_id,
|
||||
theme=v.theme,
|
||||
short_title=refined.short_title,
|
||||
long_body=refined.long_body,
|
||||
cta=refined.cta,
|
||||
cta_secondary=refined.cta_secondary or None,
|
||||
icon_id=v.icon_id,
|
||||
dam_asset_ref=v.dam_asset_ref,
|
||||
dam_asset_url=v.dam_asset_url,
|
||||
))
|
||||
|
||||
assistant_msg = ConversationMessage(
|
||||
conversation_id=conversation.id,
|
||||
role="assistant",
|
||||
kind="refinement",
|
||||
content="Here's the refined copy based on your feedback.",
|
||||
banner_set_id=new_banner_set.id,
|
||||
job_id=UUID(job_id),
|
||||
)
|
||||
db.add(assistant_msg)
|
||||
else:
|
||||
copy_variants = await _gen(brief.text, ["Medium", "Large"], 4, db)
|
||||
banner_set = await _persist_banner_set(brief.id, copy_variants, db)
|
||||
await db.flush()
|
||||
assistant_msg = ConversationMessage(
|
||||
conversation_id=conversation.id,
|
||||
role="assistant",
|
||||
kind="generation",
|
||||
content="No previous variants found — here's a fresh set.",
|
||||
banner_set_id=banner_set.id,
|
||||
job_id=UUID(job_id),
|
||||
)
|
||||
db.add(assistant_msg)
|
||||
|
||||
user_msg_result = await db.execute(
|
||||
select(ConversationMessage).where(ConversationMessage.id == message_id)
|
||||
)
|
||||
user_msg = user_msg_result.scalar_one_or_none()
|
||||
if user_msg:
|
||||
user_msg.job_id = UUID(job_id)
|
||||
|
||||
conversation.updated_at = datetime.now(_tz.utc)
|
||||
|
||||
job.status = JobStatus.DONE
|
||||
job.result = {"message_id": str(assistant_msg.id), "conversation_id": conversation_id}
|
||||
job.finished_at = datetime.now(_tz.utc)
|
||||
await db.commit()
|
||||
logger.info("chat_turn done: job=%s conv=%s", job_id, conversation_id)
|
||||
|
||||
except Exception as exc:
|
||||
job.status = JobStatus.FAILED
|
||||
job.error = str(exc)
|
||||
job.finished_at = datetime.now(_tz.utc)
|
||||
await db.commit()
|
||||
logger.exception("chat_turn failed: job=%s", job_id)
|
||||
raise
|
||||
|
||||
|
||||
def render_pdf(job_id: str, banner_set_id: str) -> None:
|
||||
"""RQ task: render PDF contact sheet for a banner set."""
|
||||
_run(_render_pdf_async(job_id, banner_set_id))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout";
|
||||
import BriefEditor from "@/pages/BriefEditor";
|
||||
import ChatBrief from "@/pages/ChatBrief";
|
||||
import ConversationLanding from "@/pages/ConversationLanding";
|
||||
import VariantsGrid from "@/pages/VariantsGrid";
|
||||
import BannerEditor from "@/pages/BannerEditor";
|
||||
import ExportPage from "@/pages/ExportPage";
|
||||
|
|
@ -26,7 +28,9 @@ export default function App() {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/brief" replace />} />
|
||||
<Route index element={<Navigate to="/conversations/new" replace />} />
|
||||
<Route path="/conversations/new" element={<ConversationLanding />} />
|
||||
<Route path="/conversations/:conversationId" element={<ChatBrief />} />
|
||||
<Route path="/brief" element={<BriefEditor />} />
|
||||
<Route path="/brief/:briefId/variants" element={<VariantsGrid />} />
|
||||
<Route path="/banner-sets/:bannerSetId/edit" element={<BannerEditor />} />
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import { JourneyStepper, type StepperStep } from "@/components/ui/stepper";
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/brief", label: "New Brief" },
|
||||
{ to: "/conversations/new", label: "New Brief" },
|
||||
{ to: "/history", label: "History" },
|
||||
{ to: "/admin", label: "Admin", adminOnly: true },
|
||||
];
|
||||
|
||||
const CREATION_ROUTES = [
|
||||
"/conversations/new",
|
||||
"/conversations/:conversationId",
|
||||
"/brief",
|
||||
"/brief/:briefId/variants",
|
||||
"/banner-sets/:bannerSetId/edit",
|
||||
|
|
@ -19,7 +21,7 @@ const CREATION_ROUTES = [
|
|||
|
||||
function useJourneySteps(): { steps: StepperStep[]; currentOrder: number } | null {
|
||||
const location = useLocation();
|
||||
const { briefId, bannerSetId, furthestStep, isFresh } = useJourneyStore();
|
||||
const { conversationId, briefId, bannerSetId, furthestStep, isFresh } = useJourneyStore();
|
||||
|
||||
const isCreationFlow = CREATION_ROUTES.some((pattern) =>
|
||||
matchPath(pattern, location.pathname)
|
||||
|
|
@ -30,13 +32,19 @@ function useJourneySteps(): { steps: StepperStep[]; currentOrder: number } | nul
|
|||
if (matchPath("/brief/:briefId/variants", location.pathname)) currentOrder = 2;
|
||||
else if (matchPath("/banner-sets/:bannerSetId/edit", location.pathname)) currentOrder = 3;
|
||||
else if (matchPath("/banner-sets/:bannerSetId/export", location.pathname)) currentOrder = 4;
|
||||
// conversation routes stay at step 1 (prompt)
|
||||
|
||||
// Stale sessions (>2 h old) must not expose backward navigation — the stored
|
||||
// bannerSetId may point to a completely different brief from a previous day.
|
||||
const fresh = isFresh();
|
||||
|
||||
const defs = [
|
||||
{ key: "prompt", label: "Prompt", order: 1, href: "/brief" },
|
||||
{
|
||||
key: "prompt",
|
||||
label: "Prompt",
|
||||
order: 1,
|
||||
href: fresh && conversationId ? `/conversations/${conversationId}` : "/conversations/new",
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
|
|
|
|||
308
frontend/src/pages/ChatBrief.tsx
Normal file
308
frontend/src/pages/ChatBrief.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { pollJob } from "@/lib/jobPolling";
|
||||
import { useJourneyStore } from "@/store/journey";
|
||||
import BannerPreview from "@/components/BannerPreview";
|
||||
import type { ConversationMessage } from "@/types/conversation";
|
||||
import type { Variant } from "@/types/banner";
|
||||
|
||||
interface BannerSetCache {
|
||||
[bannerSetId: string]: Variant[];
|
||||
}
|
||||
|
||||
export default function ChatBrief() {
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { setConversation, setBrief, setBannerSet, markStageReached, setSelectedVariants } = useJourneyStore();
|
||||
|
||||
const [messages, setMessages] = useState<ConversationMessage[]>([]);
|
||||
const [bannerSets, setBannerSets] = useState<BannerSetCache>({});
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [selectionByMessage, setSelectionByMessage] = useState<Record<string, Set<string>>>({});
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
setConversation(conversationId);
|
||||
markStageReached("prompt");
|
||||
loadConversation();
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
async function loadConversation() {
|
||||
const data = await apiClient.get<{ messages: ConversationMessage[]; brief_id: string }>(
|
||||
`/api/conversations/${conversationId}`
|
||||
);
|
||||
setBrief(data.brief_id);
|
||||
setMessages(data.messages);
|
||||
for (const m of data.messages) {
|
||||
if (m.banner_set_id && !bannerSets[m.banner_set_id]) {
|
||||
loadBannerSet(m.banner_set_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBannerSet(bannerSetId: string) {
|
||||
const data = await apiClient.get<{ variants: Variant[] }>(`/api/banner-sets/${bannerSetId}`);
|
||||
setBannerSets((prev) => ({ ...prev, [bannerSetId]: data.variants }));
|
||||
initSelection(bannerSetId, data.variants);
|
||||
}
|
||||
|
||||
function initSelection(bannerSetId: string, variants: Variant[]) {
|
||||
const msgId = messages.find((m) => m.banner_set_id === bannerSetId)?.id;
|
||||
if (!msgId) return;
|
||||
setSelectionByMessage((prev) => ({
|
||||
...prev,
|
||||
[msgId]: new Set(variants.map((v) => v.id)),
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleVariant(msgId: string, variantId: string) {
|
||||
setSelectionByMessage((prev) => {
|
||||
const cur = new Set(prev[msgId] ?? []);
|
||||
cur.has(variantId) ? cur.delete(variantId) : cur.add(variantId);
|
||||
return { ...prev, [msgId]: cur };
|
||||
});
|
||||
}
|
||||
|
||||
function canProceed(msgId: string, bannerSetId: string) {
|
||||
const variants = bannerSets[bannerSetId] ?? [];
|
||||
const sel = selectionByMessage[msgId] ?? new Set();
|
||||
return (
|
||||
variants.filter((v) => v.aspect_ratio === "Medium").some((v) => sel.has(v.id)) &&
|
||||
variants.filter((v) => v.aspect_ratio === "Large").some((v) => sel.has(v.id))
|
||||
);
|
||||
}
|
||||
|
||||
function openEditor(msgId: string, bannerSetId: string) {
|
||||
const variants = bannerSets[bannerSetId] ?? [];
|
||||
const sel = selectionByMessage[msgId] ?? new Set();
|
||||
const ids = sel.size < variants.length ? Array.from(sel) : null;
|
||||
setBannerSet(bannerSetId);
|
||||
setSelectedVariants(ids);
|
||||
markStageReached("banner-editor");
|
||||
navigate(`/banner-sets/${bannerSetId}/edit`);
|
||||
}
|
||||
|
||||
async function sendMessage(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
setInput("");
|
||||
setBusy(true);
|
||||
|
||||
const optimisticUser: ConversationMessage = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
role: "user",
|
||||
kind: "text",
|
||||
content: text,
|
||||
banner_set_id: null,
|
||||
job_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
const thinking: ConversationMessage = {
|
||||
id: `thinking-${Date.now()}`,
|
||||
role: "assistant",
|
||||
kind: "text",
|
||||
content: "...",
|
||||
banner_set_id: null,
|
||||
job_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, optimisticUser, thinking]);
|
||||
|
||||
try {
|
||||
const res = await apiClient.post<{ message_id: string; job_id: string | null; reply: string | null }>(
|
||||
`/api/conversations/${conversationId}/messages`,
|
||||
{ text }
|
||||
);
|
||||
|
||||
if (res.reply) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === thinking.id ? { ...thinking, id: res.message_id, content: res.reply! } : m
|
||||
)
|
||||
);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.job_id) {
|
||||
await pollJob(res.job_id, {
|
||||
intervalMs: 2000,
|
||||
onProgress: () => {},
|
||||
});
|
||||
await loadConversation();
|
||||
setMessages((prev) => prev.filter((m) => m.id !== thinking.id && !m.id.startsWith("optimistic-")));
|
||||
}
|
||||
} catch (err) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === thinking.id
|
||||
? { ...thinking, content: err instanceof Error ? err.message : "Something went wrong." }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-10rem)] max-w-4xl mx-auto">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-semibold text-barclays-dark">Banner brief</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Describe your campaign, then refine or ask for more options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Message list */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pb-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-gray-400 text-sm mt-16">
|
||||
Start by describing your campaign below.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-2xl px-4 py-3 text-sm ${
|
||||
m.role === "user"
|
||||
? "bg-barclays-blue text-white rounded-tr-sm"
|
||||
: "bg-white border border-gray-200 text-barclays-dark rounded-tl-sm shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{(m.kind === "generation" || m.kind === "refinement") && m.banner_set_id ? (
|
||||
<BannerSetMessage
|
||||
msgId={m.id}
|
||||
bannerSetId={m.banner_set_id}
|
||||
variants={bannerSets[m.banner_set_id]}
|
||||
selection={selectionByMessage[m.id] ?? new Set()}
|
||||
canOpen={canProceed(m.id, m.banner_set_id)}
|
||||
onToggle={(vid) => toggleVariant(m.id, vid)}
|
||||
onOpen={() => openEditor(m.id, m.banner_set_id!)}
|
||||
/>
|
||||
) : m.id.startsWith("thinking-") || m.content === "..." ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 border-2 border-barclays-blue/30 border-t-barclays-blue rounded-full animate-spin" />
|
||||
Generating…
|
||||
</span>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{m.content}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<form onSubmit={sendMessage} className="border-t pt-4 flex gap-3">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(e as unknown as React.FormEvent); }
|
||||
}}
|
||||
disabled={busy}
|
||||
rows={2}
|
||||
placeholder={
|
||||
messages.length === 0
|
||||
? "e.g. Promote our Everyday Saver to existing customers. Give me 4 variants."
|
||||
: "Ask for changes, more options, or a different tone…"
|
||||
}
|
||||
className="flex-1 border border-gray-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-barclays-blue resize-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !input.trim()}
|
||||
className="px-5 py-3 bg-barclays-dark text-white rounded-xl text-sm font-medium hover:bg-barclays-mid transition-colors disabled:opacity-40 self-end"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BannerSetMessageProps {
|
||||
msgId: string;
|
||||
bannerSetId: string;
|
||||
variants: Variant[] | undefined;
|
||||
selection: Set<string>;
|
||||
canOpen: boolean;
|
||||
onToggle: (variantId: string) => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function BannerSetMessage({ variants, selection, canOpen, onToggle, onOpen }: BannerSetMessageProps) {
|
||||
if (!variants) {
|
||||
return (
|
||||
<span className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span className="inline-block w-3 h-3 border-2 border-barclays-blue/30 border-t-barclays-blue rounded-full animate-spin" />
|
||||
Loading variants…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const byRatio: Record<string, Variant[]> = {};
|
||||
for (const v of variants) (byRatio[v.aspect_ratio] ??= []).push(v);
|
||||
const orderedRatios = ["Medium", "Large"].filter((r) => byRatio[r]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 min-w-[520px]">
|
||||
<p className="text-xs text-gray-500">{variants.length} variants — tick at least one Medium and one Large, then open the editor.</p>
|
||||
|
||||
{orderedRatios.map((ratio) => (
|
||||
<div key={ratio}>
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">{ratio}</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{byRatio[ratio].map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
onClick={() => onToggle(v.id)}
|
||||
className={`cursor-pointer rounded-xl border overflow-hidden transition-all ${
|
||||
selection.has(v.id) ? "border-barclays-blue ring-1 ring-barclays-blue/30" : "border-gray-200 opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="bg-gray-50 p-2 flex justify-center">
|
||||
<BannerPreview variant={v} scale={ratio === "Large" ? 0.8 : 1} showClose={false} />
|
||||
</div>
|
||||
<div className="p-2 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.has(v.id)}
|
||||
onChange={() => onToggle(v.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="accent-barclays-blue shrink-0"
|
||||
/>
|
||||
<p className="text-xs text-barclays-dark font-medium truncate">{v.short_title}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-1 flex items-center gap-3">
|
||||
<button
|
||||
onClick={onOpen}
|
||||
disabled={!canOpen}
|
||||
className="text-sm px-4 py-2 bg-barclays-blue text-white rounded-lg hover:bg-barclays-mid transition-colors disabled:opacity-40 font-medium"
|
||||
>
|
||||
Open banner editor ({selection.size}) →
|
||||
</button>
|
||||
{!canOpen && (
|
||||
<span className="text-xs text-amber-600">Select at least one Medium and one Large.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend/src/pages/ConversationLanding.tsx
Normal file
21
frontend/src/pages/ConversationLanding.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useJourneyStore } from "@/store/journey";
|
||||
|
||||
export default function ConversationLanding() {
|
||||
const navigate = useNavigate();
|
||||
const { resetJourney } = useJourneyStore();
|
||||
|
||||
useEffect(() => {
|
||||
resetJourney();
|
||||
apiClient
|
||||
.post<{ conversation_id: string }>("/api/conversations", {})
|
||||
.then((d) => navigate(`/conversations/${d.conversation_id}`, { replace: true }))
|
||||
.catch(() => navigate("/brief", { replace: true }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="text-sm text-gray-400 animate-pulse text-center mt-24">Starting conversation…</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -113,6 +113,14 @@ export default function VariantsGrid() {
|
|||
const hasLarge = (byRatio.Large ?? []).some((v) => checkedIds.has(v.id));
|
||||
const canProceed = hasMedium && hasLarge;
|
||||
|
||||
const editingVariant = editingId ? variants.find((v) => v.id === editingId) : null;
|
||||
const editLim = editingVariant ? (LIMITS[editingVariant.aspect_ratio] ?? DEFAULT_LIMITS) : DEFAULT_LIMITS;
|
||||
const overLimit =
|
||||
(edits.short_title ?? "").length > editLim.title ||
|
||||
(edits.long_body ?? "").length > editLim.body ||
|
||||
(edits.cta ?? "").length > CTA_MAX ||
|
||||
(edits.cta_secondary ?? "").length > CTA_MAX;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -263,13 +271,17 @@ export default function VariantsGrid() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => saveEdit(variant.id)}
|
||||
className="text-xs px-3 py-1.5 bg-barclays-dark text-white rounded hover:bg-barclays-mid transition-colors"
|
||||
disabled={overLimit}
|
||||
className="text-xs px-3 py-1.5 bg-barclays-dark text-white rounded hover:bg-barclays-mid transition-colors disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{overLimit && (
|
||||
<span className="text-xs text-red-500">One or more fields exceed the character limit.</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setEditingId(null); setEdits({}); setRefineNote(""); }}
|
||||
className="text-xs px-3 py-1.5 border rounded text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@ const STAGE_ORDER: Record<JourneyStage, 1 | 2 | 3 | 4> = {
|
|||
const JOURNEY_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||
|
||||
interface JourneyState {
|
||||
conversationId: string | null;
|
||||
briefId: string | null;
|
||||
bannerSetId: string | null;
|
||||
furthestStep: 1 | 2 | 3 | 4;
|
||||
selectedVariantIds: string[] | null;
|
||||
lastUpdatedAt: number | null;
|
||||
setConversation: (conversationId: string) => void;
|
||||
setBrief: (briefId: string) => void;
|
||||
setBannerSet: (bannerSetId: string) => void;
|
||||
markStageReached: (stage: JourneyStage) => void;
|
||||
|
|
@ -32,11 +34,13 @@ interface JourneyState {
|
|||
export const useJourneyStore = create<JourneyState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
conversationId: null,
|
||||
briefId: null,
|
||||
bannerSetId: null,
|
||||
furthestStep: 1,
|
||||
selectedVariantIds: null,
|
||||
lastUpdatedAt: null,
|
||||
setConversation: (conversationId) => set({ conversationId, lastUpdatedAt: Date.now() }),
|
||||
setBrief: (briefId) => set({ briefId, lastUpdatedAt: Date.now() }),
|
||||
setBannerSet: (bannerSetId) => set({ bannerSetId, lastUpdatedAt: Date.now() }),
|
||||
markStageReached: (stage) => {
|
||||
|
|
@ -45,6 +49,7 @@ export const useJourneyStore = create<JourneyState>()(
|
|||
},
|
||||
setSelectedVariants: (ids) => set({ selectedVariantIds: ids, lastUpdatedAt: Date.now() }),
|
||||
resetJourney: () => set({
|
||||
conversationId: null,
|
||||
briefId: null,
|
||||
bannerSetId: null,
|
||||
furthestStep: 1,
|
||||
|
|
|
|||
18
frontend/src/types/conversation.ts
Normal file
18
frontend/src/types/conversation.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
kind: "text" | "generation" | "refinement";
|
||||
content: string;
|
||||
banner_set_id: string | null;
|
||||
job_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
brief_id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
messages: ConversationMessage[];
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue