TDD: Agentic Chat Implementation
Overview
This Technical Design Document provides detailed implementation specifications for the Agentic Chat feature described in PRD: Agentic Chat.
Architecture
System Components
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (Angular) │
│ │
│ ┌──────────────┐ ┌───────────────┐ ┌───────────────────┐ │
│ │ ChatComponent│ │ ChatService │ │ ChatStateService │ │
│ │ (UI) │←→│ (API Client) │←→│ (RxJS State) │ │
│ └──────────────┘ └───────────────┘ └───────────────────┘ │
│ │ │ │
│ └──────────────────┴─────────────────────────────────┐ │
└──────────────────────────────────────────────────────────────│──┘
│
HTTPS/SSE │
▼
┌────────────────────────────────────────────────────────────────┐
│ ESPv2 Proxy (Cloud Run) │
│ - REST ↔ gRPC transcoding │
│ - Firebase authentication │
│ - Serves /v1/chat/* endpoints (NEW) │
└───────────────────────────┬────────────────────────────────────┘
│ gRPC (Internal)
▼
┌────────────────────────────────────────────────────────────────┐
│ gRPC Services (Cloud Run - Java) │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ NEW: ChatService (gRPC) │ │
│ │ - rpc StreamChat() returns stream ChatMessageChunk │ │
│ │ - rpc CreateSession() returns Session │ │
│ │ - rpc GetSessionHistory() returns SessionHistory │ │
│ └────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ┌────────────────▼───────────────────────────────────────┐ │
│ │ ChatAgentService (Java) │ │
│ │ - Manages ADK InMemoryRunner │ │
│ │ - Streams events from agent │ │
│ │ - Injects context into prompts │ │
│ └────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ┌────────────────▼───────────────────────────────────────┐ │
│ │ LlmAgent (ADK) │ │
│ │ - Model: Gemini 2.5 Flash with thinking │ │
│ │ - Tools: OpenApiToolset → calls own services │ │
│ │ - System prompt with PermitProof expertise │ │
│ └────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ┌────────────────▼───────────────────────────────────────┐ │
│ │ OpenApiToolset │ │
│ │ - Loads openapi.yaml at startup │ │
│ │ - Creates RestApiTool for each operation │ │
│ │ - Calls via localhost OR ESPv2 URL │ │
│ └────────────────┬───────────────────────────────────────┘ │
│ │ HTTP (loopback or ESPv2) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Existing gRPC Services (Same Cloud Run Instance): │ │
│ │ - ArchitecturalPlanService │ │
│ │ - ArchitecturalPlanReviewService │ │
│ │ - ComplianceCodeSearchService │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Firestore (Admin SDK) │ │
│ │ - chat_sessions collection │ │
│ │ - chat_sessions/{id}/messages sub-collection │ │
│ └─────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
Note: Agent calls other services within the same Cloud Run instance via:
Option 1: Direct Java method calls (if services are accessible)
Option 2: HTTP calls to localhost:8080 or ESPv2 URL
Backend Implementation
Architecture Decision: gRPC Native vs Spring Boot
✅ CHOSEN APPROACH: gRPC Native (Integrated into existing Cloud Run service)
This design integrates chat directly into your existing gRPC services rather than creating a separate Spring Boot backend.
| Aspect | gRPC Native (This Design) | Spring Boot Alternative |
|---|---|---|
| Deployment | Single Cloud Run service | Separate Cloud Run service |
| Stack | Pure gRPC + ESPv2 | Spring Boot + REST |
| Complexity | Lower (reuse existing infra) | Higher (new deployment) |
| Latency | Lower (fewer hops) | Higher (extra service hop) |
| Cost | Same Cloud Run instance | Additional Cloud Run cost |
| Maintenance | Single codebase | Two codebases |
Architecture Notes
Key Implementation Details:
- No Spring Boot - This integrates into your existing gRPC Java service on Cloud Run
- New gRPC Service - Add
ChatServicetosrc/main/proto/chat.proto - Service Implementation -
ChatServiceImplextends generated gRPC service stub - ADK Integration - Use ADK's
InMemoryRunnerwithLlmAgent(singleton per instance) - Tool Calls - Agent calls other services via OpenApiToolset pointing to ESPv2
- No Framework Changes - Reuses your existing gRPC infrastructure
- ESPv2 Handles REST - gRPC-Gateway annotations generate REST endpoints automatically
1. Protocol Buffers Definition
File: src/main/proto/chat.proto
syntax = "proto3";
package org.codetricks.construction.code.assistant.chat;
import "google/protobuf/timestamp.proto";
option java_multiple_files = true;
option java_package = "org.codetricks.construction.code.assistant.chat";
// Chat service for agentic conversations
service ChatService {
// Stream chat messages (server-side streaming)
rpc StreamChat(StreamChatRequest) returns (stream ChatMessageChunk);
// Create a new chat session
rpc CreateSession(CreateSessionRequest) returns (SessionResponse);
// Get session history
rpc GetSessionHistory(GetSessionHistoryRequest) returns (SessionHistoryResponse);
// Delete a session
rpc DeleteSession(DeleteSessionRequest) returns (google.protobuf.Empty);
}
message StreamChatRequest {
string session_id = 1;
string message = 2;
ChatContext context = 3;
}
message ChatContext {
string project_id = 1;
string project_name = 2;
string file_id = 3;
string file_name = 4;
int32 page_number = 5;
repeated PinnedPage pinned_pages = 6;
}
message PinnedPage {
int32 page_number = 1;
string page_title = 2;
string file_id = 3;
string file_name = 4;
google.protobuf.Timestamp pinned_at = 5;
}
message ChatMessageChunk {
enum ChunkType {
TEXT = 0;
THINKING = 1;
TOOL_CALL_START = 2;
TOOL_CALL_RESULT = 3;
ERROR = 4;
}
ChunkType type = 1;
string content = 2;
string thinking_content = 3;
ToolCallProgress tool_call = 4;
bool is_final = 5;
}
message ToolCallProgress {
string tool_name = 1;
string operation_id = 2;
string status = 3; // STARTING, IN_PROGRESS, COMPLETED, FAILED
string parameters_json = 4;
string result_json = 5;
string error = 6;
int64 duration_ms = 7;
}
message CreateSessionRequest {
string project_id = 1;
map<string, string> metadata = 2;
}
message SessionResponse {
string session_id = 1;
google.protobuf.Timestamp created_at = 2;
}
message GetSessionHistoryRequest {
string session_id = 1;
int32 limit = 2;
string cursor = 3;
}
message SessionHistoryResponse {
repeated ChatMessage messages = 1;
bool has_more = 2;
}
message ChatMessage {
enum Role {
USER = 0;
ASSISTANT = 1;
SYSTEM = 2;
}
string message_id = 1;
Role role = 2;
string content = 3;
google.protobuf.Timestamp timestamp = 4;
MessageMetadata metadata = 5;
}
message MessageMetadata {
repeated ToolCallInfo tool_calls = 1;
int64 latency_ms = 2;
string thinking_content = 3;
}
message ToolCallInfo {
string tool_name = 1;
string operation_id = 2;
string parameters_json = 3;
string result_json = 4;
string error = 5;
int64 duration_ms = 6;
}
message DeleteSessionRequest {
string session_id = 1;
}
2. Data Model Layers Explained
Understanding the Three Layers:
There are three separate data models in this design, each serving a different purpose:
Layer 1: ADK Internal Models (Already Exists)
Package: com.google.adk.sessions and com.google.adk.events
Classes:
Session- ADK's session withid,userId,appName,state,events,lastUpdateTimeEvent- ADK's event withauthor,content,timestamp,actions
Used By: ADK InMemoryRunner - manages agent conversation state internally
Persistence: Handled by ADK's BaseSessionService (we use InMemorySessionService)
Scope: Internal to ADK agent execution
We DON'T modify these - they're ADK's internal implementation.
Layer 2: gRPC/Proto Models (New - Our API Contract)
File: src/main/proto/chat.proto
Messages: StreamChatRequest, ChatMessageChunk, ChatContext, PinnedPage, etc.
Used By:
- gRPC service interface (ChatService)
- ESPv2 REST transcoding
- Wire protocol between frontend and backend
Scope: Public API contract
Why Separate?:
- ADK Session is too generic (no projectId, fileId, pinnedPages)
- We need chat-specific fields
- Proto messages define the gRPC contract
Layer 3: TypeScript Models (New - Frontend)
File: web-ng-m3/src/app/models/chat.models.ts
Interfaces: ChatSession, ChatMessage, ChatContext, PinnedPage, etc.
Used By: Angular components and services
Relationship: These are TypeScript versions of the proto messages (generated or manually typed)
Why Separate?: Frontend needs TypeScript types, not Java classes
Data Flow Example
User types message in Angular
↓ TypeScript ChatMessage
Angular ChatService
↓ HTTP/SSE (Server-Sent Events - streaming over HTTP)
ESPv2 Proxy
↓ gRPC StreamChatRequest (proto)
ChatServiceImpl.streamChat()
↓ Calls ChatAgentService.processMessage()
ChatAgentService:
├─ Creates ADK Content from user message
├─ Gets/creates ADK Session (InMemoryRunner manages this)
└─ Calls runner.runAsync() → Flowable<Event>
↓
InMemoryRunner:
├─ LlmAgent processes with Gemini
├─ Streams ADK Events: THINKING, AGENT_CHUNK, TOOL_CALL_START, TOOL_CALL_END
└─ Each Event has author, content (Parts), timestamp, etc.
↓
ChatAgentService receives ADK Events:
├─ Extracts thinking from thinking Parts
├─ Extracts tool calls from FunctionCall/FunctionResponse
├─ Extracts text from Text Parts
└─ Converts to ChatMessageChunk (proto) ← OUR MODEL
↓
ChatServiceImpl streams ChatMessageChunk
↓ gRPC stream
ESPv2 transcodes to SSE
↓ HTTP/SSE (JSON)
Angular ChatService receives:
└─ TypeScript ChatMessage ← OUR MODEL (mirrors proto)
Key Point: We consume ADK's Session/Event (Layer 1) but expose our own simplified models (Layers 2 & 3) to the frontend.
2. Firestore Models (Chat-Specific Persistence)
We persist additional metadata beyond what ADK tracks:
2.1 ChatSession (Firestore)
Collection: chat_sessions
Purpose: Store chat-specific metadata (NOT the full ADK Session)
Document Structure:
/**
* Chat session metadata stored in Firestore.
*
* NOTE: This is NOT the same as com.google.adk.sessions.Session
*
* - ADK Session: Managed by InMemoryRunner (conversation state, events)
* - ChatSession: Our metadata (projectId, pinnedPages, timestamps)
*
* We map: chatSessionId (our ID) → adkSessionId (ADK's ID) using same UUID
*/
@Document(collection = "chat_sessions")
public class ChatSession {
@Id
private String sessionId; // Maps to ADK Session.id()
private String userId; // Maps to ADK Session.userId()
private String projectId; // NEW: Chat-specific context
private Timestamp createdAt; // NEW: Our timestamp
private Timestamp lastMessageAt; // NEW: Our tracking
private Integer messageCount; // NEW: Our counter
private String agentName; // Maps to ADK Session.appName()
private String modelName; // NEW: Which Gemini model
private Map<String, Object> metadata; // NEW: Custom metadata
private SessionStatus status; // NEW: Session lifecycle
public enum SessionStatus {
ACTIVE, ARCHIVED, DELETED
}
}
Why Not Just Use ADK Session?
- ADK Session lacks: projectId, fileId, pinnedPages, timestamps, status
- ADK Session is for internal agent state, not UI metadata
- We need separate persistence for UI-specific features
Indexes:
Composite Index: userId ASC, lastMessageAt DESC
Composite Index: projectId ASC, lastMessageAt DESC
Single Index: status ASC
2.2 ChatMessage (Firestore Sub-Collection)
Collection: chat_sessions/{sessionId}/messages
Purpose: User-friendly message history for UI display
Document Structure:
/**
* Simplified message for UI consumption.
*
* NOTE: This is NOT the same as com.google.adk.events.Event
*
* - ADK Event: Complex with FunctionCalls, Parts, GroundingMetadata, etc.
* - ChatMessage: Simplified for UI (just text content + metadata)
*
* We extract from ADK Events and store in simpler format.
*/
@Document(collection = "messages")
public class ChatMessage {
@Id
private String messageId; // Maps to Event.id()
private String sessionId; // Parent session
private MessageRole role; // Derived from Event.author()
private String content; // Extracted from Event.content() parts
private Timestamp timestamp; // Derived from Event.timestamp()
private MessageMetadata metadata; // Extracted from Event
public enum MessageRole {
USER, // author="user"
ASSISTANT, // author="agent" or agent name
SYSTEM // internal messages
}
@Data
public static class MessageMetadata {
private String thinkingContent; // NEW: Extracted from thinking parts
private List<ToolCall> toolCalls; // NEW: Extracted from FunctionCalls
private TokenUsage tokenUsage; // NEW: From Gemini response
private Long latencyMs; // NEW: Measured by us
private String modelVersion; // NEW: Which Gemini model
}
@Data
public static class ToolCall {
private String toolName; // From FunctionCall.name()
private String operationId; // From tool metadata
private Map<String, Object> parameters; // From FunctionCall.args()
private Object result; // From FunctionResponse
private String error; // If tool failed
private Long durationMs; // Measured by us
}
@Data
public static class TokenUsage {
private Integer promptTokens;
private Integer completionTokens;
private Integer totalTokens;
}
}
Why Not Just Store ADK Events?
- ADK Events are complex (grounding, branches, partial flags)
- UI only needs: author, text, timestamp, tool calls
- Easier to query and display simplified messages
- Firestore documents should be simple
1.3 DTOs (Request/Response)
package org.codetricks.construction.code.assistant.dto.chat;
// Request to send a message
@Data
@Builder
public class SendMessageRequest {
private String message;
private ChatContext context;
@Data
@Builder
public static class ChatContext {
private String projectId;
private Integer pageNumber;
private String fileId;
private Map<String, Object> additionalContext;
}
}
// Response for a message (streamed via SSE)
@Data
@Builder
public class ChatMessageResponse {
private String messageId;
private String content; // Partial or complete message
private MessageChunkType type;
private ChatMessage.MessageMetadata metadata;
private boolean isFinal; // True for last chunk
public enum MessageChunkType {
TEXT, // Regular text content
TOOL_CALL_START, // Agent is calling a tool
TOOL_CALL_RESULT, // Tool call completed
THINKING, // Agent is processing
ERROR // Error occurred
}
}
// Request to create a new session
@Data
@Builder
public class CreateSessionRequest {
private String projectId; // Optional
private Map<String, Object> metadata;
}
// Response for session creation
@Data
@Builder
public class CreateSessionResponse {
private String sessionId;
private Timestamp createdAt;
}
// Response for getting session history
@Data
@Builder
public class GetSessionHistoryResponse {
private ChatSession session;
private List<ChatMessage> messages;
private Integer totalMessages;
private boolean hasMore;
}
3. gRPC Service Implementation
3.1 ChatServiceImpl
File: src/main/java/org/codetricks/construction/code/assistant/service/ChatServiceImpl.java
package org.codetricks.construction.code.assistant.service;
import com.google.protobuf.Empty;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;
import org.codetricks.construction.code.assistant.chat.*;
import java.util.logging.Logger;
/**
* gRPC service implementation for agentic chat
*/
public class ChatServiceImpl extends ChatServiceGrpc.ChatServiceImplBase {
private static final Logger logger = Logger.getLogger(ChatServiceImpl.class.getName());
private static final Gson gson = new Gson();
private final ChatAgentService chatAgentService;
private final ChatSessionService chatSessionService;
public ChatServiceImpl(
ChatAgentService chatAgentService,
ChatSessionService chatSessionService) {
this.chatAgentService = chatAgentService;
this.chatSessionService = chatSessionService;
logger.info("ChatServiceImpl initialized");
}
/**
* Stream chat messages (server-side streaming RPC)
*/
@Override
public void streamChat(
StreamChatRequest request,
StreamObserver<ChatMessageChunk> responseObserver) {
try {
// Extract user ID from auth context
String userId = AuthContextUtils.getCurrentUserId();
// Validate session ownership
chatSessionService.validateSessionOwnership(request.getSessionId(), userId);
logger.info("Streaming chat for session: " + request.getSessionId());
// Process message through agent
chatAgentService.processMessage(
userId,
request.getSessionId(),
request.getMessage(),
request.getContext()
).subscribe(
chunk -> {
// Convert to proto and send
ChatMessageChunk protoChunk = convertToProto(chunk);
responseObserver.onNext(protoChunk);
},
error -> {
logger.severe("Error processing chat message: " + error.getMessage());
responseObserver.onError(Status.INTERNAL
.withDescription("Failed to process message: " + error.getMessage())
.asRuntimeException());
},
() -> {
logger.info("Chat streaming completed for session: " + request.getSessionId());
responseObserver.onCompleted();
}
);
} catch (UnauthorizedAccessException e) {
responseObserver.onError(Status.PERMISSION_DENIED
.withDescription("Session not found or access denied")
.asRuntimeException());
} catch (Exception e) {
logger.severe("Unexpected error in streamChat: " + e.getMessage());
responseObserver.onError(Status.INTERNAL
.withDescription("Internal server error")
.asRuntimeException());
}
}
/**
* Create a new chat session
*/
@Override
public void createSession(
CreateSessionRequest request,
StreamObserver<SessionResponse> responseObserver) {
try {
String userId = AuthContextUtils.getCurrentUserId();
SessionResponse response = chatSessionService.createSession(userId, request);
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception e) {
logger.severe("Error creating session: " + e.getMessage());
responseObserver.onError(Status.INTERNAL
.withDescription("Failed to create session")
.asRuntimeException());
}
}
/**
* Get session history
*/
@Override
public void getSessionHistory(
GetSessionHistoryRequest request,
StreamObserver<SessionHistoryResponse> responseObserver) {
try {
String userId = AuthContextUtils.getCurrentUserId();
SessionHistoryResponse response = chatSessionService.getSessionHistory(
userId,
request.getSessionId(),
request.getLimit(),
request.getCursor()
);
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (UnauthorizedAccessException e) {
responseObserver.onError(Status.PERMISSION_DENIED
.withDescription("Session not found or access denied")
.asRuntimeException());
} catch (Exception e) {
logger.severe("Error getting session history: " + e.getMessage());
responseObserver.onError(Status.INTERNAL
.withDescription("Failed to get session history")
.asRuntimeException());
}
}
/**
* Delete a session
*/
@Override
public void deleteSession(
DeleteSessionRequest request,
StreamObserver<Empty> responseObserver) {
try {
String userId = AuthContextUtils.getCurrentUserId();
chatSessionService.deleteSession(userId, request.getSessionId());
responseObserver.onNext(Empty.getDefaultInstance());
responseObserver.onCompleted();
} catch (Exception e) {
logger.severe("Error deleting session: " + e.getMessage());
responseObserver.onError(Status.INTERNAL
.withDescription("Failed to delete session")
.asRuntimeException());
}
}
/**
* Convert internal ChatMessageResponse to proto ChatMessageChunk
*/
private ChatMessageChunk convertToProto(ChatMessageResponse response) {
ChatMessageChunk.Builder builder = ChatMessageChunk.newBuilder()
.setType(convertChunkType(response.getType()))
.setContent(response.getContent() != null ? response.getContent() : "")
.setIsFinal(response.isFinal());
if (response.getThinkingContent() != null) {
builder.setThinkingContent(response.getThinkingContent());
}
if (response.getToolCall() != null) {
builder.setToolCall(convertToolCallToProto(response.getToolCall()));
}
return builder.build();
}
private ChatMessageChunk.ChunkType convertChunkType(ChatMessageResponse.MessageChunkType type) {
return switch (type) {
case TEXT -> ChatMessageChunk.ChunkType.TEXT;
case THINKING -> ChatMessageChunk.ChunkType.THINKING;
case TOOL_CALL_START -> ChatMessageChunk.ChunkType.TOOL_CALL_START;
case TOOL_CALL_RESULT -> ChatMessageChunk.ChunkType.TOOL_CALL_RESULT;
case ERROR -> ChatMessageChunk.ChunkType.ERROR;
};
}
private ToolCallProgress convertToolCallToProto(ChatMessageResponse.ToolCallProgress toolCall) {
ToolCallProgress.Builder builder = ToolCallProgress.newBuilder()
.setToolName(toolCall.getToolName())
.setOperationId(toolCall.getOperationId())
.setStatus(toolCall.getStatus());
if (toolCall.getParameters() != null) {
builder.setParametersJson(gson.toJson(toolCall.getParameters()));
}
if (toolCall.getResult() != null) {
builder.setResultJson(gson.toJson(toolCall.getResult()));
}
if (toolCall.getError() != null) {
builder.setError(toolCall.getError());
}
if (toolCall.getDurationMs() != null) {
builder.setDurationMs(toolCall.getDurationMs());
}
return builder.build();
}
}
3.2 ChatAgentService
File: src/main/java/org/codetricks/construction/code/assistant/service/ChatAgentService.java
package org.codetricks.construction.code.assistant.service;
import com.google.adk.agents.BaseAgent;
import com.google.adk.agents.LlmAgent;
import com.google.adk.events.Event;
import com.google.adk.runner.InMemoryRunner;
import com.google.adk.sessions.Session;
import com.google.adk.tools.BaseTool;
import com.google.adk.tools.openapi.OpenApiToolset;
import com.google.genai.types.*;
import io.reactivex.rxjava3.core.Flowable;
import reactor.core.publisher.Flux;
import org.codetricks.construction.code.assistant.chat.*;
import java.util.logging.Logger;
/**
* Service for managing ADK agent and processing chat messages
* Initialized once at startup and reused across all requests
*/
public class ChatAgentService {
private static final Logger logger = Logger.getLogger(ChatAgentService.class.getName());
private static final String AGENT_NAME = "permitproof_assistant";
private final BaseAgent agent;
private final InMemoryRunner runner;
private final ChatSessionService sessionService;
private final String apiBaseUrl;
private final String openapiSpecPath;
public ChatAgentService(
ChatSessionService sessionService,
String apiBaseUrl,
String openapiSpecPath) {
this.sessionService = sessionService;
this.apiBaseUrl = apiBaseUrl;
this.openapiSpecPath = openapiSpecPath;
this.agent = initializeAgent();
this.runner = new InMemoryRunner(agent);
logger.info("ChatAgentService initialized with " +
agent.tools().size() + " tools from OpenAPI spec");
}
private BaseAgent initializeAgent() {
// Load OpenAPI toolset with environment-specific base URL
// This allows the agent to work in:
// - Local dev: http://localhost:8082 (gRPC-Gateway)
// - Production: https://xxx.run.app (ESPv2 on Cloud Run)
logger.info("Initializing chat agent with API base URL: " + apiBaseUrl);
OpenApiToolset toolset = OpenApiToolset.builder()
.addOpenApiSpecFromFile(openapiSpecPath)
.baseUrl(apiBaseUrl)
.build();
// Create agent with tools and thinking mode enabled
return LlmAgent.builder()
.name(AGENT_NAME)
.model(Model.GEMINI_2_5_FLASH.getModelName()) // Gemini 2.5 Flash with thinking
.generateContentConfig(
GenerateContentConfig.builder()
.temperature(0.3F) // Slightly creative but mostly deterministic
.thinkingConfig(ThinkingConfig.builder()
.mode(ThinkingMode.THINKING_MODE_ENABLED) // Enable thinking tokens
.thinkingBudget(8192) // Max thinking tokens
.build())
.build())
.includeContents(LlmAgent.IncludeContents.DEFAULT)
.description("AI assistant for PermitProof building code compliance")
.instruction(loadSystemPrompt())
.tools(toolset.getTools().toArray(new BaseTool[0]))
.build();
}
private String loadSystemPrompt() {
return """
You are PermitProof Assistant, an AI expert in building code compliance and
architectural plan review. You help users navigate construction permits,
analyze architectural plans, and understand building code requirements.
You have access to the following capabilities:
1. Retrieve and analyze architectural plans (PDFs, transcripts, explanations)
2. Identify applicable building code sections
3. Generate compliance reports
4. Search ICC building codes
5. Answer questions about building regulations
When a user asks a question:
1. Determine which tools are needed
2. Call the appropriate APIs with correct parameters
3. Synthesize results into a clear, professional response
4. Cite specific page numbers, code sections, and sources
5. Provide actionable recommendations when applicable
Be concise, accurate, and helpful. If you're unsure, say so and suggest
alternative approaches. Always cite your sources.
""";
}
/**
* Process a user message and return streaming response
*/
public Flux<ChatMessageResponse> processMessage(
String userId,
String sessionId,
String message,
ChatContext context) {
return Flux.create(sink -> {
try {
// Save user message
ChatMessage userMessage = sessionService.saveUserMessage(
sessionId, message);
// Build context-aware prompt
String enhancedPrompt = buildEnhancedPrompt(
message,
context
);
// Get or create ADK session
Session adkSession = getOrCreateAdkSession(sessionId, userId);
// Run agent
Content userContent = Content.fromParts(Part.fromText(enhancedPrompt));
Flowable<Event> events = runner.runAsync(
userId,
adkSession.id(),
userContent
);
// Track tool calls and response
StringBuilder fullResponse = new StringBuilder();
List<ChatMessage.ToolCall> toolCalls = new ArrayList<>();
long startTime = System.currentTimeMillis();
// Process ADK Events and convert to our ChatMessageResponse
events.blockingForEach(event -> {
// event is com.google.adk.events.Event (ADK's model)
// We extract info and convert to ChatMessageResponse (our model)
ChatMessageResponse response = processEvent(
event, // ADK Event (their model)
fullResponse,
toolCalls
);
if (response != null) {
sink.next(response); // Our ChatMessageResponse
}
});
// Save assistant message
long latencyMs = System.currentTimeMillis() - startTime;
ChatMessage assistantMessage = sessionService.saveAssistantMessage(
sessionId,
fullResponse.toString(),
toolCalls,
latencyMs
);
// Send final message
sink.next(ChatMessageResponse.builder()
.messageId(assistantMessage.getMessageId())
.content(fullResponse.toString())
.type(ChatMessageResponse.MessageChunkType.TEXT)
.isFinal(true)
.metadata(assistantMessage.getMetadata())
.build());
sink.complete();
} catch (Exception e) {
logger.error("Error processing message", e);
sink.error(e);
}
});
}
private String buildEnhancedPrompt(
String userMessage,
ChatContext context) {
StringBuilder prompt = new StringBuilder();
prompt.append("=== CONTEXT ===\n");
// Add context information
if (context != null) {
if (context.getProjectId() != null) {
prompt.append("Current Project: ")
.append(context.getProjectName() != null ?
context.getProjectName() + " (" + context.getProjectId() + ")" :
context.getProjectId())
.append("\n");
}
if (context.getFileId() != null) {
prompt.append("Current File: ")
.append(context.getFileName() != null ?
context.getFileName() + " (" + context.getFileId() + ")" :
context.getFileId())
.append("\n");
}
if (context.getPageNumber() != null) {
prompt.append("Current Page: ").append(context.getPageNumber()).append("\n");
}
// Add pinned pages for additional context
if (context.getPinnedPagesList() != null && !context.getPinnedPagesList().isEmpty()) {
prompt.append("Pinned Pages (for reference):\n");
for (PinnedPage pin : context.getPinnedPagesList()) {
prompt.append(" - Page ").append(pin.getPageNumber());
if (pin.getPageTitle() != null && !pin.getPageTitle().isEmpty()) {
prompt.append(": ").append(pin.getPageTitle());
}
if (pin.getFileName() != null && !pin.getFileName().isEmpty()) {
prompt.append(" (").append(pin.getFileName()).append(")");
}
prompt.append("\n");
}
}
}
prompt.append("\n=== USER QUERY ===\n");
prompt.append(userMessage);
return prompt.toString();
}
/**
* Converts ADK Event (their model) to ChatMessageResponse (our model)
*
* Extracts:
* - Thinking tokens from thinking Parts
* - Text content from Text Parts
* - Tool calls from FunctionCall/FunctionResponse in content
*/
private ChatMessageResponse processEvent(
Event event, // com.google.adk.events.Event
StringBuilder fullResponse,
List<ChatMessage.ToolCall> toolCalls) {
// Determine event type by examining Event structure
// Note: ADK doesn't have event.type(), we infer from content/author/actions
// Check if this is a thinking event (has thinking Parts)
if (isThinkingEvent(event)) {
String thinkingContent = extractThinkingContent(event);
return ChatMessageResponse.builder()
.type(ChatMessageResponse.MessageChunkType.THINKING)
.thinkingContent(thinkingContent)
.isFinal(false)
.build();
}
// Check if this is a tool call start (has FunctionCall in content)
if (isToolCallStart(event)) {
ChatMessage.ToolCall toolCall = extractToolCallFromEvent(event);
toolCalls.add(toolCall);
return ChatMessageResponse.builder()
.type(ChatMessageResponse.MessageChunkType.TOOL_CALL_START)
.toolCall(new ChatMessageResponse.ToolCallProgress(
toolCall.getToolName(),
toolCall.getOperationId(),
"STARTING",
toolCall.getParameters(),
null, null, null
))
.isFinal(false)
.build();
}
// Check if this is a tool call result (has FunctionResponse)
if (isToolCallResult(event)) {
ChatMessage.ToolCall completedCall = updateToolCallWithResult(toolCalls, event);
return ChatMessageResponse.builder()
.type(ChatMessageResponse.MessageChunkType.TOOL_CALL_RESULT)
.toolCall(new ChatMessageResponse.ToolCallProgress(
completedCall.getToolName(),
completedCall.getOperationId(),
completedCall.getError() != null ? "FAILED" : "COMPLETED",
completedCall.getParameters(),
completedCall.getResult(),
completedCall.getError(),
completedCall.getDurationMs()
))
.isFinal(false)
.build();
}
// Default: text content from agent
if (event.author().equals("agent") || event.author().equals(AGENT_NAME)) {
if (event.content().isPresent()) {
String chunk = extractTextContent(event);
fullResponse.append(chunk);
return ChatMessageResponse.builder()
.content(chunk)
.type(ChatMessageResponse.MessageChunkType.TEXT)
.isFinal(false)
.build();
}
}
return null; // Skip events we don't need to stream
}
/**
* Helper methods to identify ADK Event types
*/
private boolean isThinkingEvent(Event event) {
// Thinking events have special Parts with thinking content
// Check if event.content() has thinking Parts
if (event.content().isEmpty()) return false;
Content content = event.content().get();
if (content.parts().isEmpty()) return false;
// Thinking parts have specific metadata or type
// This depends on ADK's thinking implementation
return content.parts().get().stream()
.anyMatch(part -> part.thought().isPresent());
}
private boolean isToolCallStart(Event event) {
// Tool call starts have FunctionCall in content
if (event.content().isEmpty()) return false;
return event.content().get().parts().orElse(List.of()).stream()
.anyMatch(part -> part.functionCall().isPresent());
}
private boolean isToolCallResult(Event event) {
// Tool results have FunctionResponse in content
if (event.content().isEmpty()) return false;
return event.content().get().parts().orElse(List.of()).stream()
.anyMatch(part -> part.functionResponse().isPresent());
}
private String extractThinkingContent(Event event) {
if (event.content().isEmpty()) return "";
return event.content().get().parts().orElse(List.of()).stream()
.filter(part -> part.thought().isPresent())
.map(part -> part.thought().get())
.findFirst()
.orElse("");
}
private String extractTextContent(Event event) {
if (event.content().isEmpty()) return "";
return event.content().get().parts().orElse(List.of()).stream()
.filter(part -> part.text().isPresent())
.map(part -> part.text().get())
.reduce("", String::concat);
}
private ChatMessage.ToolCall extractToolCallFromEvent(Event event) {
// Extract FunctionCall from Event content
if (event.content().isEmpty()) return null;
return event.content().get().parts().orElse(List.of()).stream()
.filter(part -> part.functionCall().isPresent())
.findFirst()
.map(part -> {
FunctionCall fc = part.functionCall().get();
return ChatMessage.ToolCall.builder()
.toolName(fc.name())
.operationId(fc.name()) // Could extract from tool metadata
.parameters(fc.args().orElse(Map.of()))
.build();
})
.orElse(null);
}
private ChatMessage.ToolCall updateToolCallWithResult(
List<ChatMessage.ToolCall> toolCalls,
Event event) {
// Find matching tool call and update with result
if (event.content().isEmpty()) return null;
Optional<FunctionResponse> responseOpt = event.content().get().parts().orElse(List.of()).stream()
.filter(part -> part.functionResponse().isPresent())
.map(part -> part.functionResponse().get())
.findFirst();
if (responseOpt.isEmpty()) return null;
FunctionResponse fr = responseOpt.get();
// Find the corresponding tool call by name
ChatMessage.ToolCall toolCall = toolCalls.stream()
.filter(tc -> tc.getToolName().equals(fr.name()))
.filter(tc -> tc.getResult() == null) // Not yet completed
.findFirst()
.orElse(null);
if (toolCall != null) {
toolCall.setResult(fr.response().orElse(null));
// Check for errors in response
// toolCall.setError(...) if needed
}
return toolCall;
}
private Session getOrCreateAdkSession(String chatSessionId, String userId) {
// Map chat session to ADK session
// Use chat session ID as ADK session ID for consistency
return runner.sessionService()
.getSession(chatSessionId)
.blockingGet()
.orElseGet(() ->
runner.sessionService()
.createSession(AGENT_NAME, userId)
.blockingGet()
);
}
public AgentCapabilitiesResponse getCapabilities(String agentName) {
// Return list of available tools and their descriptions
List<ToolCapability> capabilities = agent.tools().stream()
.map(tool -> new ToolCapability(
tool.name(),
tool.description(),
tool.declaration().map(FunctionDeclaration::parameters).orElse(null)
))
.toList();
return AgentCapabilitiesResponse.builder()
.agentName(agentName)
.capabilities(capabilities)
.build();
}
}
3.3 Service Registration
File: src/main/java/org/codetricks/construction/code/assistant/service/ArchitecturalPlanServer.java
Add ChatService to the gRPC server:
// In ArchitecturalPlanServer.main() method, register the new service:
public static void main(String[] args) throws Exception {
// ... existing initialization ...
// Initialize chat services
ChatSessionService chatSessionService = new ChatSessionService(firestore);
String apiBaseUrl = System.getenv("CHAT_API_BASE_URL");
if (apiBaseUrl == null || apiBaseUrl.isEmpty()) {
apiBaseUrl = "http://localhost:8082"; // Default for local dev
}
ChatAgentService chatAgentService = new ChatAgentService(
chatSessionService,
apiBaseUrl,
"openapi.yaml"
);
ChatServiceImpl chatService = new ChatServiceImpl(
chatAgentService,
chatSessionService
);
// Build server with all services
Server server =
ServerBuilder.forPort(port)
.addService(new ArchitecturalPlanServiceImpl())
.addService(new ArchitecturalPlanReviewServiceImpl())
.addService(new ComplianceCodeSearchServiceImpl())
.addService(chatService) // NEW: Add chat service
.build()
.start();
logger.info("🚀 Server started on port " + port + " with ChatService");
// ... rest of startup ...
}
3.4 ChatSessionService
File: src/main/java/org/codetricks/construction/code/assistant/service/ChatSessionService.java
package org.codetricks.construction.code.assistant.service;
import com.google.cloud.Timestamp;
import com.google.cloud.firestore.Firestore;
import com.google.cloud.firestore.Query;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.UUID;
@Service
public class ChatSessionService {
private static final String SESSIONS_COLLECTION = "chat_sessions";
private static final String MESSAGES_COLLECTION = "messages";
private final Firestore firestore;
public ChatSessionService(Firestore firestore) {
this.firestore = firestore;
logger.info("ChatSessionService initialized");
}
public CreateSessionResponse createSession(
String userId,
CreateSessionRequest request) {
String sessionId = UUID.randomUUID().toString();
Timestamp now = Timestamp.now();
ChatSession session = ChatSession.builder()
.sessionId(sessionId)
.userId(userId)
.projectId(request.getProjectId())
.createdAt(now)
.lastMessageAt(now)
.messageCount(0)
.agentName("permitproof_assistant")
.modelName(Model.getLatestGeminiFlashModel().getModelName())
.metadata(request.getMetadata())
.status(ChatSession.SessionStatus.ACTIVE)
.build();
firestore.collection(SESSIONS_COLLECTION)
.document(sessionId)
.set(session)
.get();
return CreateSessionResponse.builder()
.sessionId(sessionId)
.createdAt(now)
.build();
}
public GetSessionHistoryResponse getSessionHistory(
String userId,
String sessionId,
int limit,
String cursor) {
// Get session
ChatSession session = firestore.collection(SESSIONS_COLLECTION)
.document(sessionId)
.get()
.get()
.toObject(ChatSession.class);
if (session == null || !session.getUserId().equals(userId)) {
throw new UnauthorizedAccessException("Session not found or access denied");
}
// Get messages
Query query = firestore.collection(SESSIONS_COLLECTION)
.document(sessionId)
.collection(MESSAGES_COLLECTION)
.orderBy("timestamp", Query.Direction.ASCENDING)
.limit(limit);
if (cursor != null) {
query = query.startAfter(cursor);
}
List<ChatMessage> messages = query.get().get()
.getDocuments()
.stream()
.map(doc -> doc.toObject(ChatMessage.class))
.toList();
return GetSessionHistoryResponse.builder()
.session(session)
.messages(messages)
.totalMessages(session.getMessageCount())
.hasMore(messages.size() == limit)
.build();
}
public List<ChatSession> listSessions(
String userId,
String projectId,
int limit) {
Query query = firestore.collection(SESSIONS_COLLECTION)
.whereEqualTo("userId", userId)
.whereEqualTo("status", ChatSession.SessionStatus.ACTIVE.name())
.orderBy("lastMessageAt", Query.Direction.DESCENDING)
.limit(limit);
if (projectId != null) {
query = query.whereEqualTo("projectId", projectId);
}
return query.get().get()
.getDocuments()
.stream()
.map(doc -> doc.toObject(ChatSession.class))
.toList();
}
public ChatMessage saveUserMessage(String sessionId, String content) {
String messageId = UUID.randomUUID().toString();
Timestamp now = Timestamp.now();
ChatMessage message = ChatMessage.builder()
.messageId(messageId)
.sessionId(sessionId)
.role(ChatMessage.MessageRole.USER)
.content(content)
.timestamp(now)
.build();
// Save message
firestore.collection(SESSIONS_COLLECTION)
.document(sessionId)
.collection(MESSAGES_COLLECTION)
.document(messageId)
.set(message)
.get();
// Update session
updateSessionLastMessage(sessionId);
return message;
}
public ChatMessage saveAssistantMessage(
String sessionId,
String content,
List<ChatMessage.ToolCall> toolCalls,
long latencyMs) {
String messageId = UUID.randomUUID().toString();
Timestamp now = Timestamp.now();
ChatMessage.MessageMetadata metadata = ChatMessage.MessageMetadata.builder()
.toolCalls(toolCalls)
.latencyMs(latencyMs)
.build();
ChatMessage message = ChatMessage.builder()
.messageId(messageId)
.sessionId(sessionId)
.role(ChatMessage.MessageRole.ASSISTANT)
.content(content)
.timestamp(now)
.metadata(metadata)
.build();
firestore.collection(SESSIONS_COLLECTION)
.document(sessionId)
.collection(MESSAGES_COLLECTION)
.document(messageId)
.set(message)
.get();
updateSessionLastMessage(sessionId);
return message;
}
private void updateSessionLastMessage(String sessionId) {
firestore.collection(SESSIONS_COLLECTION)
.document(sessionId)
.update(
"lastMessageAt", Timestamp.now(),
"messageCount", com.google.cloud.firestore.FieldValue.increment(1)
)
.get();
}
public void deleteSession(String userId, String sessionId) {
// Verify ownership
validateSessionOwnership(sessionId, userId);
// Soft delete - just update status
firestore.collection(SESSIONS_COLLECTION)
.document(sessionId)
.update("status", ChatSession.SessionStatus.DELETED.name())
.get();
}
public void validateSessionOwnership(String sessionId, String userId) {
ChatSession session = firestore.collection(SESSIONS_COLLECTION)
.document(sessionId)
.get()
.get()
.toObject(ChatSession.class);
if (session == null || !session.getUserId().equals(userId)) {
throw new UnauthorizedAccessException("Session not found or access denied");
}
}
}
2.4 Enhanced OpenApiToolset with Auth
File: src/main/java/com/google/adk/tools/openapi/AuthenticatedOpenApiToolset.java
package com.google.adk.tools.openapi;
import com.google.adk.tools.BaseTool;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
/**
* Extension of OpenApiToolset that adds Firebase authentication to all tool calls
*/
public class AuthenticatedOpenApiToolset extends OpenApiToolset {
private final String firebaseToken;
private AuthenticatedOpenApiToolset(List<BaseTool> tools) {
super(tools);
this.firebaseToken = null;
}
public static Builder builder() {
return new Builder();
}
public static class Builder extends OpenApiToolset.Builder {
private String firebaseToken;
public Builder withFirebaseToken(String token) {
this.firebaseToken = token;
return this;
}
@Override
public AuthenticatedOpenApiToolset build() {
// Create OkHttpClient with auth interceptor
OkHttpClient authenticatedClient = new OkHttpClient.Builder()
.addInterceptor(new FirebaseAuthInterceptor(firebaseToken))
.build();
// Build tools with authenticated client
List<BaseTool> allTools = new ArrayList<>();
SwaggerParser parser = new SwaggerParser();
for (String specString : specStrings) {
Swagger swagger = parser.parse(specString);
if (swagger != null && swagger.getPaths() != null) {
swagger.getPaths().forEach((path, pathItem) -> {
pathItem.getOperationMap().forEach((method, operation) -> {
allTools.add(new AuthenticatedRestApiTool(
swagger,
path,
method,
operation,
baseUrl,
authenticatedClient
));
});
});
}
}
return new AuthenticatedOpenApiToolset(allTools);
}
}
/**
* Interceptor that adds Firebase auth header to all requests
*/
private static class FirebaseAuthInterceptor implements Interceptor {
private final String token;
public FirebaseAuthInterceptor(String token) {
this.token = token;
}
@Override
public Response intercept(Chain chain) throws IOException {
Request original = chain.request();
Request.Builder requestBuilder = original.newBuilder()
.header("Authorization", "Bearer " + token);
Request request = requestBuilder.build();
return chain.proceed(request);
}
}
}
Frontend Implementation
Transport: Server-Sent Events (SSE)
What is SSE?
SSE (Server-Sent Events) is a web standard for server-to-client streaming over HTTP.
Key Characteristics:
- One-way: Server → Client only (not bi-directional like WebSocket)
- HTTP-based: Uses standard HTTP connections
- Text format: Sends data as
text/event-stream - Auto-reconnect: Browser automatically reconnects if connection drops
- Simple: Just use
EventSourceAPI in JavaScript
How It Works:
// Frontend creates EventSource connection
const eventSource = new EventSource('/v1/chat/sessions/123/stream');
// Server sends events in this format:
// event: message
// data: {"type":"TEXT","content":"Hello"}
//
// event: message
// data: {"type":"THINKING","thinkingContent":"I need to..."}
// Frontend receives via callback
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data); // ChatMessageChunk as JSON
console.log(data.content); // Display in UI
});
SSE vs WebSocket:
| Feature | SSE | WebSocket |
|---|---|---|
| Direction | Server → Client only | Bi-directional |
| Protocol | HTTP | Custom (ws://) |
| Complexity | Simple | More complex |
| Use Case | Streaming responses | Chat, games, real-time |
| Our Need | ✅ Perfect for chat | ❌ Overkill |
Why SSE for Chat?
- Agent responses stream one-way (server → client)
- User messages are sent via separate POST request
- Simpler than WebSocket
- Works through HTTP proxies and firewalls
- ESPv2 can transcode gRPC streams to SSE
Architecture:
gRPC Server-Side Streaming SSE
─────────────────────────────────────────────────────────
ChatService.StreamChat() EventSource
↓ stream ChatMessageChunk ↑
↓ stream ChatMessageChunk ↑ event: message
↓ stream ChatMessageChunk ↑ event: message
ESPv2 transcodes gRPC stream → SSE Browser receives
1. Angular Module Structure
web-ng-m3/src/app/
├── components/
│ └── chat/
│ ├── chat.component.ts # Main chat container
│ ├── chat.component.html
│ ├── chat.component.scss
│ ├── chat-message/
│ │ ├── chat-message.component.ts
│ │ ├── chat-message.component.html
│ │ └── chat-message.component.scss
│ ├── chat-input/
│ │ ├── chat-input.component.ts
│ │ ├── chat-input.component.html
│ │ └── chat-input.component.scss
│ └── chat-fab/
│ ├── chat-fab.component.ts
│ ├── chat-fab.component.html
│ └── chat-fab.component.scss
├── services/
│ ├── chat.service.ts # HTTP/SSE client
│ └── chat-state.service.ts # State management
└── models/
└── chat.models.ts # TypeScript interfaces
2. TypeScript Models
File: web-ng-m3/src/app/models/chat.models.ts
export interface ChatSession {
sessionId: string;
userId: string;
projectId?: string;
createdAt: Date;
lastMessageAt: Date;
messageCount: number;
agentName: string;
modelName: string;
metadata?: Record<string, any>;
status: 'ACTIVE' | 'ARCHIVED' | 'DELETED';
}
export interface ChatMessage {
messageId: string;
sessionId: string;
role: 'USER' | 'ASSISTANT' | 'SYSTEM';
content: string;
timestamp: Date;
metadata?: MessageMetadata;
}
export interface MessageMetadata {
toolCalls?: ToolCall[];
tokenUsage?: TokenUsage;
latencyMs?: number;
modelVersion?: string;
}
export interface ToolCall {
toolName: string;
operationId: string;
parameters: Record<string, any>;
result?: any;
error?: string;
durationMs: number;
}
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface SendMessageRequest {
message: string;
context?: ChatContext;
}
export interface ChatContext {
projectId?: string;
projectName?: string;
fileId?: string;
fileName?: string;
pageNumber?: number;
pinnedPages?: PinnedPage[];
additionalContext?: Record<string, any>;
}
export interface PinnedPage {
pageNumber: number;
pageTitle?: string;
fileId?: string;
fileName?: string;
pinnedAt: Date;
}
export interface ChatMessageResponse {
messageId?: string;
content: string;
type: 'TEXT' | 'TOOL_CALL_START' | 'TOOL_CALL_RESULT' | 'THINKING' | 'ERROR';
metadata?: MessageMetadata;
isFinal: boolean;
// For thinking tokens
thinkingContent?: string;
// For tool call progress
toolCall?: {
toolName: string;
operationId: string;
status: 'STARTING' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED';
parameters?: Record<string, any>;
result?: any;
error?: string;
durationMs?: number;
};
}
export interface CreateSessionRequest {
projectId?: string;
metadata?: Record<string, any>;
}
export interface CreateSessionResponse {
sessionId: string;
createdAt: Date;
}
3. Chat Service
File: web-ng-m3/src/app/services/chat.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, Subject } from 'rxjs';
import { environment } from '../../environments/environment';
import {
ChatSession,
ChatMessage,
SendMessageRequest,
ChatMessageResponse,
CreateSessionRequest,
CreateSessionResponse
} from '../models/chat.models';
@Injectable({
providedIn: 'root'
})
export class ChatService {
private readonly API_BASE = `${environment.apiUrl}/v1/chat`;
constructor(private http: HttpClient) {}
/**
* Create a new chat session
*/
createSession(request: CreateSessionRequest): Observable<CreateSessionResponse> {
return this.http.post<CreateSessionResponse>(
`${this.API_BASE}/sessions`,
request
);
}
/**
* Get session history
*/
getSessionHistory(
sessionId: string,
limit: number = 50,
cursor?: string
): Observable<{
session: ChatSession;
messages: ChatMessage[];
totalMessages: number;
hasMore: boolean;
}> {
const params: any = { limit };
if (cursor) params.cursor = cursor;
return this.http.get<any>(
`${this.API_BASE}/sessions/${sessionId}`,
{ params }
);
}
/**
* List user's sessions
*/
listSessions(projectId?: string, limit: number = 20): Observable<ChatSession[]> {
const params: any = { limit };
if (projectId) params.projectId = projectId;
return this.http.get<ChatSession[]>(
`${this.API_BASE}/sessions`,
{ params }
);
}
/**
* Send a message and receive streaming response via SSE
*
* SSE (Server-Sent Events) is a web standard for server-to-client streaming.
* The server sends events as text/event-stream, browser receives via EventSource.
*
* Flow:
* 1. POST request initiates the stream
* 2. Server responds with streaming gRPC ChatMessageChunk
* 3. ESPv2 transcodes gRPC stream → SSE (text/event-stream)
* 4. EventSource receives each chunk as a 'message' event
*/
sendMessage(
sessionId: string,
request: SendMessageRequest
): Observable<ChatMessageResponse> {
return new Observable(observer => {
// SSE endpoint - ESPv2 transcodes gRPC stream to SSE
const sseUrl = `${this.API_BASE}/sessions/${sessionId}/stream`;
// Create EventSource connection
const eventSource = new EventSource(sseUrl, {
withCredentials: true
// Note: EventSource doesn't support custom headers
// Auth is handled by ESPv2 via cookie or query param
});
// Initiate the stream with a POST request
this.http.post<void>(sseUrl, request).subscribe({
error: (err) => {
eventSource.close();
observer.error(err);
}
});
// Receive streaming events
eventSource.addEventListener('message', (event: MessageEvent) => {
// Each SSE message contains a ChatMessageChunk (as JSON)
const chunk: ChatMessageResponse = JSON.parse(event.data);
observer.next(chunk);
// Close when final chunk received
if (chunk.isFinal) {
eventSource.close();
observer.complete();
}
});
// Handle connection errors
eventSource.addEventListener('error', (error) => {
console.error('SSE error:', error);
eventSource.close();
observer.error(new Error('SSE connection failed'));
});
// Cleanup function
return () => {
eventSource.close();
};
});
}
/**
* Delete a session
*/
deleteSession(sessionId: string): Observable<void> {
return this.http.delete<void>(`${this.API_BASE}/sessions/${sessionId}`);
}
}
4. Chat State Service
File: web-ng-m3/src/app/services/chat-state.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { ChatMessage, ChatSession } from '../models/chat.models';
export interface ChatState {
currentSession: ChatSession | null;
messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean;
error: string | null;
}
const INITIAL_STATE: ChatState = {
currentSession: null,
messages: [],
isOpen: false,
isLoading: false,
error: null
};
@Injectable({
providedIn: 'root'
})
export class ChatStateService {
private state$ = new BehaviorSubject<ChatState>(INITIAL_STATE);
/**
* Observable state stream
*/
get state(): Observable<ChatState> {
return this.state$.asObservable();
}
/**
* Get current state value
*/
get currentState(): ChatState {
return this.state$.value;
}
/**
* Update state
*/
private updateState(updates: Partial<ChatState>) {
this.state$.next({
...this.currentState,
...updates
});
}
/**
* Set current session
*/
setSession(session: ChatSession) {
this.updateState({ currentSession: session });
}
/**
* Set messages
*/
setMessages(messages: ChatMessage[]) {
this.updateState({ messages });
}
/**
* Add message
*/
addMessage(message: ChatMessage) {
this.updateState({
messages: [...this.currentState.messages, message]
});
}
/**
* Update last message (for streaming)
*/
updateLastMessage(content: string) {
const messages = [...this.currentState.messages];
const lastMessage = messages[messages.length - 1];
if (lastMessage && lastMessage.role === 'ASSISTANT') {
lastMessage.content = content;
this.updateState({ messages });
}
}
/**
* Set loading state
*/
setLoading(isLoading: boolean) {
this.updateState({ isLoading });
}
/**
* Set error
*/
setError(error: string | null) {
this.updateState({ error });
}
/**
* Toggle chat panel
*/
toggleChat() {
this.updateState({ isOpen: !this.currentState.isOpen });
}
/**
* Open chat
*/
openChat() {
this.updateState({ isOpen: true });
}
/**
* Close chat
*/
closeChat() {
this.updateState({ isOpen: false });
}
/**
* Clear session
*/
clearSession() {
this.state$.next(INITIAL_STATE);
}
}
5. Chat Component
File: web-ng-m3/src/app/components/chat/chat.component.ts
import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { ChatService } from '../../services/chat.service';
import { ChatStateService } from '../../services/chat-state.service';
import { ChatMessage, SendMessageRequest } from '../../models/chat.models';
@Component({
selector: 'app-chat',
templateUrl: './chat.component.html',
styleUrls: ['./chat.component.scss']
})
export class ChatComponent implements OnInit, OnDestroy {
@ViewChild('messageContainer') private messageContainer!: ElementRef;
private destroy$ = new Subject<void>();
state$ = this.chatState.state;
constructor(
private chatService: ChatService,
private chatState: ChatStateService
) {}
ngOnInit() {
// Initialize with existing session or create new one
this.initializeSession();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
private initializeSession() {
// Try to load most recent session
this.chatService.listSessions(undefined, 1)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (sessions) => {
if (sessions.length > 0) {
this.loadSession(sessions[0].sessionId);
} else {
this.createNewSession();
}
},
error: (error) => {
console.error('Failed to load sessions', error);
this.createNewSession();
}
});
}
private loadSession(sessionId: string) {
this.chatState.setLoading(true);
this.chatService.getSessionHistory(sessionId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
this.chatState.setSession(response.session);
this.chatState.setMessages(response.messages);
this.chatState.setLoading(false);
this.scrollToBottom();
},
error: (error) => {
console.error('Failed to load session', error);
this.chatState.setError('Failed to load chat history');
this.chatState.setLoading(false);
}
});
}
private createNewSession() {
this.chatService.createSession({})
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
this.chatState.setSession({
sessionId: response.sessionId,
userId: '', // Will be populated from backend
createdAt: response.createdAt,
lastMessageAt: response.createdAt,
messageCount: 0,
agentName: 'permitproof_assistant',
modelName: 'gemini-2.5-flash',
status: 'ACTIVE'
});
},
error: (error) => {
console.error('Failed to create session', error);
this.chatState.setError('Failed to create chat session');
}
});
}
/**
* Send user message
*/
sendMessage(message: string) {
const session = this.chatState.currentState.currentSession;
if (!session) {
console.error('No active session');
return;
}
// Add user message immediately
const userMessage: ChatMessage = {
messageId: `temp-${Date.now()}`,
sessionId: session.sessionId,
role: 'USER',
content: message,
timestamp: new Date()
};
this.chatState.addMessage(userMessage);
// Add placeholder for assistant response
const assistantMessage: ChatMessage = {
messageId: `temp-${Date.now() + 1}`,
sessionId: session.sessionId,
role: 'ASSISTANT',
content: '',
timestamp: new Date()
};
this.chatState.addMessage(assistantMessage);
this.chatState.setLoading(true);
this.scrollToBottom();
// Send to backend
const request: SendMessageRequest = {
message,
context: this.getCurrentContext()
};
this.chatService.sendMessage(session.sessionId, request)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
// Update assistant message with streaming content
this.chatState.updateLastMessage(response.content);
this.scrollToBottom();
},
complete: () => {
this.chatState.setLoading(false);
},
error: (error) => {
console.error('Failed to send message', error);
this.chatState.setError('Failed to send message');
this.chatState.setLoading(false);
}
});
}
currentContext: ChatContext = {};
ngOnInit() {
// Initialize with existing session or create new one
this.initializeSession();
// Subscribe to route/state changes to update context
this.subscribeToContextChanges();
}
private subscribeToContextChanges() {
// TODO: Subscribe to router params and app state
// Example:
// this.route.params.pipe(takeUntil(this.destroy$)).subscribe(params => {
// this.currentContext = {
// projectId: params['projectId'],
// fileId: params['fileId'],
// pageNumber: params['pageNumber'] ? +params['pageNumber'] : undefined,
// pinnedPages: this.loadPinnedPages()
// };
// });
}
private getCurrentContext(): ChatContext {
return this.currentContext;
}
hasContext(): boolean {
return !!(
this.currentContext.projectId ||
this.currentContext.fileId ||
this.currentContext.pageNumber ||
(this.currentContext.pinnedPages && this.currentContext.pinnedPages.length > 0)
);
}
removeContext(type: 'project' | 'file' | 'page') {
switch (type) {
case 'project':
this.currentContext.projectId = undefined;
this.currentContext.projectName = undefined;
break;
case 'file':
this.currentContext.fileId = undefined;
this.currentContext.fileName = undefined;
break;
case 'page':
this.currentContext.pageNumber = undefined;
break;
}
}
unpinPage(pin: PinnedPage) {
if (this.currentContext.pinnedPages) {
this.currentContext.pinnedPages = this.currentContext.pinnedPages.filter(
p => p.pageNumber !== pin.pageNumber || p.fileId !== pin.fileId
);
this.savePinnedPages();
}
}
openContextDialog() {
// TODO: Open dialog to add/select context items
// Could allow selecting project, file, page, or searching for specific pages to pin
}
private loadPinnedPages(): PinnedPage[] {
// Load from localStorage or state management
const stored = localStorage.getItem('chat_pinned_pages');
return stored ? JSON.parse(stored) : [];
}
private savePinnedPages() {
if (this.currentContext.pinnedPages) {
localStorage.setItem('chat_pinned_pages',
JSON.stringify(this.currentContext.pinnedPages));
}
}
private scrollToBottom() {
setTimeout(() => {
if (this.messageContainer) {
const element = this.messageContainer.nativeElement;
element.scrollTop = element.scrollHeight;
}
}, 100);
}
/**
* Clear chat history
*/
clearChat() {
if (confirm('Are you sure you want to clear this conversation?')) {
this.createNewSession();
}
}
/**
* Close chat panel
*/
close() {
this.chatState.closeChat();
}
}
File: web-ng-m3/src/app/components/chat/chat.component.html
<div class="chat-container" *ngIf="(state$ | async)?.isOpen">
<mat-card class="chat-panel">
<!-- Header -->
<mat-card-header>
<mat-card-title>
<span class="material-symbols-outlined">smart_toy</span>
PermitProof Assistant
</mat-card-title>
<button mat-icon-button (click)="close()" aria-label="Close chat">
<span class="material-symbols-outlined">close</span>
</button>
</mat-card-header>
<!-- Context Bar -->
<div class="context-bar" *ngIf="hasContext()">
<div class="context-label">
<span class="material-symbols-outlined">info</span>
Context:
</div>
<div class="context-chips">
<!-- Project chip -->
<mat-chip *ngIf="currentContext.projectId"
[removable]="true"
(removed)="removeContext('project')">
<span class="chip-icon material-symbols-outlined">folder</span>
{{ currentContext.projectName || currentContext.projectId }}
<button matChipRemove>
<span class="material-symbols-outlined">cancel</span>
</button>
</mat-chip>
<!-- File chip -->
<mat-chip *ngIf="currentContext.fileId"
[removable]="true"
(removed)="removeContext('file')">
<span class="chip-icon material-symbols-outlined">draft</span>
{{ currentContext.fileName || currentContext.fileId }}
<button matChipRemove>
<span class="material-symbols-outlined">cancel</span>
</button>
</mat-chip>
<!-- Page chip -->
<mat-chip *ngIf="currentContext.pageNumber"
[removable]="true"
(removed)="removeContext('page')">
<span class="chip-icon material-symbols-outlined">article</span>
Page {{ currentContext.pageNumber }}
<button matChipRemove>
<span class="material-symbols-outlined">cancel</span>
</button>
</mat-chip>
<!-- Pinned pages -->
<mat-chip *ngFor="let pin of currentContext.pinnedPages"
[removable]="true"
(removed)="unpinPage(pin)"
class="pinned-chip">
<span class="chip-icon material-symbols-outlined">push_pin</span>
{{ pin.pageTitle || 'Page ' + pin.pageNumber }}
<button matChipRemove>
<span class="material-symbols-outlined">cancel</span>
</button>
</mat-chip>
<!-- Add context button -->
<button mat-icon-button
class="add-context-btn"
(click)="openContextDialog()"
matTooltip="Add context">
<span class="material-symbols-outlined">add_circle</span>
</button>
</div>
</div>
<!-- Messages -->
<mat-card-content #messageContainer class="messages-container">
<div *ngIf="(state$ | async)?.messages.length === 0" class="empty-state">
<span class="material-symbols-outlined">chat</span>
<p>Hi! I'm your PermitProof assistant.</p>
<p>Ask me about architectural plans, compliance codes, or anything else!</p>
<div class="suggested-queries">
<button mat-stroked-button (click)="sendMessage('What can you help me with?')">
What can you help me with?
</button>
<button mat-stroked-button (click)="sendMessage('List my recent projects')">
List my recent projects
</button>
</div>
</div>
<div *ngFor="let message of (state$ | async)?.messages" class="message-wrapper">
<app-chat-message
[message]="message"
[showAvatar]="true">
</app-chat-message>
</div>
<div *ngIf="(state$ | async)?.isLoading" class="typing-indicator">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</mat-card-content>
<!-- Input -->
<mat-card-actions>
<app-chat-input
[disabled]="(state$ | async)?.isLoading"
(messageSent)="sendMessage($event)">
</app-chat-input>
</mat-card-actions>
</mat-card>
</div>
File: web-ng-m3/src/app/components/chat/chat.component.scss
.chat-container {
position: fixed;
bottom: 0;
right: 0;
z-index: 1000;
// Desktop: side panel
@media (min-width: 768px) {
top: 64px; // Account for toolbar
right: 16px;
bottom: 16px;
width: 400px;
}
// Mobile: full screen overlay
@media (max-width: 767px) {
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}
.chat-panel {
height: 100%;
display: flex;
flex-direction: column;
mat-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
mat-card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
margin: 0;
}
}
// Context bar styling
.context-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--mat-sys-surface-container-low);
border-bottom: 1px solid var(--mat-sys-outline-variant);
flex-wrap: wrap;
.context-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
white-space: nowrap;
.material-symbols-outlined {
font-size: 18px;
}
}
.context-chips {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
flex: 1;
mat-chip {
height: 28px;
font-size: 12px;
.chip-icon {
font-size: 16px;
margin-right: 4px;
}
&.pinned-chip {
background: var(--mat-sys-tertiary-container);
color: var(--mat-sys-on-tertiary-container);
}
}
.add-context-btn {
transform: scale(0.8);
color: var(--mat-sys-primary);
.material-symbols-outlined {
font-size: 20px;
}
}
}
}
mat-card-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
mat-card-actions {
padding: 16px;
border-top: 1px solid var(--mat-sys-outline-variant);
}
}
.messages-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.empty-state {
text-align: center;
padding: 32px 16px;
color: var(--mat-sys-on-surface-variant);
.material-symbols-outlined {
font-size: 64px;
opacity: 0.5;
}
p {
margin: 8px 0;
}
.suggested-queries {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 24px;
button {
text-align: left;
}
}
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 12px;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mat-sys-on-surface-variant);
animation: typing 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes typing {
0%, 60%, 100% {
opacity: 0.3;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-8px);
}
}
6. Chat Message Component
File: web-ng-m3/src/app/components/chat/chat-message/chat-message.component.ts
import { Component, Input } from '@angular/core';
import { ChatMessage } from '../../../models/chat.models';
import { marked } from 'marked';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Component({
selector: 'app-chat-message',
templateUrl: './chat-message.component.html',
styleUrls: ['./chat-message.component.scss']
})
export class ChatMessageComponent {
@Input() message!: ChatMessage;
@Input() showAvatar: boolean = true;
constructor(private sanitizer: DomSanitizer) {}
get renderedContent(): SafeHtml {
const html = marked.parse(this.message.content);
return this.sanitizer.sanitize(1, html) || '';
}
get isUser(): boolean {
return this.message.role === 'USER';
}
get avatar(): string {
return this.isUser ? 'person' : 'smart_toy';
}
copyContent() {
navigator.clipboard.writeText(this.message.content);
}
// Helper methods for tool call rendering
getToolIcon(toolName: string): string {
if (toolName.includes('Plan')) return 'description';
if (toolName.includes('Compliance')) return 'verified';
if (toolName.includes('Search')) return 'search';
if (toolName.includes('Code')) return 'code';
return 'api';
}
formatToolName(toolName: string): string {
// Convert "ArchitecturalPlanService_GetArchitecturalPlan" to "Get Architectural Plan"
const parts = toolName.split('_');
if (parts.length > 1) {
return parts[1]
.replace(/([A-Z])/g, ' $1')
.trim();
}
return toolName;
}
getStatusClass(toolCall: any): string {
return toolCall.error ? 'status-error' : 'status-success';
}
truncateJson(obj: any, maxLength: number = 500): string {
const jsonStr = JSON.stringify(obj, null, 2);
if (jsonStr.length <= maxLength) {
return jsonStr;
}
return jsonStr.substring(0, maxLength) + '\n... (truncated)';
}
isResultTruncated(obj: any, maxLength: number = 500): boolean {
return JSON.stringify(obj).length > maxLength;
}
showFullResult(toolCall: any) {
// Open dialog with full JSON result
// TODO: Implement dialog
console.log('Full result:', toolCall.result);
}
}
File: web-ng-m3/src/app/components/chat/chat-message/chat-message.component.html
<div class="message" [class.user-message]="isUser" [class.assistant-message]="!isUser">
<div class="avatar" *ngIf="showAvatar">
<span class="material-symbols-outlined">{{ avatar }}</span>
</div>
<div class="message-content">
<!-- Thinking section (collapsible, muted) -->
<div *ngIf="message.metadata?.thinkingContent" class="thinking-section">
<mat-expansion-panel class="thinking-panel">
<mat-expansion-panel-header>
<mat-panel-title>
<span class="material-symbols-outlined">psychology</span>
Thinking...
</mat-panel-title>
</mat-expansion-panel-header>
<div class="thinking-content">
{{ message.metadata.thinkingContent }}
</div>
</mat-expansion-panel>
</div>
<!-- Tool calls (collapsible, shows progress) -->
<div *ngIf="message.metadata?.toolCalls && message.metadata.toolCalls.length > 0"
class="tool-calls-section">
<mat-expansion-panel class="tools-panel">
<mat-expansion-panel-header>
<mat-panel-title>
<span class="material-symbols-outlined">construction</span>
Used {{ message.metadata.toolCalls.length }} tool(s)
</mat-panel-title>
</mat-expansion-panel-header>
<div class="tool-calls-list">
<div *ngFor="let toolCall of message.metadata.toolCalls; let i = index"
class="tool-call-item">
<!-- Tool call header -->
<div class="tool-call-header">
<div class="tool-info">
<span class="tool-icon material-symbols-outlined">
{{ getToolIcon(toolCall.toolName) }}
</span>
<strong>{{ formatToolName(toolCall.toolName) }}</strong>
<span class="status-badge" [class]="getStatusClass(toolCall)">
{{ toolCall.error ? '✗ Failed' : '✓ Success' }}
</span>
</div>
<span class="duration">{{ toolCall.durationMs }}ms</span>
</div>
<!-- Expandable details -->
<mat-expansion-panel class="tool-detail-panel">
<mat-expansion-panel-header>
<mat-panel-title class="detail-title">
View details
</mat-panel-title>
</mat-expansion-panel-header>
<div class="tool-details">
<!-- Parameters -->
<div class="detail-section">
<h4>Parameters</h4>
<pre class="code-block">{{ toolCall.parameters | json: 2 }}</pre>
</div>
<!-- Result (truncated if large) -->
<div *ngIf="toolCall.result && !toolCall.error" class="detail-section">
<h4>Response</h4>
<pre class="code-block">{{ truncateJson(toolCall.result) }}</pre>
<button *ngIf="isResultTruncated(toolCall.result)"
mat-button
(click)="showFullResult(toolCall)">
Show full response
</button>
</div>
<!-- Error -->
<div *ngIf="toolCall.error" class="detail-section error">
<h4>Error</h4>
<pre class="error-block">{{ toolCall.error }}</pre>
</div>
<!-- Operation ID -->
<div class="detail-section metadata">
<span class="meta-label">Operation:</span>
<code>{{ toolCall.operationId }}</code>
</div>
</div>
</mat-expansion-panel>
</div>
</div>
</mat-expansion-panel>
</div>
<!-- Main message text -->
<div class="message-text" [innerHTML]="renderedContent"></div>
<!-- Message actions -->
<div class="message-actions">
<button mat-icon-button (click)="copyContent()" matTooltip="Copy">
<span class="material-symbols-outlined">content_copy</span>
</button>
<span *ngIf="message.metadata?.latencyMs" class="metadata">
{{ message.metadata.latencyMs }}ms
</span>
</div>
</div>
</div>
File: web-ng-m3/src/app/components/chat/chat-message/chat-message.component.scss
.message {
display: flex;
gap: 12px;
margin-bottom: 16px;
&.user-message {
flex-direction: row-reverse;
.message-content {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
}
&.assistant-message {
.message-content {
background: var(--mat-sys-surface-variant);
color: var(--mat-sys-on-surface-variant);
}
}
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--mat-sys-surface-container);
flex-shrink: 0;
.material-symbols-outlined {
font-size: 24px;
color: var(--mat-sys-on-surface);
}
}
.message-content {
padding: 12px 16px;
border-radius: 16px;
max-width: 80%;
.message-text {
::ng-deep {
p {
margin: 0 0 8px 0;
&:last-child {
margin-bottom: 0;
}
}
code {
background: rgba(0, 0, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
pre {
background: rgba(0, 0, 0, 0.1);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
code {
background: none;
padding: 0;
}
}
a {
color: var(--mat-sys-primary);
text-decoration: underline;
}
ul, ol {
margin: 8px 0;
padding-left: 24px;
}
}
}
}
// Thinking section styling
.thinking-section {
margin-bottom: 12px;
.thinking-panel {
background: rgba(0, 0, 0, 0.03);
border-left: 3px solid var(--mat-sys-tertiary);
mat-expansion-panel-header {
padding: 8px 16px;
min-height: 40px;
mat-panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mat-sys-on-surface-variant);
font-style: italic;
.material-symbols-outlined {
font-size: 18px;
}
}
}
}
.thinking-content {
padding: 12px;
font-size: 13px;
line-height: 1.6;
color: var(--mat-sys-on-surface-variant);
font-style: italic;
white-space: pre-wrap;
}
}
// Tool calls section styling
.tool-calls-section {
margin-bottom: 12px;
.tools-panel {
background: rgba(0, 0, 0, 0.03);
border-left: 3px solid var(--mat-sys-primary);
mat-expansion-panel-header {
padding: 8px 16px;
min-height: 40px;
mat-panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
.material-symbols-outlined {
font-size: 18px;
}
}
}
}
.tool-calls-list {
padding: 8px;
}
.tool-call-item {
margin-bottom: 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.5);
padding: 8px;
&:last-child {
margin-bottom: 0;
}
}
.tool-call-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.tool-info {
display: flex;
align-items: center;
gap: 8px;
.tool-icon {
font-size: 16px;
color: var(--mat-sys-primary);
}
strong {
font-size: 13px;
}
.status-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 12px;
&.status-success {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
&.status-error {
background: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
}
}
}
.duration {
font-size: 11px;
opacity: 0.7;
}
}
.tool-detail-panel {
margin-top: 4px;
background: transparent;
box-shadow: none;
mat-expansion-panel-header {
padding: 4px 12px;
min-height: 32px;
.detail-title {
font-size: 12px;
color: var(--mat-sys-primary);
}
}
}
.tool-details {
padding: 8px 12px;
.detail-section {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
h4 {
font-size: 12px;
font-weight: 500;
margin: 0 0 4px 0;
color: var(--mat-sys-on-surface-variant);
}
.code-block {
font-size: 11px;
background: rgba(0, 0, 0, 0.05);
padding: 8px;
border-radius: 4px;
overflow-x: auto;
margin: 0;
}
&.error {
.error-block {
font-size: 11px;
background: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
padding: 8px;
border-radius: 4px;
margin: 0;
}
}
&.metadata {
display: flex;
align-items: center;
gap: 8px;
.meta-label {
font-size: 11px;
color: var(--mat-sys-on-surface-variant);
}
code {
font-size: 11px;
background: rgba(0, 0, 0, 0.05);
padding: 2px 6px;
border-radius: 3px;
}
}
}
}
}
.message-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-top: 8px;
opacity: 0.7;
.metadata {
font-size: 11px;
}
button {
transform: scale(0.8);
}
}
7. Chat Input Component
File: web-ng-m3/src/app/components/chat/chat-input/chat-input.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'app-chat-input',
templateUrl: './chat-input.component.html',
styleUrls: ['./chat-input.component.scss']
})
export class ChatInputComponent {
@Input() disabled: boolean = false;
@Output() messageSent = new EventEmitter<string>();
message: string = '';
send() {
if (this.message.trim() && !this.disabled) {
this.messageSent.emit(this.message.trim());
this.message = '';
}
}
onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.send();
}
}
}
File: web-ng-m3/src/app/components/chat/chat-input/chat-input.component.html
<div class="chat-input-container">
<mat-form-field appearance="outline" class="message-field">
<textarea
matInput
[(ngModel)]="message"
[disabled]="disabled"
(keydown)="onKeydown($event)"
placeholder="Ask a question..."
rows="1"
cdkTextareaAutosize
cdkAutosizeMinRows="1"
cdkAutosizeMaxRows="5">
</textarea>
</mat-form-field>
<button
mat-fab
color="primary"
[disabled]="!message.trim() || disabled"
(click)="send()"
aria-label="Send message">
<span class="material-symbols-outlined">send</span>
</button>
</div>
File: web-ng-m3/src/app/components/chat/chat-input/chat-input.component.scss
.chat-input-container {
display: flex;
gap: 12px;
align-items: flex-end;
.message-field {
flex: 1;
margin-bottom: 0;
}
button {
margin-bottom: 8px;
}
}
8. Chat FAB Component
File: web-ng-m3/src/app/components/chat/chat-fab/chat-fab.component.ts
import { Component } from '@angular/core';
import { ChatStateService } from '../../../services/chat-state.service';
@Component({
selector: 'app-chat-fab',
templateUrl: './chat-fab.component.html',
styleUrls: ['./chat-fab.component.scss']
})
export class ChatFabComponent {
isOpen$ = this.chatState.state.pipe(
map(state => state.isOpen)
);
constructor(private chatState: ChatStateService) {}
toggle() {
this.chatState.toggleChat();
}
}
File: web-ng-m3/src/app/components/chat/chat-fab/chat-fab.component.html
<button
mat-fab
extended
color="primary"
class="chat-fab"
[class.open]="isOpen$ | async"
(click)="toggle()"
aria-label="Toggle chat">
<span class="material-symbols-outlined">{{ (isOpen$ | async) ? 'close' : 'chat' }}</span>
<span class="label">{{ (isOpen$ | async) ? 'Close' : 'Chat' }}</span>
</button>
File: web-ng-m3/src/app/components/chat/chat-fab/chat-fab.component.scss
.chat-fab {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 999;
.label {
margin-left: 8px;
}
&.open {
// Hide on desktop when chat is open
@media (min-width: 768px) {
display: none;
}
}
}
Deployment
1. Protocol Buffer Annotations for REST
The chat.proto file needs gRPC-Gateway annotations to generate REST endpoints:
File: src/main/proto/chat.proto (add these annotations)
import "google/api/annotations.proto";
service ChatService {
rpc StreamChat(StreamChatRequest) returns (stream ChatMessageChunk) {
option (google.api.http) = {
post: "/v1/chat/sessions/{session_id}/stream"
body: "*"
};
}
rpc CreateSession(CreateSessionRequest) returns (SessionResponse) {
option (google.api.http) = {
post: "/v1/chat/sessions"
body: "*"
};
}
rpc GetSessionHistory(GetSessionHistoryRequest) returns (SessionHistoryResponse) {
option (google.api.http) = {
get: "/v1/chat/sessions/{session_id}"
};
}
rpc DeleteSession(DeleteSessionRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {
delete: "/v1/chat/sessions/{session_id}"
};
}
}
After adding annotations, regenerate the OpenAPI spec:
./cli/start-local-grpc-gateway.sh
# This will regenerate openapi.yaml with new /v1/chat/* endpoints
2. Environment Configuration
Environment Variables (for all environments):
# Set in Cloud Run environment variables or .env for local
CHAT_API_BASE_URL=<gateway-url> # http://localhost:8082 or https://xxx.run.app
CHAT_AGENT_MODEL=gemini-2.5-flash
CHAT_THINKING_ENABLED=true
CHAT_THINKING_BUDGET=8192
Development (env/dev/gcp/cloud-run/grpc/vars.yaml):
# Chat agent configuration
CHAT_API_BASE_URL: "http://localhost:8082"
CHAT_AGENT_MODEL: "gemini-2.5-flash"
CHAT_THINKING_ENABLED: "true"
CHAT_THINKING_BUDGET: "8192"
Staging/Demo (env/demo/gcp/cloud-run/grpc/vars.yaml):
# Chat agent configuration
CHAT_API_BASE_URL: "https://construction-code-expert-esp2-demo-xxx.a.run.app"
CHAT_AGENT_MODEL: "gemini-2.5-flash"
CHAT_THINKING_ENABLED: "true"
CHAT_THINKING_BUDGET: "8192"
Production (env/prod/gcp/cloud-run/grpc/vars.yaml):
# Chat agent configuration
CHAT_API_BASE_URL: "https://construction-code-expert-esp2-prod-xxx.a.run.app"
CHAT_AGENT_MODEL: "gemini-2.5-flash"
CHAT_THINKING_ENABLED: "true"
CHAT_THINKING_BUDGET: "8192"
Note: Load these in ChatAgentService constructor:
public ChatAgentService(ChatSessionService sessionService) {
this.sessionService = sessionService;
this.apiBaseUrl = System.getenv("CHAT_API_BASE_URL");
this.openapiSpecPath = "openapi.yaml";
// ... rest of initialization
}
API Gateway URL Resolution
The chat.openapi.base.url determines where the agent's tools make API calls:
| Environment | Gateway Type | Base URL | Description |
|---|---|---|---|
| Local Dev | gRPC-Gateway | http://localhost:8082 | Local proxy started via cli/start-local-grpc-gateway.sh |
| Demo | ESPv2 (Cloud Run) | https://construction-code-expert-esp2-demo-xxx.a.run.app | Deployed via env/deploy-endpoints.sh demo |
| Test | ESPv2 (Cloud Run) | https://construction-code-expert-esp2-test-xxx.a.run.app | Deployed via env/deploy-endpoints.sh test |
| Staging | ESPv2 (Cloud Run) | https://construction-code-expert-esp2-stg-xxx.a.run.app | Deployed via env/deploy-endpoints.sh stg |
| Production | ESPv2 (Cloud Run) | https://construction-code-expert-esp2-prod-xxx.a.run.app | Deployed via env/deploy-endpoints.sh prod |
Note: The ESPv2 Cloud Run URLs can be retrieved using:
ENV="demo" # or test, stg, prod
gcloud run services describe "construction-code-expert-esp2-${ENV}" \
--region=us-central1 \
--project="construction-code-expert-${ENV}" \
--format='value(status.url)'
Local Development Setup
For local development, you need to run both the gRPC server (with ChatService) and the gRPC-Gateway proxy:
Step 1: Update proto files
# Add chat.proto with gRPC-Gateway annotations
# Regenerate Java classes and OpenAPI spec
mvn clean compile
Step 2: Start gRPC Server with ChatService (Port 8080)
# From project root
export JAVA_HOME=/usr/lib/jvm/temurin-23-jdk-arm64
export CHAT_API_BASE_URL=http://localhost:8082
./cli/start-local-server.sh
# Verify ChatService is registered
grpcurl -plaintext localhost:8080 list | grep ChatService
Step 3: Start gRPC-Gateway Proxy (Port 8082)
# In a separate terminal
./cli/start-local-grpc-gateway.sh
# Verify chat endpoints are available
curl http://localhost:8082/openapi.yaml | grep "/v1/chat"
Step 4: Test Chat API
# Create a session
curl -X POST http://localhost:8082/v1/chat/sessions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $FIREBASE_TOKEN" \
-d '{}'
# Stream a message (SSE)
curl -N http://localhost:8082/v1/chat/sessions/{session_id}/stream \
-H "Authorization: Bearer $FIREBASE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "Hello", "context": {}}'
Architecture in Local Dev:
Frontend → localhost:8082 (gRPC-Gateway) → localhost:8080 (gRPC + ChatService + ADK Agent)
↓
Agent Tools → localhost:8082 → localhost:8080 (loopback)
Architecture in Production:
Frontend → ESPv2 (Cloud Run) → gRPC Service (Cloud Run with ChatService + ADK Agent)
↓
Agent Tools → ESPv2 URL → Same gRPC Service (loopback via Cloud Run)
3. Deployment Checklist
Before deploying chat feature:
- Add
chat.protowith gRPC-Gateway annotations - Regenerate proto classes:
mvn clean compile - Update
ArchitecturalPlanServer.javato registerChatServiceImpl - Add environment variables to
env/{env}/gcp/cloud-run/grpc/vars.yaml - Deploy to dev:
./cli/sdlc/full-stack-deploy.sh dev - Verify new endpoints:
curl https://xxx.run.app/openapi.yaml | grep chat - Test via grpcurl:
grpcurl ... ChatService/CreateSession - Test via REST:
curl -X POST .../v1/chat/sessions - Create Firestore indexes
- Deploy frontend with chat component
- End-to-end testing
4. Database Migrations
Firestore Indexes (deploy via firestore.indexes.json):
{
"indexes": [
{
"collectionGroup": "chat_sessions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "userId", "order": "ASCENDING" },
{ "fieldPath": "lastMessageAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "chat_sessions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "projectId", "order": "ASCENDING" },
{ "fieldPath": "lastMessageAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "messages",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "sessionId", "order": "ASCENDING" },
{ "fieldPath": "timestamp", "order": "ASCENDING" }
]
}
]
}
3. Security Rules
Firestore Rules (firestore.rules):
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Chat sessions - users can only access their own
match /chat_sessions/{sessionId} {
allow read, write: if request.auth != null
&& request.auth.uid == resource.data.userId;
// Messages sub-collection
match /messages/{messageId} {
allow read: if request.auth != null
&& get(/databases/$(database)/documents/chat_sessions/$(sessionId)).data.userId == request.auth.uid;
allow create: if request.auth != null
&& get(/databases/$(database)/documents/chat_sessions/$(sessionId)).data.userId == request.auth.uid;
}
}
}
}
Testing
1. Unit Tests
Backend Unit Tests (JUnit 5)
ChatAgentServiceTest.java:
package org.codetricks.construction.code.assistant.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.codetricks.construction.code.assistant.chat.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
@ExtendWith(MockitoExtension.class)
class ChatAgentServiceTest {
@Mock
private ChatSessionService sessionService;
private ChatAgentService agentService;
@BeforeEach
void setUp() {
// Initialize service with test configuration
agentService = new ChatAgentService(
sessionService,
"http://localhost:8082", // Test API URL
"openapi.yaml"
);
}
@Test
void testProcessMessage_SimpleQuery() {
// Given
String userId = "test-user";
String sessionId = "test-session";
String message = "What can you help me with?";
ChatContext context = ChatContext.newBuilder().build();
when(sessionService.saveUserMessage(eq(sessionId), eq(message)))
.thenReturn(createMockUserMessage());
// When
Flux<ChatMessageResponse> responses =
agentService.processMessage(userId, sessionId, message, context);
// Then - use Reactor's StepVerifier
StepVerifier.create(responses)
.expectNextMatches(resp -> resp.getType() != null)
.expectNextMatches(resp -> resp.isFinal())
.verifyComplete();
verify(sessionService).saveUserMessage(sessionId, message);
}
@Test
void testAgentInitialization() {
// Verify agent was created with correct model
assertNotNull(agentService);
// Could add more assertions about agent configuration
}
private ChatMessage createMockUserMessage() {
return ChatMessage.builder()
.messageId("msg-123")
.sessionId("test-session")
.role(ChatMessage.MessageRole.USER)
.content("Test message")
.build();
}
}
Frontend Unit Tests
chat.service.spec.ts:
describe('ChatService', () => {
let service: ChatService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ChatService]
});
service = TestBed.inject(ChatService);
httpMock = TestBed.inject(HttpTestingController);
});
it('should create a new session', () => {
const mockResponse: CreateSessionResponse = {
sessionId: 'test-123',
createdAt: new Date()
};
service.createSession({}).subscribe(response => {
expect(response.sessionId).toBe('test-123');
});
const req = httpMock.expectOne(`${service['API_BASE']}/sessions`);
expect(req.request.method).toBe('POST');
req.flush(mockResponse);
});
afterEach(() => {
httpMock.verify();
});
});
2. Integration Tests (gRPC Service Tests)
ChatServiceImplTest.java:
package org.codetricks.construction.code.assistant.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import io.grpc.ManagedChannel;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.StreamObserver;
import io.grpc.testing.GrpcCleanupRule;
import org.codetricks.construction.code.assistant.chat.*;
import org.junit.Rule;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Integration test for ChatService using in-process gRPC server
*/
@ExtendWith(MockitoExtension.class)
class ChatServiceImplTest {
@Rule
public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule();
@Mock
private ChatAgentService agentService;
@Mock
private ChatSessionService sessionService;
private ChatServiceGrpc.ChatServiceBlockingStub blockingStub;
private ChatServiceGrpc.ChatServiceStub asyncStub;
@BeforeEach
void setUp() throws Exception {
// Create in-process server
String serverName = InProcessServerBuilder.generateName();
ChatServiceImpl service = new ChatServiceImpl(agentService, sessionService);
grpcCleanup.register(
InProcessServerBuilder.forName(serverName)
.directExecutor()
.addService(service)
.build()
.start()
);
// Create client stubs
ManagedChannel channel = grpcCleanup.register(
InProcessChannelBuilder.forName(serverName)
.directExecutor()
.build()
);
blockingStub = ChatServiceGrpc.newBlockingStub(channel);
asyncStub = ChatServiceGrpc.newStub(channel);
}
@Test
void testCreateSession() {
// Given
CreateSessionRequest request = CreateSessionRequest.newBuilder()
.setProjectId("test-project")
.build();
SessionResponse expectedResponse = SessionResponse.newBuilder()
.setSessionId("session-123")
.build();
when(sessionService.createSession(anyString(), any()))
.thenReturn(expectedResponse);
// When
SessionResponse response = blockingStub.createSession(request);
// Then
assertNotNull(response);
assertEquals("session-123", response.getSessionId());
}
@Test
void testStreamChat() throws Exception {
// Given
StreamChatRequest request = StreamChatRequest.newBuilder()
.setSessionId("session-123")
.setMessage("Hello")
.build();
// Mock agent response stream
when(agentService.processMessage(anyString(), anyString(), anyString(), any()))
.thenReturn(Flux.just(
ChatMessageResponse.builder()
.content("Hello!")
.type(ChatMessageResponse.MessageChunkType.TEXT)
.isFinal(true)
.build()
));
// When
CountDownLatch latch = new CountDownLatch(1);
List<ChatMessageChunk> responses = new ArrayList<>();
asyncStub.streamChat(request, new StreamObserver<ChatMessageChunk>() {
@Override
public void onNext(ChatMessageChunk chunk) {
responses.add(chunk);
}
@Override
public void onError(Throwable t) {
latch.countDown();
}
@Override
public void onCompleted() {
latch.countDown();
}
});
// Wait for completion
assertTrue(latch.await(5, TimeUnit.SECONDS));
// Then
assertFalse(responses.isEmpty());
assertEquals(ChatMessageChunk.ChunkType.TEXT, responses.get(0).getType());
}
}
3. E2E Tests
chat.e2e.spec.ts (Cypress):
describe('Chat Feature', () => {
beforeEach(() => {
cy.login('test-user@example.com');
cy.visit('/projects');
});
it('should open chat and send message', () => {
// Open chat
cy.get('.chat-fab').click();
cy.get('.chat-panel').should('be.visible');
// Send message
cy.get('textarea[placeholder="Ask a question..."]')
.type('What can you help me with?');
cy.get('button[aria-label="Send message"]').click();
// Wait for response
cy.get('.message.assistant-message', { timeout: 10000 })
.should('be.visible')
.and('contain', 'capabilities');
});
it('should persist session across page reload', () => {
// Send message
cy.get('.chat-fab').click();
cy.get('textarea').type('Test message{enter}');
// Reload page
cy.reload();
// Chat should remember history
cy.get('.chat-fab').click();
cy.get('.message.user-message')
.should('contain', 'Test message');
});
});
Monitoring and Observability
1. Metrics
Track the following metrics in Cloud Monitoring:
- chat_messages_sent_total (counter) - Total messages sent by users
- chat_messages_received_total (counter) - Total responses from agent
- chat_session_duration_seconds (histogram) - Session duration
- chat_api_latency_seconds (histogram) - API response time
- chat_tool_calls_total (counter) - Tool usage by tool name
- chat_errors_total (counter) - Errors by type
- chat_gemini_tokens_total (counter) - Token usage
- chat_gemini_cost_usd (gauge) - Estimated cost
2. Logging
Log the following events:
// Session events
logger.info("Chat session created",
kv("sessionId", sessionId),
kv("userId", userId),
kv("projectId", projectId));
// Message events
logger.info("User message received",
kv("sessionId", sessionId),
kv("messageLength", message.length()));
logger.info("Agent response complete",
kv("sessionId", sessionId),
kv("latencyMs", latencyMs),
kv("toolCallCount", toolCalls.size()),
kv("tokenCount", tokens));
// Tool call events
logger.info("Tool called",
kv("toolName", toolName),
kv("operationId", operationId),
kv("durationMs", durationMs));
// Error events
logger.error("Chat error",
kv("sessionId", sessionId),
kv("errorType", error.getClass().getSimpleName()),
kv("errorMessage", error.getMessage()));
3. Alerts
Configure alerts in Cloud Monitoring:
- High Error Rate: Alert if error rate > 10% over 5 minutes
- High Latency: Alert if P95 latency > 10 seconds
- Quota Limit: Alert if Gemini API quota usage > 80%
- Session Service Down: Alert if session create/get failures > 50%
Cost Estimation
Gemini API Costs
Model: Gemini 2.5 Flash
- Input: $0.075 per 1M tokens
- Output: $0.30 per 1M tokens
Assumptions:
- 1,000 active users
- 5 queries per user per day
- Average 200 input tokens per query
- Average 500 output tokens per response
- 30% of queries require 2 tool calls (adding context)
Monthly Cost:
Total queries/month = 1,000 users × 5 queries/day × 30 days = 150,000 queries
Input tokens = 150,000 × 200 = 30M tokens
Input cost = 30M × $0.075 / 1M = $2.25
Output tokens = 150,000 × 500 = 75M tokens
Output cost = 75M × $0.30 / 1M = $22.50
Tool call tokens = 150,000 × 0.3 × 2 × 500 = 45M tokens
Tool call cost = 45M × $0.075 / 1M = $3.38
TOTAL = $2.25 + $22.50 + $3.38 = $28.13/month
Infrastructure Costs
- Firestore: ~$0.18/GiB/month (estimate 2 GiB) = $0.36/month
- Cloud Run: Included in existing infrastructure
- Cloud Logging: ~$0.50/month
Total Monthly Cost: ~$29/month
Security Considerations
1. Prompt Injection Prevention
- Sanitize user input before sending to LLM
- Implement rate limiting per user
- Monitor for unusual patterns (very long messages, special characters)
private String sanitizeUserInput(String input) {
// Remove potential injection patterns
String sanitized = input
.replaceAll("<\\|.*?\\|>", "") // Remove special tokens
.replaceAll("\\bignore\\b.*?\\bprevious\\b", "") // Remove ignore instructions
.substring(0, Math.min(input.length(), 5000)); // Limit length
return sanitized;
}
2. Data Privacy
- Never log sensitive user data (PII, plan contents)
- Implement data retention policy (auto-delete sessions after 30 days)
- Allow users to delete their chat history
3. Authorization
- Validate Firebase token on every request
- Ensure agent respects RBAC permissions when calling tools
- Session ownership validation before any operation
Rollout Plan
Phase 1: Internal Beta (Week 1-2)
- Deploy to staging environment
- Internal team testing
- Collect feedback and iterate
Phase 2: Limited Beta (Week 3-4)
- Release to 10 beta customers
- Monitor usage and costs
- Fix critical bugs
Phase 3: General Availability (Week 5+)
- Release to all users (opt-in)
- Monitor adoption metrics
- Iterate based on feedback
Open Questions
-
SSE vs WebSocket: Which transport should we prioritize?
- Recommendation: Start with SSE (simpler), add WebSocket if needed
-
Session Persistence: How long should we keep chat history?
- Recommendation: 30 days for active sessions, allow manual export
-
Model Selection: Should we use Flash or Pro?
- Recommendation: Flash by default, add option to upgrade to Pro
-
Tool Visibility: Show all tool calls or just results?
- Recommendation: Collapsible "debug" section with tool details
Appendices
A. API Reference
See Chat Controller section.
B. Database Schema
See Data Models section.
C. Component Hierarchy
AppComponent
├── ChatFabComponent
└── ChatComponent
├── ChatMessageComponent (multiple)
└── ChatInputComponent
Document Version: 1.0
Last Updated: 2024-10-24
Owner: Engineering Team
Status: Ready for Implementation