-
-
- } />
- }
- />
- }
- />
-
+
+
+ {/* Left side - Main content (2/3 width) - Hidden on small screens */}
+
+
+ } />
+ }
+ />
+ }
+ />
+
+
+
+ {/* Right side - Chat window (1/3 width on small+ screens, full width on extra small screens) - Sticky */}
+
+
);
diff --git a/vite-app/src/components/BubbleContainer.tsx b/vite-app/src/components/BubbleContainer.tsx
new file mode 100644
index 00000000..ed38e56a
--- /dev/null
+++ b/vite-app/src/components/BubbleContainer.tsx
@@ -0,0 +1,85 @@
+import type { ReactNode } from "react";
+import Button from "./Button";
+import { Tooltip } from "./Tooltip";
+
+interface BubbleContainerProps {
+ role: "user" | "assistant" | "system" | "tool" | "thinking";
+ children: ReactNode;
+ onCopy?: () => void;
+ copySuccess?: boolean;
+ showCopyButton?: boolean;
+}
+
+export const BubbleContainer = ({
+ role,
+ children,
+ onCopy,
+ copySuccess = false,
+ showCopyButton = true,
+}: BubbleContainerProps) => {
+ const isUser = role === "user";
+ const isSystem = role === "system";
+ const isTool = role === "tool";
+ const isAssistant = role === "assistant";
+ const isThinking = role === "thinking";
+
+ const handleCopy = async () => {
+ if (onCopy) {
+ await onCopy();
+ }
+ };
+
+ return (
+
+
+ {/* Copy button positioned in top-right corner */}
+ {showCopyButton && onCopy && (
+
+
+
+
+
+ )}
+
+
+ {role}
+
+ {children}
+
+
+ );
+};
diff --git a/vite-app/src/components/ChatMessages.tsx b/vite-app/src/components/ChatMessages.tsx
new file mode 100644
index 00000000..639e80d1
--- /dev/null
+++ b/vite-app/src/components/ChatMessages.tsx
@@ -0,0 +1,69 @@
+import { useRef, useEffect } from "react";
+import type { Message } from "../types/eval-protocol";
+import { MessageBubble } from "./MessageBubble";
+import { ThinkingBubble } from "./ThinkingBubble";
+
+interface ChatMessagesProps {
+ messages: Message[];
+ isLoading?: boolean;
+}
+
+export const ChatMessages = ({
+ messages,
+ isLoading = false,
+}: ChatMessagesProps) => {
+ const scrollContainerRef = useRef
(null);
+ const prevMessagesLengthRef = useRef(0);
+
+ // Auto-scroll to bottom when new messages come in
+ useEffect(() => {
+ // On first render, just set the initial length without scrolling
+ if (prevMessagesLengthRef.current === 0) {
+ prevMessagesLengthRef.current = messages.length;
+ return;
+ }
+
+ // Only scroll if we have messages and the number of messages has increased
+ // This prevents scrolling on initial mount or when messages are removed
+ if (
+ messages.length > 0 &&
+ messages.length > prevMessagesLengthRef.current
+ ) {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTo({
+ top: scrollContainerRef.current.scrollHeight,
+ behavior: "smooth",
+ });
+ }
+ }
+ // Update the previous length for the next comparison
+ prevMessagesLengthRef.current = messages.length;
+ }, [messages]);
+
+ return (
+
+ {messages.length === 0 ? (
+
+
+
🤖
+
+ Start a conversation with the AI assistant
+
+
+ Ask about your evaluation data, trends, or insights
+
+
+
+ ) : (
+ messages.map((message, index) => (
+
+ ))
+ )}
+
+ {isLoading &&
}
+
+ );
+};
diff --git a/vite-app/src/components/ChatWindow.tsx b/vite-app/src/components/ChatWindow.tsx
new file mode 100644
index 00000000..1b692c02
--- /dev/null
+++ b/vite-app/src/components/ChatWindow.tsx
@@ -0,0 +1,244 @@
+import { useState } from "react";
+import { observer } from "mobx-react";
+import { ChatMessages } from "./ChatMessages";
+import Textarea from "./Textarea";
+import Button from "./Button";
+import { AgentService } from "../services/AgentService";
+import type { Message } from "../types/eval-protocol";
+import { ChatState } from "../stores/ChatState";
+
+interface ChatWindowProps {
+ className?: string;
+}
+
+// Create singleton instances at module level
+const agentService = new AgentService();
+const chatState = new ChatState();
+
+export const ChatWindow = observer(({ className = "" }: ChatWindowProps) => {
+ const [chatInput, setChatInput] = useState("");
+
+ const processMessage = async (message: string) => {
+ // Add user message
+ const userMessage: Message = {
+ role: "user",
+ content: message,
+ };
+ chatState.addMessage(userMessage);
+ chatState.setLoading(true);
+ chatState.setError(null);
+
+ try {
+ // For now, simulate AI response with tool calls
+ // In a real implementation, you'd call an AI service here
+ await simulateAIResponse(message);
+ } catch (error) {
+ chatState.setError(
+ error instanceof Error ? error.message : "Unknown error"
+ );
+ } finally {
+ chatState.setLoading(false);
+ }
+ };
+
+ const simulateAIResponse = async (userMessage: string) => {
+ // Simulate AI thinking time
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
+ // Generate a simple response based on the message
+ let response = "I understand you want to analyze your evaluation data. ";
+ let toolCalls = [];
+
+ // Simple keyword-based tool call generation
+ if (
+ userMessage.toLowerCase().includes("failed") ||
+ userMessage.toLowerCase().includes("error")
+ ) {
+ response += "Let me find failed evaluations for you.";
+ toolCalls = [
+ {
+ id: agentService.generateToolCallId(),
+ name: "analyzeData",
+ parameters: {
+ filters: [
+ {
+ field: "evaluation_result.score",
+ operator: "<",
+ value: "0.5",
+ },
+ ],
+ visualizationType: "table",
+ },
+ },
+ ];
+ } else if (
+ userMessage.toLowerCase().includes("model") ||
+ userMessage.toLowerCase().includes("compare")
+ ) {
+ response += "Let me compare model performance for you.";
+ toolCalls = [
+ {
+ id: agentService.generateToolCallId(),
+ name: "analyzeData",
+ parameters: {
+ groupBy: ["input_metadata.completion_params.model"],
+ aggregations: [
+ {
+ field: "evaluation_result.score",
+ operation: "avg",
+ alias: "average_score",
+ },
+ ],
+ visualizationType: "chart",
+ },
+ },
+ ];
+ } else if (
+ userMessage.toLowerCase().includes("trend") ||
+ userMessage.toLowerCase().includes("time")
+ ) {
+ response += "Let me analyze trends over time for you.";
+ toolCalls = [
+ {
+ id: agentService.generateToolCallId(),
+ name: "analyzeData",
+ parameters: {
+ groupBy: ["created_at"],
+ aggregations: [
+ {
+ field: "evaluation_result.score",
+ operation: "avg",
+ alias: "average_score",
+ },
+ ],
+ visualizationType: "chart",
+ },
+ },
+ ];
+ } else {
+ response += "Let me show you a general overview of your data.";
+ toolCalls = [
+ {
+ id: agentService.generateToolCallId(),
+ name: "analyzeData",
+ parameters: {
+ limit: 10,
+ visualizationType: "table",
+ },
+ },
+ ];
+ }
+
+ // Add AI message
+ const aiMessage: Message = {
+ role: "assistant",
+ content: response,
+ tool_calls: toolCalls.map((tc) => ({
+ id: tc.id,
+ type: "function" as const,
+ function: {
+ name: tc.name,
+ arguments: JSON.stringify(tc.parameters),
+ },
+ })),
+ };
+ chatState.addMessage(aiMessage);
+
+ // Execute tool calls
+ if (toolCalls.length > 0) {
+ const toolResults = [];
+ for (const toolCall of toolCalls) {
+ const result = await agentService.executeToolCall(toolCall);
+ toolResults.push(result);
+ }
+ chatState.addToolResultsToLastMessage(toolResults);
+ }
+ };
+
+ const handleSendMessage = async () => {
+ if (!chatInput.trim()) return;
+
+ const message = chatInput.trim();
+ setChatInput("");
+ await processMessage(message);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSendMessage();
+ }
+ };
+
+ const handleSuggestionClick = (suggestion: string) => {
+ setChatInput(suggestion);
+ };
+
+ const handleClearChat = () => {
+ chatState.clearMessages();
+ // Re-add welcome message
+ chatState.addWelcomeMessage();
+ };
+
+ const toolSuggestions = agentService.getToolSuggestions();
+
+ return (
+
+
+ {/* Chat header - following Dashboard pattern */}
+
+
+
AI Assistant
+
+
+
+
+ {/* Chat messages */}
+
+
+ {/* Tool suggestions */}
+
+
+ Try asking:
+ {toolSuggestions.map((suggestion, index) => (
+
+ ))}
+
+
+
+ {/* Chat input */}
+
+
+
+
+ );
+});
diff --git a/vite-app/src/components/Dashboard.tsx b/vite-app/src/components/Dashboard.tsx
index f7c5a8c4..ab4e0bf6 100644
--- a/vite-app/src/components/Dashboard.tsx
+++ b/vite-app/src/components/Dashboard.tsx
@@ -7,6 +7,7 @@ import Button from "./Button";
import { EvaluationTable } from "./EvaluationTable";
import PivotTab from "./PivotTab";
import TabButton from "./TabButton";
+import { Spinner } from "./Spinner";
interface DashboardProps {
onRefresh: () => void;
@@ -55,7 +56,7 @@ const LoadingState = () => {
Loading evaluation data...
diff --git a/vite-app/src/components/MessageBubble.tsx b/vite-app/src/components/MessageBubble.tsx
index 5981a860..40380503 100644
--- a/vite-app/src/components/MessageBubble.tsx
+++ b/vite-app/src/components/MessageBubble.tsx
@@ -1,7 +1,6 @@
import type { Message } from "../types/eval-protocol";
import { useState } from "react";
-import Button from "./Button";
-import { Tooltip } from "./Tooltip";
+import { BubbleContainer } from "./BubbleContainer";
export const MessageBubble = ({ message }: { message: Message }) => {
const [isExpanded, setIsExpanded] = useState(false);
@@ -9,6 +8,8 @@ export const MessageBubble = ({ message }: { message: Message }) => {
const isUser = message.role === "user";
const isSystem = message.role === "system";
const isTool = message.role === "tool";
+
+ // Check for tool calls and results
const hasToolCalls = message.tool_calls && message.tool_calls.length > 0;
const hasFunctionCall = message.function_call;
@@ -19,7 +20,7 @@ export const MessageBubble = ({ message }: { message: Message }) => {
return message.content;
} else if (Array.isArray(message.content)) {
return message.content
- .map((part, i) =>
+ .map((part) =>
part.type === "text" ? part.text : JSON.stringify(part)
)
.join("");
@@ -46,154 +47,98 @@ export const MessageBubble = ({ message }: { message: Message }) => {
};
return (
-
-
- {/* Copy button positioned in top-right corner */}
-
-
-
-
-
-
-
- {message.role}
-
-
- {displayContent}
-
- {isLongMessage && (
-
- )}
- {reasoning && reasoning.trim().length > 0 && (
-
-
- Thinking:
-
-
- Show reasoning
- {reasoning}
-
-
- )}
- {hasToolCalls && message.tool_calls && (
+
+
+ {displayContent}
+
+ {isLongMessage && (
+
+ )}
+ {reasoning && reasoning.trim().length > 0 && (
+
-
- Tool Calls:
-
- {message.tool_calls.map((call, i) => (
-
-
- {call.function.name}
-
-
- {call.function.arguments}
-
-
- ))}
+ Thinking:
- )}
- {hasFunctionCall && message.function_call && (
-
-
+
- Function Call:
-
-
+
-
- {message.function_call.name}
+ {reasoning}
+
+
+
+ )}
+ {hasToolCalls && (
+
+
+ Tool Calls:
+
+ {message.tool_calls!.map((call: any, i: number) => (
+
+
+ 🔧 {call.function.name}
-
- {message.function_call.arguments}
+
+ {call.function.arguments}
+ ))}
+
+ )}
+ {hasFunctionCall && (
+
+
+ Function Call:
- )}
-
-
+
+
+ 🔧 {message.function_call!.name}
+
+
+ {message.function_call!.arguments}
+
+
+
+ )}
+
);
};
diff --git a/vite-app/src/components/Spinner.tsx b/vite-app/src/components/Spinner.tsx
new file mode 100644
index 00000000..b8be4563
--- /dev/null
+++ b/vite-app/src/components/Spinner.tsx
@@ -0,0 +1,18 @@
+interface SpinnerProps {
+ size?: "sm" | "md" | "lg";
+ className?: string;
+}
+
+export const Spinner = ({ size = "md", className = "" }: SpinnerProps) => {
+ const sizeClasses = {
+ sm: "h-3 w-3",
+ md: "h-6 w-6",
+ lg: "h-8 w-8",
+ };
+
+ return (
+
+ );
+};
diff --git a/vite-app/src/components/Textarea.tsx b/vite-app/src/components/Textarea.tsx
new file mode 100644
index 00000000..5678699a
--- /dev/null
+++ b/vite-app/src/components/Textarea.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import { commonStyles } from "../styles/common";
+
+interface TextareaProps
+ extends Omit
, "size"> {
+ size?: "sm" | "md";
+ className?: string;
+}
+
+const Textarea = React.forwardRef(
+ ({ className = "", size = "sm", disabled = false, ...props }, ref) => {
+ const disabledStyles = disabled
+ ? "bg-gray-50 text-gray-300 border-gray-200 cursor-not-allowed opacity-60"
+ : "";
+
+ return (
+
+ );
+ }
+);
+
+Textarea.displayName = "Textarea";
+
+export default Textarea;
diff --git a/vite-app/src/components/ThinkingBubble.tsx b/vite-app/src/components/ThinkingBubble.tsx
new file mode 100644
index 00000000..928db92a
--- /dev/null
+++ b/vite-app/src/components/ThinkingBubble.tsx
@@ -0,0 +1,12 @@
+import { BubbleContainer } from "./BubbleContainer";
+import { Spinner } from "./Spinner";
+
+export const ThinkingBubble = () => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/vite-app/src/components/chat/ToolResults.tsx b/vite-app/src/components/chat/ToolResults.tsx
new file mode 100644
index 00000000..8aeb05e9
--- /dev/null
+++ b/vite-app/src/components/chat/ToolResults.tsx
@@ -0,0 +1,151 @@
+import type { ToolResult } from "../../services/AgentService";
+
+interface ToolResultsProps {
+ toolResults: ToolResult[];
+}
+
+export const ToolResults = ({ toolResults }: ToolResultsProps) => {
+ if (!toolResults || toolResults.length === 0) return null;
+
+ return (
+
+ {toolResults.map((result) => (
+
+ ))}
+
+ );
+};
+
+interface ToolResultItemProps {
+ result: ToolResult;
+}
+
+const ToolResultItem = ({ result }: ToolResultItemProps) => {
+ if (!result.success) {
+ return (
+
+
+ Tool Error: {result.error}
+
+
+ );
+ }
+
+ if (!result.data) {
+ return (
+
+ );
+ }
+
+ const renderContent = () => {
+ switch (result.visualizationType) {
+ case "chart":
+ return ;
+ case "text":
+ return ;
+ case "insight":
+ return ;
+ case "table":
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+ Analysis Result
+
+ {renderContent()}
+
+ );
+};
+
+// Table visualization for structured data
+const TableVisualization = ({ data }: { data: any[] }) => {
+ if (!Array.isArray(data) || data.length === 0) {
+ return No data to display
;
+ }
+
+ const columns = Object.keys(data[0] || {});
+
+ return (
+
+
+
+
+ {columns.map((column) => (
+ |
+ {column}
+ |
+ ))}
+
+
+
+ {data.slice(0, 10).map((row, index) => (
+
+ {columns.map((column) => (
+ |
+ {String(row[column] || "")}
+ |
+ ))}
+
+ ))}
+
+
+ {data.length > 10 && (
+
+ Showing 10 of {data.length} results
+
+ )}
+
+ );
+};
+
+// Chart visualization (placeholder - you can integrate with a charting library)
+const ChartVisualization = ({ data }: { data: any[] }) => {
+ // For now, just show a table with a note about charting
+ return (
+
+
+ 📊 Chart visualization (integrate with charting library)
+
+
+
+ );
+};
+
+// Text visualization for insights and summaries
+const TextVisualization = ({ data }: { data: any }) => {
+ if (Array.isArray(data)) {
+ return (
+
+ {data.map((item, index) => (
+
+ {String(item)}
+
+ ))}
+
+ );
+ }
+
+ return {String(data)}
;
+};
+
+// Insight visualization for high-level analysis
+const InsightVisualization = ({ data }: { data: any }) => {
+ return (
+
+
💡 Key Insights
+
+ {Array.isArray(data) ? data.join(" • ") : String(data)}
+
+
+ );
+};
diff --git a/vite-app/src/services/AgentService.ts b/vite-app/src/services/AgentService.ts
new file mode 100644
index 00000000..636eeb33
--- /dev/null
+++ b/vite-app/src/services/AgentService.ts
@@ -0,0 +1,360 @@
+import { state } from "../App";
+import type { EvaluationRow } from "../types/eval-protocol";
+
+export interface ToolCall {
+ id: string;
+ name: string;
+ parameters: Record;
+}
+
+export interface ToolResult {
+ id: string;
+ success: boolean;
+ data?: any;
+ error?: string;
+ visualizationType?: "table" | "chart" | "text" | "insight";
+}
+
+// We'll store tool results separately and associate them with messages by index or timestamp
+
+export class AgentService {
+ private messageIdCounter = 0;
+
+ // Available tools for the AI to use
+ getAvailableTools() {
+ return [
+ {
+ name: "analyzeData",
+ description:
+ "Analyze evaluation data with flexible filtering, grouping, and aggregation",
+ parameters: {
+ type: "object",
+ properties: {
+ filters: {
+ type: "array",
+ description: "Array of filter conditions",
+ items: {
+ type: "object",
+ properties: {
+ field: {
+ type: "string",
+ description: 'Field path (e.g., "evaluation_result.score")',
+ },
+ operator: {
+ type: "string",
+ enum: [
+ "=",
+ "!=",
+ ">",
+ ">=",
+ "<",
+ "<=",
+ "contains",
+ "startsWith",
+ "endsWith",
+ ],
+ },
+ value: {
+ type: "string",
+ description: "Value to compare against",
+ },
+ },
+ required: ["field", "operator", "value"],
+ },
+ },
+ groupBy: {
+ type: "array",
+ description: "Fields to group by",
+ items: { type: "string" },
+ },
+ aggregations: {
+ type: "array",
+ description: "Aggregations to perform",
+ items: {
+ type: "object",
+ properties: {
+ field: { type: "string" },
+ operation: {
+ type: "string",
+ enum: ["count", "sum", "avg", "min", "max", "std"],
+ },
+ alias: { type: "string" },
+ },
+ required: ["field", "operation"],
+ },
+ },
+ limit: { type: "number", description: "Maximum number of results" },
+ visualizationType: {
+ type: "string",
+ enum: ["table", "chart", "text", "insight"],
+ description: "Preferred visualization type",
+ },
+ },
+ },
+ },
+ ];
+ }
+
+ // Tool suggestion chips for UI
+ getToolSuggestions() {
+ return [
+ "Show me failed evaluations",
+ "Compare model performance",
+ "Find score trends over time",
+ "Group by evaluation name",
+ "Average scores by model",
+ "Find common error patterns",
+ ];
+ }
+
+ // Execute a tool call
+ async executeToolCall(toolCall: ToolCall): Promise {
+ try {
+ let result;
+
+ switch (toolCall.name) {
+ case "analyzeData":
+ result = await this.analyzeData(toolCall.parameters);
+ break;
+ default:
+ throw new Error(`Unknown tool: ${toolCall.name}`);
+ }
+
+ return {
+ id: toolCall.id,
+ success: true,
+ data: result.data,
+ visualizationType: result.visualizationType,
+ };
+ } catch (error) {
+ return {
+ id: toolCall.id,
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ };
+ }
+ }
+
+ // Main analysis tool implementation
+ private async analyzeData(params: any) {
+ const {
+ filters = [],
+ groupBy = [],
+ aggregations = [],
+ limit,
+ visualizationType = "table",
+ } = params;
+
+ // Get filtered data from GlobalState
+ let data = state.filteredOriginalDataset;
+
+ // Apply filters
+ if (filters.length > 0) {
+ data = this.applyFilters(data, filters);
+ }
+
+ // Apply grouping and aggregations
+ let result;
+ if (groupBy.length > 0 || aggregations.length > 0) {
+ result = this.applyGroupingAndAggregation(data, groupBy, aggregations);
+ } else {
+ result = data;
+ }
+
+ // Apply limit
+ if (limit && limit > 0) {
+ result = result.slice(0, limit);
+ }
+
+ // Determine visualization type based on data structure
+ const finalVisualizationType = this.determineVisualizationType(
+ result,
+ visualizationType
+ );
+
+ return {
+ data: result,
+ visualizationType: finalVisualizationType,
+ metadata: {
+ totalRows: data.length,
+ filteredRows: result.length,
+ appliedFilters: filters,
+ groupBy,
+ aggregations,
+ },
+ };
+ }
+
+ private applyFilters(data: EvaluationRow[], filters: any[]) {
+ return data.filter((row) => {
+ return filters.every((filter) => {
+ const value = this.getNestedValue(row, filter.field);
+ return this.evaluateFilter(value, filter.operator, filter.value);
+ });
+ });
+ }
+
+ private getNestedValue(obj: any, path: string): any {
+ return path.split(".").reduce((current, key) => {
+ return current?.[key];
+ }, obj);
+ }
+
+ private evaluateFilter(
+ value: any,
+ operator: string,
+ filterValue: string
+ ): boolean {
+ const numValue = typeof value === "string" ? parseFloat(value) : value;
+ const numFilterValue = parseFloat(filterValue);
+
+ switch (operator) {
+ case "=":
+ return value == filterValue;
+ case "!=":
+ return value != filterValue;
+ case ">":
+ return numValue > numFilterValue;
+ case ">=":
+ return numValue >= numFilterValue;
+ case "<":
+ return numValue < numFilterValue;
+ case "<=":
+ return numValue <= numFilterValue;
+ case "contains":
+ return String(value).toLowerCase().includes(filterValue.toLowerCase());
+ case "startsWith":
+ return String(value)
+ .toLowerCase()
+ .startsWith(filterValue.toLowerCase());
+ case "endsWith":
+ return String(value).toLowerCase().endsWith(filterValue.toLowerCase());
+ default:
+ return false;
+ }
+ }
+
+ private applyGroupingAndAggregation(
+ data: EvaluationRow[],
+ groupBy: string[],
+ aggregations: any[]
+ ) {
+ if (groupBy.length === 0 && aggregations.length === 0) {
+ return data;
+ }
+
+ // Group data
+ const groups = new Map();
+
+ data.forEach((row) => {
+ const groupKey = groupBy
+ .map((field) => this.getNestedValue(row, field))
+ .join("|");
+ if (!groups.has(groupKey)) {
+ groups.set(groupKey, []);
+ }
+ groups.get(groupKey)!.push(row);
+ });
+
+ // Apply aggregations to each group
+ const result = Array.from(groups.entries()).map(([groupKey, rows]) => {
+ const groupValues = groupKey.split("|");
+ const result: any = {};
+
+ // Add group values
+ groupBy.forEach((field, index) => {
+ result[field] = groupValues[index];
+ });
+
+ // Add aggregations
+ aggregations.forEach((agg) => {
+ const values = rows
+ .map((row) => this.getNestedValue(row, agg.field))
+ .filter((v) => v != null);
+ const alias = agg.alias || `${agg.operation}_${agg.field}`;
+
+ switch (agg.operation) {
+ case "count":
+ result[alias] = values.length;
+ break;
+ case "sum":
+ result[alias] = values.reduce(
+ (sum, val) => sum + (Number(val) || 0),
+ 0
+ );
+ break;
+ case "avg":
+ result[alias] =
+ values.length > 0
+ ? values.reduce((sum, val) => sum + (Number(val) || 0), 0) /
+ values.length
+ : 0;
+ break;
+ case "min":
+ result[alias] =
+ values.length > 0
+ ? Math.min(...values.map((v) => Number(v) || 0))
+ : 0;
+ break;
+ case "max":
+ result[alias] =
+ values.length > 0
+ ? Math.max(...values.map((v) => Number(v) || 0))
+ : 0;
+ break;
+ case "std":
+ if (values.length > 0) {
+ const avg =
+ values.reduce((sum, val) => sum + (Number(val) || 0), 0) /
+ values.length;
+ const variance =
+ values.reduce(
+ (sum, val) => sum + Math.pow((Number(val) || 0) - avg, 2),
+ 0
+ ) / values.length;
+ result[alias] = Math.sqrt(variance);
+ } else {
+ result[alias] = 0;
+ }
+ break;
+ }
+ });
+
+ return result;
+ });
+
+ return result;
+ }
+
+ private determineVisualizationType(
+ data: any[],
+ preferredType: string
+ ): "table" | "chart" | "text" | "insight" {
+ // If it's aggregated data with numeric values, prefer chart
+ if (preferredType === "chart" && data.length > 0) {
+ const firstRow = data[0];
+ const hasNumericValues = Object.values(firstRow).some(
+ (val) => typeof val === "number"
+ );
+ if (hasNumericValues) return "chart";
+ }
+
+ // If it's a single insight or very small dataset, prefer text
+ if (data.length <= 3 && Object.keys(data[0] || {}).length <= 2) {
+ return "text";
+ }
+
+ // Default to table for most cases
+ return "table";
+ }
+
+ // Generate a unique message ID
+ generateMessageId(): string {
+ return `msg_${++this.messageIdCounter}_${Date.now()}`;
+ }
+
+ // Generate a unique tool call ID
+ generateToolCallId(): string {
+ return `tool_${++this.messageIdCounter}_${Date.now()}`;
+ }
+}
diff --git a/vite-app/src/stores/ChatState.ts b/vite-app/src/stores/ChatState.ts
new file mode 100644
index 00000000..89d72b52
--- /dev/null
+++ b/vite-app/src/stores/ChatState.ts
@@ -0,0 +1,85 @@
+import { makeAutoObservable } from "mobx";
+import type { Message } from "../types/eval-protocol";
+import type { ToolResult } from "../services/AgentService";
+
+export class ChatState {
+ messages: Message[] = [];
+ isLoading = false;
+ error: string | null = null;
+
+ constructor() {
+ makeAutoObservable(this);
+
+ // Add welcome message on initialization
+ this.addWelcomeMessage();
+ }
+
+ addWelcomeMessage() {
+ const welcomeMessage: Message = {
+ role: "assistant",
+ content:
+ "Hello! I'm your evaluation analysis assistant. I can help you analyze your evaluation data, find trends, compare models, and discover insights. What would you like to explore?",
+ };
+ this.messages.push(welcomeMessage);
+ }
+
+ // Add a new message
+ addMessage(message: Message) {
+ this.messages.push(message);
+ }
+
+ // Update the last message (useful for streaming responses)
+ updateLastMessage(updates: Partial) {
+ if (this.messages.length > 0) {
+ const lastMessage = this.messages[this.messages.length - 1];
+ Object.assign(lastMessage, updates);
+ }
+ }
+
+ // Add tool results as separate tool messages
+ addToolResultsToLastMessage(toolResults: ToolResult[]) {
+ toolResults.forEach((toolResult) => {
+ const toolMessage: Message = {
+ role: "tool",
+ content: toolResult.success
+ ? JSON.stringify(toolResult.data)
+ : `Error: ${toolResult.error}`,
+ tool_call_id: toolResult.id,
+ };
+ this.messages.push(toolMessage);
+ });
+ }
+
+ // Clear all messages
+ clearMessages() {
+ this.messages = [];
+ }
+
+ // Set loading state
+ setLoading(loading: boolean) {
+ this.isLoading = loading;
+ }
+
+ // Set error state
+ setError(error: string | null) {
+ this.error = error;
+ }
+
+ // Get messages for a specific conversation (if we add conversation support later)
+ getMessagesForConversation(_conversationId?: string): Message[] {
+ // For now, return all messages
+ // Later we can filter by conversationId
+ return this.messages;
+ }
+
+ // Get the last message
+ getLastMessage(): Message | undefined {
+ return this.messages[this.messages.length - 1];
+ }
+
+ // Check if the last message has pending tool calls
+ hasPendingToolCalls(): boolean {
+ const lastMessage = this.getLastMessage();
+ return !!(lastMessage?.tool_calls && lastMessage.tool_calls.length > 0);
+ }
+}