docs(06-web-chat): create phase plan
This commit is contained in:
329
.planning/phases/06-web-chat/06-01-PLAN.md
Normal file
329
.planning/phases/06-web-chat/06-01-PLAN.md
Normal file
@@ -0,0 +1,329 @@
|
||||
---
|
||||
phase: 06-web-chat
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- packages/shared/shared/models/message.py
|
||||
- packages/shared/shared/redis_keys.py
|
||||
- packages/shared/shared/models/chat.py
|
||||
- packages/shared/shared/api/chat.py
|
||||
- packages/shared/shared/api/__init__.py
|
||||
- packages/gateway/gateway/channels/web.py
|
||||
- packages/gateway/gateway/main.py
|
||||
- packages/orchestrator/orchestrator/tasks.py
|
||||
- migrations/versions/008_web_chat.py
|
||||
- tests/unit/test_web_channel.py
|
||||
- tests/unit/test_chat_api.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- CHAT-01
|
||||
- CHAT-02
|
||||
- CHAT-03
|
||||
- CHAT-04
|
||||
- CHAT-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Web channel messages normalize into valid KonstructMessage with channel='web'"
|
||||
- "Celery _send_response publishes web channel responses to Redis pub-sub"
|
||||
- "WebSocket endpoint accepts connections and dispatches messages to Celery pipeline"
|
||||
- "Typing indicator event is sent immediately after receiving a user message"
|
||||
- "Chat REST API enforces RBAC — non-members get 403"
|
||||
- "Platform admin can access conversations for any tenant"
|
||||
- "Conversation history persists in DB and is loadable via REST"
|
||||
artifacts:
|
||||
- path: "packages/shared/shared/models/chat.py"
|
||||
provides: "WebConversation and WebConversationMessage ORM models"
|
||||
contains: "class WebConversation"
|
||||
- path: "packages/gateway/gateway/channels/web.py"
|
||||
provides: "WebSocket endpoint and web channel normalizer"
|
||||
contains: "async def chat_websocket"
|
||||
- path: "packages/shared/shared/api/chat.py"
|
||||
provides: "REST API for conversation CRUD"
|
||||
exports: ["chat_router"]
|
||||
- path: "migrations/versions/008_web_chat.py"
|
||||
provides: "DB migration for web_conversations and web_conversation_messages tables"
|
||||
contains: "web_conversations"
|
||||
- path: "tests/unit/test_web_channel.py"
|
||||
provides: "Unit tests for web channel adapter"
|
||||
contains: "test_normalize_web_event"
|
||||
- path: "tests/unit/test_chat_api.py"
|
||||
provides: "Unit tests for chat REST API with RBAC"
|
||||
contains: "test_chat_rbac_enforcement"
|
||||
key_links:
|
||||
- from: "packages/gateway/gateway/channels/web.py"
|
||||
to: "packages/orchestrator/orchestrator/tasks.py"
|
||||
via: "handle_message.delay() Celery dispatch"
|
||||
pattern: "handle_message\\.delay"
|
||||
- from: "packages/orchestrator/orchestrator/tasks.py"
|
||||
to: "packages/shared/shared/redis_keys.py"
|
||||
via: "Redis pub-sub publish for web channel"
|
||||
pattern: "webchat_response_key"
|
||||
- from: "packages/gateway/gateway/channels/web.py"
|
||||
to: "packages/shared/shared/redis_keys.py"
|
||||
via: "Redis pub-sub subscribe for response delivery"
|
||||
pattern: "webchat_response_key"
|
||||
- from: "packages/shared/shared/api/chat.py"
|
||||
to: "packages/shared/shared/api/rbac.py"
|
||||
via: "require_tenant_member RBAC guard"
|
||||
pattern: "require_tenant_member"
|
||||
|
||||
user_setup: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the complete backend infrastructure for web chat: DB schema, ORM models, web channel adapter with WebSocket endpoint, Redis pub-sub response bridge, chat REST API with RBAC, and orchestrator integration. After this plan, the portal can send messages via WebSocket and receive responses through the full agent pipeline.
|
||||
|
||||
Purpose: Enables the portal to use the same agent pipeline as Slack/WhatsApp via a new "web" channel — the foundational plumbing that the frontend chat UI (Plan 02) connects to.
|
||||
Output: Working WebSocket endpoint, conversation persistence, RBAC-enforced REST API, and unit tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-web-chat/06-CONTEXT.md
|
||||
@.planning/phases/06-web-chat/06-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From packages/shared/shared/models/message.py:
|
||||
```python
|
||||
class ChannelType(StrEnum):
|
||||
SLACK = "slack"
|
||||
WHATSAPP = "whatsapp"
|
||||
MATTERMOST = "mattermost"
|
||||
ROCKETCHAT = "rocketchat"
|
||||
TEAMS = "teams"
|
||||
TELEGRAM = "telegram"
|
||||
SIGNAL = "signal"
|
||||
# WEB = "web" <-- ADD THIS
|
||||
|
||||
class KonstructMessage(BaseModel):
|
||||
id: str
|
||||
tenant_id: str | None
|
||||
channel: ChannelType
|
||||
channel_metadata: dict[str, Any]
|
||||
sender: SenderInfo
|
||||
content: MessageContent
|
||||
timestamp: datetime
|
||||
thread_id: str | None
|
||||
reply_to: str | None
|
||||
context: dict[str, Any]
|
||||
```
|
||||
|
||||
From packages/shared/shared/redis_keys.py:
|
||||
```python
|
||||
# All keys follow: {tenant_id}:{key_type}:{discriminator}
|
||||
def memory_short_key(tenant_id: str, agent_id: str, user_id: str) -> str
|
||||
def escalation_status_key(tenant_id: str, thread_id: str) -> str
|
||||
# ADD: webchat_response_key(tenant_id, conversation_id)
|
||||
```
|
||||
|
||||
From packages/shared/shared/api/rbac.py:
|
||||
```python
|
||||
@dataclass
|
||||
class PortalCaller:
|
||||
user_id: uuid.UUID
|
||||
role: str
|
||||
tenant_id: uuid.UUID | None = None
|
||||
|
||||
async def get_portal_caller(...) -> PortalCaller
|
||||
async def require_tenant_member(tenant_id: UUID, caller: PortalCaller, session: AsyncSession) -> None
|
||||
async def require_tenant_admin(tenant_id: UUID, caller: PortalCaller, session: AsyncSession) -> None
|
||||
```
|
||||
|
||||
From packages/orchestrator/orchestrator/tasks.py:
|
||||
```python
|
||||
# handle_message pops extras before model_validate:
|
||||
# placeholder_ts, channel_id, phone_number_id, bot_token
|
||||
# ADD: conversation_id, portal_user_id, tenant_id (for web)
|
||||
|
||||
# _send_response routes by channel_str:
|
||||
# "slack" -> _update_slack_placeholder
|
||||
# "whatsapp" -> send_whatsapp_message
|
||||
# ADD: "web" -> Redis pub-sub publish
|
||||
|
||||
# _build_response_extras builds channel-specific extras dict
|
||||
# ADD: "web" case returning conversation_id + tenant_id
|
||||
```
|
||||
|
||||
From packages/shared/shared/api/__init__.py:
|
||||
```python
|
||||
# Current routers mounted on gateway:
|
||||
# portal_router, billing_router, channels_router, llm_keys_router,
|
||||
# usage_router, webhook_router, invitations_router, templates_router
|
||||
# ADD: chat_router
|
||||
```
|
||||
|
||||
From packages/gateway/gateway/main.py:
|
||||
```python
|
||||
# CORS allows: localhost:3000, 127.0.0.1:3000, 100.64.0.10:3000
|
||||
# WebSocket doesn't use CORS (browser doesn't enforce) but same origin rules apply
|
||||
# Include chat_router and WebSocket router here
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Backend models, migration, channel type, Redis key, and unit tests</name>
|
||||
<files>
|
||||
packages/shared/shared/models/message.py,
|
||||
packages/shared/shared/redis_keys.py,
|
||||
packages/shared/shared/models/chat.py,
|
||||
migrations/versions/008_web_chat.py,
|
||||
tests/unit/test_web_channel.py,
|
||||
tests/unit/test_chat_api.py
|
||||
</files>
|
||||
<behavior>
|
||||
- test_normalize_web_event: normalize_web_event({text, tenant_id, agent_id, user_id, conversation_id}) -> KonstructMessage with channel=WEB, thread_id=conversation_id, sender.user_id=portal_user_id
|
||||
- test_send_response_web_publishes_to_redis: _send_response("web", "hello", {conversation_id, tenant_id}) publishes JSON to Redis channel matching webchat_response_key(tenant_id, conversation_id)
|
||||
- test_typing_indicator_sent: WebSocket handler sends {"type": "typing"} immediately after receiving user message, before Celery dispatch
|
||||
- test_chat_rbac_enforcement: GET /api/portal/chat/conversations?tenant_id=X returns 403 when caller is not a member of tenant X
|
||||
- test_platform_admin_cross_tenant: GET /api/portal/chat/conversations?tenant_id=X returns 200 when caller is platform_admin (bypasses membership)
|
||||
- test_list_conversation_history: GET /api/portal/chat/conversations/{id}/messages returns paginated messages ordered by created_at
|
||||
- test_create_conversation: POST /api/portal/chat/conversations with {tenant_id, agent_id} creates or returns existing conversation for user+agent pair
|
||||
</behavior>
|
||||
<action>
|
||||
1. Add WEB = "web" to ChannelType in packages/shared/shared/models/message.py
|
||||
|
||||
2. Add webchat_response_key(tenant_id, conversation_id) to packages/shared/shared/redis_keys.py following existing pattern: return f"{tenant_id}:webchat:response:{conversation_id}"
|
||||
|
||||
3. Create packages/shared/shared/models/chat.py with ORM models:
|
||||
- WebConversation: id (UUID PK), tenant_id (UUID, FK tenants.id), agent_id (UUID, FK agents.id), user_id (UUID, FK portal_users.id), created_at, updated_at. UniqueConstraint on (tenant_id, agent_id, user_id). RLS via tenant_id.
|
||||
- WebConversationMessage: id (UUID PK), conversation_id (UUID, FK web_conversations.id ON DELETE CASCADE), tenant_id (UUID), role (TEXT, CHECK "user"/"assistant"), content (TEXT), created_at. RLS via tenant_id.
|
||||
Use mapped_column() + Mapped[] (SQLAlchemy 2.0 pattern, not Column()).
|
||||
|
||||
4. Create migration 008_web_chat.py:
|
||||
- Create web_conversations table with columns matching ORM model
|
||||
- Create web_conversation_messages table with FK to web_conversations
|
||||
- Enable RLS on both tables (FORCE ROW LEVEL SECURITY)
|
||||
- Create RLS policies matching existing pattern (current_setting('app.current_tenant')::uuid)
|
||||
- ALTER CHECK constraint on channel_connections.channel_type to include 'web' (see Pitfall 5 in RESEARCH.md — the existing CHECK must be replaced, not just added to)
|
||||
- Add index on web_conversation_messages(conversation_id, created_at)
|
||||
|
||||
5. Write test files FIRST (RED phase):
|
||||
- tests/unit/test_web_channel.py: test normalize_web_event, test _send_response web publishes to Redis (mock aioredis), test typing indicator
|
||||
- tests/unit/test_chat_api.py: test RBAC enforcement (403 for non-member), platform admin cross-tenant (200), list history (paginated), create conversation (get-or-create)
|
||||
Use httpx AsyncClient with app fixture pattern from existing tests. Mock DB sessions and Redis.
|
||||
|
||||
IMPORTANT: Celery tasks MUST be sync def with asyncio.run() — never async def (hard architectural constraint).
|
||||
IMPORTANT: Use TEXT+CHECK for role column (not sa.Enum) per Phase 1 convention.
|
||||
IMPORTANT: _send_response "web" case must use try/finally around aioredis.from_url() to avoid connection leaks (Pitfall 2 from RESEARCH.md).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct && python -m pytest tests/unit/test_web_channel.py tests/unit/test_chat_api.py -x -v</automated>
|
||||
</verify>
|
||||
<done>
|
||||
ChannelType.WEB exists. webchat_response_key function exists. ORM models define web_conversations and web_conversation_messages. Migration 008 creates both tables with RLS and updates channel_type CHECK constraint. All test assertions pass (RED then GREEN).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: WebSocket endpoint, web channel adapter, REST API, orchestrator wiring</name>
|
||||
<files>
|
||||
packages/gateway/gateway/channels/web.py,
|
||||
packages/shared/shared/api/chat.py,
|
||||
packages/shared/shared/api/__init__.py,
|
||||
packages/gateway/gateway/main.py,
|
||||
packages/orchestrator/orchestrator/tasks.py
|
||||
</files>
|
||||
<action>
|
||||
1. Create packages/gateway/gateway/channels/web.py with:
|
||||
a. normalize_web_event() function: takes dict with {text, tenant_id, agent_id, user_id, display_name, conversation_id} and returns KonstructMessage with channel=ChannelType.WEB, thread_id=conversation_id, sender.user_id=user_id (portal user UUID string), channel_metadata={portal_user_id, tenant_id, conversation_id}
|
||||
b. WebSocket endpoint at /chat/ws/{conversation_id}:
|
||||
- Accept connection
|
||||
- Wait for first JSON message with type="auth" containing {userId, role, tenantId} (browser cannot send custom headers — Pitfall 1 from RESEARCH.md)
|
||||
- Validate auth: userId must be non-empty UUID string, role must be valid
|
||||
- For each subsequent message (type="message"):
|
||||
* Immediately send {"type": "typing"} back to client (CHAT-05)
|
||||
* Normalize message to KonstructMessage via normalize_web_event
|
||||
* Save user message to web_conversation_messages table
|
||||
* Build extras dict: conversation_id, portal_user_id, tenant_id
|
||||
* Dispatch handle_message.delay(msg.model_dump() | extras)
|
||||
* Subscribe to Redis pub-sub channel webchat_response_key(tenant_id, conversation_id) with 60s timeout
|
||||
* When response arrives: save assistant message to web_conversation_messages, send {"type": "response", "text": ..., "conversation_id": ...} to WebSocket
|
||||
- On disconnect: unsubscribe and close Redis connections
|
||||
c. Create an APIRouter with the WebSocket route for mounting
|
||||
|
||||
2. Create packages/shared/shared/api/chat.py with REST endpoints:
|
||||
a. GET /api/portal/chat/conversations?tenant_id={id} — list conversations for the authenticated user within a tenant. For platform_admin: returns conversations across all tenants if no tenant_id. Uses require_tenant_member for RBAC. Returns [{id, agent_id, agent_name, updated_at, last_message_preview}] sorted by updated_at DESC.
|
||||
b. GET /api/portal/chat/conversations/{id}/messages?limit=50&before={cursor} — paginated message history. Verify caller owns the conversation (same user_id) OR is platform_admin. Returns [{id, role, content, created_at}] ordered by created_at ASC.
|
||||
c. POST /api/portal/chat/conversations — create or get-or-create conversation. Body: {tenant_id, agent_id}. Uses require_tenant_member. Returns conversation object with id.
|
||||
d. DELETE /api/portal/chat/conversations/{id} — reset conversation (delete messages, keep row). Updates updated_at. Verify ownership.
|
||||
All endpoints use Depends(get_portal_caller) and Depends(get_session). Set RLS context var (configure_rls_hook + current_tenant_id.set) before DB queries.
|
||||
|
||||
3. Update packages/shared/shared/api/__init__.py: add chat_router to imports and __all__
|
||||
|
||||
4. Update packages/gateway/gateway/main.py:
|
||||
- Import chat_router from shared.api and web channel router from gateway.channels.web
|
||||
- app.include_router(chat_router) for REST endpoints
|
||||
- app.include_router(web_chat_router) for WebSocket endpoint
|
||||
- Add comment block "Phase 6 Web Chat routers"
|
||||
|
||||
5. Update packages/orchestrator/orchestrator/tasks.py:
|
||||
a. In handle_message: pop "conversation_id" and "portal_user_id" before model_validate (same pattern as placeholder_ts, channel_id). Add to extras dict.
|
||||
b. In _build_response_extras: add "web" case returning {"conversation_id": extras.get("conversation_id"), "tenant_id": extras.get("tenant_id")}. Note: tenant_id for web comes from extras, not from channel_metadata like Slack.
|
||||
c. In _send_response: add "web" case that publishes to Redis pub-sub:
|
||||
```python
|
||||
elif channel_str == "web":
|
||||
conversation_id = extras.get("conversation_id", "")
|
||||
tenant_id = extras.get("tenant_id", "")
|
||||
if not conversation_id or not tenant_id:
|
||||
logger.warning("_send_response: web channel missing conversation_id or tenant_id")
|
||||
return
|
||||
response_channel = webchat_response_key(tenant_id, conversation_id)
|
||||
publish_redis = aioredis.from_url(settings.redis_url)
|
||||
try:
|
||||
await publish_redis.publish(response_channel, json.dumps({
|
||||
"type": "response", "text": text, "conversation_id": conversation_id,
|
||||
}))
|
||||
finally:
|
||||
await publish_redis.aclose()
|
||||
```
|
||||
d. Import webchat_response_key from shared.redis_keys at module level (matches existing import pattern for other keys)
|
||||
|
||||
IMPORTANT: WebSocket auth via JSON message after connection (NOT URL params or headers — browser limitation).
|
||||
IMPORTANT: Redis pub-sub subscribe in WebSocket handler must use try/finally for cleanup (Pitfall 2).
|
||||
IMPORTANT: The web normalizer must set thread_id = conversation_id (Pitfall 3 — conversation ID scopes memory correctly).
|
||||
IMPORTANT: For DB access in WebSocket handler, use configure_rls_hook + current_tenant_id context var per existing pattern.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct && python -m pytest tests/unit/test_web_channel.py tests/unit/test_chat_api.py -x -v</automated>
|
||||
</verify>
|
||||
<done>
|
||||
WebSocket endpoint at /chat/ws/{conversation_id} accepts connections, authenticates via JSON message, dispatches to Celery, subscribes to Redis for response. REST API provides conversation CRUD with RBAC. Orchestrator _send_response handles "web" channel via Redis pub-sub publish. All unit tests pass. Gateway mounts both routers.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. All unit tests pass: `pytest tests/unit/test_web_channel.py tests/unit/test_chat_api.py -x`
|
||||
2. Migration 008 applies cleanly: `cd /home/adelorenzo/repos/konstruct && alembic upgrade head`
|
||||
3. Gateway starts without errors: `cd /home/adelorenzo/repos/konstruct/packages/gateway && python -c "from gateway.main import app; print('OK')"`
|
||||
4. Full test suite still green: `pytest tests/unit -x`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- ChannelType includes WEB
|
||||
- WebSocket endpoint exists at /chat/ws/{conversation_id}
|
||||
- REST API at /api/portal/chat/* provides conversation CRUD with RBAC
|
||||
- _send_response in tasks.py handles "web" channel via Redis pub-sub
|
||||
- web_conversations and web_conversation_messages tables created with RLS
|
||||
- All 7+ unit tests pass covering CHAT-01 through CHAT-05
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-web-chat/06-01-SUMMARY.md`
|
||||
</output>
|
||||
325
.planning/phases/06-web-chat/06-02-PLAN.md
Normal file
325
.planning/phases/06-web-chat/06-02-PLAN.md
Normal file
@@ -0,0 +1,325 @@
|
||||
---
|
||||
phase: 06-web-chat
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["06-01"]
|
||||
files_modified:
|
||||
- packages/portal/app/(dashboard)/chat/page.tsx
|
||||
- packages/portal/components/chat-sidebar.tsx
|
||||
- packages/portal/components/chat-window.tsx
|
||||
- packages/portal/components/chat-message.tsx
|
||||
- packages/portal/components/typing-indicator.tsx
|
||||
- packages/portal/lib/use-chat-socket.ts
|
||||
- packages/portal/lib/queries.ts
|
||||
- packages/portal/lib/api.ts
|
||||
- packages/portal/components/nav.tsx
|
||||
- packages/portal/package.json
|
||||
autonomous: true
|
||||
requirements:
|
||||
- CHAT-01
|
||||
- CHAT-03
|
||||
- CHAT-04
|
||||
- CHAT-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can navigate to /chat from the sidebar and see a conversation list"
|
||||
- "User can select an agent and start a new conversation"
|
||||
- "User can type a message and see it appear as a right-aligned bubble"
|
||||
- "Agent response appears as a left-aligned bubble with markdown rendering"
|
||||
- "Typing indicator (animated dots) shows while waiting for agent response"
|
||||
- "Conversation history loads when user returns to a previous conversation"
|
||||
- "Operator, customer admin, and platform admin can all access /chat"
|
||||
artifacts:
|
||||
- path: "packages/portal/app/(dashboard)/chat/page.tsx"
|
||||
provides: "Main chat page with sidebar + active conversation"
|
||||
min_lines: 50
|
||||
- path: "packages/portal/components/chat-sidebar.tsx"
|
||||
provides: "Conversation list with agent names and timestamps"
|
||||
contains: "ChatSidebar"
|
||||
- path: "packages/portal/components/chat-window.tsx"
|
||||
provides: "Active conversation with message list, input, and send button"
|
||||
contains: "ChatWindow"
|
||||
- path: "packages/portal/components/chat-message.tsx"
|
||||
provides: "Message bubble with markdown rendering and role-based alignment"
|
||||
contains: "ChatMessage"
|
||||
- path: "packages/portal/components/typing-indicator.tsx"
|
||||
provides: "Animated typing dots component"
|
||||
contains: "TypingIndicator"
|
||||
- path: "packages/portal/lib/use-chat-socket.ts"
|
||||
provides: "React hook managing WebSocket lifecycle"
|
||||
contains: "useChatSocket"
|
||||
key_links:
|
||||
- from: "packages/portal/lib/use-chat-socket.ts"
|
||||
to: "packages/gateway/gateway/channels/web.py"
|
||||
via: "WebSocket connection to /chat/ws/{conversationId}"
|
||||
pattern: "new WebSocket"
|
||||
- from: "packages/portal/app/(dashboard)/chat/page.tsx"
|
||||
to: "packages/portal/lib/queries.ts"
|
||||
via: "useConversations + useConversationHistory hooks"
|
||||
pattern: "useConversations|useConversationHistory"
|
||||
- from: "packages/portal/components/nav.tsx"
|
||||
to: "packages/portal/app/(dashboard)/chat/page.tsx"
|
||||
via: "Nav link to /chat"
|
||||
pattern: 'href.*"/chat"'
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the complete portal chat UI: a dedicated /chat page with conversation sidebar, message window with markdown rendering, typing indicators, and WebSocket integration. Users can start conversations with AI Employees, see real-time responses, and browse conversation history.
|
||||
|
||||
Purpose: Delivers the user-facing chat experience that connects to the backend infrastructure from Plan 01.
|
||||
Output: Fully interactive chat page in the portal with all CHAT requirements addressed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-web-chat/06-CONTEXT.md
|
||||
@.planning/phases/06-web-chat/06-RESEARCH.md
|
||||
@.planning/phases/06-web-chat/06-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 01 — backend contracts the frontend connects to -->
|
||||
|
||||
WebSocket endpoint: ws://localhost:8001/chat/ws/{conversationId}
|
||||
Protocol:
|
||||
1. Client connects
|
||||
2. Client sends: {"type": "auth", "userId": "uuid", "role": "role_string", "tenantId": "uuid|null"}
|
||||
3. Client sends: {"type": "message", "text": "user message"}
|
||||
4. Server sends: {"type": "typing"} (immediate)
|
||||
5. Server sends: {"type": "response", "text": "agent reply", "conversation_id": "uuid"}
|
||||
|
||||
REST API:
|
||||
GET /api/portal/chat/conversations?tenant_id={id}
|
||||
-> [{id, agent_id, agent_name, updated_at, last_message_preview}]
|
||||
GET /api/portal/chat/conversations/{id}/messages?limit=50&before={cursor}
|
||||
-> [{id, role, content, created_at}]
|
||||
POST /api/portal/chat/conversations
|
||||
Body: {tenant_id, agent_id}
|
||||
-> {id, tenant_id, agent_id, user_id, created_at, updated_at}
|
||||
DELETE /api/portal/chat/conversations/{id}
|
||||
-> 204
|
||||
|
||||
From packages/portal/lib/api.ts:
|
||||
```typescript
|
||||
export function setPortalSession(session: {...}): void;
|
||||
function getAuthHeaders(): Record<string, string>;
|
||||
const api = { get<T>, post<T>, put<T>, delete };
|
||||
```
|
||||
|
||||
From packages/portal/lib/queries.ts:
|
||||
```typescript
|
||||
export const queryKeys = { tenants, agents, ... };
|
||||
export function useAgents(tenantId: string): UseQueryResult<Agent[]>;
|
||||
export function useTenants(page?: number): UseQueryResult<TenantsListResponse>;
|
||||
// ADD: useConversations, useConversationHistory, useCreateConversation, useDeleteConversation
|
||||
```
|
||||
|
||||
From packages/portal/components/nav.tsx:
|
||||
```typescript
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/dashboard", ... },
|
||||
{ href: "/agents", label: "Employees", ... },
|
||||
// ADD: { href: "/chat", label: "Chat", icon: MessageSquare }
|
||||
// Visible to ALL roles (no allowedRoles restriction)
|
||||
];
|
||||
```
|
||||
|
||||
From packages/portal/proxy.ts:
|
||||
```typescript
|
||||
const CUSTOMER_OPERATOR_RESTRICTED = ["/billing", "/settings/api-keys", "/users", "/admin", "/agents/new"];
|
||||
// /chat is NOT in this list — operators CAN access chat (per CONTEXT.md: "chatting IS the product")
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install dependencies, add API types/hooks, create WebSocket hook</name>
|
||||
<files>
|
||||
packages/portal/package.json,
|
||||
packages/portal/lib/api.ts,
|
||||
packages/portal/lib/queries.ts,
|
||||
packages/portal/lib/use-chat-socket.ts
|
||||
</files>
|
||||
<action>
|
||||
1. Install react-markdown and remark-gfm:
|
||||
`cd packages/portal && npm install react-markdown remark-gfm`
|
||||
|
||||
2. Add chat types to packages/portal/lib/api.ts (at the bottom, after existing types):
|
||||
```typescript
|
||||
// Chat types
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
updated_at: string;
|
||||
last_message_preview: string | null;
|
||||
}
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
export interface CreateConversationRequest {
|
||||
tenant_id: string;
|
||||
agent_id: string;
|
||||
}
|
||||
export interface ConversationDetail {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
agent_id: string;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
3. Add chat hooks to packages/portal/lib/queries.ts:
|
||||
- Add to queryKeys: conversations(tenantId) and conversationHistory(conversationId)
|
||||
- useConversations(tenantId: string) — GET /api/portal/chat/conversations?tenant_id={tenantId}, returns Conversation[], enabled: !!tenantId
|
||||
- useConversationHistory(conversationId: string) — GET /api/portal/chat/conversations/{conversationId}/messages, returns ConversationMessage[], enabled: !!conversationId
|
||||
- useCreateConversation() — POST mutation to /api/portal/chat/conversations, invalidates conversations query on success
|
||||
- useDeleteConversation() — DELETE mutation, invalidates conversations + history queries
|
||||
Follow the exact same pattern as useAgents, useCreateAgent, etc.
|
||||
|
||||
4. Create packages/portal/lib/use-chat-socket.ts:
|
||||
- "use client" directive at top
|
||||
- useChatSocket({ conversationId, onMessage, onTyping, authHeaders }) hook
|
||||
- authHeaders: { userId: string; role: string; tenantId: string | null }
|
||||
- On mount: create WebSocket to `${NEXT_PUBLIC_WS_URL ?? "ws://localhost:8001"}/chat/ws/${conversationId}`
|
||||
- On open: send auth JSON message immediately
|
||||
- On message: parse JSON, if type="typing" call onTyping(true), if type="response" call onTyping(false) then onMessage(data.text)
|
||||
- send(text: string) function: sends {"type": "message", "text": text} if connected
|
||||
- Return { send, isConnected }
|
||||
- On unmount/conversationId change: close WebSocket (useEffect cleanup)
|
||||
- Simple reconnect: on close, attempt reconnect after 3s (limit to 3 retries, then show error)
|
||||
- Use useRef for WebSocket instance, useState for isConnected
|
||||
- Use useCallback for send to keep stable reference
|
||||
|
||||
IMPORTANT: Read packages/portal/node_modules/next/dist/docs/ for any relevant Next.js 16 patterns before writing code.
|
||||
IMPORTANT: Use NEXT_PUBLIC_WS_URL env var (not NEXT_PUBLIC_API_URL) — WebSocket URL may differ from REST API URL.
|
||||
IMPORTANT: Auth message sent as first JSON payload after connection (browser WebSocket cannot send custom headers).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npx next build 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>
|
||||
react-markdown and remark-gfm installed. Chat types exported from api.ts. Four query hooks (useConversations, useConversationHistory, useCreateConversation, useDeleteConversation) added to queries.ts. useChatSocket hook manages WebSocket lifecycle with auth and reconnection. Portal builds without errors.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Chat page, components, nav link, and styling</name>
|
||||
<files>
|
||||
packages/portal/app/(dashboard)/chat/page.tsx,
|
||||
packages/portal/components/chat-sidebar.tsx,
|
||||
packages/portal/components/chat-window.tsx,
|
||||
packages/portal/components/chat-message.tsx,
|
||||
packages/portal/components/typing-indicator.tsx,
|
||||
packages/portal/components/nav.tsx
|
||||
</files>
|
||||
<action>
|
||||
1. Create packages/portal/components/typing-indicator.tsx:
|
||||
- "use client" component
|
||||
- Three animated dots with CSS animation (scale/opacity pulsing with staggered delays)
|
||||
- Wrapped in a message-bubble-style container (left-aligned, muted background)
|
||||
- Use Tailwind animate classes or inline keyframes
|
||||
|
||||
2. Create packages/portal/components/chat-message.tsx:
|
||||
- "use client" component
|
||||
- Props: { role: "user" | "assistant"; content: string; createdAt: string }
|
||||
- User messages: right-aligned, primary color background, white text
|
||||
- Assistant messages: left-aligned, muted background, with agent avatar icon (Bot from lucide-react)
|
||||
- Render content with react-markdown + remark-gfm for assistant messages (code blocks, lists, bold, links)
|
||||
- User messages: plain text (no markdown rendering needed)
|
||||
- Show timestamp in relative format (e.g., "2m ago") on hover or below message
|
||||
- Inline image display for any markdown image links in agent responses
|
||||
|
||||
3. Create packages/portal/components/chat-sidebar.tsx:
|
||||
- "use client" component
|
||||
- Props: { conversations: Conversation[]; activeId: string | null; onSelect: (id: string) => void; onNewChat: () => void }
|
||||
- "New Conversation" button at top (Plus icon from lucide-react)
|
||||
- Scrollable list of conversations showing: agent name (bold), last message preview (truncated, muted), relative timestamp
|
||||
- Active conversation highlighted with accent background
|
||||
- Empty state: "No conversations yet"
|
||||
|
||||
4. Create packages/portal/components/chat-window.tsx:
|
||||
- "use client" component
|
||||
- Props: { conversationId: string; authHeaders: { userId, role, tenantId } }
|
||||
- Uses useConversationHistory(conversationId) for initial load
|
||||
- Uses useChatSocket for real-time messaging
|
||||
- State: messages array (merged from history + new), isTyping boolean, inputText string
|
||||
- On history load: populate messages from query data
|
||||
- On WebSocket message: append to messages array, scroll to bottom
|
||||
- On typing indicator: show TypingIndicator below last message
|
||||
- Input area at bottom: textarea (auto-growing, max 4 lines) + Send button (SendHorizontal icon from lucide-react)
|
||||
- Send on Enter (Shift+Enter for newline), clear input after send
|
||||
- Auto-scroll to bottom on new messages (use ref + scrollIntoView)
|
||||
- Show "Connecting..." state when WebSocket not connected
|
||||
- Empty state when no conversationId selected: "Select a conversation or start a new one"
|
||||
|
||||
5. Create packages/portal/app/(dashboard)/chat/page.tsx:
|
||||
- "use client" component
|
||||
- Layout: flex row, full height (h-[calc(100vh-4rem)] or similar to fill dashboard area)
|
||||
- Left: ChatSidebar (w-80, border-right)
|
||||
- Right: ChatWindow (flex-1)
|
||||
- State: activeConversationId (string | null), showAgentPicker (boolean)
|
||||
- On mount: load conversations via useConversations(activeTenantId)
|
||||
- For platform admin: use tenant switcher pattern — show all tenants, load agents per tenant
|
||||
- "New Conversation" flow: show agent picker dialog (Dialog from shadcn base-ui). List agents from useAgents(tenantId). On agent select: call useCreateConversation, set activeConversationId to result.id
|
||||
- URL state: sync activeConversationId to URL search param ?id={conversationId} for bookmark/refresh support
|
||||
- Get auth headers from session (useSession from next-auth/react) — userId, role, activeTenantId
|
||||
|
||||
6. Update packages/portal/components/nav.tsx:
|
||||
- Import MessageSquare from lucide-react
|
||||
- Add { href: "/chat", label: "Chat", icon: MessageSquare } to navItems array
|
||||
- Position after "Employees" and before "Usage"
|
||||
- No allowedRoles restriction (all roles can chat per CONTEXT.md)
|
||||
|
||||
The chat should feel like a modern messaging app (Slack DMs / iMessage style) — not a clinical chatbot widget. Clean spacing, smooth scrolling, readable typography.
|
||||
|
||||
IMPORTANT: Use standardSchemaResolver (not zodResolver) if any forms are needed (per STATE.md convention).
|
||||
IMPORTANT: use(searchParams) pattern for reading URL params in client components (Next.js 15/16 convention).
|
||||
IMPORTANT: base-ui DialogTrigger uses render prop not asChild (per Phase 4 STATE.md decision).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npx next build 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Chat page renders at /chat with sidebar (conversation list) and main panel (active conversation). New Conversation button opens agent picker dialog. Messages display with role-based alignment and markdown rendering. Typing indicator animates during response wait. Nav sidebar includes Chat link visible to all roles. Portal builds without errors.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. Portal builds: `cd packages/portal && npx next build`
|
||||
2. Chat page accessible at /chat after login
|
||||
3. Nav shows "Chat" link for all roles
|
||||
4. No TypeScript errors in new files
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- /chat page renders with left sidebar and right conversation panel
|
||||
- New Conversation flow: agent picker -> create conversation -> WebSocket connect
|
||||
- Messages render with markdown (assistant) and plain text (user)
|
||||
- Typing indicator shows animated dots during response generation
|
||||
- Conversation history loads from REST API on page visit
|
||||
- WebSocket connects and authenticates via JSON auth message
|
||||
- Nav includes Chat link visible to all three roles
|
||||
- Portal builds successfully
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-web-chat/06-02-SUMMARY.md`
|
||||
</output>
|
||||
119
.planning/phases/06-web-chat/06-03-PLAN.md
Normal file
119
.planning/phases/06-web-chat/06-03-PLAN.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
phase: 06-web-chat
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["06-01", "06-02"]
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements:
|
||||
- CHAT-01
|
||||
- CHAT-02
|
||||
- CHAT-03
|
||||
- CHAT-04
|
||||
- CHAT-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "End-to-end chat works: user sends message via WebSocket, receives LLM response"
|
||||
- "Conversation history persists and loads on page revisit"
|
||||
- "Typing indicator appears during response generation"
|
||||
- "Markdown renders correctly in agent responses"
|
||||
- "RBAC enforced: operator can chat, but cannot see admin-only nav items"
|
||||
- "Platform admin can chat with agents across tenants"
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Human verification of the complete web chat feature. Test end-to-end flow, RBAC enforcement, conversation persistence, and UX quality.
|
||||
|
||||
Purpose: Confirm all CHAT requirements are met before marking Phase 6 complete.
|
||||
Output: Verified working chat feature.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/06-web-chat/06-01-SUMMARY.md
|
||||
@.planning/phases/06-web-chat/06-02-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 1: Verify end-to-end web chat feature</name>
|
||||
<files></files>
|
||||
<action>
|
||||
Present the following verification checklist to the user. This is a human verification checkpoint — no code changes needed.
|
||||
|
||||
What was built:
|
||||
- WebSocket-based real-time chat in the portal at /chat
|
||||
- Conversation sidebar with agent list, timestamps, message previews
|
||||
- Message bubbles with markdown rendering and typing indicators
|
||||
- Full agent pipeline integration (memory, tools, escalation, audit)
|
||||
- Conversation history persistence in PostgreSQL
|
||||
- RBAC enforcement (all roles can chat, scoped to accessible tenants)
|
||||
|
||||
Prerequisites:
|
||||
- Docker Compose stack running (gateway, orchestrator, portal, postgres, redis)
|
||||
- At least one active agent configured for a tenant
|
||||
- Migration applied: `alembic upgrade head`
|
||||
|
||||
Test 1 — Basic Chat (CHAT-01, CHAT-05):
|
||||
1. Log in to portal as customer_admin
|
||||
2. Click "Chat" in the sidebar navigation
|
||||
3. Click "New Conversation" and select an AI Employee
|
||||
4. Type a message and press Enter
|
||||
5. Verify: typing indicator (animated dots) appears immediately
|
||||
6. Verify: agent response appears as a left-aligned message bubble
|
||||
7. Verify: your message appears right-aligned
|
||||
|
||||
Test 2 — Markdown Rendering (CHAT-05):
|
||||
1. Send a message that triggers a formatted response (e.g., "Give me a bulleted list of 3 tips")
|
||||
2. Verify: response renders with proper markdown (bold, lists, code blocks)
|
||||
|
||||
Test 3 — Conversation History (CHAT-03):
|
||||
1. After sending a few messages, navigate away from /chat (e.g., go to /dashboard)
|
||||
2. Navigate back to /chat
|
||||
3. Verify: previous conversation appears in sidebar with last message preview
|
||||
4. Click the conversation
|
||||
5. Verify: full message history loads (all previous messages visible)
|
||||
|
||||
Test 4 — RBAC (CHAT-04):
|
||||
1. Log in as customer_operator
|
||||
2. Verify: "Chat" link visible in sidebar
|
||||
3. Navigate to /chat, start a conversation with an agent
|
||||
4. Verify: chat works (operators can chat)
|
||||
5. Verify: admin-only nav items (Billing, API Keys, Users) are still hidden
|
||||
|
||||
Test 5 — Full Pipeline (CHAT-02):
|
||||
1. If the agent has tools configured, send a message that triggers tool use
|
||||
2. Verify: agent invokes the tool and incorporates the result
|
||||
3. (Optional) If escalation rules are configured, trigger one and verify handoff message
|
||||
</action>
|
||||
<verify>Human confirms all 5 test scenarios pass</verify>
|
||||
<done>User types "approved" confirming end-to-end web chat works correctly across all CHAT requirements</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
All 5 test scenarios pass as described above.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Human confirms end-to-end chat works with real LLM responses
|
||||
- Conversation history persists across page navigations
|
||||
- Typing indicator visible during response generation
|
||||
- Markdown renders correctly
|
||||
- RBAC correctly scopes agent access
|
||||
- All three roles (platform_admin, customer_admin, customer_operator) can chat
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-web-chat/06-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user