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" && (
+
+ setUploadNotification(prev => ({ ...prev, show: false }))
+ }
+ className="ml-2 text-gray-400 hover:text-gray-600"
+ >
+ ✕
+
+ )}
+
+
+ )}
+
{/* Header */}
@@ -822,7 +1344,6 @@ export default function ChatPlaygroundPage() {
{
- console.log("🤖 User selected agent:", agentId);
setSelectedAgentId(agentId);
SessionUtils.saveCurrentAgentId(agentId);
loadAgentConfig(agentId);
@@ -861,7 +1382,7 @@ export default function ChatPlaygroundPage() {
))}
- {selectedAgentId && agents.length > 1 && (
+ {selectedAgentId && (
deleteAgent(selectedAgentId)}
variant="outline"
@@ -922,14 +1443,16 @@ export default function ChatPlaygroundPage() {
/>
- {models.map(model => (
-
- {model.identifier}
-
- ))}
+ {models
+ .filter(model => model.model_type === "llm")
+ .map(model => (
+
+ {model.identifier}
+
+ ))}
{modelsError && (
@@ -982,34 +1505,63 @@ export default function ChatPlaygroundPage() {
const toolArgs =
typeof toolgroup === "object" ? toolgroup.args : null;
+ const isRAGTool = toolName.includes("rag");
+ const displayName = isRAGTool ? "RAG Search" : toolName;
+ const displayIcon = isRAGTool
+ ? "🔍"
+ : toolName.includes("search")
+ ? "🌐"
+ : "🔧";
+
return (
-
- {toolName}
-
-
- {toolName.includes("rag")
- ? "🔍 RAG"
- : toolName.includes("search")
- ? "🌐 Search"
- : "🔧 Tool"}
-
-
- {toolArgs && Object.keys(toolArgs).length > 0 && (
-
-
Args: {" "}
- {Object.entries(toolArgs)
- .map(
- ([key, value]) =>
- `${key}: ${JSON.stringify(value)}`
- )
- .join(", ")}
+
+ {displayIcon}
+
+ {displayName}
+
- )}
+
+ {isRAGTool && toolArgs && toolArgs.vector_db_ids ? (
+
+
+ Vector Databases:
+
+
+ {Array.isArray(toolArgs.vector_db_ids) ? (
+ toolArgs.vector_db_ids.map(
+ (dbId: string, idx: number) => (
+
+ {dbId}
+
+ )
+ )
+ ) : (
+
+ {String(toolArgs.vector_db_ids)}
+
+ )}
+
+
+ ) : null}
+ {!isRAGTool &&
+ toolArgs &&
+ Object.keys(toolArgs).length > 0 && (
+
+
+ Configuration:
+ {" "}
+ {Object.keys(toolArgs).length} parameter
+ {Object.keys(toolArgs).length > 1 ? "s" : ""}
+
+ )}
);
}
@@ -1043,21 +1595,45 @@ export default function ChatPlaygroundPage() {
)}
-
- 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.
+
+
setShowCreateAgent(true)}
+ size="lg"
+ className="mt-4"
+ >
+ Create Your First Agent
+
+
+
+ ) : (
+
+ 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") && (
+
+
+ Vector Databases for RAG
+
+
+ setShowCreateVectorDB(true)}
+ >
+ + Create Vector DB
+
+
+ {availableVectorDBs.length} available
+
+
+
+ {availableVectorDBs.length === 0 ? (
+
+ No vector databases available. Create one to use RAG
+ tools.
+
+ ) : (
+ availableVectorDBs.map(vectorDB => (
+
+ {
+ if (e.target.checked) {
+ setSelectedVectorDBs(prev => [
+ ...prev,
+ vectorDB.identifier,
+ ]);
+ } else {
+ setSelectedVectorDBs(prev =>
+ prev.filter(id => id !== vectorDB.identifier)
+ );
+ }
+ }}
+ className="rounded border-input"
+ />
+
+
+ {vectorDB.identifier}
+
+ {vectorDB.vector_db_name && (
+
+ ({vectorDB.vector_db_name})
+
+ )}
+
+
+ ))
+ )}
+
+ {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 && (
deleteSession(currentSession.id)}
variant="outline"
diff --git a/llama_stack/ui/components/chat-playground/message-input.tsx b/llama_stack/ui/components/chat-playground/message-input.tsx
index 8cfa73b30..fdd0b4164 100644
--- a/llama_stack/ui/components/chat-playground/message-input.tsx
+++ b/llama_stack/ui/components/chat-playground/message-input.tsx
@@ -21,6 +21,7 @@ interface MessageInputBaseProps
isGenerating: boolean;
enableInterrupt?: boolean;
transcribeAudio?: (blob: Blob) => Promise;
+ onRAGFileUpload?: (file: File) => Promise;
}
interface MessageInputWithoutAttachmentProps extends MessageInputBaseProps {
@@ -213,8 +214,13 @@ export function MessageInput({
className
)}
{...(props.allowAttachments
- ? omit(props, ["allowAttachments", "files", "setFiles"])
- : omit(props, ["allowAttachments"]))}
+ ? omit(props, [
+ "allowAttachments",
+ "files",
+ "setFiles",
+ "onRAGFileUpload",
+ ])
+ : omit(props, ["allowAttachments", "onRAGFileUpload"]))}
/>
{props.allowAttachments && (
@@ -254,11 +260,19 @@ export function MessageInput({
size="icon"
variant="outline"
className="h-8 w-8"
- aria-label="Attach a file"
- disabled={true}
+ aria-label="Upload file to RAG"
+ disabled={false}
onClick={async () => {
- const files = await showFileUploadDialog();
- addFiles(files);
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = ".pdf,.txt,.md,.html,.csv,.json";
+ input.onchange = async e => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (file && props.onRAGFileUpload) {
+ await props.onRAGFileUpload(file);
+ }
+ };
+ input.click();
}}
>
@@ -337,28 +351,6 @@ function FileUploadOverlay({ isDragging }: FileUploadOverlayProps) {
);
}
-function showFileUploadDialog() {
- const input = document.createElement("input");
-
- input.type = "file";
- input.multiple = true;
- input.accept = "*/*";
- input.click();
-
- return new Promise(resolve => {
- input.onchange = e => {
- const files = (e.currentTarget as HTMLInputElement).files;
-
- if (files) {
- resolve(Array.from(files));
- return;
- }
-
- resolve(null);
- };
- });
-}
-
function TranscribingOverlay() {
return (
void;
+ onCancel?: () => void;
+}
+
+interface VectorDBProvider {
+ api: string;
+ provider_id: string;
+ provider_type: string;
+}
+
+export function VectorDBCreator({
+ models,
+ onVectorDBCreated,
+ onCancel,
+}: VectorDBCreatorProps) {
+ const [vectorDbName, setVectorDbName] = useState("");
+ const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState("");
+ const [selectedProvider, setSelectedProvider] = useState("faiss");
+ const [availableProviders, setAvailableProviders] = useState<
+ VectorDBProvider[]
+ >([]);
+ const [isCreating, setIsCreating] = useState(false);
+ const [isLoadingProviders, setIsLoadingProviders] = useState(false);
+ const [error, setError] = useState(null);
+ const client = useAuthClient();
+
+ const embeddingModels = models.filter(
+ model => model.model_type === "embedding"
+ );
+
+ useEffect(() => {
+ const fetchProviders = async () => {
+ setIsLoadingProviders(true);
+ try {
+ const providersResponse = await client.providers.list();
+
+ const vectorIoProviders = providersResponse.filter(
+ (provider: VectorDBProvider) => provider.api === "vector_io"
+ );
+
+ setAvailableProviders(vectorIoProviders);
+
+ if (vectorIoProviders.length > 0) {
+ const faissProvider = vectorIoProviders.find(
+ (p: VectorDBProvider) => p.provider_id === "faiss"
+ );
+ setSelectedProvider(
+ faissProvider?.provider_id || vectorIoProviders[0].provider_id
+ );
+ }
+ } catch (err) {
+ console.error("Error fetching providers:", err);
+ setAvailableProviders([
+ {
+ api: "vector_io",
+ provider_id: "faiss",
+ provider_type: "inline::faiss",
+ },
+ ]);
+ } finally {
+ setIsLoadingProviders(false);
+ }
+ };
+
+ fetchProviders();
+ }, [client]);
+
+ const handleCreate = async () => {
+ if (!vectorDbName.trim() || !selectedEmbeddingModel) {
+ setError("Please provide a name and select an embedding model");
+ return;
+ }
+
+ setIsCreating(true);
+ setError(null);
+
+ try {
+ const embeddingModel = embeddingModels.find(
+ m => m.identifier === selectedEmbeddingModel
+ );
+
+ if (!embeddingModel) {
+ throw new Error("Selected embedding model not found");
+ }
+
+ const embeddingDimension = embeddingModel.metadata
+ ?.embedding_dimension as number;
+
+ if (!embeddingDimension) {
+ throw new Error("Embedding dimension not available for selected model");
+ }
+
+ const vectorDbId = vectorDbName.trim() || `vector_db_${Date.now()}`;
+
+ const response = await client.vectorDBs.register({
+ vector_db_id: vectorDbId,
+ embedding_model: selectedEmbeddingModel,
+ embedding_dimension: embeddingDimension,
+ provider_id: selectedProvider,
+ });
+
+ onVectorDBCreated?.(response.identifier || vectorDbId);
+ } catch (err) {
+ console.error("Error creating vector DB:", err);
+ setError(
+ err instanceof Error ? err.message : "Failed to create vector DB"
+ );
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ return (
+
+ Create Vector Database
+
+
+
+
+ Vector DB Name
+
+ setVectorDbName(e.target.value)}
+ placeholder="My Vector Database"
+ />
+
+
+
+
+ Embedding Model
+
+
+
+
+
+
+ {embeddingModels.map(model => (
+
+ {model.identifier}
+
+ ))}
+
+
+ {selectedEmbeddingModel && (
+
+ Dimension:{" "}
+ {embeddingModels.find(
+ m => m.identifier === selectedEmbeddingModel
+ )?.metadata?.embedding_dimension || "Unknown"}
+
+ )}
+
+
+
+
+ Vector Database Provider
+
+
+
+
+
+
+ {availableProviders.map(provider => (
+
+ {provider.provider_id}
+
+ ))}
+
+
+ {selectedProvider && (
+
+ Selected provider: {selectedProvider}
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {isCreating ? "Creating..." : "Create Vector DB"}
+
+ {onCancel && (
+
+ Cancel
+
+ )}
+
+
+
+
+ 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);
+ }
+};