diff --git a/llama_stack/ui/app/chat-playground/chunk-processor.test.tsx b/llama_stack/ui/app/chat-playground/chunk-processor.test.tsx new file mode 100644 index 000000000..70e8b3afa --- /dev/null +++ b/llama_stack/ui/app/chat-playground/chunk-processor.test.tsx @@ -0,0 +1,610 @@ +import { describe, test, expect } from "@jest/globals"; + +// Extract the exact processChunk function implementation for testing +function createProcessChunk() { + return (chunk: unknown): { text: string | null; isToolCall: boolean } => { + const chunkObj = chunk as Record; + + // Helper function to check if content contains function call JSON + const containsToolCall = (content: string): boolean => { + return ( + content.includes('"type": "function"') || + content.includes('"name": "knowledge_search"') || + content.includes('"parameters":') || + !!content.match(/\{"type":\s*"function".*?\}/) + ); + }; + + // Check if this chunk contains a tool call (function call) + let isToolCall = false; + + // Check direct chunk content if it's a string + if (typeof chunk === "string") { + isToolCall = containsToolCall(chunk); + } + + // Check delta structures + if ( + chunkObj?.delta && + typeof chunkObj.delta === "object" && + chunkObj.delta !== null + ) { + const delta = chunkObj.delta as Record; + if ("tool_calls" in delta) { + isToolCall = true; + } + if (typeof delta.text === "string") { + if (containsToolCall(delta.text)) { + isToolCall = true; + } + } + } + + // Check event structures + if ( + chunkObj?.event && + typeof chunkObj.event === "object" && + chunkObj.event !== null + ) { + const event = chunkObj.event as Record; + + // Check event payload + if ( + event?.payload && + typeof event.payload === "object" && + event.payload !== null + ) { + const payload = event.payload as Record; + if (typeof payload.content === "string") { + if (containsToolCall(payload.content)) { + isToolCall = true; + } + } + + // Check payload delta + if ( + payload?.delta && + typeof payload.delta === "object" && + payload.delta !== null + ) { + const delta = payload.delta as Record; + if (typeof delta.text === "string") { + if (containsToolCall(delta.text)) { + isToolCall = true; + } + } + } + } + + // Check event delta + if ( + event?.delta && + typeof event.delta === "object" && + event.delta !== null + ) { + const delta = event.delta as Record; + if (typeof delta.text === "string") { + if (containsToolCall(delta.text)) { + isToolCall = true; + } + } + if (typeof delta.content === "string") { + if (containsToolCall(delta.content)) { + isToolCall = true; + } + } + } + } + + // if it's a tool call, skip it (don't display in chat) + if (isToolCall) { + return { text: null, isToolCall: true }; + } + + // Extract text content from various chunk formats + let text: string | null = null; + + // Helper function to extract clean text content, filtering out function calls + const extractCleanText = (content: string): string | null => { + if (containsToolCall(content)) { + try { + // Try to parse and extract non-function call parts + const jsonMatch = content.match( + /\{"type":\s*"function"[^}]*\}[^}]*\}/ + ); + if (jsonMatch) { + const jsonPart = jsonMatch[0]; + const parsedJson = JSON.parse(jsonPart); + + // If it's a function call, extract text after JSON + if (parsedJson.type === "function") { + const textAfterJson = content + .substring(content.indexOf(jsonPart) + jsonPart.length) + .trim(); + return textAfterJson || null; + } + } + // If we can't parse it properly, skip the whole thing + return null; + } catch { + return null; + } + } + return content; + }; + + // Try direct delta text + if ( + chunkObj?.delta && + typeof chunkObj.delta === "object" && + chunkObj.delta !== null + ) { + const delta = chunkObj.delta as Record; + if (typeof delta.text === "string") { + text = extractCleanText(delta.text); + } + } + + // Try event structures + if ( + !text && + chunkObj?.event && + typeof chunkObj.event === "object" && + chunkObj.event !== null + ) { + const event = chunkObj.event as Record; + + // Try event payload content + if ( + event?.payload && + typeof event.payload === "object" && + event.payload !== null + ) { + const payload = event.payload as Record; + + // Try direct payload content + if (typeof payload.content === "string") { + text = extractCleanText(payload.content); + } + + // Try turn_complete event structure: payload.turn.output_message.content + if ( + !text && + payload?.turn && + typeof payload.turn === "object" && + payload.turn !== null + ) { + const turn = payload.turn as Record; + if ( + turn?.output_message && + typeof turn.output_message === "object" && + turn.output_message !== null + ) { + const outputMessage = turn.output_message as Record< + string, + unknown + >; + if (typeof outputMessage.content === "string") { + text = extractCleanText(outputMessage.content); + } + } + + // Fallback to model_response in steps if no output_message + if ( + !text && + turn?.steps && + Array.isArray(turn.steps) && + turn.steps.length > 0 + ) { + for (const step of turn.steps) { + if (step && typeof step === "object" && step !== null) { + const stepObj = step as Record; + if ( + stepObj?.model_response && + typeof stepObj.model_response === "object" && + stepObj.model_response !== null + ) { + const modelResponse = stepObj.model_response as Record< + string, + unknown + >; + if (typeof modelResponse.content === "string") { + text = extractCleanText(modelResponse.content); + break; + } + } + } + } + } + } + + // Try payload delta + if ( + !text && + payload?.delta && + typeof payload.delta === "object" && + payload.delta !== null + ) { + const delta = payload.delta as Record; + if (typeof delta.text === "string") { + text = extractCleanText(delta.text); + } + } + } + + // Try event delta + if ( + !text && + event?.delta && + typeof event.delta === "object" && + event.delta !== null + ) { + const delta = event.delta as Record; + if (typeof delta.text === "string") { + text = extractCleanText(delta.text); + } + if (!text && typeof delta.content === "string") { + text = extractCleanText(delta.content); + } + } + } + + // Try choices structure (ChatML format) + if ( + !text && + chunkObj?.choices && + Array.isArray(chunkObj.choices) && + chunkObj.choices.length > 0 + ) { + const choice = chunkObj.choices[0] as Record; + if ( + choice?.delta && + typeof choice.delta === "object" && + choice.delta !== null + ) { + const delta = choice.delta as Record; + if (typeof delta.content === "string") { + text = extractCleanText(delta.content); + } + } + } + + // Try direct string content + if (!text && typeof chunk === "string") { + text = extractCleanText(chunk); + } + + return { text, isToolCall: false }; + }; +} + +describe("Chunk Processor", () => { + const processChunk = createProcessChunk(); + + describe("Real Event Structures", () => { + test("handles turn_complete event with cancellation policy response", () => { + const chunk = { + event: { + payload: { + event_type: "turn_complete", + turn: { + turn_id: "50a2d6b7-49ed-4d1e-b1c2-6d68b3f726db", + session_id: "e7f62b8e-518c-4450-82df-e65fe49f27a3", + input_messages: [ + { + role: "user", + content: "nice, what's the cancellation policy?", + context: null, + }, + ], + steps: [ + { + turn_id: "50a2d6b7-49ed-4d1e-b1c2-6d68b3f726db", + step_id: "54074310-af42-414c-9ffe-fba5b2ead0ad", + started_at: "2025-08-27T18:15:25.870703Z", + completed_at: "2025-08-27T18:15:51.288993Z", + step_type: "inference", + model_response: { + role: "assistant", + content: + "According to the search results, the cancellation policy for Red Hat Summit is as follows:\n\n* Cancellations must be received by 5 PM EDT on April 18, 2025 for a 50% refund of the registration fee.\n* No refunds will be given for cancellations received after 5 PM EDT on April 18, 2025.\n* Cancellation of travel reservations and hotel reservations are the responsibility of the registrant.", + stop_reason: "end_of_turn", + tool_calls: [], + }, + }, + ], + output_message: { + role: "assistant", + content: + "According to the search results, the cancellation policy for Red Hat Summit is as follows:\n\n* Cancellations must be received by 5 PM EDT on April 18, 2025 for a 50% refund of the registration fee.\n* No refunds will be given for cancellations received after 5 PM EDT on April 18, 2025.\n* Cancellation of travel reservations and hotel reservations are the responsibility of the registrant.", + stop_reason: "end_of_turn", + tool_calls: [], + }, + output_attachments: [], + started_at: "2025-08-27T18:15:25.868548Z", + completed_at: "2025-08-27T18:15:51.289262Z", + }, + }, + }, + }; + + const result = processChunk(chunk); + expect(result.isToolCall).toBe(false); + expect(result.text).toContain( + "According to the search results, the cancellation policy for Red Hat Summit is as follows:" + ); + expect(result.text).toContain("5 PM EDT on April 18, 2025"); + }); + + test("handles turn_complete event with address response", () => { + const chunk = { + event: { + payload: { + event_type: "turn_complete", + turn: { + turn_id: "2f4a1520-8ecc-4cb7-bb7b-886939e042b0", + session_id: "e7f62b8e-518c-4450-82df-e65fe49f27a3", + input_messages: [ + { + role: "user", + content: "what's francisco's address", + context: null, + }, + ], + steps: [ + { + turn_id: "2f4a1520-8ecc-4cb7-bb7b-886939e042b0", + step_id: "c13dd277-1acb-4419-8fbf-d5e2f45392ea", + started_at: "2025-08-27T18:14:52.558761Z", + completed_at: "2025-08-27T18:15:11.306032Z", + step_type: "inference", + model_response: { + role: "assistant", + content: + "Francisco Arceo's address is:\n\nRed Hat\nUnited States\n17 Primrose Ln \nBasking Ridge New Jersey 07920", + stop_reason: "end_of_turn", + tool_calls: [], + }, + }, + ], + output_message: { + role: "assistant", + content: + "Francisco Arceo's address is:\n\nRed Hat\nUnited States\n17 Primrose Ln \nBasking Ridge New Jersey 07920", + stop_reason: "end_of_turn", + tool_calls: [], + }, + output_attachments: [], + started_at: "2025-08-27T18:14:52.553707Z", + completed_at: "2025-08-27T18:15:11.306729Z", + }, + }, + }, + }; + + const result = processChunk(chunk); + expect(result.isToolCall).toBe(false); + expect(result.text).toContain("Francisco Arceo's address is:"); + expect(result.text).toContain("17 Primrose Ln"); + expect(result.text).toContain("Basking Ridge New Jersey 07920"); + }); + + test("handles turn_complete event with ticket cost response", () => { + const chunk = { + event: { + payload: { + event_type: "turn_complete", + turn: { + turn_id: "7ef244a3-efee-42ca-a9c8-942865251002", + session_id: "e7f62b8e-518c-4450-82df-e65fe49f27a3", + input_messages: [ + { + role: "user", + content: "what was the ticket cost for summit?", + context: null, + }, + ], + steps: [ + { + turn_id: "7ef244a3-efee-42ca-a9c8-942865251002", + step_id: "7651dda0-315a-472d-b1c1-3c2725f55bc5", + started_at: "2025-08-27T18:14:21.710611Z", + completed_at: "2025-08-27T18:14:39.706452Z", + step_type: "inference", + model_response: { + role: "assistant", + content: + "The ticket cost for the Red Hat Summit was $999.00 for a conference pass.", + stop_reason: "end_of_turn", + tool_calls: [], + }, + }, + ], + output_message: { + role: "assistant", + content: + "The ticket cost for the Red Hat Summit was $999.00 for a conference pass.", + stop_reason: "end_of_turn", + tool_calls: [], + }, + output_attachments: [], + started_at: "2025-08-27T18:14:21.705289Z", + completed_at: "2025-08-27T18:14:39.706752Z", + }, + }, + }, + }; + + const result = processChunk(chunk); + expect(result.isToolCall).toBe(false); + expect(result.text).toBe( + "The ticket cost for the Red Hat Summit was $999.00 for a conference pass." + ); + }); + }); + + describe("Function Call Detection", () => { + test("detects function calls in direct string chunks", () => { + const chunk = + '{"type": "function", "name": "knowledge_search", "parameters": {"query": "test"}}'; + const result = processChunk(chunk); + expect(result.isToolCall).toBe(true); + expect(result.text).toBe(null); + }); + + test("detects function calls in event payload content", () => { + const chunk = { + event: { + payload: { + content: + '{"type": "function", "name": "knowledge_search", "parameters": {"query": "test"}}', + }, + }, + }; + const result = processChunk(chunk); + expect(result.isToolCall).toBe(true); + expect(result.text).toBe(null); + }); + + test("detects tool_calls in delta structure", () => { + const chunk = { + delta: { + tool_calls: [{ function: { name: "knowledge_search" } }], + }, + }; + const result = processChunk(chunk); + expect(result.isToolCall).toBe(true); + expect(result.text).toBe(null); + }); + + test("detects function call in mixed content but skips it", () => { + const chunk = + '{"type": "function", "name": "knowledge_search", "parameters": {"query": "test"}} Based on the search results, here is your answer.'; + const result = processChunk(chunk); + // This is detected as a tool call and skipped entirely - the implementation prioritizes safety + expect(result.isToolCall).toBe(true); + expect(result.text).toBe(null); + }); + }); + + describe("Text Extraction", () => { + test("extracts text from direct string chunks", () => { + const chunk = "Hello, this is a normal response."; + const result = processChunk(chunk); + expect(result.isToolCall).toBe(false); + expect(result.text).toBe("Hello, this is a normal response."); + }); + + test("extracts text from delta structure", () => { + const chunk = { + delta: { + text: "Hello, this is a normal response.", + }, + }; + const result = processChunk(chunk); + expect(result.isToolCall).toBe(false); + expect(result.text).toBe("Hello, this is a normal response."); + }); + + test("extracts text from choices structure", () => { + const chunk = { + choices: [ + { + delta: { + content: "Hello, this is a normal response.", + }, + }, + ], + }; + const result = processChunk(chunk); + expect(result.isToolCall).toBe(false); + expect(result.text).toBe("Hello, this is a normal response."); + }); + + test("prioritizes output_message over model_response in turn structure", () => { + const chunk = { + event: { + payload: { + turn: { + steps: [ + { + model_response: { + content: "Model response content.", + }, + }, + ], + output_message: { + content: "Final output message content.", + }, + }, + }, + }, + }; + const result = processChunk(chunk); + expect(result.isToolCall).toBe(false); + expect(result.text).toBe("Final output message content."); + }); + + test("falls back to model_response when no output_message", () => { + const chunk = { + event: { + payload: { + turn: { + steps: [ + { + model_response: { + content: "This is from the model response.", + }, + }, + ], + }, + }, + }, + }; + const result = processChunk(chunk); + expect(result.isToolCall).toBe(false); + expect(result.text).toBe("This is from the model response."); + }); + }); + + describe("Edge Cases", () => { + test("handles empty chunks", () => { + const result = processChunk(""); + expect(result.isToolCall).toBe(false); + expect(result.text).toBe(""); + }); + + test("handles null chunks", () => { + const result = processChunk(null); + expect(result.isToolCall).toBe(false); + expect(result.text).toBe(null); + }); + + test("handles undefined chunks", () => { + const result = processChunk(undefined); + expect(result.isToolCall).toBe(false); + expect(result.text).toBe(null); + }); + + test("handles chunks with no text content", () => { + const chunk = { + event: { + metadata: { + timestamp: "2024-01-01", + }, + }, + }; + const result = processChunk(chunk); + expect(result.isToolCall).toBe(false); + expect(result.text).toBe(null); + }); + + test("handles malformed JSON in function calls gracefully", () => { + const chunk = + '{"type": "function", "name": "knowledge_search"} incomplete json'; + const result = processChunk(chunk); + expect(result.isToolCall).toBe(true); + expect(result.text).toBe(null); + }); + }); +}); diff --git a/llama_stack/ui/app/chat-playground/page.test.tsx b/llama_stack/ui/app/chat-playground/page.test.tsx index 54c15f95a..d9025e523 100644 --- a/llama_stack/ui/app/chat-playground/page.test.tsx +++ b/llama_stack/ui/app/chat-playground/page.test.tsx @@ -31,6 +31,9 @@ const mockClient = { toolgroups: { list: jest.fn(), }, + vectorDBs: { + list: jest.fn(), + }, }; jest.mock("@/hooks/use-auth-client", () => ({ @@ -164,7 +167,7 @@ describe("ChatPlaygroundPage", () => { session_name: "Test Session", started_at: new Date().toISOString(), turns: [], - }); // No turns by default + }); mockClient.agents.retrieve.mockResolvedValue({ agent_id: "test-agent", agent_config: { @@ -417,7 +420,6 @@ describe("ChatPlaygroundPage", () => { }); await waitFor(() => { - // first agent should be auto-selected expect(mockClient.agents.session.create).toHaveBeenCalledWith( "agent_123", { session_name: "Default Session" } @@ -464,7 +466,7 @@ describe("ChatPlaygroundPage", () => { }); }); - test("hides delete button when only one agent exists", async () => { + test("shows delete button even when only one agent exists", async () => { mockClient.agents.list.mockResolvedValue({ data: [mockAgents[0]], }); @@ -474,9 +476,7 @@ describe("ChatPlaygroundPage", () => { }); await waitFor(() => { - expect( - screen.queryByTitle("Delete current agent") - ).not.toBeInTheDocument(); + expect(screen.getByTitle("Delete current agent")).toBeInTheDocument(); }); }); @@ -505,7 +505,7 @@ describe("ChatPlaygroundPage", () => { await waitFor(() => { expect(mockClient.agents.delete).toHaveBeenCalledWith("agent_123"); expect(global.confirm).toHaveBeenCalledWith( - "Are you sure you want to delete this agent? This action cannot be undone and will delete all associated sessions." + "Are you sure you want to delete this agent? This action cannot be undone and will delete the agent and all its sessions." ); }); @@ -584,4 +584,207 @@ describe("ChatPlaygroundPage", () => { consoleSpy.mockRestore(); }); }); + + describe("RAG File Upload", () => { + let mockFileReader: { + readAsDataURL: jest.Mock; + readAsText: jest.Mock; + result: string | null; + onload: (() => void) | null; + onerror: (() => void) | null; + }; + let mockRAGTool: { + insert: jest.Mock; + }; + + beforeEach(() => { + mockFileReader = { + readAsDataURL: jest.fn(), + readAsText: jest.fn(), + result: null, + onload: null, + onerror: null, + }; + global.FileReader = jest.fn(() => mockFileReader); + + mockRAGTool = { + insert: jest.fn().mockResolvedValue({}), + }; + mockClient.toolRuntime = { + ragTool: mockRAGTool, + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("handles text file upload", async () => { + new File(["Hello, world!"], "test.txt", { + type: "text/plain", + }); + + mockClient.agents.retrieve.mockResolvedValue({ + agent_id: "test-agent", + agent_config: { + toolgroups: [ + { + name: "builtin::rag/knowledge_search", + args: { vector_db_ids: ["test-vector-db"] }, + }, + ], + }, + }); + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(screen.getByTestId("chat-component")).toBeInTheDocument(); + }); + + const chatComponent = screen.getByTestId("chat-component"); + chatComponent.getAttribute("data-onragfileupload"); + + // this is a simplified test + expect(mockRAGTool.insert).not.toHaveBeenCalled(); + }); + + test("handles PDF file upload with FileReader", async () => { + new File([new ArrayBuffer(1000)], "test.pdf", { + type: "application/pdf", + }); + + const mockDataURL = "data:application/pdf;base64,JVBERi0xLjQK"; + mockFileReader.result = mockDataURL; + + mockClient.agents.retrieve.mockResolvedValue({ + agent_id: "test-agent", + agent_config: { + toolgroups: [ + { + name: "builtin::rag/knowledge_search", + args: { vector_db_ids: ["test-vector-db"] }, + }, + ], + }, + }); + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(screen.getByTestId("chat-component")).toBeInTheDocument(); + }); + + expect(global.FileReader).toBeDefined(); + }); + + test("handles different file types correctly", () => { + const getContentType = (filename: string): string => { + const ext = filename.toLowerCase().split(".").pop(); + switch (ext) { + case "pdf": + return "application/pdf"; + case "txt": + return "text/plain"; + case "md": + return "text/markdown"; + case "html": + return "text/html"; + case "csv": + return "text/csv"; + case "json": + return "application/json"; + case "docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + case "doc": + return "application/msword"; + default: + return "application/octet-stream"; + } + }; + + expect(getContentType("test.pdf")).toBe("application/pdf"); + expect(getContentType("test.txt")).toBe("text/plain"); + expect(getContentType("test.md")).toBe("text/markdown"); + expect(getContentType("test.html")).toBe("text/html"); + expect(getContentType("test.csv")).toBe("text/csv"); + expect(getContentType("test.json")).toBe("application/json"); + expect(getContentType("test.docx")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + expect(getContentType("test.doc")).toBe("application/msword"); + expect(getContentType("test.unknown")).toBe("application/octet-stream"); + }); + + test("determines text vs binary file types correctly", () => { + const isTextFile = (mimeType: string): boolean => { + return ( + mimeType.startsWith("text/") || + mimeType === "application/json" || + mimeType === "text/markdown" || + mimeType === "text/html" || + mimeType === "text/csv" + ); + }; + + expect(isTextFile("text/plain")).toBe(true); + expect(isTextFile("text/markdown")).toBe(true); + expect(isTextFile("text/html")).toBe(true); + expect(isTextFile("text/csv")).toBe(true); + expect(isTextFile("application/json")).toBe(true); + + expect(isTextFile("application/pdf")).toBe(false); + expect(isTextFile("application/msword")).toBe(false); + expect( + isTextFile( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + ).toBe(false); + expect(isTextFile("application/octet-stream")).toBe(false); + }); + + test("handles FileReader error gracefully", async () => { + const pdfFile = new File([new ArrayBuffer(1000)], "test.pdf", { + type: "application/pdf", + }); + + mockFileReader.onerror = jest.fn(); + const mockError = new Error("FileReader failed"); + + const fileReaderPromise = new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error || mockError); + reader.readAsDataURL(pdfFile); + + setTimeout(() => { + reader.onerror?.(new ProgressEvent("error")); + }, 0); + }); + + await expect(fileReaderPromise).rejects.toBeDefined(); + }); + + test("handles large file upload with FileReader approach", () => { + // create a large file + const largeFile = new File( + [new ArrayBuffer(10 * 1024 * 1024)], + "large.pdf", + { + type: "application/pdf", + } + ); + + expect(largeFile.size).toBe(10 * 1024 * 1024); // 10MB + + expect(global.FileReader).toBeDefined(); + + const reader = new FileReader(); + expect(reader.readAsDataURL).toBeDefined(); + }); + }); }); diff --git a/llama_stack/ui/app/chat-playground/page.tsx b/llama_stack/ui/app/chat-playground/page.tsx index f26791a41..0417f7083 100644 --- a/llama_stack/ui/app/chat-playground/page.tsx +++ b/llama_stack/ui/app/chat-playground/page.tsx @@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input"; import { Trash2 } from "lucide-react"; import { Chat } from "@/components/chat-playground/chat"; import { type Message } from "@/components/chat-playground/chat-message"; +import { VectorDBCreator } from "@/components/chat-playground/vector-db-creator"; import { useAuthClient } from "@/hooks/use-auth-client"; import type { Model } from "llama-stack-client/resources/models"; import type { TurnCreateParams } from "llama-stack-client/resources/agents/turn"; @@ -22,6 +23,10 @@ import { SessionUtils, type ChatSession, } from "@/components/chat-playground/conversations"; +import { + cleanMessageContent, + extractCleanText, +} from "@/lib/message-content-utils"; export default function ChatPlaygroundPage() { const [currentSession, setCurrentSession] = useState( null @@ -65,6 +70,20 @@ export default function ChatPlaygroundPage() { provider_resource_id?: string; }> >([]); + const [showCreateVectorDB, setShowCreateVectorDB] = useState(false); + const [availableVectorDBs, setAvailableVectorDBs] = useState< + Array<{ + identifier: string; + vector_db_name?: string; + embedding_model: string; + }> + >([]); + const [uploadNotification, setUploadNotification] = useState<{ + show: boolean; + message: string; + type: "success" | "error" | "loading"; + }>({ show: false, message: "", type: "success" }); + const [selectedVectorDBs, setSelectedVectorDBs] = useState([]); const client = useAuthClient(); const abortControllerRef = useRef(null); @@ -73,26 +92,22 @@ export default function ChatPlaygroundPage() { const loadAgentConfig = useCallback( async (agentId: string) => { try { - console.log("Loading agent config for:", agentId); - // try to load from cache first const cachedConfig = SessionUtils.loadAgentConfig(agentId); if (cachedConfig) { - console.log("✅ Loaded agent config from cache:", cachedConfig); setSelectedAgentConfig({ toolgroups: cachedConfig.toolgroups, }); return; } - console.log("📡 Fetching agent config from API..."); const agentDetails = await client.agents.retrieve(agentId); - console.log("Agent details retrieved:", agentDetails); - console.log("Agent config:", agentDetails.agent_config); - console.log("Agent toolgroups:", agentDetails.agent_config?.toolgroups); - // cache the config - SessionUtils.saveAgentConfig(agentId, agentDetails.agent_config); + // cache config + SessionUtils.saveAgentConfig(agentId, { + ...agentDetails.agent_config, + toolgroups: agentDetails.agent_config?.toolgroups, + }); setSelectedAgentConfig({ toolgroups: agentDetails.agent_config?.toolgroups, @@ -116,7 +131,7 @@ export default function ChatPlaygroundPage() { id: response.session_id, name: "Default Session", messages: [], - selectedModel: selectedModel, // Use current selected model + selectedModel: selectedModel, // use current selected model systemMessage: "You are a helpful assistant.", agentId, createdAt: Date.now(), @@ -124,10 +139,6 @@ export default function ChatPlaygroundPage() { }; setCurrentSession(defaultSession); - console.log( - `💾 Saving default session ID for agent ${agentId}:`, - defaultSession.id - ); SessionUtils.saveCurrentSessionId(defaultSession.id, agentId); // cache entire session data SessionUtils.saveSessionData(agentId, defaultSession); @@ -152,7 +163,6 @@ export default function ChatPlaygroundPage() { const messages: Message[] = []; for (const turn of session.turns) { - // add user messages if (turn.input_messages && Array.isArray(turn.input_messages)) { for (const input of turn.input_messages) { if (input.role === "user" && input.content) { @@ -169,15 +179,18 @@ export default function ChatPlaygroundPage() { } } - // add assistant message from output_message if (turn.output_message && turn.output_message.content) { + console.log("Raw message content:", turn.output_message.content); + console.log("Content type:", typeof turn.output_message.content); + + const cleanContent = cleanMessageContent( + turn.output_message.content + ); + messages.push({ id: `${turn.turn_id}-assistant-${messages.length}`, role: "assistant", - content: - typeof turn.output_message.content === "string" - ? turn.output_message.content - : JSON.stringify(turn.output_message.content), + content: cleanContent, createdAt: new Date( turn.completed_at || turn.started_at || Date.now() ), @@ -197,27 +210,22 @@ export default function ChatPlaygroundPage() { const loadAgentSessions = useCallback( async (agentId: string) => { try { - console.log("Loading sessions for agent:", agentId); const response = await client.agents.session.list(agentId); - console.log("Available sessions:", response.data); if ( response.data && Array.isArray(response.data) && response.data.length > 0 ) { - // check for a previously saved session ID for this specific agent + // check for saved session ID for this agent const savedSessionId = SessionUtils.loadCurrentSessionId(agentId); - console.log(`Saved session ID for agent ${agentId}:`, savedSessionId); - - // try to load cached session data first + // try to load cached agent session data first if (savedSessionId) { const cachedSession = SessionUtils.loadSessionData( agentId, savedSessionId ); if (cachedSession) { - console.log("✅ Loaded session from cache:", cachedSession.id); setCurrentSession(cachedSession); SessionUtils.saveCurrentSessionId(cachedSession.id, agentId); return; @@ -238,7 +246,8 @@ export default function ChatPlaygroundPage() { // try to find saved session id in available sessions if (savedSessionId) { const foundSession = response.data.find( - (s: { session_id: string }) => s.session_id === savedSessionId + (s: { [key: string]: unknown }) => + (s as { session_id: string }).session_id === savedSessionId ); console.log("Found saved session in list:", foundSession); if (foundSession) { @@ -269,7 +278,7 @@ export default function ChatPlaygroundPage() { id: sessionToLoad.session_id, name: sessionToLoad.session_name || "Session", messages, - selectedModel: selectedModel || "", // Preserve current model or use empty + selectedModel: selectedModel || "", systemMessage: "You are a helpful assistant.", agentId, createdAt: sessionToLoad.started_at @@ -330,7 +339,8 @@ export default function ChatPlaygroundPage() { // if we have a saved agent ID, find it in the available agents if (savedAgentId) { const foundAgent = agentList.data.find( - (a: { agent_id: string }) => a.agent_id === savedAgentId + (a: { [key: string]: unknown }) => + (a as { agent_id: string }).agent_id === savedAgentId ); if (foundAgent) { agentToSelect = foundAgent as typeof agentToSelect; @@ -353,14 +363,10 @@ export default function ChatPlaygroundPage() { fetchAgents(); - // fetch available toolgroups const fetchToolgroups = async () => { try { - console.log("Fetching toolgroups..."); const toolgroups = await client.toolgroups.list(); - console.log("Toolgroups response:", toolgroups); - // The client returns data directly, not wrapped in .data const toolGroupsArray = Array.isArray(toolgroups) ? toolgroups : toolgroups && @@ -381,7 +387,6 @@ export default function ChatPlaygroundPage() { if (toolGroupsArray && Array.isArray(toolGroupsArray)) { setAvailableToolgroups(toolGroupsArray); - console.log("Set toolgroups:", toolGroupsArray); } else { console.error("Invalid toolgroups data format:", toolgroups); } @@ -398,6 +403,24 @@ export default function ChatPlaygroundPage() { }; fetchToolgroups(); + + const fetchVectorDBs = async () => { + try { + const vectorDBs = await client.vectorDBs.list(); + + const vectorDBsArray = Array.isArray(vectorDBs) ? vectorDBs : []; + + if (vectorDBsArray && Array.isArray(vectorDBsArray)) { + setAvailableVectorDBs(vectorDBsArray); + } else { + console.error("Invalid vector DBs data format:", vectorDBs); + } + } catch (error) { + console.error("Error fetching vector DBs:", error); + } + }; + + fetchVectorDBs(); }, [client, loadAgentSessions, loadAgentConfig]); const createNewAgent = useCallback( @@ -405,24 +428,35 @@ export default function ChatPlaygroundPage() { name: string, instructions: string, model: string, - toolgroups: string[] = [] + toolgroups: string[] = [], + vectorDBs: string[] = [] ) => { try { - console.log("Creating agent with toolgroups:", toolgroups); + const processedToolgroups = toolgroups.map(toolgroup => { + if (toolgroup === "builtin::rag" && vectorDBs.length > 0) { + return { + name: "builtin::rag/knowledge_search", + args: { + vector_db_ids: vectorDBs, + }, + }; + } + return toolgroup; + }); + const agentConfig = { model, instructions, name: name || undefined, enable_session_persistence: true, - toolgroups: toolgroups.length > 0 ? toolgroups : undefined, + toolgroups: + processedToolgroups.length > 0 ? processedToolgroups : undefined, }; - console.log("Agent config being sent:", agentConfig); const response = await client.agents.create({ agent_config: agentConfig, }); - // refresh agents list const agentList = await client.agents.list(); setAgents( (agentList.data as Array<{ @@ -436,7 +470,6 @@ export default function ChatPlaygroundPage() { }>) || [] ); - // set the new agent as selected setSelectedAgentId(response.agent_id); await loadAgentConfig(response.agent_id); await loadAgentSessions(response.agent_id); @@ -450,24 +483,47 @@ export default function ChatPlaygroundPage() { [client, loadAgentSessions, loadAgentConfig] ); + const handleVectorDBCreated = useCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async (_vectorDbId: string) => { + setShowCreateVectorDB(false); + + try { + const vectorDBs = await client.vectorDBs.list(); + const vectorDBsArray = Array.isArray(vectorDBs) ? vectorDBs : []; + + if (vectorDBsArray && Array.isArray(vectorDBsArray)) { + setAvailableVectorDBs(vectorDBsArray); + } + } catch (error) { + console.error("Error refreshing vector DBs:", error); + } + }, + [client] + ); + const deleteAgent = useCallback( async (agentId: string) => { - if (agents.length <= 1) { - return; - } - if ( confirm( - "Are you sure you want to delete this agent? This action cannot be undone and will delete all associated sessions." + "Are you sure you want to delete this agent? This action cannot be undone and will delete the agent and all its sessions." ) ) { try { - await client.agents.delete(agentId); + // there's a known error where the delete API returns 500 even on success + try { + await client.agents.delete(agentId); + console.log("Agent deleted successfully"); + } catch (deleteError) { + // log the error but don't re-throw - we know deletion succeeded + console.log( + "Agent delete API returned error (but deletion likely succeeded):", + deleteError + ); + } - // clear cached data for agent SessionUtils.clearAgentCache(agentId); - // Refresh agents list const agentList = await client.agents.list(); setAgents( (agentList.data as Array<{ @@ -481,10 +537,11 @@ export default function ChatPlaygroundPage() { }>) || [] ); - // if we deleted the current agent, switch to another one + // if we delete current agent, switch to another if (selectedAgentId === agentId) { const remainingAgents = agentList.data?.filter( - (a: { agent_id: string }) => a.agent_id !== agentId + (a: { [key: string]: unknown }) => + (a as { agent_id: string }).agent_id !== agentId ); if (remainingAgents && remainingAgents.length > 0) { const newAgent = remainingAgents[0] as { @@ -501,7 +558,7 @@ export default function ChatPlaygroundPage() { await loadAgentConfig(newAgent.agent_id); await loadAgentSessions(newAgent.agent_id); } else { - // No agents left + // no agents left setSelectedAgentId(""); setCurrentSession(null); setSelectedAgentConfig(null); @@ -509,10 +566,76 @@ export default function ChatPlaygroundPage() { } } catch (error) { console.error("Error deleting agent:", error); + + // check if this is known server bug where deletion succeeds but returns 500 + // The error message will typically contain status codes or "Could not find agent" + const errorMessage = + error instanceof Error ? error.message : String(error); + const isKnownServerBug = + errorMessage.includes("500") || + errorMessage.includes("Internal Server Error") || + errorMessage.includes("Could not find agent") || + errorMessage.includes("400"); + + if (isKnownServerBug) { + console.log( + "Agent deletion succeeded despite error, cleaning up UI" + ); + SessionUtils.clearAgentCache(agentId); + try { + const agentList = await client.agents.list(); + setAgents( + (agentList.data as Array<{ + agent_id: string; + agent_config?: { + agent_name?: string; + name?: string; + instructions?: string; + }; + [key: string]: unknown; + }>) || [] + ); + + if (selectedAgentId === agentId) { + const remainingAgents = agentList.data?.filter( + (a: { [key: string]: unknown }) => + (a as { agent_id: string }).agent_id !== agentId + ); + if (remainingAgents && remainingAgents.length > 0) { + const newAgent = remainingAgents[0] as { + agent_id: string; + agent_config?: { + agent_name?: string; + name?: string; + instructions?: string; + }; + [key: string]: unknown; + }; + setSelectedAgentId(newAgent.agent_id); + SessionUtils.saveCurrentAgentId(newAgent.agent_id); + await loadAgentConfig(newAgent.agent_id); + await loadAgentSessions(newAgent.agent_id); + } else { + // no agents left + setSelectedAgentId(""); + setCurrentSession(null); + setSelectedAgentConfig(null); + } + } + } catch (refreshError) { + console.error("Error refreshing agents list:", refreshError); + } + } else { + // show error that we don't know about to user + console.error("Unexpected error during agent deletion:", error); + if (error instanceof Error) { + alert(`Failed to delete agent: ${error.message}`); + } + } } } }, - [agents.length, client, selectedAgentId, loadAgentConfig, loadAgentSessions] + [client, selectedAgentId, loadAgentConfig, loadAgentSessions] ); const handleModelChange = useCallback((newModel: string) => { @@ -530,10 +653,6 @@ export default function ChatPlaygroundPage() { useEffect(() => { if (currentSession) { - console.log( - `💾 Auto-saving session ID for agent ${currentSession.agentId}:`, - currentSession.id - ); SessionUtils.saveCurrentSessionId( currentSession.id, currentSession.agentId @@ -556,8 +675,12 @@ export default function ChatPlaygroundPage() { setModelsLoading(true); setModelsError(null); const modelList = await client.models.list(); + + // store all models (including embedding models for vector DB creation) + setModels(modelList); + + // set default LLM model for chat const llmModels = modelList.filter(model => model.model_type === "llm"); - setModels(llmModels); if (llmModels.length > 0) { handleModelChange(llmModels[0].identifier); } @@ -614,7 +737,7 @@ export default function ChatPlaygroundPage() { messages: [...prev.messages, userMessage], updatedAt: Date.now(), }; - // Update cache with new message + // update cache with new message SessionUtils.saveSessionData(prev.agentId, updatedSession); return updatedSession; }); @@ -653,7 +776,8 @@ export default function ChatPlaygroundPage() { turnParams, { signal: abortController.signal, - } as { signal: AbortSignal } + timeout: 300000, // 5 minutes timeout for RAG queries + } as { signal: AbortSignal; timeout: number } ); const assistantMessage: Message = { @@ -663,42 +787,242 @@ export default function ChatPlaygroundPage() { createdAt: new Date(), }; - const extractDeltaText = (chunk: unknown): string | null => { - // this is an awful way to handle different chunk formats, but i'm not sure if there's much of a better way - if (chunk?.delta?.text && typeof chunk.delta.text === "string") { - return chunk.delta.text; - } + const processChunk = ( + chunk: unknown + ): { text: string | null; isToolCall: boolean } => { + const chunkObj = chunk as Record; - if ( - chunk?.event?.delta?.text && - typeof chunk.event.delta.text === "string" - ) { - return chunk.event.delta.text; - } + // helper to check if content contains function call JSON + const containsToolCall = (content: string): boolean => { + return ( + content.includes('"type": "function"') || + content.includes('"name": "knowledge_search"') || + content.includes('"parameters":') || + !!content.match(/\{"type":\s*"function".*?\}/) + ); + }; - if ( - chunk?.choices?.[0]?.delta?.content && - typeof chunk.choices[0].delta.content === "string" - ) { - return chunk.choices[0].delta.content; - } + let isToolCall = false; + let potentialContent = ""; if (typeof chunk === "string") { - return chunk; + potentialContent = chunk; + isToolCall = containsToolCall(chunk); } if ( - chunk?.event?.payload?.delta?.text && - typeof chunk.event.payload.delta.text === "string" + chunkObj?.delta && + typeof chunkObj.delta === "object" && + chunkObj.delta !== null ) { - return chunk.event.payload.delta.text; + const delta = chunkObj.delta as Record; + if ("tool_calls" in delta) { + isToolCall = true; + } + if (typeof delta.text === "string") { + potentialContent = delta.text; + if (containsToolCall(delta.text)) { + isToolCall = true; + } + } } - if (process.env.NODE_ENV !== "production") { - console.debug("Unrecognized chunk format:", chunk); + if ( + chunkObj?.event && + typeof chunkObj.event === "object" && + chunkObj.event !== null + ) { + const event = chunkObj.event as Record; + + if ( + event?.payload && + typeof event.payload === "object" && + event.payload !== null + ) { + const payload = event.payload as Record; + if (typeof payload.content === "string") { + potentialContent = payload.content; + if (containsToolCall(payload.content)) { + isToolCall = true; + } + } + + if ( + payload?.delta && + typeof payload.delta === "object" && + payload.delta !== null + ) { + const delta = payload.delta as Record; + if (typeof delta.text === "string") { + potentialContent = delta.text; + if (containsToolCall(delta.text)) { + isToolCall = true; + } + } + } + } + + if ( + event?.delta && + typeof event.delta === "object" && + event.delta !== null + ) { + const delta = event.delta as Record; + if (typeof delta.text === "string") { + potentialContent = delta.text; + if (containsToolCall(delta.text)) { + isToolCall = true; + } + } + if (typeof delta.content === "string") { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + potentialContent = delta.content; + if (containsToolCall(delta.content)) { + isToolCall = true; + } + } + } } - return null; + // if it's a tool call, skip it (don't display in chat) + if (isToolCall) { + return { text: null, isToolCall: true }; + } + + let text: string | null = null; + + if ( + chunkObj?.delta && + typeof chunkObj.delta === "object" && + chunkObj.delta !== null + ) { + const delta = chunkObj.delta as Record; + if (typeof delta.text === "string") { + text = extractCleanText(delta.text); + } + } + + if ( + !text && + chunkObj?.event && + typeof chunkObj.event === "object" && + chunkObj.event !== null + ) { + const event = chunkObj.event as Record; + + if ( + event?.payload && + typeof event.payload === "object" && + event.payload !== null + ) { + const payload = event.payload as Record; + + if (typeof payload.content === "string") { + text = extractCleanText(payload.content); + } + + if ( + !text && + payload?.turn && + typeof payload.turn === "object" && + payload.turn !== null + ) { + const turn = payload.turn as Record; + if ( + turn?.output_message && + typeof turn.output_message === "object" && + turn.output_message !== null + ) { + const outputMessage = turn.output_message as Record< + string, + unknown + >; + if (typeof outputMessage.content === "string") { + text = extractCleanText(outputMessage.content); + } + } + + if ( + !text && + turn?.steps && + Array.isArray(turn.steps) && + turn.steps.length > 0 + ) { + for (const step of turn.steps) { + if (step && typeof step === "object" && step !== null) { + const stepObj = step as Record; + if ( + stepObj?.model_response && + typeof stepObj.model_response === "object" && + stepObj.model_response !== null + ) { + const modelResponse = stepObj.model_response as Record< + string, + unknown + >; + if (typeof modelResponse.content === "string") { + text = extractCleanText(modelResponse.content); + break; + } + } + } + } + } + } + + if ( + !text && + payload?.delta && + typeof payload.delta === "object" && + payload.delta !== null + ) { + const delta = payload.delta as Record; + if (typeof delta.text === "string") { + text = extractCleanText(delta.text); + } + } + } + + if ( + !text && + event?.delta && + typeof event.delta === "object" && + event.delta !== null + ) { + const delta = event.delta as Record; + if (typeof delta.text === "string") { + text = extractCleanText(delta.text); + } + if (!text && typeof delta.content === "string") { + text = extractCleanText(delta.content); + } + } + } + + if ( + !text && + chunkObj?.choices && + Array.isArray(chunkObj.choices) && + chunkObj.choices.length > 0 + ) { + const choice = chunkObj.choices[0] as Record; + if ( + choice?.delta && + typeof choice.delta === "object" && + choice.delta !== null + ) { + const delta = choice.delta as Record; + if (typeof delta.content === "string") { + text = extractCleanText(delta.content); + } + } + } + + if (!text && typeof chunk === "string") { + text = extractCleanText(chunk); + } + + return { text, isToolCall: false }; }; setCurrentSession(prev => { if (!prev) return null; @@ -713,8 +1037,34 @@ export default function ChatPlaygroundPage() { }); let fullContent = ""; + for await (const chunk of response) { - const deltaText = extractDeltaText(chunk); + const { text: deltaText } = processChunk(chunk); + + // logging for debugging function calls + // if (deltaText && deltaText.includes("knowledge_search")) { + // console.log("🔍 Function call detected in text output:", deltaText); + // console.log("🔍 Original chunk:", JSON.stringify(chunk, null, 2)); + // } + + if (chunk && typeof chunk === "object" && "event" in chunk) { + const event = ( + chunk as { + event: { + payload?: { + event_type?: string; + turn?: { output_message?: { content?: string } }; + }; + }; + } + ).event; + if (event?.payload?.event_type === "turn_complete") { + const content = event?.payload?.turn?.output_message?.content; + if (content && content.includes("knowledge_search")) { + console.log("🔍 Function call found in turn_complete:", content); + } + } + } if (deltaText) { fullContent += deltaText; @@ -732,9 +1082,9 @@ export default function ChatPlaygroundPage() { messages: newMessages, updatedAt: Date.now(), }; - // update cache with streaming content (throttled) + // update cache with streaming content if (fullContent.length % 100 === 0) { - // Only cache every 100 characters to avoid spam + // Only cache every 100 characters SessionUtils.saveSessionData(prev.agentId, updatedSession); } return updatedSession; @@ -809,8 +1159,180 @@ export default function ChatPlaygroundPage() { setError(null); }; + const handleRAGFileUpload = async (file: File) => { + if (!selectedAgentConfig?.toolgroups || !selectedAgentId) { + setError("No agent selected or agent has no RAG tools configured"); + return; + } + + // find RAG toolgroups that have vector_db_ids configured + const ragToolgroups = selectedAgentConfig.toolgroups.filter(toolgroup => { + if (typeof toolgroup === "object" && toolgroup.name?.includes("rag")) { + return toolgroup.args && "vector_db_ids" in toolgroup.args; + } + return false; + }); + + if (ragToolgroups.length === 0) { + setError("Current agent has no vector databases configured for RAG"); + return; + } + + try { + setError(null); + console.log("Uploading file using RAG tool..."); + + setUploadNotification({ + show: true, + message: `📄 Uploading and indexing "${file.name}"...`, + type: "loading", + }); + + const vectorDbIds = ragToolgroups.flatMap(toolgroup => { + if ( + typeof toolgroup === "object" && + toolgroup.args && + "vector_db_ids" in toolgroup.args + ) { + return toolgroup.args.vector_db_ids as string[]; + } + return []; + }); + + // determine mime type from file extension - this should be in the Llama Stack Client IMO + const getContentType = (filename: string): string => { + const ext = filename.toLowerCase().split(".").pop(); + switch (ext) { + case "pdf": + return "application/pdf"; + case "txt": + return "text/plain"; + case "md": + return "text/markdown"; + case "html": + return "text/html"; + case "csv": + return "text/csv"; + case "json": + return "application/json"; + case "docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + case "doc": + return "application/msword"; + default: + return "application/octet-stream"; + } + }; + + const mimeType = getContentType(file.name); + let fileContent: string; + + // handle text files vs binary files differently + const isTextFile = + mimeType.startsWith("text/") || + mimeType === "application/json" || + mimeType === "text/markdown" || + mimeType === "text/html" || + mimeType === "text/csv"; + + if (isTextFile) { + fileContent = await file.text(); + } else { + // for PDFs and other binary files, create a data URL + // use FileReader for efficient base64 conversion + fileContent = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); + } + + for (const vectorDbId of vectorDbIds) { + await client.toolRuntime.ragTool.insert({ + documents: [ + { + content: fileContent, + document_id: `${file.name}-${Date.now()}`, + metadata: { + filename: file.name, + file_size: file.size, + uploaded_at: new Date().toISOString(), + agent_id: selectedAgentId, + }, + mime_type: mimeType, + }, + ], + vector_db_id: vectorDbId, + // TODO: parameterize this somewhere, probably in settings + chunk_size_in_tokens: 512, + }); + } + + console.log("✅ File successfully uploaded using RAG tool"); + + setUploadNotification({ + show: true, + message: `📄 File "${file.name}" uploaded and indexed successfully!`, + type: "success", + }); + + setTimeout(() => { + setUploadNotification(prev => ({ ...prev, show: false })); + }, 4000); + } catch (err) { + console.error("Error uploading file using RAG tool:", err); + const errorMessage = + err instanceof Error + ? `Failed to upload file: ${err.message}` + : "Failed to upload file using RAG tool"; + + setUploadNotification({ + show: true, + message: errorMessage, + type: "error", + }); + + setTimeout(() => { + setUploadNotification(prev => ({ ...prev, show: false })); + }, 6000); + } + }; + return (
+ {/* Upload Notification */} + {uploadNotification.show && ( +
+
+ {uploadNotification.type === "loading" && ( +
+ )} + + {uploadNotification.message} + + {uploadNotification.type !== "loading" && ( + + )} +
+
+ )} + {/* Header */}
@@ -822,7 +1344,6 @@ export default function ChatPlaygroundPage() { - {selectedAgentId && agents.length > 1 && ( + {selectedAgentId && (
)} - - setCurrentSession(prev => - prev ? { ...prev, messages, updatedAt: Date.now() } : prev - ) - } - /> + {!agentsLoading && agents.length === 0 ? ( +
+
+
🦙
+

+ Create an Agent with Llama Stack +

+

+ To get started, create your first agent. Each agent is + configured with specific instructions, models, and tools to + help you with different tasks. +

+ +
+
+ ) : ( + + setCurrentSession(prev => + prev ? { ...prev, messages, updatedAt: Date.now() } : prev + ) + } + onRAGFileUpload={handleRAGFileUpload} + /> + )}
@@ -1086,14 +1662,16 @@ export default function ChatPlaygroundPage() { - {models.map(model => ( - - {model.identifier} - - ))} + {models + .filter(model => model.model_type === "llm") + .map(model => ( + + {model.identifier} + + ))} @@ -1137,21 +1715,12 @@ export default function ChatPlaygroundPage() { toolgroup.identifier )} onChange={e => { - console.log( - "Tool selection changed:", - toolgroup.identifier, - e.target.checked - ); if (e.target.checked) { setSelectedToolgroups(prev => { const newSelection = [ ...prev, toolgroup.identifier, ]; - console.log( - "New selected toolgroups:", - newSelection - ); return newSelection; }); } else { @@ -1159,10 +1728,6 @@ export default function ChatPlaygroundPage() { const newSelection = prev.filter( id => id !== toolgroup.identifier ); - console.log( - "New selected toolgroups:", - newSelection - ); return newSelection; }); } @@ -1194,6 +1759,80 @@ export default function ChatPlaygroundPage() { text generation agents work without tools.

+ + {/* Vector DB Configuration for RAG */} + {selectedToolgroups.includes("builtin::rag") && ( +
+ +
+ + + {availableVectorDBs.length} available + +
+
+ {availableVectorDBs.length === 0 ? ( +

+ No vector databases available. Create one to use RAG + tools. +

+ ) : ( + availableVectorDBs.map(vectorDB => ( + + )) + )} +
+ {selectedVectorDBs.length === 0 && + selectedToolgroups.includes("builtin::rag") && ( +

+ ⚠️ RAG tool selected but no vector databases chosen. + Create or select a vector database. +

+ )} +
+ )}
@@ -1204,12 +1843,14 @@ export default function ChatPlaygroundPage() { newAgentName, newAgentInstructions, selectedModel, - selectedToolgroups + selectedToolgroups, + selectedVectorDBs ); setShowCreateAgent(false); setNewAgentName(""); setNewAgentInstructions("You are a helpful assistant."); setSelectedToolgroups([]); + setSelectedVectorDBs([]); } catch (error) { console.error("Failed to create agent:", error); } @@ -1226,6 +1867,7 @@ export default function ChatPlaygroundPage() { setNewAgentName(""); setNewAgentInstructions("You are a helpful assistant."); setSelectedToolgroups([]); + setSelectedVectorDBs([]); }} className="flex-1" > @@ -1235,6 +1877,17 @@ export default function ChatPlaygroundPage() {
)} + + {/* Create Vector DB Modal */} + {showCreateVectorDB && ( +
+ setShowCreateVectorDB(false)} + /> +
+ )} ); } diff --git a/llama_stack/ui/components/chat-playground/chat.tsx b/llama_stack/ui/components/chat-playground/chat.tsx index 023bf0728..3b37c4dfe 100644 --- a/llama_stack/ui/components/chat-playground/chat.tsx +++ b/llama_stack/ui/components/chat-playground/chat.tsx @@ -35,6 +35,7 @@ interface ChatPropsBase { ) => void; setMessages?: (messages: Message[]) => void; transcribeAudio?: (blob: Blob) => Promise; + onRAGFileUpload?: (file: File) => Promise; } interface ChatPropsWithoutSuggestions extends ChatPropsBase { @@ -62,6 +63,7 @@ export function Chat({ onRateResponse, setMessages, transcribeAudio, + onRAGFileUpload, }: ChatProps) { const lastMessage = messages.at(-1); const isEmpty = messages.length === 0; @@ -226,16 +228,17 @@ export function Chat({ isPending={isGenerating || isTyping} handleSubmit={handleSubmit} > - {({ files, setFiles }) => ( + {() => ( {}} stop={handleStop} isGenerating={isGenerating} transcribeAudio={transcribeAudio} + onRAGFileUpload={onRAGFileUpload} /> )} diff --git a/llama_stack/ui/components/chat-playground/conversations.tsx b/llama_stack/ui/components/chat-playground/conversations.tsx index 1a9c960fe..40045b9fe 100644 --- a/llama_stack/ui/components/chat-playground/conversations.tsx +++ b/llama_stack/ui/components/chat-playground/conversations.tsx @@ -14,6 +14,7 @@ import { Card } from "@/components/ui/card"; import { Trash2 } from "lucide-react"; import type { Message } from "@/components/chat-playground/chat-message"; import { useAuthClient } from "@/hooks/use-auth-client"; +import { cleanMessageContent } from "@/lib/message-content-utils"; import type { Session, SessionCreateParams, @@ -219,10 +220,7 @@ export function Conversations({ messages.push({ id: `${turn.turn_id}-assistant-${messages.length}`, role: "assistant", - content: - typeof turn.output_message.content === "string" - ? turn.output_message.content - : JSON.stringify(turn.output_message.content), + content: cleanMessageContent(turn.output_message.content), createdAt: new Date( turn.completed_at || turn.started_at || Date.now() ), @@ -271,7 +269,7 @@ export function Conversations({ ); const deleteSession = async (sessionId: string) => { - if (sessions.length <= 1 || !selectedAgentId) { + if (!selectedAgentId) { return; } @@ -324,7 +322,6 @@ export function Conversations({ } }, [currentSession]); - // Don't render if no agent is selected if (!selectedAgentId) { return null; } @@ -357,7 +354,7 @@ export function Conversations({ + New - {currentSession && sessions.length > 1 && ( + {currentSession && ( + {onCancel && ( + + )} + + + +
+ Note: This will create a new vector database that can + be used with RAG tools. After creation, you'll be able to upload + documents and use it for knowledge search in your agent conversations. +
+ + ); +} diff --git a/llama_stack/ui/lib/message-content-utils.ts b/llama_stack/ui/lib/message-content-utils.ts new file mode 100644 index 000000000..378f8d669 --- /dev/null +++ b/llama_stack/ui/lib/message-content-utils.ts @@ -0,0 +1,51 @@ +// check if content contains function call JSON +export const containsToolCall = (content: string): boolean => { + return ( + content.includes('"type": "function"') || + content.includes('"name": "knowledge_search"') || + content.includes('"parameters":') || + !!content.match(/\{"type":\s*"function".*?\}/) + ); +}; + +export const extractCleanText = (content: string): string | null => { + if (containsToolCall(content)) { + try { + // parse and extract non-function call parts + const jsonMatch = content.match(/\{"type":\s*"function"[^}]*\}[^}]*\}/); + if (jsonMatch) { + const jsonPart = jsonMatch[0]; + const parsedJson = JSON.parse(jsonPart); + + // if function call, extract text after JSON + if (parsedJson.type === "function") { + const textAfterJson = content + .substring(content.indexOf(jsonPart) + jsonPart.length) + .trim(); + return textAfterJson || null; + } + } + return null; + } catch { + return null; + } + } + return content; +}; + +// removes function call JSON handling different content types +export const cleanMessageContent = ( + content: string | unknown[] | unknown +): string => { + if (typeof content === "string") { + const cleaned = extractCleanText(content); + return cleaned || ""; + } else if (Array.isArray(content)) { + return content + .filter((item: { type: string }) => item.type === "text") + .map((item: { text: string }) => item.text) + .join(""); + } else { + return JSON.stringify(content); + } +};