mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-06-28 02:53:30 +00:00
feat(ui): implement chat completion views (#2201)
# What does this PR do? Implements table and detail views for chat completions <img width="1548" alt="image" src="https://github.com/user-attachments/assets/01061b7f-0d47-4b3b-b5ac-2df8f9035ef6" /> <img width="1549" alt="image" src="https://github.com/user-attachments/assets/738d8612-8258-4c2c-858b-bee39030649f" /> ## Test Plan npm run test
This commit is contained in:
parent
d8c6ab9bfc
commit
2708312168
27 changed files with 6729 additions and 38 deletions
193
llama_stack/ui/lib/format-message-content.test.ts
Normal file
193
llama_stack/ui/lib/format-message-content.test.ts
Normal file
|
@ -0,0 +1,193 @@
|
|||
import {
|
||||
extractTextFromContentPart,
|
||||
extractDisplayableText,
|
||||
} from "./format-message-content";
|
||||
import { ChatMessage } from "@/lib/types";
|
||||
|
||||
describe("extractTextFromContentPart", () => {
|
||||
it("should return an empty string for null or undefined input", () => {
|
||||
expect(extractTextFromContentPart(null)).toBe("");
|
||||
expect(extractTextFromContentPart(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should return the string itself if input is a string", () => {
|
||||
expect(extractTextFromContentPart("Hello, world!")).toBe("Hello, world!");
|
||||
expect(extractTextFromContentPart("")).toBe("");
|
||||
});
|
||||
|
||||
it("should extract text from an array of text content objects", () => {
|
||||
const content = [{ type: "text", text: "Which planet do humans live on?" }];
|
||||
expect(extractTextFromContentPart(content)).toBe(
|
||||
"Which planet do humans live on?",
|
||||
);
|
||||
});
|
||||
|
||||
it("should join text from multiple text content objects in an array", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Hello," },
|
||||
{ type: "text", text: "world!" },
|
||||
];
|
||||
expect(extractTextFromContentPart(content)).toBe("Hello, world!");
|
||||
});
|
||||
|
||||
it("should handle mixed text and image_url types in an array", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Look at this:" },
|
||||
{ type: "image_url", image_url: { url: "http://example.com/image.png" } },
|
||||
{ type: "text", text: "It's an image." },
|
||||
];
|
||||
expect(extractTextFromContentPart(content)).toBe(
|
||||
"Look at this: [Image] It's an image.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return '[Image]' for an array with only an image_url object", () => {
|
||||
const content = [
|
||||
{ type: "image_url", image_url: { url: "http://example.com/image.png" } },
|
||||
];
|
||||
expect(extractTextFromContentPart(content)).toBe("[Image]");
|
||||
});
|
||||
|
||||
it("should return an empty string for an empty array", () => {
|
||||
expect(extractTextFromContentPart([])).toBe("");
|
||||
});
|
||||
|
||||
it("should handle arrays with plain strings", () => {
|
||||
const content = ["This is", " a test."] as any;
|
||||
expect(extractTextFromContentPart(content)).toBe("This is a test.");
|
||||
});
|
||||
|
||||
it("should filter out malformed or unrecognized objects in an array", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "Valid" },
|
||||
{ type: "unknown" },
|
||||
{ text: "Missing type" },
|
||||
null,
|
||||
undefined,
|
||||
{ type: "text", noTextProperty: true },
|
||||
] as any;
|
||||
expect(extractTextFromContentPart(content)).toBe("Valid");
|
||||
});
|
||||
|
||||
it("should handle an array of mixed valid items and plain strings", () => {
|
||||
const content = [
|
||||
{ type: "text", text: "First part." },
|
||||
"Just a string.",
|
||||
{ type: "image_url", image_url: { url: "http://example.com/image.png" } },
|
||||
{ type: "text", text: "Last part." },
|
||||
] as any;
|
||||
expect(extractTextFromContentPart(content)).toBe(
|
||||
"First part. Just a string. [Image] Last part.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDisplayableText (composite function)", () => {
|
||||
const mockFormatToolCallToString = (toolCall: any) => {
|
||||
if (!toolCall || !toolCall.function || !toolCall.function.name) return "";
|
||||
const args = toolCall.function.arguments
|
||||
? JSON.stringify(toolCall.function.arguments)
|
||||
: "";
|
||||
return `${toolCall.function.name}(${args})`;
|
||||
};
|
||||
|
||||
it("should return empty string for null or undefined message", () => {
|
||||
expect(extractDisplayableText(null)).toBe("");
|
||||
expect(extractDisplayableText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should return only content part if no tool calls", () => {
|
||||
const message: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "Hello there!",
|
||||
};
|
||||
expect(extractDisplayableText(message)).toBe("Hello there!");
|
||||
});
|
||||
|
||||
it("should return only content part for complex content if no tool calls", () => {
|
||||
const message: ChatMessage = {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Part 1" },
|
||||
{ type: "text", text: "Part 2" },
|
||||
],
|
||||
};
|
||||
expect(extractDisplayableText(message)).toBe("Part 1 Part 2");
|
||||
});
|
||||
|
||||
it("should return only formatted tool call if content is empty or null", () => {
|
||||
const toolCall = {
|
||||
function: { name: "search", arguments: { query: "cats" } },
|
||||
};
|
||||
const messageWithEffectivelyEmptyContent: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [toolCall],
|
||||
};
|
||||
expect(extractDisplayableText(messageWithEffectivelyEmptyContent)).toBe(
|
||||
mockFormatToolCallToString(toolCall),
|
||||
);
|
||||
|
||||
const messageWithEmptyContent: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [toolCall],
|
||||
};
|
||||
expect(extractDisplayableText(messageWithEmptyContent)).toBe(
|
||||
mockFormatToolCallToString(toolCall),
|
||||
);
|
||||
});
|
||||
|
||||
it("should combine content and formatted tool call", () => {
|
||||
const toolCall = {
|
||||
function: { name: "calculator", arguments: { expr: "2+2" } },
|
||||
};
|
||||
const message: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "The result is:",
|
||||
tool_calls: [toolCall],
|
||||
};
|
||||
const expectedToolCallStr = mockFormatToolCallToString(toolCall);
|
||||
expect(extractDisplayableText(message)).toBe(
|
||||
`The result is: ${expectedToolCallStr}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle message with content an array and a tool call", () => {
|
||||
const toolCall = {
|
||||
function: { name: "get_weather", arguments: { city: "London" } },
|
||||
};
|
||||
const message: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Okay, checking weather for" },
|
||||
{ type: "text", text: "London." },
|
||||
],
|
||||
tool_calls: [toolCall],
|
||||
};
|
||||
const expectedToolCallStr = mockFormatToolCallToString(toolCall);
|
||||
expect(extractDisplayableText(message)).toBe(
|
||||
`Okay, checking weather for London. ${expectedToolCallStr}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return only content if tool_calls array is empty or undefined", () => {
|
||||
const messageEmptyToolCalls: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "No tools here.",
|
||||
tool_calls: [],
|
||||
};
|
||||
expect(extractDisplayableText(messageEmptyToolCalls)).toBe(
|
||||
"No tools here.",
|
||||
);
|
||||
|
||||
const messageUndefinedToolCalls: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "Still no tools.",
|
||||
tool_calls: undefined,
|
||||
};
|
||||
expect(extractDisplayableText(messageUndefinedToolCalls)).toBe(
|
||||
"Still no tools.",
|
||||
);
|
||||
});
|
||||
});
|
61
llama_stack/ui/lib/format-message-content.ts
Normal file
61
llama_stack/ui/lib/format-message-content.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { ChatMessage, ChatMessageContentPart } from "@/lib/types";
|
||||
import { formatToolCallToString } from "@/lib/format-tool-call";
|
||||
|
||||
export function extractTextFromContentPart(
|
||||
content: string | ChatMessageContentPart[] | null | undefined,
|
||||
): string {
|
||||
if (content === null || content === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
} else if (Array.isArray(content)) {
|
||||
const parts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
item.type === "text" &&
|
||||
typeof item.text === "string"
|
||||
) {
|
||||
parts.push(item.text);
|
||||
} else if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
item.type === "image_url"
|
||||
) {
|
||||
parts.push("[Image]"); // Placeholder for images
|
||||
} else if (typeof item === "string") {
|
||||
// Handle cases where an array might contain plain strings
|
||||
parts.push(item);
|
||||
}
|
||||
}
|
||||
return parts.join(" ");
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractDisplayableText(
|
||||
message: ChatMessage | undefined | null,
|
||||
): string {
|
||||
if (!message) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let textPart = extractTextFromContentPart(message.content);
|
||||
let toolCallPart = "";
|
||||
|
||||
if (message.tool_calls && message.tool_calls.length > 0) {
|
||||
// For summary, usually the first tool call is sufficient
|
||||
toolCallPart = formatToolCallToString(message.tool_calls[0]);
|
||||
}
|
||||
|
||||
if (textPart && toolCallPart) {
|
||||
return `${textPart} ${toolCallPart}`;
|
||||
} else if (toolCallPart) {
|
||||
return toolCallPart;
|
||||
} else {
|
||||
return textPart; // textPart will be "" if message.content was initially null/undefined/empty array etc.
|
||||
}
|
||||
}
|
33
llama_stack/ui/lib/format-tool-call.tsx
Normal file
33
llama_stack/ui/lib/format-tool-call.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Formats a tool_call object into a string representation.
|
||||
* Example: "functionName(argumentsString)"
|
||||
* @param toolCall The tool_call object, expected to have a `function` property
|
||||
* with `name` and `arguments`.
|
||||
* @returns A formatted string or an empty string if data is malformed.
|
||||
*/
|
||||
export function formatToolCallToString(toolCall: any): string {
|
||||
if (
|
||||
!toolCall ||
|
||||
!toolCall.function ||
|
||||
typeof toolCall.function.name !== "string" ||
|
||||
toolCall.function.arguments === undefined
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const name = toolCall.function.name;
|
||||
const args = toolCall.function.arguments;
|
||||
let argsString: string;
|
||||
|
||||
if (typeof args === "string") {
|
||||
argsString = args;
|
||||
} else {
|
||||
try {
|
||||
argsString = JSON.stringify(args);
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return `${name}(${argsString})`;
|
||||
}
|
8
llama_stack/ui/lib/truncate-text.ts
Normal file
8
llama_stack/ui/lib/truncate-text.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export function truncateText(
|
||||
text: string | null | undefined,
|
||||
maxLength: number = 50,
|
||||
): string {
|
||||
if (!text) return "N/A";
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + "...";
|
||||
}
|
44
llama_stack/ui/lib/types.ts
Normal file
44
llama_stack/ui/lib/types.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
export interface TextContentBlock {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ImageUrlDetail {
|
||||
url: string;
|
||||
detail?: "low" | "high" | "auto";
|
||||
}
|
||||
|
||||
export interface ImageUrlContentBlock {
|
||||
type: "image_url";
|
||||
// Support both simple URL string and detailed object, though our parser currently just looks for type: "image_url"
|
||||
image_url: string | ImageUrlDetail;
|
||||
}
|
||||
|
||||
// Union of known content part types. Add more specific types as needed.
|
||||
export type ChatMessageContentPart =
|
||||
| TextContentBlock
|
||||
| ImageUrlContentBlock
|
||||
| { type: string; [key: string]: any }; // Fallback for other potential types
|
||||
|
||||
export interface ChatMessage {
|
||||
role: string;
|
||||
content: string | ChatMessageContentPart[]; // Updated content type
|
||||
name?: string | null;
|
||||
tool_calls?: any | null; // This could also be refined to a more specific ToolCall[] type
|
||||
}
|
||||
|
||||
export interface Choice {
|
||||
message: ChatMessage;
|
||||
finish_reason: string;
|
||||
index: number;
|
||||
logprobs?: any | null;
|
||||
}
|
||||
|
||||
export interface ChatCompletion {
|
||||
id: string;
|
||||
choices: Choice[];
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
input_messages: ChatMessage[];
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue