feat(UI): Adding linter and prettier for UI (#3156)

This commit is contained in:
Francisco Arceo 2025-08-14 15:58:43 -06:00 committed by GitHub
parent 61582f327c
commit e69acbafbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 1452 additions and 1226 deletions

View file

@ -2,6 +2,7 @@ exclude: 'build/'
default_language_version: default_language_version:
python: python3.12 python: python3.12
node: "22"
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
@ -145,6 +146,20 @@ repos:
pass_filenames: false pass_filenames: false
require_serial: true require_serial: true
files: ^.github/workflows/.*$ files: ^.github/workflows/.*$
- id: ui-prettier
name: Format UI code with Prettier
entry: bash -c 'cd llama_stack/ui && npm run format'
language: system
files: ^llama_stack/ui/.*\.(ts|tsx)$
pass_filenames: false
require_serial: true
- id: ui-eslint
name: Lint UI code with ESLint
entry: bash -c 'cd llama_stack/ui && npm run lint -- --fix --quiet'
language: system
files: ^llama_stack/ui/.*\.(ts|tsx)$
pass_filenames: false
require_serial: true
ci: ci:
autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks

1
llama_stack/ui/.nvmrc Normal file
View file

@ -0,0 +1 @@
22.5.1

View file

@ -1,3 +1,12 @@
# Ignore artifacts: # Ignore artifacts:
build build
coverage coverage
.next
node_modules
dist
*.lock
*.log
# Generated files
*.min.js
*.min.css

View file

@ -1 +1,10 @@
{} {
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid"
}

View file

@ -47,7 +47,7 @@ async function proxyRequest(request: NextRequest, method: string) {
const responseText = await response.text(); const responseText = await response.text();
console.log( console.log(
`Response from FastAPI: ${response.status} ${response.statusText}`, `Response from FastAPI: ${response.status} ${response.statusText}`
); );
// Create response with same status and headers // Create response with same status and headers
@ -74,7 +74,7 @@ async function proxyRequest(request: NextRequest, method: string) {
backend_url: BACKEND_URL, backend_url: BACKEND_URL,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
{ status: 500 }, { status: 500 }
); );
} }
} }

View file

@ -51,9 +51,9 @@ export default function SignInPage() {
onClick={() => { onClick={() => {
console.log("Signing in with GitHub..."); console.log("Signing in with GitHub...");
signIn("github", { callbackUrl: "/auth/signin" }).catch( signIn("github", { callbackUrl: "/auth/signin" }).catch(
(error) => { error => {
console.error("Sign in error:", error); console.error("Sign in error:", error);
}, }
); );
}} }}
className="w-full" className="w-full"

View file

@ -29,14 +29,13 @@ export default function ChatPlaygroundPage() {
const isModelsLoading = modelsLoading ?? true; const isModelsLoading = modelsLoading ?? true;
useEffect(() => { useEffect(() => {
const fetchModels = async () => { const fetchModels = async () => {
try { try {
setModelsLoading(true); setModelsLoading(true);
setModelsError(null); setModelsError(null);
const modelList = await client.models.list(); const modelList = await client.models.list();
const llmModels = modelList.filter(model => model.model_type === 'llm'); const llmModels = modelList.filter(model => model.model_type === "llm");
setModels(llmModels); setModels(llmModels);
if (llmModels.length > 0) { if (llmModels.length > 0) {
setSelectedModel(llmModels[0].identifier); setSelectedModel(llmModels[0].identifier);
@ -53,26 +52,42 @@ export default function ChatPlaygroundPage() {
}, [client]); }, [client]);
const extractTextContent = (content: unknown): string => { const extractTextContent = (content: unknown): string => {
if (typeof content === 'string') { if (typeof content === "string") {
return content; return content;
} }
if (Array.isArray(content)) { if (Array.isArray(content)) {
return content return content
.filter(item => item && typeof item === 'object' && 'type' in item && item.type === 'text') .filter(
.map(item => (item && typeof item === 'object' && 'text' in item) ? String(item.text) : '') item =>
.join(''); item &&
typeof item === "object" &&
"type" in item &&
item.type === "text"
)
.map(item =>
item && typeof item === "object" && "text" in item
? String(item.text)
: ""
)
.join("");
} }
if (content && typeof content === 'object' && 'type' in content && content.type === 'text' && 'text' in content) { if (
return String(content.text) || ''; content &&
typeof content === "object" &&
"type" in content &&
content.type === "text" &&
"text" in content
) {
return String(content.text) || "";
} }
return ''; return "";
}; };
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value); setInput(e.target.value);
}; };
const handleSubmit = async (event?: { preventDefault?: () => void }) => { const handleSubmit = async (event?: { preventDefault?: () => void }) => {
event?.preventDefault?.(); event?.preventDefault?.();
if (!input.trim()) return; if (!input.trim()) return;
@ -89,16 +104,19 @@ const handleSubmit = async (event?: { preventDefault?: () => void }) => {
// Use the helper function with the content // Use the helper function with the content
await handleSubmitWithContent(userMessage.content); await handleSubmitWithContent(userMessage.content);
}; };
const handleSubmitWithContent = async (content: string) => { const handleSubmitWithContent = async (content: string) => {
setIsGenerating(true); setIsGenerating(true);
setError(null); setError(null);
try { try {
const messageParams: CompletionCreateParams["messages"] = [ const messageParams: CompletionCreateParams["messages"] = [
...messages.map(msg => { ...messages.map(msg => {
const msgContent = typeof msg.content === 'string' ? msg.content : extractTextContent(msg.content); const msgContent =
typeof msg.content === "string"
? msg.content
: extractTextContent(msg.content);
if (msg.role === "user") { if (msg.role === "user") {
return { role: "user" as const, content: msgContent }; return { role: "user" as const, content: msgContent };
} else if (msg.role === "assistant") { } else if (msg.role === "assistant") {
@ -107,7 +125,7 @@ const handleSubmitWithContent = async (content: string) => {
return { role: "system" as const, content: msgContent }; return { role: "system" as const, content: msgContent };
} }
}), }),
{ role: "user" as const, content } { role: "user" as const, content },
]; ];
const response = await client.chat.completions.create({ const response = await client.chat.completions.create({
@ -149,7 +167,7 @@ const handleSubmitWithContent = async (content: string) => {
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }
}; };
const suggestions = [ const suggestions = [
"Write a Python function that prints 'Hello, World!'", "Write a Python function that prints 'Hello, World!'",
"Explain step-by-step how to solve this math problem: If x² + 6x + 9 = 25, what is x?", "Explain step-by-step how to solve this math problem: If x² + 6x + 9 = 25, what is x?",
@ -163,7 +181,7 @@ const handleSubmitWithContent = async (content: string) => {
content: message.content, content: message.content,
createdAt: new Date(), createdAt: new Date(),
}; };
setMessages(prev => [...prev, newMessage]) setMessages(prev => [...prev, newMessage]);
handleSubmitWithContent(newMessage.content); handleSubmitWithContent(newMessage.content);
}; };
@ -177,12 +195,20 @@ const handleSubmitWithContent = async (content: string) => {
<div className="mb-4 flex justify-between items-center"> <div className="mb-4 flex justify-between items-center">
<h1 className="text-2xl font-bold">Chat Playground (Completions)</h1> <h1 className="text-2xl font-bold">Chat Playground (Completions)</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={selectedModel} onValueChange={setSelectedModel} disabled={isModelsLoading || isGenerating}> <Select
value={selectedModel}
onValueChange={setSelectedModel}
disabled={isModelsLoading || isGenerating}
>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder={isModelsLoading ? "Loading models..." : "Select Model"} /> <SelectValue
placeholder={
isModelsLoading ? "Loading models..." : "Select Model"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{models.map((model) => ( {models.map(model => (
<SelectItem key={model.identifier} value={model.identifier}> <SelectItem key={model.identifier} value={model.identifier}>
{model.identifier} {model.identifier}
</SelectItem> </SelectItem>

View file

@ -33,12 +33,12 @@ export default function ChatCompletionDetailPage() {
} catch (err) { } catch (err) {
console.error( console.error(
`Error fetching chat completion detail for ID ${id}:`, `Error fetching chat completion detail for ID ${id}:`,
err, err
); );
setError( setError(
err instanceof Error err instanceof Error
? err ? err
: new Error("Failed to fetch completion detail"), : new Error("Failed to fetch completion detail")
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View file

@ -13,10 +13,10 @@ export default function ResponseDetailPage() {
const client = useAuthClient(); const client = useAuthClient();
const [responseDetail, setResponseDetail] = useState<OpenAIResponse | null>( const [responseDetail, setResponseDetail] = useState<OpenAIResponse | null>(
null, null
); );
const [inputItems, setInputItems] = useState<InputItemListResponse | null>( const [inputItems, setInputItems] = useState<InputItemListResponse | null>(
null, null
); );
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [isLoadingInputItems, setIsLoadingInputItems] = useState<boolean>(true); const [isLoadingInputItems, setIsLoadingInputItems] = useState<boolean>(true);
@ -25,7 +25,7 @@ export default function ResponseDetailPage() {
// Helper function to convert ResponseObject to OpenAIResponse // Helper function to convert ResponseObject to OpenAIResponse
const convertResponseObject = ( const convertResponseObject = (
responseData: ResponseObject, responseData: ResponseObject
): OpenAIResponse => { ): OpenAIResponse => {
return { return {
id: responseData.id, id: responseData.id,
@ -73,12 +73,12 @@ export default function ResponseDetailPage() {
} else { } else {
console.error( console.error(
`Error fetching response detail for ID ${id}:`, `Error fetching response detail for ID ${id}:`,
responseResult.reason, responseResult.reason
); );
setError( setError(
responseResult.reason instanceof Error responseResult.reason instanceof Error
? responseResult.reason ? responseResult.reason
: new Error("Failed to fetch response detail"), : new Error("Failed to fetch response detail")
); );
} }
@ -90,18 +90,18 @@ export default function ResponseDetailPage() {
} else { } else {
console.error( console.error(
`Error fetching input items for response ID ${id}:`, `Error fetching input items for response ID ${id}:`,
inputItemsResult.reason, inputItemsResult.reason
); );
setInputItemsError( setInputItemsError(
inputItemsResult.reason instanceof Error inputItemsResult.reason instanceof Error
? inputItemsResult.reason ? inputItemsResult.reason
: new Error("Failed to fetch input items"), : new Error("Failed to fetch input items")
); );
} }
} catch (err) { } catch (err) {
console.error(`Unexpected error fetching data for ID ${id}:`, err); console.error(`Unexpected error fetching data for ID ${id}:`, err);
setError( setError(
err instanceof Error ? err : new Error("Unexpected error occurred"), err instanceof Error ? err : new Error("Unexpected error occurred")
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View file

@ -18,7 +18,10 @@ import {
PropertiesCard, PropertiesCard,
PropertyItem, PropertyItem,
} from "@/components/layout/detail-layout"; } from "@/components/layout/detail-layout";
import { PageBreadcrumb, BreadcrumbSegment } from "@/components/layout/page-breadcrumb"; import {
PageBreadcrumb,
BreadcrumbSegment,
} from "@/components/layout/page-breadcrumb";
export default function ContentDetailPage() { export default function ContentDetailPage() {
const params = useParams(); const params = useParams();
@ -28,13 +31,13 @@ export default function ContentDetailPage() {
const contentId = params.contentId as string; const contentId = params.contentId as string;
const client = useAuthClient(); const client = useAuthClient();
const getTextFromContent = (content: any): string => { const getTextFromContent = (content: unknown): string => {
if (typeof content === 'string') { if (typeof content === "string") {
return content; return content;
} else if (content && content.type === 'text') { } else if (content && content.type === "text") {
return content.text; return content.text;
} }
return ''; return "";
}; };
const [store, setStore] = useState<VectorStore | null>(null); const [store, setStore] = useState<VectorStore | null>(null);
@ -44,7 +47,9 @@ export default function ContentDetailPage() {
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editedContent, setEditedContent] = useState(""); const [editedContent, setEditedContent] = useState("");
const [editedMetadata, setEditedMetadata] = useState<Record<string, any>>({}); const [editedMetadata, setEditedMetadata] = useState<Record<string, unknown>>(
{}
);
const [isEditingEmbedding, setIsEditingEmbedding] = useState(false); const [isEditingEmbedding, setIsEditingEmbedding] = useState(false);
const [editedEmbedding, setEditedEmbedding] = useState<number[]>([]); const [editedEmbedding, setEditedEmbedding] = useState<number[]>([]);
@ -64,8 +69,13 @@ export default function ContentDetailPage() {
setFile(fileResponse as VectorStoreFile); setFile(fileResponse as VectorStoreFile);
const contentsAPI = new ContentsAPI(client); const contentsAPI = new ContentsAPI(client);
const contentsResponse = await contentsAPI.listContents(vectorStoreId, fileId); const contentsResponse = await contentsAPI.listContents(
const targetContent = contentsResponse.data.find(c => c.id === contentId); vectorStoreId,
fileId
);
const targetContent = contentsResponse.data.find(
c => c.id === contentId
);
if (targetContent) { if (targetContent) {
setContent(targetContent); setContent(targetContent);
@ -76,7 +86,9 @@ export default function ContentDetailPage() {
throw new Error(`Content ${contentId} not found`); throw new Error(`Content ${contentId} not found`);
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err : new Error("Failed to load content.")); setError(
err instanceof Error ? err : new Error("Failed to load content.")
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -88,7 +100,8 @@ export default function ContentDetailPage() {
if (!content) return; if (!content) return;
try { try {
const updates: { content?: string; metadata?: Record<string, any> } = {}; const updates: { content?: string; metadata?: Record<string, unknown> } =
{};
if (editedContent !== getTextFromContent(content.content)) { if (editedContent !== getTextFromContent(content.content)) {
updates.content = editedContent; updates.content = editedContent;
@ -100,25 +113,32 @@ export default function ContentDetailPage() {
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
const contentsAPI = new ContentsAPI(client); const contentsAPI = new ContentsAPI(client);
const updatedContent = await contentsAPI.updateContent(vectorStoreId, fileId, contentId, updates); const updatedContent = await contentsAPI.updateContent(
vectorStoreId,
fileId,
contentId,
updates
);
setContent(updatedContent); setContent(updatedContent);
} }
setIsEditing(false); setIsEditing(false);
} catch (err) { } catch (err) {
console.error('Failed to update content:', err); console.error("Failed to update content:", err);
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this content?')) return; if (!confirm("Are you sure you want to delete this content?")) return;
try { try {
const contentsAPI = new ContentsAPI(client); const contentsAPI = new ContentsAPI(client);
await contentsAPI.deleteContent(vectorStoreId, fileId, contentId); await contentsAPI.deleteContent(vectorStoreId, fileId, contentId);
router.push(`/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents`); router.push(
`/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents`
);
} catch (err) { } catch (err) {
console.error('Failed to delete content:', err); console.error("Failed to delete content:", err);
} }
}; };
@ -134,10 +154,19 @@ export default function ContentDetailPage() {
const breadcrumbSegments: BreadcrumbSegment[] = [ const breadcrumbSegments: BreadcrumbSegment[] = [
{ label: "Vector Stores", href: "/logs/vector-stores" }, { label: "Vector Stores", href: "/logs/vector-stores" },
{ label: store?.name || vectorStoreId, href: `/logs/vector-stores/${vectorStoreId}` }, {
label: store?.name || vectorStoreId,
href: `/logs/vector-stores/${vectorStoreId}`,
},
{ label: "Files", href: `/logs/vector-stores/${vectorStoreId}` }, { label: "Files", href: `/logs/vector-stores/${vectorStoreId}` },
{ label: fileId, href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}` }, {
{ label: "Contents", href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents` }, label: fileId,
href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}`,
},
{
label: "Contents",
href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents`,
},
{ label: contentId }, { label: contentId },
]; ];
@ -186,7 +215,7 @@ export default function ContentDetailPage() {
{isEditing ? ( {isEditing ? (
<textarea <textarea
value={editedContent} value={editedContent}
onChange={(e) => setEditedContent(e.target.value)} onChange={e => setEditedContent(e.target.value)}
className="w-full h-64 p-3 border rounded-md resize-none font-mono text-sm" className="w-full h-64 p-3 border rounded-md resize-none font-mono text-sm"
placeholder="Enter content..." placeholder="Enter content..."
/> />
@ -206,16 +235,23 @@ export default function ContentDetailPage() {
<div className="flex gap-2"> <div className="flex gap-2">
{isEditingEmbedding ? ( {isEditingEmbedding ? (
<> <>
<Button size="sm" onClick={() => { <Button
size="sm"
onClick={() => {
setIsEditingEmbedding(false); setIsEditingEmbedding(false);
}}> }}
>
<Save className="h-4 w-4 mr-1" /> <Save className="h-4 w-4 mr-1" />
Save Save
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => { <Button
size="sm"
variant="outline"
onClick={() => {
setEditedEmbedding(content?.embedding || []); setEditedEmbedding(content?.embedding || []);
setIsEditingEmbedding(false); setIsEditingEmbedding(false);
}}> }}
>
<X className="h-4 w-4 mr-1" /> <X className="h-4 w-4 mr-1" />
Cancel Cancel
</Button> </Button>
@ -237,14 +273,16 @@ export default function ContentDetailPage() {
</p> </p>
<textarea <textarea
value={JSON.stringify(editedEmbedding, null, 2)} value={JSON.stringify(editedEmbedding, null, 2)}
onChange={(e) => { onChange={e => {
try { try {
const parsed = JSON.parse(e.target.value); const parsed = JSON.parse(e.target.value);
if (Array.isArray(parsed) && parsed.every(v => typeof v === 'number')) { if (
Array.isArray(parsed) &&
parsed.every(v => typeof v === "number")
) {
setEditedEmbedding(parsed); setEditedEmbedding(parsed);
} }
} catch { } catch {}
}
}} }}
className="w-full h-32 p-3 border rounded-md resize-none font-mono text-xs" className="w-full h-32 p-3 border rounded-md resize-none font-mono text-xs"
placeholder="Enter embedding as JSON array..." placeholder="Enter embedding as JSON array..."
@ -259,8 +297,15 @@ export default function ContentDetailPage() {
</div> </div>
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md max-h-32 overflow-y-auto"> <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-md max-h-32 overflow-y-auto">
<pre className="whitespace-pre-wrap font-mono text-xs text-gray-900 dark:text-gray-100"> <pre className="whitespace-pre-wrap font-mono text-xs text-gray-900 dark:text-gray-100">
[{content.embedding.slice(0, 20).map(v => v.toFixed(6)).join(', ')} [
{content.embedding.length > 20 ? `\n... and ${content.embedding.length - 20} more values` : ''}] {content.embedding
.slice(0, 20)
.map(v => v.toFixed(6))
.join(", ")}
{content.embedding.length > 20
? `\n... and ${content.embedding.length - 20} more values`
: ""}
]
</pre> </pre>
</div> </div>
</div> </div>
@ -284,7 +329,7 @@ export default function ContentDetailPage() {
<div key={key} className="flex gap-2"> <div key={key} className="flex gap-2">
<Input <Input
value={key} value={key}
onChange={(e) => { onChange={e => {
const newMetadata = { ...editedMetadata }; const newMetadata = { ...editedMetadata };
delete newMetadata[key]; delete newMetadata[key];
newMetadata[e.target.value] = value; newMetadata[e.target.value] = value;
@ -294,11 +339,13 @@ export default function ContentDetailPage() {
className="flex-1" className="flex-1"
/> />
<Input <Input
value={typeof value === 'string' ? value : JSON.stringify(value)} value={
onChange={(e) => { typeof value === "string" ? value : JSON.stringify(value)
}
onChange={e => {
setEditedMetadata({ setEditedMetadata({
...editedMetadata, ...editedMetadata,
[key]: e.target.value [key]: e.target.value,
}); });
}} }}
placeholder="Value" placeholder="Value"
@ -312,7 +359,7 @@ export default function ContentDetailPage() {
onClick={() => { onClick={() => {
setEditedMetadata({ setEditedMetadata({
...editedMetadata, ...editedMetadata,
['']: '' [""]: "",
}); });
}} }}
> >
@ -325,7 +372,7 @@ export default function ContentDetailPage() {
<div key={key} className="flex justify-between py-1"> <div key={key} className="flex justify-between py-1">
<span className="font-medium text-gray-600">{key}:</span> <span className="font-medium text-gray-600">{key}:</span>
<span className="font-mono text-sm"> <span className="font-mono text-sm">
{typeof value === 'string' ? value : JSON.stringify(value)} {typeof value === "string" ? value : JSON.stringify(value)}
</span> </span>
</div> </div>
))} ))}
@ -351,15 +398,15 @@ export default function ContentDetailPage() {
value={`${getTextFromContent(content.content).length} chars`} value={`${getTextFromContent(content.content).length} chars`}
/> />
{content.metadata.chunk_window && ( {content.metadata.chunk_window && (
<PropertyItem <PropertyItem label="Position" value={content.metadata.chunk_window} />
label="Position"
value={content.metadata.chunk_window}
/>
)} )}
{file && ( {file && (
<> <>
<PropertyItem label="File Status" value={file.status} /> <PropertyItem label="File Status" value={file.status} />
<PropertyItem label="File Usage" value={`${file.usage_bytes} bytes`} /> <PropertyItem
label="File Usage"
value={`${file.usage_bytes} bytes`}
/>
</> </>
)} )}
{store && ( {store && (

View file

@ -18,7 +18,10 @@ import {
PropertiesCard, PropertiesCard,
PropertyItem, PropertyItem,
} from "@/components/layout/detail-layout"; } from "@/components/layout/detail-layout";
import { PageBreadcrumb, BreadcrumbSegment } from "@/components/layout/page-breadcrumb"; import {
PageBreadcrumb,
BreadcrumbSegment,
} from "@/components/layout/page-breadcrumb";
import { import {
Table, Table,
TableBody, TableBody,
@ -36,23 +39,21 @@ export default function ContentsListPage() {
const fileId = params.fileId as string; const fileId = params.fileId as string;
const client = useAuthClient(); const client = useAuthClient();
const getTextFromContent = (content: any): string => { const getTextFromContent = (content: unknown): string => {
if (typeof content === 'string') { if (typeof content === "string") {
return content; return content;
} else if (content && content.type === 'text') { } else if (content && content.type === "text") {
return content.text; return content.text;
} }
return ''; return "";
}; };
const [store, setStore] = useState<VectorStore | null>(null); const [store, setStore] = useState<VectorStore | null>(null);
const [file, setFile] = useState<VectorStoreFile | null>(null); const [file, setFile] = useState<VectorStoreFile | null>(null);
const [contents, setContents] = useState<VectorStoreContentItem[]>([]); const [contents, setContents] = useState<VectorStoreContentItem[]>([]);
const [isLoadingStore, setIsLoadingStore] = useState(true); const [isLoadingStore, setIsLoadingStore] = useState(true);
const [isLoadingFile, setIsLoadingFile] = useState(true);
const [isLoadingContents, setIsLoadingContents] = useState(true); const [isLoadingContents, setIsLoadingContents] = useState(true);
const [errorStore, setErrorStore] = useState<Error | null>(null); const [errorStore, setErrorStore] = useState<Error | null>(null);
const [errorFile, setErrorFile] = useState<Error | null>(null);
const [errorContents, setErrorContents] = useState<Error | null>(null); const [errorContents, setErrorContents] = useState<Error | null>(null);
useEffect(() => { useEffect(() => {
@ -65,7 +66,9 @@ export default function ContentsListPage() {
const response = await client.vectorStores.retrieve(vectorStoreId); const response = await client.vectorStores.retrieve(vectorStoreId);
setStore(response as VectorStore); setStore(response as VectorStore);
} catch (err) { } catch (err) {
setErrorStore(err instanceof Error ? err : new Error("Failed to load vector store.")); setErrorStore(
err instanceof Error ? err : new Error("Failed to load vector store.")
);
} finally { } finally {
setIsLoadingStore(false); setIsLoadingStore(false);
} }
@ -80,10 +83,15 @@ export default function ContentsListPage() {
setIsLoadingFile(true); setIsLoadingFile(true);
setErrorFile(null); setErrorFile(null);
try { try {
const response = await client.vectorStores.files.retrieve(vectorStoreId, fileId); const response = await client.vectorStores.files.retrieve(
vectorStoreId,
fileId
);
setFile(response as VectorStoreFile); setFile(response as VectorStoreFile);
} catch (err) { } catch (err) {
setErrorFile(err instanceof Error ? err : new Error("Failed to load file.")); setErrorFile(
err instanceof Error ? err : new Error("Failed to load file.")
);
} finally { } finally {
setIsLoadingFile(false); setIsLoadingFile(false);
} }
@ -99,10 +107,16 @@ export default function ContentsListPage() {
setErrorContents(null); setErrorContents(null);
try { try {
const contentsAPI = new ContentsAPI(client); const contentsAPI = new ContentsAPI(client);
const contentsResponse = await contentsAPI.listContents(vectorStoreId, fileId, { limit: 100 }); const contentsResponse = await contentsAPI.listContents(
vectorStoreId,
fileId,
{ limit: 100 }
);
setContents(contentsResponse.data); setContents(contentsResponse.data);
} catch (err) { } catch (err) {
setErrorContents(err instanceof Error ? err : new Error("Failed to load contents.")); setErrorContents(
err instanceof Error ? err : new Error("Failed to load contents.")
);
} finally { } finally {
setIsLoadingContents(false); setIsLoadingContents(false);
} }
@ -116,26 +130,36 @@ export default function ContentsListPage() {
await contentsAPI.deleteContent(vectorStoreId, fileId, contentId); await contentsAPI.deleteContent(vectorStoreId, fileId, contentId);
setContents(contents.filter(content => content.id !== contentId)); setContents(contents.filter(content => content.id !== contentId));
} catch (err) { } catch (err) {
console.error('Failed to delete content:', err); console.error("Failed to delete content:", err);
} }
}; };
const handleViewContent = (contentId: string) => { const handleViewContent = (contentId: string) => {
router.push(`/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents/${contentId}`); router.push(
`/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents/${contentId}`
);
}; };
const title = `Contents in File: ${fileId}`; const title = `Contents in File: ${fileId}`;
const breadcrumbSegments: BreadcrumbSegment[] = [ const breadcrumbSegments: BreadcrumbSegment[] = [
{ label: "Vector Stores", href: "/logs/vector-stores" }, { label: "Vector Stores", href: "/logs/vector-stores" },
{ label: store?.name || vectorStoreId, href: `/logs/vector-stores/${vectorStoreId}` }, {
label: store?.name || vectorStoreId,
href: `/logs/vector-stores/${vectorStoreId}`,
},
{ label: "Files", href: `/logs/vector-stores/${vectorStoreId}` }, { label: "Files", href: `/logs/vector-stores/${vectorStoreId}` },
{ label: fileId, href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}` }, {
label: fileId,
href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}`,
},
{ label: "Contents" }, { label: "Contents" },
]; ];
if (errorStore) { if (errorStore) {
return <DetailErrorView title={title} id={vectorStoreId} error={errorStore} />; return (
<DetailErrorView title={title} id={vectorStoreId} error={errorStore} />
);
} }
if (isLoadingStore) { if (isLoadingStore) {
return <DetailLoadingView title={title} />; return <DetailLoadingView title={title} />;
@ -175,7 +199,7 @@ export default function ContentsListPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{contents.map((content) => ( {contents.map(content => (
<TableRow key={content.id}> <TableRow key={content.id}>
<TableCell className="font-mono text-xs"> <TableCell className="font-mono text-xs">
<Button <Button
@ -189,7 +213,10 @@ export default function ContentsListPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="max-w-md"> <div className="max-w-md">
<p className="text-sm truncate" title={getTextFromContent(content.content)}> <p
className="text-sm truncate"
title={getTextFromContent(content.content)}
>
{getTextFromContent(content.content)} {getTextFromContent(content.content)}
</p> </p>
</div> </div>
@ -197,12 +224,25 @@ export default function ContentsListPage() {
<TableCell className="text-xs text-gray-500"> <TableCell className="text-xs text-gray-500">
{content.embedding && content.embedding.length > 0 ? ( {content.embedding && content.embedding.length > 0 ? (
<div className="max-w-xs"> <div className="max-w-xs">
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-800 rounded px-1 py-0.5" title={`${content.embedding.length}D vector: [${content.embedding.slice(0, 3).map(v => v.toFixed(3)).join(', ')}...]`}> <span
[{content.embedding.slice(0, 3).map(v => v.toFixed(3)).join(', ')}...] ({content.embedding.length}D) className="font-mono text-xs bg-gray-100 dark:bg-gray-800 rounded px-1 py-0.5"
title={`${content.embedding.length}D vector: [${content.embedding
.slice(0, 3)
.map(v => v.toFixed(3))
.join(", ")}...]`}
>
[
{content.embedding
.slice(0, 3)
.map(v => v.toFixed(3))
.join(", ")}
...] ({content.embedding.length}D)
</span> </span>
</div> </div>
) : ( ) : (
<span className="text-gray-400 dark:text-gray-500 italic">No embedding</span> <span className="text-gray-400 dark:text-gray-500 italic">
No embedding
</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-xs text-gray-500"> <TableCell className="text-xs text-gray-500">
@ -211,7 +251,9 @@ export default function ContentsListPage() {
: `${content.metadata.content_length || 0} chars`} : `${content.metadata.content_length || 0} chars`}
</TableCell> </TableCell>
<TableCell className="text-xs"> <TableCell className="text-xs">
{new Date(content.created_timestamp * 1000).toLocaleString()} {new Date(
content.created_timestamp * 1000
).toLocaleString()}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex gap-1"> <div className="flex gap-1">

View file

@ -4,9 +4,12 @@ import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useAuthClient } from "@/hooks/use-auth-client"; import { useAuthClient } from "@/hooks/use-auth-client";
import type { VectorStore } from "llama-stack-client/resources/vector-stores/vector-stores"; import type { VectorStore } from "llama-stack-client/resources/vector-stores/vector-stores";
import type { VectorStoreFile, FileContentResponse } from "llama-stack-client/resources/vector-stores/files"; import type {
VectorStoreFile,
FileContentResponse,
} from "llama-stack-client/resources/vector-stores/files";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { List } from "lucide-react"; import { List } from "lucide-react";
import { import {
@ -17,7 +20,10 @@ import {
PropertiesCard, PropertiesCard,
PropertyItem, PropertyItem,
} from "@/components/layout/detail-layout"; } from "@/components/layout/detail-layout";
import { PageBreadcrumb, BreadcrumbSegment } from "@/components/layout/page-breadcrumb"; import {
PageBreadcrumb,
BreadcrumbSegment,
} from "@/components/layout/page-breadcrumb";
export default function FileDetailPage() { export default function FileDetailPage() {
const params = useParams(); const params = useParams();
@ -46,7 +52,9 @@ export default function FileDetailPage() {
const response = await client.vectorStores.retrieve(vectorStoreId); const response = await client.vectorStores.retrieve(vectorStoreId);
setStore(response as VectorStore); setStore(response as VectorStore);
} catch (err) { } catch (err) {
setErrorStore(err instanceof Error ? err : new Error("Failed to load vector store.")); setErrorStore(
err instanceof Error ? err : new Error("Failed to load vector store.")
);
} finally { } finally {
setIsLoadingStore(false); setIsLoadingStore(false);
} }
@ -61,10 +69,15 @@ export default function FileDetailPage() {
setIsLoadingFile(true); setIsLoadingFile(true);
setErrorFile(null); setErrorFile(null);
try { try {
const response = await client.vectorStores.files.retrieve(vectorStoreId, fileId); const response = await client.vectorStores.files.retrieve(
vectorStoreId,
fileId
);
setFile(response as VectorStoreFile); setFile(response as VectorStoreFile);
} catch (err) { } catch (err) {
setErrorFile(err instanceof Error ? err : new Error("Failed to load file.")); setErrorFile(
err instanceof Error ? err : new Error("Failed to load file.")
);
} finally { } finally {
setIsLoadingFile(false); setIsLoadingFile(false);
} }
@ -79,10 +92,15 @@ export default function FileDetailPage() {
setIsLoadingContents(true); setIsLoadingContents(true);
setErrorContents(null); setErrorContents(null);
try { try {
const response = await client.vectorStores.files.content(vectorStoreId, fileId); const response = await client.vectorStores.files.content(
vectorStoreId,
fileId
);
setContents(response); setContents(response);
} catch (err) { } catch (err) {
setErrorContents(err instanceof Error ? err : new Error("Failed to load contents.")); setErrorContents(
err instanceof Error ? err : new Error("Failed to load contents.")
);
} finally { } finally {
setIsLoadingContents(false); setIsLoadingContents(false);
} }
@ -91,20 +109,27 @@ export default function FileDetailPage() {
}, [vectorStoreId, fileId, client]); }, [vectorStoreId, fileId, client]);
const handleViewContents = () => { const handleViewContents = () => {
router.push(`/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents`); router.push(
`/logs/vector-stores/${vectorStoreId}/files/${fileId}/contents`
);
}; };
const title = `File: ${fileId}`; const title = `File: ${fileId}`;
const breadcrumbSegments: BreadcrumbSegment[] = [ const breadcrumbSegments: BreadcrumbSegment[] = [
{ label: "Vector Stores", href: "/logs/vector-stores" }, { label: "Vector Stores", href: "/logs/vector-stores" },
{ label: store?.name || vectorStoreId, href: `/logs/vector-stores/${vectorStoreId}` }, {
label: store?.name || vectorStoreId,
href: `/logs/vector-stores/${vectorStoreId}`,
},
{ label: "Files", href: `/logs/vector-stores/${vectorStoreId}` }, { label: "Files", href: `/logs/vector-stores/${vectorStoreId}` },
{ label: fileId }, { label: fileId },
]; ];
if (errorStore) { if (errorStore) {
return <DetailErrorView title={title} id={vectorStoreId} error={errorStore} />; return (
<DetailErrorView title={title} id={vectorStoreId} error={errorStore} />
);
} }
if (isLoadingStore) { if (isLoadingStore) {
return <DetailLoadingView title={title} />; return <DetailLoadingView title={title} />;
@ -136,19 +161,29 @@ export default function FileDetailPage() {
<h3 className="text-lg font-medium mb-2">File Details</h3> <h3 className="text-lg font-medium mb-2">File Details</h3>
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="font-medium text-gray-600 dark:text-gray-400">Status:</span> <span className="font-medium text-gray-600 dark:text-gray-400">
Status:
</span>
<span className="ml-2">{file.status}</span> <span className="ml-2">{file.status}</span>
</div> </div>
<div> <div>
<span className="font-medium text-gray-600 dark:text-gray-400">Size:</span> <span className="font-medium text-gray-600 dark:text-gray-400">
Size:
</span>
<span className="ml-2">{file.usage_bytes} bytes</span> <span className="ml-2">{file.usage_bytes} bytes</span>
</div> </div>
<div> <div>
<span className="font-medium text-gray-600 dark:text-gray-400">Created:</span> <span className="font-medium text-gray-600 dark:text-gray-400">
<span className="ml-2">{new Date(file.created_at * 1000).toLocaleString()}</span> Created:
</span>
<span className="ml-2">
{new Date(file.created_at * 1000).toLocaleString()}
</span>
</div> </div>
<div> <div>
<span className="font-medium text-gray-600 dark:text-gray-400">Content Strategy:</span> <span className="font-medium text-gray-600 dark:text-gray-400">
Content Strategy:
</span>
<span className="ml-2">{file.chunking_strategy.type}</span> <span className="ml-2">{file.chunking_strategy.type}</span>
</div> </div>
</div> </div>
@ -166,9 +201,7 @@ export default function FileDetailPage() {
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-gray-500 italic text-sm"> <p className="text-gray-500 italic text-sm">File not found.</p>
File not found.
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@ -192,16 +225,27 @@ export default function FileDetailPage() {
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="font-medium text-gray-600 dark:text-gray-400">Content Items:</span> <span className="font-medium text-gray-600 dark:text-gray-400">
Content Items:
</span>
<span className="ml-2">{contents.content.length}</span> <span className="ml-2">{contents.content.length}</span>
</div> </div>
<div> <div>
<span className="font-medium text-gray-600 dark:text-gray-400">Total Characters:</span> <span className="font-medium text-gray-600 dark:text-gray-400">
<span className="ml-2">{contents.content.reduce((total, item) => total + item.text.length, 0)}</span> Total Characters:
</span>
<span className="ml-2">
{contents.content.reduce(
(total, item) => total + item.text.length,
0
)}
</span>
</div> </div>
</div> </div>
<div className="pt-2"> <div className="pt-2">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">Preview:</span> <span className="text-sm font-medium text-gray-600 dark:text-gray-400">
Preview:
</span>
<div className="mt-1 bg-gray-50 dark:bg-gray-800 rounded-md p-3"> <div className="mt-1 bg-gray-50 dark:bg-gray-800 rounded-md p-3">
<p className="text-sm text-gray-900 dark:text-gray-100 line-clamp-3"> <p className="text-sm text-gray-900 dark:text-gray-100 line-clamp-3">
{contents.content[0]?.text.substring(0, 200)}... {contents.content[0]?.text.substring(0, 200)}...

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams } from "next/navigation";
import { useAuthClient } from "@/hooks/use-auth-client"; import { useAuthClient } from "@/hooks/use-auth-client";
import type { VectorStore } from "llama-stack-client/resources/vector-stores/vector-stores"; import type { VectorStore } from "llama-stack-client/resources/vector-stores/vector-stores";
import type { VectorStoreFile } from "llama-stack-client/resources/vector-stores/files"; import type { VectorStoreFile } from "llama-stack-client/resources/vector-stores/files";
@ -11,7 +11,6 @@ export default function VectorStoreDetailPage() {
const params = useParams(); const params = useParams();
const id = params.id as string; const id = params.id as string;
const client = useAuthClient(); const client = useAuthClient();
const router = useRouter();
const [store, setStore] = useState<VectorStore | null>(null); const [store, setStore] = useState<VectorStore | null>(null);
const [files, setFiles] = useState<VectorStoreFile[]>([]); const [files, setFiles] = useState<VectorStoreFile[]>([]);
@ -34,9 +33,7 @@ export default function VectorStoreDetailPage() {
setStore(response as VectorStore); setStore(response as VectorStore);
} catch (err) { } catch (err) {
setErrorStore( setErrorStore(
err instanceof Error err instanceof Error ? err : new Error("Failed to load vector store.")
? err
: new Error("Failed to load vector store."),
); );
} finally { } finally {
setIsLoadingStore(false); setIsLoadingStore(false);
@ -55,18 +52,18 @@ export default function VectorStoreDetailPage() {
setIsLoadingFiles(true); setIsLoadingFiles(true);
setErrorFiles(null); setErrorFiles(null);
try { try {
const result = await client.vectorStores.files.list(id as any); const result = await client.vectorStores.files.list(id);
setFiles((result as any).data); setFiles((result as { data: VectorStoreFile[] }).data);
} catch (err) { } catch (err) {
setErrorFiles( setErrorFiles(
err instanceof Error ? err : new Error("Failed to load files."), err instanceof Error ? err : new Error("Failed to load files.")
); );
} finally { } finally {
setIsLoadingFiles(false); setIsLoadingFiles(false);
} }
}; };
fetchFiles(); fetchFiles();
}, [id]); }, [id, client.vectorStores.files]);
return ( return (
<VectorStoreDetailView <VectorStoreDetailView

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { useAuthClient } from "@/hooks/use-auth-client";
import type { import type {
ListVectorStoresResponse, ListVectorStoresResponse,
VectorStore, VectorStore,
@ -12,7 +11,6 @@ import { Button } from "@/components/ui/button";
import { import {
Table, Table,
TableBody, TableBody,
TableCaption,
TableCell, TableCell,
TableHead, TableHead,
TableHeader, TableHeader,
@ -21,7 +19,6 @@ import {
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
export default function VectorStoresPage() { export default function VectorStoresPage() {
const client = useAuthClient();
const router = useRouter(); const router = useRouter();
const { const {
data: stores, data: stores,
@ -37,7 +34,7 @@ export default function VectorStoresPage() {
after: params.after, after: params.after,
limit: params.limit, limit: params.limit,
order: params.order, order: params.order,
} as any); } as Parameters<typeof client.vectorStores.list>[0]);
return response as ListVectorStoresResponse; return response as ListVectorStoresResponse;
}, },
errorMessagePrefix: "vector stores", errorMessagePrefix: "vector stores",
@ -54,9 +51,9 @@ export default function VectorStoresPage() {
if (status === "loading") { if (status === "loading") {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-8 w-full"/> <Skeleton className="h-8 w-full" />
<Skeleton className="h-4 w-full"/> <Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full"/> <Skeleton className="h-4 w-full" />
</div> </div>
); );
} }
@ -88,7 +85,7 @@ export default function VectorStoresPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{stores.map((store) => { {stores.map(store => {
const fileCounts = store.file_counts; const fileCounts = store.file_counts;
const metadata = store.metadata || {}; const metadata = store.metadata || {};
const providerId = metadata.provider_id ?? ""; const providerId = metadata.provider_id ?? "";

View file

@ -14,7 +14,7 @@ describe("ChatCompletionDetailView", () => {
isLoading={true} isLoading={true}
error={null} error={null}
id="test-id" id="test-id"
/>, />
); );
// Use the data-slot attribute for Skeletons // Use the data-slot attribute for Skeletons
const skeletons = container.querySelectorAll('[data-slot="skeleton"]'); const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
@ -28,10 +28,10 @@ describe("ChatCompletionDetailView", () => {
isLoading={false} isLoading={false}
error={{ name: "Error", message: "Network Error" }} error={{ name: "Error", message: "Network Error" }}
id="err-id" id="err-id"
/>, />
); );
expect( expect(
screen.getByText(/Error loading details for ID err-id: Network Error/), screen.getByText(/Error loading details for ID err-id: Network Error/)
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -42,11 +42,11 @@ describe("ChatCompletionDetailView", () => {
isLoading={false} isLoading={false}
error={{ name: "Error", message: "" }} error={{ name: "Error", message: "" }}
id="err-id" id="err-id"
/>, />
); );
// Use regex to match the error message regardless of whitespace // Use regex to match the error message regardless of whitespace
expect( expect(
screen.getByText(/Error loading details for ID\s*err-id\s*:/), screen.getByText(/Error loading details for ID\s*err-id\s*:/)
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -57,11 +57,11 @@ describe("ChatCompletionDetailView", () => {
isLoading={false} isLoading={false}
error={{} as Error} error={{} as Error}
id="err-id" id="err-id"
/>, />
); );
// Use regex to match the error message regardless of whitespace // Use regex to match the error message regardless of whitespace
expect( expect(
screen.getByText(/Error loading details for ID\s*err-id\s*:/), screen.getByText(/Error loading details for ID\s*err-id\s*:/)
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -72,10 +72,10 @@ describe("ChatCompletionDetailView", () => {
isLoading={false} isLoading={false}
error={null} error={null}
id="notfound-id" id="notfound-id"
/>, />
); );
expect( expect(
screen.getByText("No details found for ID: notfound-id."), screen.getByText("No details found for ID: notfound-id.")
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -100,7 +100,7 @@ describe("ChatCompletionDetailView", () => {
isLoading={false} isLoading={false}
error={null} error={null}
id={mockCompletion.id} id={mockCompletion.id}
/>, />
); );
// Input // Input
expect(screen.getByText("Input")).toBeInTheDocument(); expect(screen.getByText("Input")).toBeInTheDocument();
@ -112,7 +112,7 @@ describe("ChatCompletionDetailView", () => {
expect(screen.getByText("Properties")).toBeInTheDocument(); expect(screen.getByText("Properties")).toBeInTheDocument();
expect(screen.getByText("Created:")).toBeInTheDocument(); expect(screen.getByText("Created:")).toBeInTheDocument();
expect( expect(
screen.getByText(new Date(1710000000 * 1000).toLocaleString()), screen.getByText(new Date(1710000000 * 1000).toLocaleString())
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("ID:")).toBeInTheDocument(); expect(screen.getByText("ID:")).toBeInTheDocument();
expect(screen.getByText("comp_123")).toBeInTheDocument(); expect(screen.getByText("comp_123")).toBeInTheDocument();
@ -150,7 +150,7 @@ describe("ChatCompletionDetailView", () => {
isLoading={false} isLoading={false}
error={null} error={null}
id={mockCompletion.id} id={mockCompletion.id}
/>, />
); );
// Output should include the tool call block (should be present twice: input and output) // Output should include the tool call block (should be present twice: input and output)
const toolCallLabels = screen.getAllByText("Tool Call"); const toolCallLabels = screen.getAllByText("Tool Call");
@ -178,13 +178,13 @@ describe("ChatCompletionDetailView", () => {
isLoading={false} isLoading={false}
error={null} error={null}
id={mockCompletion.id} id={mockCompletion.id}
/>, />
); );
// Input section should be present but empty // Input section should be present but empty
expect(screen.getByText("Input")).toBeInTheDocument(); expect(screen.getByText("Input")).toBeInTheDocument();
// Output section should show fallback message // Output section should show fallback message
expect( expect(
screen.getByText("No message found in assistant's choice."), screen.getByText("No message found in assistant's choice.")
).toBeInTheDocument(); ).toBeInTheDocument();
// Properties should show N/A for finish reason // Properties should show N/A for finish reason
expect(screen.getByText("Finish Reason:")).toBeInTheDocument(); expect(screen.getByText("Finish Reason:")).toBeInTheDocument();

View file

@ -53,14 +53,14 @@ export function ChatCompletionDetailView({
{completion.choices?.[0]?.message?.tool_calls && {completion.choices?.[0]?.message?.tool_calls &&
Array.isArray(completion.choices[0].message.tool_calls) && Array.isArray(completion.choices[0].message.tool_calls) &&
!completion.input_messages?.some( !completion.input_messages?.some(
(im) => im =>
im.role === "assistant" && im.role === "assistant" &&
im.tool_calls && im.tool_calls &&
Array.isArray(im.tool_calls) && Array.isArray(im.tool_calls) &&
im.tool_calls.length > 0, im.tool_calls.length > 0
) )
? completion.choices[0].message.tool_calls.map( ? completion.choices[0].message.tool_calls.map(
(toolCall: any, index: number) => { (toolCall: { function?: { name?: string } }, index: number) => {
const assistantToolCallMessage: ChatMessage = { const assistantToolCallMessage: ChatMessage = {
role: "assistant", role: "assistant",
tool_calls: [toolCall], tool_calls: [toolCall],
@ -72,7 +72,7 @@ export function ChatCompletionDetailView({
message={assistantToolCallMessage} message={assistantToolCallMessage}
/> />
); );
}, }
) )
: null} : null}
</CardContent> </CardContent>
@ -89,7 +89,7 @@ export function ChatCompletionDetailView({
/> />
) : ( ) : (
<p className="text-gray-500 italic text-sm"> <p className="text-gray-500 italic text-sm">
No message found in assistant's choice. No message found in assistant&apos;s choice.
</p> </p>
)} )}
</CardContent> </CardContent>
@ -120,13 +120,18 @@ export function ChatCompletionDetailView({
value={ value={
<div> <div>
<ul className="list-disc list-inside pl-4 mt-1"> <ul className="list-disc list-inside pl-4 mt-1">
{toolCalls.map((toolCall: any, index: number) => ( {toolCalls.map(
(
toolCall: { function?: { name?: string } },
index: number
) => (
<li key={index}> <li key={index}>
<span className="text-gray-900 font-medium"> <span className="text-gray-900 font-medium">
{toolCall.function?.name || "N/A"} {toolCall.function?.name || "N/A"}
</span> </span>
</li> </li>
))} )
)}
</ul> </ul>
</div> </div>
} }

View file

@ -83,7 +83,7 @@ describe("ChatCompletionsTable", () => {
// Default pass-through implementations // Default pass-through implementations
truncateText.mockImplementation((text: string | undefined) => text); truncateText.mockImplementation((text: string | undefined) => text);
extractTextFromContentPart.mockImplementation((content: unknown) => extractTextFromContentPart.mockImplementation((content: unknown) =>
typeof content === "string" ? content : "extracted text", typeof content === "string" ? content : "extracted text"
); );
extractDisplayableText.mockImplementation((message: unknown) => { extractDisplayableText.mockImplementation((message: unknown) => {
const msg = message as { content?: string }; const msg = message as { content?: string };
@ -138,7 +138,7 @@ describe("ChatCompletionsTable", () => {
if (row) { if (row) {
fireEvent.click(row); fireEvent.click(row);
expect(mockPush).toHaveBeenCalledWith( expect(mockPush).toHaveBeenCalledWith(
"/logs/chat-completions/completion_123", "/logs/chat-completions/completion_123"
); );
} else { } else {
throw new Error('Row with "Test prompt" not found for router mock test.'); throw new Error('Row with "Test prompt" not found for router mock test.');
@ -162,7 +162,7 @@ describe("ChatCompletionsTable", () => {
expect(tableCaption).toBeInTheDocument(); expect(tableCaption).toBeInTheDocument();
if (tableCaption) { if (tableCaption) {
const captionSkeleton = tableCaption.querySelector( const captionSkeleton = tableCaption.querySelector(
'[data-slot="skeleton"]', '[data-slot="skeleton"]'
); );
expect(captionSkeleton).toBeInTheDocument(); expect(captionSkeleton).toBeInTheDocument();
} }
@ -172,7 +172,7 @@ describe("ChatCompletionsTable", () => {
expect(tableBody).toBeInTheDocument(); expect(tableBody).toBeInTheDocument();
if (tableBody) { if (tableBody) {
const bodySkeletons = tableBody.querySelectorAll( const bodySkeletons = tableBody.querySelectorAll(
'[data-slot="skeleton"]', '[data-slot="skeleton"]'
); );
expect(bodySkeletons.length).toBeGreaterThan(0); expect(bodySkeletons.length).toBeGreaterThan(0);
} }
@ -192,14 +192,14 @@ describe("ChatCompletionsTable", () => {
render(<ChatCompletionsTable {...defaultProps} />); render(<ChatCompletionsTable {...defaultProps} />);
expect( expect(
screen.getByText("Unable to load chat completions"), screen.getByText("Unable to load chat completions")
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(errorMessage)).toBeInTheDocument(); expect(screen.getByText(errorMessage)).toBeInTheDocument();
}); });
test.each([{ name: "Error", message: "" }, {}])( test.each([{ name: "Error", message: "" }, {}])(
"renders default error message when error has no message", "renders default error message when error has no message",
(errorObject) => { errorObject => {
mockedUsePagination.mockReturnValue({ mockedUsePagination.mockReturnValue({
data: [], data: [],
status: "error", status: "error",
@ -210,14 +210,14 @@ describe("ChatCompletionsTable", () => {
render(<ChatCompletionsTable {...defaultProps} />); render(<ChatCompletionsTable {...defaultProps} />);
expect( expect(
screen.getByText("Unable to load chat completions"), screen.getByText("Unable to load chat completions")
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(
"An unexpected error occurred while loading the data.", "An unexpected error occurred while loading the data."
), )
).toBeInTheDocument(); ).toBeInTheDocument();
}, }
); );
}); });
@ -225,7 +225,7 @@ describe("ChatCompletionsTable", () => {
test('renders "No chat completions found." and no table when data array is empty', () => { test('renders "No chat completions found." and no table when data array is empty', () => {
render(<ChatCompletionsTable {...defaultProps} />); render(<ChatCompletionsTable {...defaultProps} />);
expect( expect(
screen.getByText("No chat completions found."), screen.getByText("No chat completions found.")
).toBeInTheDocument(); ).toBeInTheDocument();
// Ensure that the table structure is NOT rendered in the empty state // Ensure that the table structure is NOT rendered in the empty state
@ -292,7 +292,7 @@ describe("ChatCompletionsTable", () => {
// Table caption // Table caption
expect( expect(
screen.getByText("A list of your recent chat completions."), screen.getByText("A list of your recent chat completions.")
).toBeInTheDocument(); ).toBeInTheDocument();
// Table headers // Table headers
@ -306,14 +306,14 @@ describe("ChatCompletionsTable", () => {
expect(screen.getByText("Test output")).toBeInTheDocument(); expect(screen.getByText("Test output")).toBeInTheDocument();
expect(screen.getByText("llama-test-model")).toBeInTheDocument(); expect(screen.getByText("llama-test-model")).toBeInTheDocument();
expect( expect(
screen.getByText(new Date(1710000000 * 1000).toLocaleString()), screen.getByText(new Date(1710000000 * 1000).toLocaleString())
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Another input")).toBeInTheDocument(); expect(screen.getByText("Another input")).toBeInTheDocument();
expect(screen.getByText("Another output")).toBeInTheDocument(); expect(screen.getByText("Another output")).toBeInTheDocument();
expect(screen.getByText("llama-another-model")).toBeInTheDocument(); expect(screen.getByText("llama-another-model")).toBeInTheDocument();
expect( expect(
screen.getByText(new Date(1710001000 * 1000).toLocaleString()), screen.getByText(new Date(1710001000 * 1000).toLocaleString())
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
@ -328,7 +328,7 @@ describe("ChatCompletionsTable", () => {
return typeof text === "string" && text.length > effectiveMaxLength return typeof text === "string" && text.length > effectiveMaxLength
? text.slice(0, effectiveMaxLength) + "..." ? text.slice(0, effectiveMaxLength) + "..."
: text; : text;
}, }
); );
const longInput = const longInput =
@ -368,7 +368,7 @@ describe("ChatCompletionsTable", () => {
// The truncated text should be present for both input and output // The truncated text should be present for both input and output
const truncatedTexts = screen.getAllByText( const truncatedTexts = screen.getAllByText(
longInput.slice(0, 10) + "...", longInput.slice(0, 10) + "..."
); );
expect(truncatedTexts.length).toBe(2); // one for input, one for output expect(truncatedTexts.length).toBe(2); // one for input, one for output
}); });
@ -420,7 +420,7 @@ describe("ChatCompletionsTable", () => {
// Verify the extracted text appears in the table // Verify the extracted text appears in the table
expect(screen.getByText("Extracted input")).toBeInTheDocument(); expect(screen.getByText("Extracted input")).toBeInTheDocument();
expect( expect(
screen.getByText("Extracted output from assistant"), screen.getByText("Extracted output from assistant")
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });

View file

@ -5,6 +5,7 @@ import {
UsePaginationOptions, UsePaginationOptions,
ListChatCompletionsResponse, ListChatCompletionsResponse,
} from "@/lib/types"; } from "@/lib/types";
import { ListChatCompletionsParams } from "@/lib/llama-stack-client";
import { LogsTable, LogTableRow } from "@/components/logs/logs-table"; import { LogsTable, LogTableRow } from "@/components/logs/logs-table";
import { import {
extractTextFromContentPart, extractTextFromContentPart,
@ -38,14 +39,14 @@ export function ChatCompletionsTable({
limit: number; limit: number;
model?: string; model?: string;
order?: string; order?: string;
}, }
) => { ) => {
const response = await client.chat.completions.list({ const response = await client.chat.completions.list({
after: params.after, after: params.after,
limit: params.limit, limit: params.limit,
...(params.model && { model: params.model }), ...(params.model && { model: params.model }),
...(params.order && { order: params.order }), ...(params.order && { order: params.order }),
} as any); } as ListChatCompletionsParams);
return response as ListChatCompletionsResponse; return response as ListChatCompletionsResponse;
}; };

View file

@ -37,7 +37,11 @@ export function ChatMessageItem({ message }: ChatMessageItemProps) {
) { ) {
return ( return (
<> <>
{message.tool_calls.map((toolCall: any, index: number) => { {message.tool_calls.map(
(
toolCall: { function?: { name?: string; arguments?: unknown } },
index: number
) => {
const formattedToolCall = formatToolCallToString(toolCall); const formattedToolCall = formatToolCallToString(toolCall);
const toolCallContent = ( const toolCallContent = (
<ToolCallBlock> <ToolCallBlock>
@ -51,7 +55,8 @@ export function ChatMessageItem({ message }: ChatMessageItemProps) {
content={toolCallContent} content={toolCallContent}
/> />
); );
})} }
)}
</> </>
); );
} else { } else {

View file

@ -1,18 +1,18 @@
"use client" "use client";
import React, { useMemo, useState } from "react" import React, { useMemo, useState } from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { motion } from "framer-motion" import { motion } from "framer-motion";
import { Ban, ChevronRight, Code2, Loader2, Terminal } from "lucide-react" import { Ban, ChevronRight, Code2, Loader2, Terminal } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible" } from "@/components/ui/collapsible";
import { FilePreview } from "@/components/ui/file-preview" import { FilePreview } from "@/components/ui/file-preview";
import { MarkdownRenderer } from "@/components/chat-playground/markdown-renderer" import { MarkdownRenderer } from "@/components/chat-playground/markdown-renderer";
const chatBubbleVariants = cva( const chatBubbleVariants = cva(
"group/message relative break-words rounded-lg p-3 text-sm sm:max-w-[70%]", "group/message relative break-words rounded-lg p-3 text-sm sm:max-w-[70%]",
@ -52,66 +52,66 @@ const chatBubbleVariants = cva(
}, },
], ],
} }
) );
type Animation = VariantProps<typeof chatBubbleVariants>["animation"] type Animation = VariantProps<typeof chatBubbleVariants>["animation"];
interface Attachment { interface Attachment {
name?: string name?: string;
contentType?: string contentType?: string;
url: string url: string;
} }
interface PartialToolCall { interface PartialToolCall {
state: "partial-call" state: "partial-call";
toolName: string toolName: string;
} }
interface ToolCall { interface ToolCall {
state: "call" state: "call";
toolName: string toolName: string;
} }
interface ToolResult { interface ToolResult {
state: "result" state: "result";
toolName: string toolName: string;
result: { result: {
__cancelled?: boolean __cancelled?: boolean;
[key: string]: any [key: string]: unknown;
} };
} }
type ToolInvocation = PartialToolCall | ToolCall | ToolResult type ToolInvocation = PartialToolCall | ToolCall | ToolResult;
interface ReasoningPart { interface ReasoningPart {
type: "reasoning" type: "reasoning";
reasoning: string reasoning: string;
} }
interface ToolInvocationPart { interface ToolInvocationPart {
type: "tool-invocation" type: "tool-invocation";
toolInvocation: ToolInvocation toolInvocation: ToolInvocation;
} }
interface TextPart { interface TextPart {
type: "text" type: "text";
text: string text: string;
} }
// For compatibility with AI SDK types, not used // For compatibility with AI SDK types, not used
interface SourcePart { interface SourcePart {
type: "source" type: "source";
source?: any source?: unknown;
} }
interface FilePart { interface FilePart {
type: "file" type: "file";
mimeType: string mimeType: string;
data: string data: string;
} }
interface StepStartPart { interface StepStartPart {
type: "step-start" type: "step-start";
} }
type MessagePart = type MessagePart =
@ -120,22 +120,22 @@ type MessagePart =
| ToolInvocationPart | ToolInvocationPart
| SourcePart | SourcePart
| FilePart | FilePart
| StepStartPart | StepStartPart;
export interface Message { export interface Message {
id: string id: string;
role: "user" | "assistant" | (string & {}) role: "user" | "assistant" | (string & {});
content: string content: string;
createdAt?: Date createdAt?: Date;
experimental_attachments?: Attachment[] experimental_attachments?: Attachment[];
toolInvocations?: ToolInvocation[] toolInvocations?: ToolInvocation[];
parts?: MessagePart[] parts?: MessagePart[];
} }
export interface ChatMessageProps extends Message { export interface ChatMessageProps extends Message {
showTimeStamp?: boolean showTimeStamp?: boolean;
animation?: Animation animation?: Animation;
actions?: React.ReactNode actions?: React.ReactNode;
} }
export const ChatMessage: React.FC<ChatMessageProps> = ({ export const ChatMessage: React.FC<ChatMessageProps> = ({
@ -150,21 +150,21 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
parts, parts,
}) => { }) => {
const files = useMemo(() => { const files = useMemo(() => {
return experimental_attachments?.map((attachment) => { return experimental_attachments?.map(attachment => {
const dataArray = dataUrlToUint8Array(attachment.url) const dataArray = dataUrlToUint8Array(attachment.url);
const file = new File([dataArray], attachment.name ?? "Unknown", { const file = new File([dataArray], attachment.name ?? "Unknown", {
type: attachment.contentType, type: attachment.contentType,
}) });
return file return file;
}) });
}, [experimental_attachments]) }, [experimental_attachments]);
const isUser = role === "user" const isUser = role === "user";
const formattedTime = createdAt?.toLocaleTimeString("en-US", { const formattedTime = createdAt?.toLocaleTimeString("en-US", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}) });
if (isUser) { if (isUser) {
return ( return (
@ -174,7 +174,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
{files ? ( {files ? (
<div className="mb-1 flex flex-wrap gap-2"> <div className="mb-1 flex flex-wrap gap-2">
{files.map((file, index) => { {files.map((file, index) => {
return <FilePreview file={file} key={index} /> return <FilePreview file={file} key={index} />;
})} })}
</div> </div>
) : null} ) : null}
@ -195,7 +195,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
</time> </time>
) : null} ) : null}
</div> </div>
) );
} }
if (parts && parts.length > 0) { if (parts && parts.length > 0) {
@ -230,23 +230,23 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
</time> </time>
) : null} ) : null}
</div> </div>
) );
} else if (part.type === "reasoning") { } else if (part.type === "reasoning") {
return <ReasoningBlock key={`reasoning-${index}`} part={part} /> return <ReasoningBlock key={`reasoning-${index}`} part={part} />;
} else if (part.type === "tool-invocation") { } else if (part.type === "tool-invocation") {
return ( return (
<ToolCall <ToolCall
key={`tool-${index}`} key={`tool-${index}`}
toolInvocations={[part.toolInvocation]} toolInvocations={[part.toolInvocation]}
/> />
) );
} }
return null return null;
}) });
} }
if (toolInvocations && toolInvocations.length > 0) { if (toolInvocations && toolInvocations.length > 0) {
return <ToolCall toolInvocations={toolInvocations} /> return <ToolCall toolInvocations={toolInvocations} />;
} }
return ( return (
@ -272,17 +272,17 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
</time> </time>
) : null} ) : null}
</div> </div>
) );
} };
function dataUrlToUint8Array(data: string) { function dataUrlToUint8Array(data: string) {
const base64 = data.split(",")[1] const base64 = data.split(",")[1];
const buf = Buffer.from(base64, "base64") const buf = Buffer.from(base64, "base64");
return new Uint8Array(buf) return new Uint8Array(buf);
} }
const ReasoningBlock = ({ part }: { part: ReasoningPart }) => { const ReasoningBlock = ({ part }: { part: ReasoningPart }) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false);
return ( return (
<div className="mb-2 flex flex-col items-start sm:max-w-[70%]"> <div className="mb-2 flex flex-col items-start sm:max-w-[70%]">
@ -319,20 +319,20 @@ const ReasoningBlock = ({ part }: { part: ReasoningPart }) => {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
) );
} };
function ToolCall({ function ToolCall({
toolInvocations, toolInvocations,
}: Pick<ChatMessageProps, "toolInvocations">) { }: Pick<ChatMessageProps, "toolInvocations">) {
if (!toolInvocations?.length) return null if (!toolInvocations?.length) return null;
return ( return (
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
{toolInvocations.map((invocation, index) => { {toolInvocations.map((invocation, index) => {
const isCancelled = const isCancelled =
invocation.state === "result" && invocation.state === "result" &&
invocation.result.__cancelled === true invocation.result.__cancelled === true;
if (isCancelled) { if (isCancelled) {
return ( return (
@ -350,7 +350,7 @@ function ToolCall({
</span> </span>
</span> </span>
</div> </div>
) );
} }
switch (invocation.state) { switch (invocation.state) {
@ -373,7 +373,7 @@ function ToolCall({
</span> </span>
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
</div> </div>
) );
case "result": case "result":
return ( return (
<div <div
@ -395,11 +395,11 @@ function ToolCall({
{JSON.stringify(invocation.result, null, 2)} {JSON.stringify(invocation.result, null, 2)}
</pre> </pre>
</div> </div>
) );
default: default:
return null return null;
} }
})} })}
</div> </div>
) );
} }

View file

@ -1,4 +1,4 @@
"use client" "use client";
import { import {
forwardRef, forwardRef,
@ -6,48 +6,48 @@ import {
useRef, useRef,
useState, useState,
type ReactElement, type ReactElement,
} from "react" } from "react";
import { ArrowDown, ThumbsDown, ThumbsUp } from "lucide-react" import { ArrowDown, ThumbsDown, ThumbsUp } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { useAutoScroll } from "@/hooks/use-auto-scroll" import { useAutoScroll } from "@/hooks/use-auto-scroll";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { type Message } from "@/components/chat-playground/chat-message" import { type Message } from "@/components/chat-playground/chat-message";
import { CopyButton } from "@/components/ui/copy-button" import { CopyButton } from "@/components/ui/copy-button";
import { MessageInput } from "@/components/chat-playground/message-input" import { MessageInput } from "@/components/chat-playground/message-input";
import { MessageList } from "@/components/chat-playground/message-list" import { MessageList } from "@/components/chat-playground/message-list";
import { PromptSuggestions } from "@/components/chat-playground/prompt-suggestions" import { PromptSuggestions } from "@/components/chat-playground/prompt-suggestions";
interface ChatPropsBase { interface ChatPropsBase {
handleSubmit: ( handleSubmit: (
event?: { preventDefault?: () => void }, event?: { preventDefault?: () => void },
options?: { experimental_attachments?: FileList } options?: { experimental_attachments?: FileList }
) => void ) => void;
messages: Array<Message> messages: Array<Message>;
input: string input: string;
className?: string className?: string;
handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement>;
isGenerating: boolean isGenerating: boolean;
stop?: () => void stop?: () => void;
onRateResponse?: ( onRateResponse?: (
messageId: string, messageId: string,
rating: "thumbs-up" | "thumbs-down" rating: "thumbs-up" | "thumbs-down"
) => void ) => void;
setMessages?: (messages: any[]) => void setMessages?: (messages: Message[]) => void;
transcribeAudio?: (blob: Blob) => Promise<string> transcribeAudio?: (blob: Blob) => Promise<string>;
} }
interface ChatPropsWithoutSuggestions extends ChatPropsBase { interface ChatPropsWithoutSuggestions extends ChatPropsBase {
append?: never append?: never;
suggestions?: never suggestions?: never;
} }
interface ChatPropsWithSuggestions extends ChatPropsBase { interface ChatPropsWithSuggestions extends ChatPropsBase {
append: (message: { role: "user"; content: string }) => void append: (message: { role: "user"; content: string }) => void;
suggestions: string[] suggestions: string[];
} }
type ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions type ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions;
export function Chat({ export function Chat({
messages, messages,
@ -63,34 +63,34 @@ export function Chat({
setMessages, setMessages,
transcribeAudio, transcribeAudio,
}: ChatProps) { }: ChatProps) {
const lastMessage = messages.at(-1) const lastMessage = messages.at(-1);
const isEmpty = messages.length === 0 const isEmpty = messages.length === 0;
const isTyping = lastMessage?.role === "user" const isTyping = lastMessage?.role === "user";
const messagesRef = useRef(messages) const messagesRef = useRef(messages);
messagesRef.current = messages messagesRef.current = messages;
// Enhanced stop function that marks pending tool calls as cancelled // Enhanced stop function that marks pending tool calls as cancelled
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
stop?.() stop?.();
if (!setMessages) return if (!setMessages) return;
const latestMessages = [...messagesRef.current] const latestMessages = [...messagesRef.current];
const lastAssistantMessage = latestMessages.findLast( const lastAssistantMessage = latestMessages.findLast(
(m) => m.role === "assistant" m => m.role === "assistant"
) );
if (!lastAssistantMessage) return if (!lastAssistantMessage) return;
let needsUpdate = false let needsUpdate = false;
let updatedMessage = { ...lastAssistantMessage } let updatedMessage = { ...lastAssistantMessage };
if (lastAssistantMessage.toolInvocations) { if (lastAssistantMessage.toolInvocations) {
const updatedToolInvocations = lastAssistantMessage.toolInvocations.map( const updatedToolInvocations = lastAssistantMessage.toolInvocations.map(
(toolInvocation) => { toolInvocation => {
if (toolInvocation.state === "call") { if (toolInvocation.state === "call") {
needsUpdate = true needsUpdate = true;
return { return {
...toolInvocation, ...toolInvocation,
state: "result", state: "result",
@ -98,28 +98,32 @@ export function Chat({
content: "Tool execution was cancelled", content: "Tool execution was cancelled",
__cancelled: true, // Special marker to indicate cancellation __cancelled: true, // Special marker to indicate cancellation
}, },
} as const } as const;
} }
return toolInvocation return toolInvocation;
} }
) );
if (needsUpdate) { if (needsUpdate) {
updatedMessage = { updatedMessage = {
...updatedMessage, ...updatedMessage,
toolInvocations: updatedToolInvocations, toolInvocations: updatedToolInvocations,
} };
} }
} }
if (lastAssistantMessage.parts && lastAssistantMessage.parts.length > 0) { if (lastAssistantMessage.parts && lastAssistantMessage.parts.length > 0) {
const updatedParts = lastAssistantMessage.parts.map((part: any) => { const updatedParts = lastAssistantMessage.parts.map(
(part: {
type: string;
toolInvocation?: { state: string; toolName: string };
}) => {
if ( if (
part.type === "tool-invocation" && part.type === "tool-invocation" &&
part.toolInvocation && part.toolInvocation &&
part.toolInvocation.state === "call" part.toolInvocation.state === "call"
) { ) {
needsUpdate = true needsUpdate = true;
return { return {
...part, ...part,
toolInvocation: { toolInvocation: {
@ -130,29 +134,30 @@ export function Chat({
__cancelled: true, __cancelled: true,
}, },
}, },
};
} }
return part;
} }
return part );
})
if (needsUpdate) { if (needsUpdate) {
updatedMessage = { updatedMessage = {
...updatedMessage, ...updatedMessage,
parts: updatedParts, parts: updatedParts,
} };
} }
} }
if (needsUpdate) { if (needsUpdate) {
const messageIndex = latestMessages.findIndex( const messageIndex = latestMessages.findIndex(
(m) => m.id === lastAssistantMessage.id m => m.id === lastAssistantMessage.id
) );
if (messageIndex !== -1) { if (messageIndex !== -1) {
latestMessages[messageIndex] = updatedMessage latestMessages[messageIndex] = updatedMessage;
setMessages(latestMessages) setMessages(latestMessages);
} }
} }
}, [stop, setMessages, messagesRef]) }, [stop, setMessages, messagesRef]);
const messageOptions = useCallback( const messageOptions = useCallback(
(message: Message) => ({ (message: Message) => ({
@ -189,7 +194,7 @@ export function Chat({
), ),
}), }),
[onRateResponse] [onRateResponse]
) );
return ( return (
<ChatContainer className={className}> <ChatContainer className={className}>
@ -237,15 +242,15 @@ export function Chat({
</div> </div>
</div> </div>
</ChatContainer> </ChatContainer>
) );
} }
Chat.displayName = "Chat" Chat.displayName = "Chat";
export function ChatMessages({ export function ChatMessages({
messages, messages,
children, children,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
messages: Message[] messages: Message[];
}>) { }>) {
const { const {
containerRef, containerRef,
@ -253,7 +258,7 @@ export function ChatMessages({
handleScroll, handleScroll,
shouldAutoScroll, shouldAutoScroll,
handleTouchStart, handleTouchStart,
} = useAutoScroll([messages]) } = useAutoScroll([messages]);
return ( return (
<div <div
@ -281,7 +286,7 @@ export function ChatMessages({
</div> </div>
)} )}
</div> </div>
) );
} }
export const ChatContainer = forwardRef< export const ChatContainer = forwardRef<
@ -294,56 +299,56 @@ export const ChatContainer = forwardRef<
className={cn("flex flex-col max-h-full w-full", className)} className={cn("flex flex-col max-h-full w-full", className)}
{...props} {...props}
/> />
) );
}) });
ChatContainer.displayName = "ChatContainer" ChatContainer.displayName = "ChatContainer";
interface ChatFormProps { interface ChatFormProps {
className?: string className?: string;
isPending: boolean isPending: boolean;
handleSubmit: ( handleSubmit: (
event?: { preventDefault?: () => void }, event?: { preventDefault?: () => void },
options?: { experimental_attachments?: FileList } options?: { experimental_attachments?: FileList }
) => void ) => void;
children: (props: { children: (props: {
files: File[] | null files: File[] | null;
setFiles: React.Dispatch<React.SetStateAction<File[] | null>> setFiles: React.Dispatch<React.SetStateAction<File[] | null>>;
}) => ReactElement }) => ReactElement;
} }
export const ChatForm = forwardRef<HTMLFormElement, ChatFormProps>( export const ChatForm = forwardRef<HTMLFormElement, ChatFormProps>(
({ children, handleSubmit, isPending, className }, ref) => { ({ children, handleSubmit, isPending, className }, ref) => {
const [files, setFiles] = useState<File[] | null>(null) const [files, setFiles] = useState<File[] | null>(null);
const onSubmit = (event: React.FormEvent) => { const onSubmit = (event: React.FormEvent) => {
// if (isPending) { if (isPending) {
// event.preventDefault() event.preventDefault();
// return return;
// } }
if (!files) { if (!files) {
handleSubmit(event) handleSubmit(event);
return return;
} }
const fileList = createFileList(files) const fileList = createFileList(files);
handleSubmit(event, { experimental_attachments: fileList }) handleSubmit(event, { experimental_attachments: fileList });
setFiles(null) setFiles(null);
} };
return ( return (
<form ref={ref} onSubmit={onSubmit} className={className}> <form ref={ref} onSubmit={onSubmit} className={className}>
{children({ files, setFiles })} {children({ files, setFiles })}
</form> </form>
) );
} }
) );
ChatForm.displayName = "ChatForm" ChatForm.displayName = "ChatForm";
function createFileList(files: File[] | FileList): FileList { function createFileList(files: File[] | FileList): FileList {
const dataTransfer = new DataTransfer() const dataTransfer = new DataTransfer();
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
dataTransfer.items.add(file) dataTransfer.items.add(file);
} }
return dataTransfer.files return dataTransfer.files;
} }

View file

@ -1,11 +1,11 @@
"use client" "use client";
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react" import { X } from "lucide-react";
interface InterruptPromptProps { interface InterruptPromptProps {
isOpen: boolean isOpen: boolean;
close: () => void close: () => void;
} }
export function InterruptPrompt({ isOpen, close }: InterruptPromptProps) { export function InterruptPrompt({ isOpen, close }: InterruptPromptProps) {
@ -37,5 +37,5 @@ export function InterruptPrompt({ isOpen, close }: InterruptPromptProps) {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
) );
} }

View file

@ -1,12 +1,12 @@
import React, { Suspense, useEffect, useState } from "react" import React, { Suspense, useEffect, useState } from "react";
import Markdown from "react-markdown" import Markdown from "react-markdown";
import remarkGfm from "remark-gfm" import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { CopyButton } from "@/components/ui/copy-button" import { CopyButton } from "@/components/ui/copy-button";
interface MarkdownRendererProps { interface MarkdownRendererProps {
children: string children: string;
} }
export function MarkdownRenderer({ children }: MarkdownRendererProps) { export function MarkdownRenderer({ children }: MarkdownRendererProps) {
@ -16,34 +16,34 @@ export function MarkdownRenderer({ children }: MarkdownRendererProps) {
{children} {children}
</Markdown> </Markdown>
</div> </div>
) );
} }
interface HighlightedPre extends React.HTMLAttributes<HTMLPreElement> { interface HighlightedPre extends React.HTMLAttributes<HTMLPreElement> {
children: string children: string;
language: string language: string;
} }
const HighlightedPre = React.memo( const HighlightedPre = React.memo(
({ children, language, ...props }: HighlightedPre) => { ({ children, language, ...props }: HighlightedPre) => {
const [tokens, setTokens] = useState<any[] | null>(null) const [tokens, setTokens] = useState<unknown[] | null>(null);
const [isSupported, setIsSupported] = useState(false) const [isSupported, setIsSupported] = useState(false);
useEffect(() => { useEffect(() => {
let mounted = true let mounted = true;
const loadAndHighlight = async () => { const loadAndHighlight = async () => {
try { try {
const { codeToTokens, bundledLanguages } = await import("shiki") const { codeToTokens, bundledLanguages } = await import("shiki");
if (!mounted) return if (!mounted) return;
if (!(language in bundledLanguages)) { if (!(language in bundledLanguages)) {
setIsSupported(false) setIsSupported(false);
return return;
} }
setIsSupported(true) setIsSupported(true);
const { tokens: highlightedTokens } = await codeToTokens(children, { const { tokens: highlightedTokens } = await codeToTokens(children, {
lang: language as keyof typeof bundledLanguages, lang: language as keyof typeof bundledLanguages,
@ -52,31 +52,31 @@ const HighlightedPre = React.memo(
light: "github-light", light: "github-light",
dark: "github-dark", dark: "github-dark",
}, },
}) });
if (mounted) { if (mounted) {
setTokens(highlightedTokens) setTokens(highlightedTokens);
} }
} catch (error) { } catch {
if (mounted) { if (mounted) {
setIsSupported(false) setIsSupported(false);
}
} }
} }
};
loadAndHighlight() loadAndHighlight();
return () => { return () => {
mounted = false mounted = false;
} };
}, [children, language]) }, [children, language]);
if (!isSupported) { if (!isSupported) {
return <pre {...props}>{children}</pre> return <pre {...props}>{children}</pre>;
} }
if (!tokens) { if (!tokens) {
return <pre {...props}>{children}</pre> return <pre {...props}>{children}</pre>;
} }
return ( return (
@ -89,7 +89,7 @@ const HighlightedPre = React.memo(
const style = const style =
typeof token.htmlStyle === "string" typeof token.htmlStyle === "string"
? undefined ? undefined
: token.htmlStyle : token.htmlStyle;
return ( return (
<span <span
@ -99,7 +99,7 @@ const HighlightedPre = React.memo(
> >
{token.content} {token.content}
</span> </span>
) );
})} })}
</span> </span>
{lineIndex !== tokens.length - 1 && "\n"} {lineIndex !== tokens.length - 1 && "\n"}
@ -107,15 +107,15 @@ const HighlightedPre = React.memo(
))} ))}
</code> </code>
</pre> </pre>
) );
} }
) );
HighlightedPre.displayName = "HighlightedCode" HighlightedPre.displayName = "HighlightedCode";
interface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> { interface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> {
children: React.ReactNode children: React.ReactNode;
className?: string className?: string;
language: string language: string;
} }
const CodeBlock = ({ const CodeBlock = ({
@ -127,12 +127,12 @@ const CodeBlock = ({
const code = const code =
typeof children === "string" typeof children === "string"
? children ? children
: childrenTakeAllStringContents(children) : childrenTakeAllStringContents(children);
const preClass = cn( const preClass = cn(
"overflow-x-scroll rounded-md border bg-background/50 p-4 font-mono text-sm [scrollbar-width:none]", "overflow-x-scroll rounded-md border bg-background/50 p-4 font-mono text-sm [scrollbar-width:none]",
className className
) );
return ( return (
<div className="group/code relative mb-4"> <div className="group/code relative mb-4">
@ -152,27 +152,27 @@ const CodeBlock = ({
<CopyButton content={code} copyMessage="Copied code to clipboard" /> <CopyButton content={code} copyMessage="Copied code to clipboard" />
</div> </div>
</div> </div>
) );
} };
function childrenTakeAllStringContents(element: any): string { function childrenTakeAllStringContents(element: unknown): string {
if (typeof element === "string") { if (typeof element === "string") {
return element return element;
} }
if (element?.props?.children) { if (element?.props?.children) {
let children = element.props.children const children = element.props.children;
if (Array.isArray(children)) { if (Array.isArray(children)) {
return children return children
.map((child) => childrenTakeAllStringContents(child)) .map(child => childrenTakeAllStringContents(child))
.join("") .join("");
} else { } else {
return childrenTakeAllStringContents(children) return childrenTakeAllStringContents(children);
} }
} }
return "" return "";
} }
const COMPONENTS = { const COMPONENTS = {
@ -184,8 +184,14 @@ const COMPONENTS = {
strong: withClass("strong", "font-semibold"), strong: withClass("strong", "font-semibold"),
a: withClass("a", "text-primary underline underline-offset-2"), a: withClass("a", "text-primary underline underline-offset-2"),
blockquote: withClass("blockquote", "border-l-2 border-primary pl-4"), blockquote: withClass("blockquote", "border-l-2 border-primary pl-4"),
code: ({ children, className, node, ...rest }: any) => { code: ({
const match = /language-(\w+)/.exec(className || "") children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
const match = /language-(\w+)/.exec(className || "");
return match ? ( return match ? (
<CodeBlock className={className} language={match[1]} {...rest}> <CodeBlock className={className} language={match[1]} {...rest}>
{children} {children}
@ -199,9 +205,9 @@ const COMPONENTS = {
> >
{children} {children}
</code> </code>
) );
}, },
pre: ({ children }: any) => children, pre: ({ children }: { children: React.ReactNode }) => children,
ol: withClass("ol", "list-decimal space-y-2 pl-6"), ol: withClass("ol", "list-decimal space-y-2 pl-6"),
ul: withClass("ul", "list-disc space-y-2 pl-6"), ul: withClass("ul", "list-disc space-y-2 pl-6"),
li: withClass("li", "my-1.5"), li: withClass("li", "my-1.5"),
@ -220,14 +226,14 @@ const COMPONENTS = {
tr: withClass("tr", "m-0 border-t p-0 even:bg-muted"), tr: withClass("tr", "m-0 border-t p-0 even:bg-muted"),
p: withClass("p", "whitespace-pre-wrap"), p: withClass("p", "whitespace-pre-wrap"),
hr: withClass("hr", "border-foreground/20"), hr: withClass("hr", "border-foreground/20"),
} };
function withClass(Tag: keyof JSX.IntrinsicElements, classes: string) { function withClass(Tag: keyof JSX.IntrinsicElements, classes: string) {
const Component = ({ node, ...props }: any) => ( const Component = ({ ...props }: Record<string, unknown>) => (
<Tag className={classes} {...props} /> <Tag className={classes} {...props} />
) );
Component.displayName = Tag Component.displayName = Tag;
return Component return Component;
} }
export default MarkdownRenderer export default MarkdownRenderer;

View file

@ -1,41 +1,41 @@
"use client" "use client";
import React, { useEffect, useRef, useState } from "react" import React, { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion";
import { ArrowUp, Info, Loader2, Mic, Paperclip, Square } from "lucide-react" import { ArrowUp, Info, Loader2, Mic, Paperclip, Square } from "lucide-react";
import { omit } from "remeda" import { omit } from "remeda";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { useAudioRecording } from "@/hooks/use-audio-recording" import { useAudioRecording } from "@/hooks/use-audio-recording";
import { useAutosizeTextArea } from "@/hooks/use-autosize-textarea" import { useAutosizeTextArea } from "@/hooks/use-autosize-textarea";
import { AudioVisualizer } from "@/components/ui/audio-visualizer" import { AudioVisualizer } from "@/components/ui/audio-visualizer";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { FilePreview } from "@/components/ui/file-preview" import { FilePreview } from "@/components/ui/file-preview";
import { InterruptPrompt } from "@/components/chat-playground/interrupt-prompt" import { InterruptPrompt } from "@/components/chat-playground/interrupt-prompt";
interface MessageInputBaseProps interface MessageInputBaseProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
value: string value: string;
submitOnEnter?: boolean submitOnEnter?: boolean;
stop?: () => void stop?: () => void;
isGenerating: boolean isGenerating: boolean;
enableInterrupt?: boolean enableInterrupt?: boolean;
transcribeAudio?: (blob: Blob) => Promise<string> transcribeAudio?: (blob: Blob) => Promise<string>;
} }
interface MessageInputWithoutAttachmentProps extends MessageInputBaseProps { interface MessageInputWithoutAttachmentProps extends MessageInputBaseProps {
allowAttachments?: false allowAttachments?: false;
} }
interface MessageInputWithAttachmentsProps extends MessageInputBaseProps { interface MessageInputWithAttachmentsProps extends MessageInputBaseProps {
allowAttachments: true allowAttachments: true;
files: File[] | null files: File[] | null;
setFiles: React.Dispatch<React.SetStateAction<File[] | null>> setFiles: React.Dispatch<React.SetStateAction<File[] | null>>;
} }
type MessageInputProps = type MessageInputProps =
| MessageInputWithoutAttachmentProps | MessageInputWithoutAttachmentProps
| MessageInputWithAttachmentsProps | MessageInputWithAttachmentsProps;
export function MessageInput({ export function MessageInput({
placeholder = "Ask AI...", placeholder = "Ask AI...",
@ -48,8 +48,8 @@ export function MessageInput({
transcribeAudio, transcribeAudio,
...props ...props
}: MessageInputProps) { }: MessageInputProps) {
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false);
const [showInterruptPrompt, setShowInterruptPrompt] = useState(false) const [showInterruptPrompt, setShowInterruptPrompt] = useState(false);
const { const {
isListening, isListening,
@ -61,123 +61,124 @@ export function MessageInput({
stopRecording, stopRecording,
} = useAudioRecording({ } = useAudioRecording({
transcribeAudio, transcribeAudio,
onTranscriptionComplete: (text) => { onTranscriptionComplete: text => {
props.onChange?.({ target: { value: text } } as any) props.onChange?.({
target: { value: text },
} as React.ChangeEvent<HTMLTextAreaElement>);
}, },
}) });
useEffect(() => { useEffect(() => {
if (!isGenerating) { if (!isGenerating) {
setShowInterruptPrompt(false) setShowInterruptPrompt(false);
} }
}, [isGenerating]) }, [isGenerating]);
const addFiles = (files: File[] | null) => { const addFiles = (files: File[] | null) => {
if (props.allowAttachments) { if (props.allowAttachments) {
props.setFiles((currentFiles) => { props.setFiles(currentFiles => {
if (currentFiles === null) { if (currentFiles === null) {
return files return files;
} }
if (files === null) { if (files === null) {
return currentFiles return currentFiles;
} }
return [...currentFiles, ...files] return [...currentFiles, ...files];
}) });
}
} }
};
const onDragOver = (event: React.DragEvent) => { const onDragOver = (event: React.DragEvent) => {
if (props.allowAttachments !== true) return if (props.allowAttachments !== true) return;
event.preventDefault() event.preventDefault();
setIsDragging(true) setIsDragging(true);
} };
const onDragLeave = (event: React.DragEvent) => { const onDragLeave = (event: React.DragEvent) => {
if (props.allowAttachments !== true) return if (props.allowAttachments !== true) return;
event.preventDefault() event.preventDefault();
setIsDragging(false) setIsDragging(false);
} };
const onDrop = (event: React.DragEvent) => { const onDrop = (event: React.DragEvent) => {
setIsDragging(false) setIsDragging(false);
if (props.allowAttachments !== true) return if (props.allowAttachments !== true) return;
event.preventDefault() event.preventDefault();
const dataTransfer = event.dataTransfer const dataTransfer = event.dataTransfer;
if (dataTransfer.files.length) { if (dataTransfer.files.length) {
addFiles(Array.from(dataTransfer.files)) addFiles(Array.from(dataTransfer.files));
}
} }
};
const onPaste = (event: React.ClipboardEvent) => { const onPaste = (event: React.ClipboardEvent) => {
const items = event.clipboardData?.items const items = event.clipboardData?.items;
if (!items) return if (!items) return;
const text = event.clipboardData.getData("text") const text = event.clipboardData.getData("text");
if (text && text.length > 500 && props.allowAttachments) { if (text && text.length > 500 && props.allowAttachments) {
event.preventDefault() event.preventDefault();
const blob = new Blob([text], { type: "text/plain" }) const blob = new Blob([text], { type: "text/plain" });
const file = new File([blob], "Pasted text", { const file = new File([blob], "Pasted text", {
type: "text/plain", type: "text/plain",
lastModified: Date.now(), lastModified: Date.now(),
}) });
addFiles([file]) addFiles([file]);
return return;
} }
const files = Array.from(items) const files = Array.from(items)
.map((item) => item.getAsFile()) .map(item => item.getAsFile())
.filter((file) => file !== null) .filter(file => file !== null);
if (props.allowAttachments && files.length > 0) { if (props.allowAttachments && files.length > 0) {
addFiles(files) addFiles(files);
}
} }
};
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (submitOnEnter && event.key === "Enter" && !event.shiftKey) { if (submitOnEnter && event.key === "Enter" && !event.shiftKey) {
event.preventDefault() event.preventDefault();
if (isGenerating && stop && enableInterrupt) { if (isGenerating && stop && enableInterrupt) {
if (showInterruptPrompt) { if (showInterruptPrompt) {
stop() stop();
setShowInterruptPrompt(false) setShowInterruptPrompt(false);
event.currentTarget.form?.requestSubmit() event.currentTarget.form?.requestSubmit();
} else if ( } else if (
props.value || props.value ||
(props.allowAttachments && props.files?.length) (props.allowAttachments && props.files?.length)
) { ) {
setShowInterruptPrompt(true) setShowInterruptPrompt(true);
return return;
} }
} }
event.currentTarget.form?.requestSubmit() event.currentTarget.form?.requestSubmit();
} }
onKeyDownProp?.(event) onKeyDownProp?.(event);
} };
const textAreaRef = useRef<HTMLTextAreaElement>(null) const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [textAreaHeight, setTextAreaHeight] = useState<number>(0) const [textAreaHeight, setTextAreaHeight] = useState<number>(0);
useEffect(() => { useEffect(() => {
if (textAreaRef.current) { if (textAreaRef.current) {
setTextAreaHeight(textAreaRef.current.offsetHeight) setTextAreaHeight(textAreaRef.current.offsetHeight);
} }
}, [props.value]) }, [props.value]);
const showFileList = const showFileList =
props.allowAttachments && props.files && props.files.length > 0 props.allowAttachments && props.files && props.files.length > 0;
useAutosizeTextArea({ useAutosizeTextArea({
ref: textAreaRef, ref: textAreaRef,
maxHeight: 240, maxHeight: 240,
borderWidth: 1, borderWidth: 1,
dependencies: [props.value, showFileList], dependencies: [props.value, showFileList],
}) });
return ( return (
<div <div
@ -220,24 +221,24 @@ export function MessageInput({
<div className="absolute inset-x-3 bottom-0 z-20 overflow-x-scroll py-3"> <div className="absolute inset-x-3 bottom-0 z-20 overflow-x-scroll py-3">
<div className="flex space-x-3"> <div className="flex space-x-3">
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{props.files?.map((file) => { {props.files?.map(file => {
return ( return (
<FilePreview <FilePreview
key={file.name + String(file.lastModified)} key={file.name + String(file.lastModified)}
file={file} file={file}
onRemove={() => { onRemove={() => {
props.setFiles((files) => { props.setFiles(files => {
if (!files) return null if (!files) return null;
const filtered = Array.from(files).filter( const filtered = Array.from(files).filter(
(f) => f !== file f => f !== file
) );
if (filtered.length === 0) return null if (filtered.length === 0) return null;
return filtered return filtered;
}) });
}} }}
/> />
) );
})} })}
</AnimatePresence> </AnimatePresence>
</div> </div>
@ -256,8 +257,8 @@ export function MessageInput({
aria-label="Attach a file" aria-label="Attach a file"
disabled={true} disabled={true}
onClick={async () => { onClick={async () => {
const files = await showFileUploadDialog() const files = await showFileUploadDialog();
addFiles(files) addFiles(files);
}} }}
> >
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
@ -308,12 +309,12 @@ export function MessageInput({
onStopRecording={stopRecording} onStopRecording={stopRecording}
/> />
</div> </div>
) );
} }
MessageInput.displayName = "MessageInput" MessageInput.displayName = "MessageInput";
interface FileUploadOverlayProps { interface FileUploadOverlayProps {
isDragging: boolean isDragging: boolean;
} }
function FileUploadOverlay({ isDragging }: FileUploadOverlayProps) { function FileUploadOverlay({ isDragging }: FileUploadOverlayProps) {
@ -333,29 +334,29 @@ function FileUploadOverlay({ isDragging }: FileUploadOverlayProps) {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
) );
} }
function showFileUploadDialog() { function showFileUploadDialog() {
const input = document.createElement("input") const input = document.createElement("input");
input.type = "file" input.type = "file";
input.multiple = true input.multiple = true;
input.accept = "*/*" input.accept = "*/*";
input.click() input.click();
return new Promise<File[] | null>((resolve) => { return new Promise<File[] | null>(resolve => {
input.onchange = (e) => { input.onchange = e => {
const files = (e.currentTarget as HTMLInputElement).files const files = (e.currentTarget as HTMLInputElement).files;
if (files) { if (files) {
resolve(Array.from(files)) resolve(Array.from(files));
return return;
} }
resolve(null) resolve(null);
} };
}) });
} }
function TranscribingOverlay() { function TranscribingOverlay() {
@ -385,12 +386,12 @@ function TranscribingOverlay() {
Transcribing audio... Transcribing audio...
</p> </p>
</motion.div> </motion.div>
) );
} }
interface RecordingPromptProps { interface RecordingPromptProps {
isVisible: boolean isVisible: boolean;
onStopRecording: () => void onStopRecording: () => void;
} }
function RecordingPrompt({ isVisible, onStopRecording }: RecordingPromptProps) { function RecordingPrompt({ isVisible, onStopRecording }: RecordingPromptProps) {
@ -418,15 +419,15 @@ function RecordingPrompt({ isVisible, onStopRecording }: RecordingPromptProps) {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
) );
} }
interface RecordingControlsProps { interface RecordingControlsProps {
isRecording: boolean isRecording: boolean;
isTranscribing: boolean isTranscribing: boolean;
audioStream: MediaStream | null audioStream: MediaStream | null;
textAreaHeight: number textAreaHeight: number;
onStopRecording: () => void onStopRecording: () => void;
} }
function RecordingControls({ function RecordingControls({
@ -448,7 +449,7 @@ function RecordingControls({
onClick={onStopRecording} onClick={onStopRecording}
/> />
</div> </div>
) );
} }
if (isTranscribing) { if (isTranscribing) {
@ -459,8 +460,8 @@ function RecordingControls({
> >
<TranscribingOverlay /> <TranscribingOverlay />
</div> </div>
) );
} }
return null return null;
} }

View file

@ -2,18 +2,18 @@ import {
ChatMessage, ChatMessage,
type ChatMessageProps, type ChatMessageProps,
type Message, type Message,
} from "@/components/chat-playground/chat-message" } from "@/components/chat-playground/chat-message";
import { TypingIndicator } from "@/components/chat-playground/typing-indicator" import { TypingIndicator } from "@/components/chat-playground/typing-indicator";
type AdditionalMessageOptions = Omit<ChatMessageProps, keyof Message> type AdditionalMessageOptions = Omit<ChatMessageProps, keyof Message>;
interface MessageListProps { interface MessageListProps {
messages: Message[] messages: Message[];
showTimeStamps?: boolean showTimeStamps?: boolean;
isTyping?: boolean isTyping?: boolean;
messageOptions?: messageOptions?:
| AdditionalMessageOptions | AdditionalMessageOptions
| ((message: Message) => AdditionalMessageOptions) | ((message: Message) => AdditionalMessageOptions);
} }
export function MessageList({ export function MessageList({
@ -28,7 +28,7 @@ export function MessageList({
const additionalOptions = const additionalOptions =
typeof messageOptions === "function" typeof messageOptions === "function"
? messageOptions(message) ? messageOptions(message)
: messageOptions : messageOptions;
return ( return (
<ChatMessage <ChatMessage
@ -37,9 +37,9 @@ export function MessageList({
{...message} {...message}
{...additionalOptions} {...additionalOptions}
/> />
) );
})} })}
{isTyping && <TypingIndicator />} {isTyping && <TypingIndicator />}
</div> </div>
) );
} }

View file

@ -1,7 +1,7 @@
interface PromptSuggestionsProps { interface PromptSuggestionsProps {
label: string label: string;
append: (message: { role: "user"; content: string }) => void append: (message: { role: "user"; content: string }) => void;
suggestions: string[] suggestions: string[];
} }
export function PromptSuggestions({ export function PromptSuggestions({
@ -13,7 +13,7 @@ export function PromptSuggestions({
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-center text-2xl font-bold">{label}</h2> <h2 className="text-center text-2xl font-bold">{label}</h2>
<div className="flex gap-6 text-sm"> <div className="flex gap-6 text-sm">
{suggestions.map((suggestion) => ( {suggestions.map(suggestion => (
<button <button
key={suggestion} key={suggestion}
onClick={() => append({ role: "user", content: suggestion })} onClick={() => append({ role: "user", content: suggestion })}
@ -24,5 +24,5 @@ export function PromptSuggestions({
))} ))}
</div> </div>
</div> </div>
) );
} }

View file

@ -1,4 +1,4 @@
import { Dot } from "lucide-react" import { Dot } from "lucide-react";
export function TypingIndicator() { export function TypingIndicator() {
return ( return (
@ -11,5 +11,5 @@ export function TypingIndicator() {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View file

@ -56,7 +56,8 @@ const manageItems = [
}, },
]; ];
const optimizeItems: { title: string; url: string; icon: React.ElementType }[] = [ const optimizeItems: { title: string; url: string; icon: React.ElementType }[] =
[
{ {
title: "Evaluations", title: "Evaluations",
url: "", url: "",
@ -67,7 +68,7 @@ const optimizeItems: { title: string; url: string; icon: React.ElementType }[] =
url: "", url: "",
icon: Settings2, icon: Settings2,
}, },
]; ];
interface SidebarItem { interface SidebarItem {
title: string; title: string;
@ -79,7 +80,7 @@ export function AppSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const renderSidebarItems = (items: SidebarItem[]) => { const renderSidebarItems = (items: SidebarItem[]) => {
return items.map((item) => { return items.map(item => {
const isActive = pathname.startsWith(item.url); const isActive = pathname.startsWith(item.url);
return ( return (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
@ -88,14 +89,14 @@ export function AppSidebar() {
className={cn( className={cn(
"justify-start", "justify-start",
isActive && isActive &&
"bg-gray-200 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100", "bg-gray-200 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100"
)} )}
> >
<Link href={item.url}> <Link href={item.url}>
<item.icon <item.icon
className={cn( className={cn(
isActive && "text-gray-900 dark:text-gray-100", isActive && "text-gray-900 dark:text-gray-100",
"mr-2 h-4 w-4", "mr-2 h-4 w-4"
)} )}
/> />
<span>{item.title}</span> <span>{item.title}</span>
@ -106,7 +107,7 @@ export function AppSidebar() {
}); });
}; };
return ( return (
<Sidebar> <Sidebar>
<SidebarHeader> <SidebarHeader>
<Link href="/">Llama Stack</Link> <Link href="/">Llama Stack</Link>
@ -130,7 +131,7 @@ return (
<SidebarGroupLabel>Optimize</SidebarGroupLabel> <SidebarGroupLabel>Optimize</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{optimizeItems.map((item) => ( {optimizeItems.map(item => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton <SidebarMenuButton
disabled disabled
@ -138,7 +139,9 @@ return (
> >
<item.icon className="mr-2 h-4 w-4" /> <item.icon className="mr-2 h-4 w-4" />
<span>{item.title}</span> <span>{item.title}</span>
<span className="ml-2 text-xs text-gray-500">(Coming Soon)</span> <span className="ml-2 text-xs text-gray-500">
(Coming Soon)
</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}

View file

@ -2,7 +2,7 @@ import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
export function DetailLoadingView({ title }: { title: string }) { export function DetailLoadingView() {
return ( return (
<> <>
<Skeleton className="h-8 w-3/4 mb-6" /> {/* Title Skeleton */} <Skeleton className="h-8 w-3/4 mb-6" /> {/* Title Skeleton */}

View file

@ -67,7 +67,7 @@ describe("LogsTable Viewport Loading", () => {
() => { () => {
expect(mockLoadMore).toHaveBeenCalled(); expect(mockLoadMore).toHaveBeenCalled();
}, },
{ timeout: 300 }, { timeout: 300 }
); );
expect(mockLoadMore).toHaveBeenCalledTimes(1); expect(mockLoadMore).toHaveBeenCalledTimes(1);
@ -81,11 +81,11 @@ describe("LogsTable Viewport Loading", () => {
{...defaultProps} {...defaultProps}
status="loading-more" status="loading-more"
onLoadMore={mockLoadMore} onLoadMore={mockLoadMore}
/>, />
); );
// Wait for possible triggers // Wait for possible triggers
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise(resolve => setTimeout(resolve, 300));
expect(mockLoadMore).not.toHaveBeenCalled(); expect(mockLoadMore).not.toHaveBeenCalled();
}); });
@ -94,15 +94,11 @@ describe("LogsTable Viewport Loading", () => {
const mockLoadMore = jest.fn(); const mockLoadMore = jest.fn();
render( render(
<LogsTable <LogsTable {...defaultProps} status="loading" onLoadMore={mockLoadMore} />
{...defaultProps}
status="loading"
onLoadMore={mockLoadMore}
/>,
); );
// Wait for possible triggers // Wait for possible triggers
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise(resolve => setTimeout(resolve, 300));
expect(mockLoadMore).not.toHaveBeenCalled(); expect(mockLoadMore).not.toHaveBeenCalled();
}); });
@ -111,18 +107,18 @@ describe("LogsTable Viewport Loading", () => {
const mockLoadMore = jest.fn(); const mockLoadMore = jest.fn();
render( render(
<LogsTable {...defaultProps} hasMore={false} onLoadMore={mockLoadMore} />, <LogsTable {...defaultProps} hasMore={false} onLoadMore={mockLoadMore} />
); );
// Wait for possible triggers // Wait for possible triggers
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise(resolve => setTimeout(resolve, 300));
expect(mockLoadMore).not.toHaveBeenCalled(); expect(mockLoadMore).not.toHaveBeenCalled();
}); });
test("sentinel element should not be rendered when loading", () => { test("sentinel element should not be rendered when loading", () => {
const { container } = render( const { container } = render(
<LogsTable {...defaultProps} status="loading-more" />, <LogsTable {...defaultProps} status="loading-more" />
); );
// Check that no sentinel row with height: 1 exists // Check that no sentinel row with height: 1 exists
@ -132,7 +128,7 @@ describe("LogsTable Viewport Loading", () => {
test("sentinel element should be rendered when not loading and hasMore", () => { test("sentinel element should be rendered when not loading and hasMore", () => {
const { container } = render( const { container } = render(
<LogsTable {...defaultProps} hasMore={true} status="idle" />, <LogsTable {...defaultProps} hasMore={true} status="idle" />
); );
// Check that sentinel row exists // Check that sentinel row exists

View file

@ -70,7 +70,7 @@ describe("LogsTable", () => {
describe("Loading State", () => { describe("Loading State", () => {
test("renders skeleton UI when isLoading is true", () => { test("renders skeleton UI when isLoading is true", () => {
const { container } = render( const { container } = render(
<LogsTable {...defaultProps} status="loading" />, <LogsTable {...defaultProps} status="loading" />
); );
// Check for skeleton in the table caption // Check for skeleton in the table caption
@ -78,7 +78,7 @@ describe("LogsTable", () => {
expect(tableCaption).toBeInTheDocument(); expect(tableCaption).toBeInTheDocument();
if (tableCaption) { if (tableCaption) {
const captionSkeleton = tableCaption.querySelector( const captionSkeleton = tableCaption.querySelector(
'[data-slot="skeleton"]', '[data-slot="skeleton"]'
); );
expect(captionSkeleton).toBeInTheDocument(); expect(captionSkeleton).toBeInTheDocument();
} }
@ -88,7 +88,7 @@ describe("LogsTable", () => {
expect(tableBody).toBeInTheDocument(); expect(tableBody).toBeInTheDocument();
if (tableBody) { if (tableBody) {
const bodySkeletons = tableBody.querySelectorAll( const bodySkeletons = tableBody.querySelectorAll(
'[data-slot="skeleton"]', '[data-slot="skeleton"]'
); );
expect(bodySkeletons.length).toBeGreaterThan(0); expect(bodySkeletons.length).toBeGreaterThan(0);
} }
@ -102,7 +102,7 @@ describe("LogsTable", () => {
test("renders correct number of skeleton rows", () => { test("renders correct number of skeleton rows", () => {
const { container } = render( const { container } = render(
<LogsTable {...defaultProps} status="loading" />, <LogsTable {...defaultProps} status="loading" />
); );
const skeletonRows = container.querySelectorAll("tbody tr"); const skeletonRows = container.querySelectorAll("tbody tr");
@ -118,10 +118,10 @@ describe("LogsTable", () => {
{...defaultProps} {...defaultProps}
status="error" status="error"
error={{ name: "Error", message: errorMessage } as Error} error={{ name: "Error", message: errorMessage } as Error}
/>, />
); );
expect( expect(
screen.getByText("Unable to load chat completions"), screen.getByText("Unable to load chat completions")
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(errorMessage)).toBeInTheDocument(); expect(screen.getByText(errorMessage)).toBeInTheDocument();
}); });
@ -132,29 +132,25 @@ describe("LogsTable", () => {
{...defaultProps} {...defaultProps}
status="error" status="error"
error={{ name: "Error", message: "" } as Error} error={{ name: "Error", message: "" } as Error}
/>, />
); );
expect( expect(
screen.getByText("Unable to load chat completions"), screen.getByText("Unable to load chat completions")
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText("An unexpected error occurred while loading the data.")
"An unexpected error occurred while loading the data.",
),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
test("renders default error message when error prop is an object without message", () => { test("renders default error message when error prop is an object without message", () => {
render( render(
<LogsTable {...defaultProps} status="error" error={{} as Error} />, <LogsTable {...defaultProps} status="error" error={{} as Error} />
); );
expect( expect(
screen.getByText("Unable to load chat completions"), screen.getByText("Unable to load chat completions")
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText("An unexpected error occurred while loading the data.")
"An unexpected error occurred while loading the data.",
),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -164,7 +160,7 @@ describe("LogsTable", () => {
{...defaultProps} {...defaultProps}
status="error" status="error"
error={{ name: "Error", message: "Test error" } as Error} error={{ name: "Error", message: "Test error" } as Error}
/>, />
); );
const table = screen.queryByRole("table"); const table = screen.queryByRole("table");
expect(table).not.toBeInTheDocument(); expect(table).not.toBeInTheDocument();
@ -178,7 +174,7 @@ describe("LogsTable", () => {
{...defaultProps} {...defaultProps}
data={[]} data={[]}
emptyMessage="Custom empty message" emptyMessage="Custom empty message"
/>, />
); );
expect(screen.getByText("Custom empty message")).toBeInTheDocument(); expect(screen.getByText("Custom empty message")).toBeInTheDocument();
@ -214,7 +210,7 @@ describe("LogsTable", () => {
{...defaultProps} {...defaultProps}
data={mockData} data={mockData}
caption="Custom table caption" caption="Custom table caption"
/>, />
); );
// Table caption // Table caption
@ -311,8 +307,8 @@ describe("LogsTable", () => {
// Verify truncated text is displayed // Verify truncated text is displayed
const truncatedTexts = screen.getAllByText("This is a ..."); const truncatedTexts = screen.getAllByText("This is a ...");
expect(truncatedTexts).toHaveLength(2); // one for input, one for output expect(truncatedTexts).toHaveLength(2); // one for input, one for output
truncatedTexts.forEach((textElement) => truncatedTexts.forEach(textElement =>
expect(textElement).toBeInTheDocument(), expect(textElement).toBeInTheDocument()
); );
}); });
@ -332,12 +328,12 @@ describe("LogsTable", () => {
// Model name should not be passed to truncateText // Model name should not be passed to truncateText
expect(truncateText).not.toHaveBeenCalledWith( expect(truncateText).not.toHaveBeenCalledWith(
"very-long-model-name-that-should-not-be-truncated", "very-long-model-name-that-should-not-be-truncated"
); );
// Full model name should be displayed // Full model name should be displayed
expect( expect(
screen.getByText("very-long-model-name-that-should-not-be-truncated"), screen.getByText("very-long-model-name-that-should-not-be-truncated")
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });

View file

@ -142,7 +142,7 @@ export function LogsTable({
<Table> <Table>
<TableCaption className="sr-only">{caption}</TableCaption> <TableCaption className="sr-only">{caption}</TableCaption>
<TableBody> <TableBody>
{data.map((row) => ( {data.map(row => (
<TableRow <TableRow
key={row.id} key={row.id}
onClick={() => router.push(row.detailPath)} onClick={() => router.push(row.detailPath)}

View file

@ -22,7 +22,7 @@ export function GroupedItemsDisplay({
return ( return (
<> <>
{groupedItems.map((groupedItem) => { {groupedItems.map(groupedItem => {
// If this is a function call with an output, render the grouped component // If this is a function call with an output, render the grouped component
if ( if (
groupedItem.outputItem && groupedItem.outputItem &&

View file

@ -18,7 +18,7 @@ export interface GroupedItem {
* @returns Array of grouped items with their outputs * @returns Array of grouped items with their outputs
*/ */
export function useFunctionCallGrouping( export function useFunctionCallGrouping(
items: AnyResponseItem[], items: AnyResponseItem[]
): GroupedItem[] { ): GroupedItem[] {
return useMemo(() => { return useMemo(() => {
const groupedItems: GroupedItem[] = []; const groupedItems: GroupedItem[] = [];

View file

@ -52,7 +52,7 @@ export function ItemRenderer({
// Fallback to generic item for unknown types // Fallback to generic item for unknown types
return ( return (
<GenericItemComponent <GenericItemComponent
item={item as any} item={item as Record<string, unknown>}
index={index} index={index}
keyPrefix={keyPrefix} keyPrefix={keyPrefix}
/> />

View file

@ -20,7 +20,7 @@ export function MessageItemComponent({
content = item.content; content = item.content;
} else if (Array.isArray(item.content)) { } else if (Array.isArray(item.content)) {
content = item.content content = item.content
.map((c) => { .map(c => {
return c.type === "input_text" || c.type === "output_text" return c.type === "input_text" || c.type === "output_text"
? c.text ? c.text
: JSON.stringify(c); : JSON.stringify(c);

View file

@ -18,7 +18,7 @@ describe("ResponseDetailView", () => {
describe("Loading State", () => { describe("Loading State", () => {
test("renders loading skeleton when isLoading is true", () => { test("renders loading skeleton when isLoading is true", () => {
const { container } = render( const { container } = render(
<ResponseDetailView {...defaultProps} isLoading={true} />, <ResponseDetailView {...defaultProps} isLoading={true} />
); );
// Check for skeleton elements // Check for skeleton elements
@ -36,13 +36,13 @@ describe("ResponseDetailView", () => {
<ResponseDetailView <ResponseDetailView
{...defaultProps} {...defaultProps}
error={{ name: "Error", message: errorMessage }} error={{ name: "Error", message: errorMessage }}
/>, />
); );
expect(screen.getByText("Responses Details")).toBeInTheDocument(); expect(screen.getByText("Responses Details")).toBeInTheDocument();
// The error message is split across elements, so we check for parts // The error message is split across elements, so we check for parts
expect( expect(
screen.getByText(/Error loading details for ID/), screen.getByText(/Error loading details for ID/)
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(/test_id/)).toBeInTheDocument(); expect(screen.getByText(/test_id/)).toBeInTheDocument();
expect(screen.getByText(/Network Error/)).toBeInTheDocument(); expect(screen.getByText(/Network Error/)).toBeInTheDocument();
@ -53,11 +53,11 @@ describe("ResponseDetailView", () => {
<ResponseDetailView <ResponseDetailView
{...defaultProps} {...defaultProps}
error={{ name: "Error", message: "" }} error={{ name: "Error", message: "" }}
/>, />
); );
expect( expect(
screen.getByText(/Error loading details for ID/), screen.getByText(/Error loading details for ID/)
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(/test_id/)).toBeInTheDocument(); expect(screen.getByText(/test_id/)).toBeInTheDocument();
}); });
@ -124,14 +124,14 @@ describe("ResponseDetailView", () => {
// Check properties - use regex to handle text split across elements // Check properties - use regex to handle text split across elements
expect(screen.getByText(/Created/)).toBeInTheDocument(); expect(screen.getByText(/Created/)).toBeInTheDocument();
expect( expect(
screen.getByText(new Date(1710000000 * 1000).toLocaleString()), screen.getByText(new Date(1710000000 * 1000).toLocaleString())
).toBeInTheDocument(); ).toBeInTheDocument();
// Check for the specific ID label (not Previous Response ID) // Check for the specific ID label (not Previous Response ID)
expect( expect(
screen.getByText((content, element) => { screen.getByText((content, element) => {
return element?.tagName === "STRONG" && content === "ID:"; return element?.tagName === "STRONG" && content === "ID:";
}), })
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("resp_123")).toBeInTheDocument(); expect(screen.getByText("resp_123")).toBeInTheDocument();
@ -166,7 +166,7 @@ describe("ResponseDetailView", () => {
}; };
render( render(
<ResponseDetailView {...defaultProps} response={minimalResponse} />, <ResponseDetailView {...defaultProps} response={minimalResponse} />
); );
// Should show required properties // Should show required properties
@ -179,7 +179,7 @@ describe("ResponseDetailView", () => {
expect(screen.queryByText("Top P")).not.toBeInTheDocument(); expect(screen.queryByText("Top P")).not.toBeInTheDocument();
expect(screen.queryByText("Parallel Tool Calls")).not.toBeInTheDocument(); expect(screen.queryByText("Parallel Tool Calls")).not.toBeInTheDocument();
expect( expect(
screen.queryByText("Previous Response ID"), screen.queryByText("Previous Response ID")
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
@ -196,7 +196,7 @@ describe("ResponseDetailView", () => {
// The error is shown in the properties sidebar, not as a separate "Error" label // The error is shown in the properties sidebar, not as a separate "Error" label
expect( expect(
screen.getByText("invalid_request: The request was invalid"), screen.getByText("invalid_request: The request was invalid")
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
@ -218,7 +218,7 @@ describe("ResponseDetailView", () => {
{...defaultProps} {...defaultProps}
response={mockResponse} response={mockResponse}
isLoadingInputItems={true} isLoadingInputItems={true}
/>, />
); );
// Check for skeleton loading in input items section // Check for skeleton loading in input items section
@ -227,7 +227,7 @@ describe("ResponseDetailView", () => {
{...defaultProps} {...defaultProps}
response={mockResponse} response={mockResponse}
isLoadingInputItems={true} isLoadingInputItems={true}
/>, />
); );
const skeletons = container.querySelectorAll('[data-slot="skeleton"]'); const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
@ -243,16 +243,16 @@ describe("ResponseDetailView", () => {
name: "Error", name: "Error",
message: "Failed to load input items", message: "Failed to load input items",
}} }}
/>, />
); );
expect( expect(
screen.getByText( screen.getByText(
"Error loading input items: Failed to load input items", "Error loading input items: Failed to load input items"
), )
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText("Falling back to response input data."), screen.getByText("Falling back to response input data.")
).toBeInTheDocument(); ).toBeInTheDocument();
// Should still show fallback input data // Should still show fallback input data
@ -276,7 +276,7 @@ describe("ResponseDetailView", () => {
{...defaultProps} {...defaultProps}
response={mockResponse} response={mockResponse}
inputItems={mockInputItems} inputItems={mockInputItems}
/>, />
); );
// Should show input items data, not response.input // Should show input items data, not response.input
@ -295,7 +295,7 @@ describe("ResponseDetailView", () => {
{...defaultProps} {...defaultProps}
response={mockResponse} response={mockResponse}
inputItems={emptyInputItems} inputItems={emptyInputItems}
/>, />
); );
// Should show fallback input data // Should show fallback input data
@ -313,7 +313,7 @@ describe("ResponseDetailView", () => {
{...defaultProps} {...defaultProps}
response={responseWithoutInput} response={responseWithoutInput}
inputItems={null} inputItems={null}
/>, />
); );
expect(screen.getByText("No input data available.")).toBeInTheDocument(); expect(screen.getByText("No input data available.")).toBeInTheDocument();
@ -443,7 +443,7 @@ describe("ResponseDetailView", () => {
render(<ResponseDetailView {...defaultProps} response={mockResponse} />); render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
expect( expect(
screen.getByText('input_function({"param": "value"})'), screen.getByText('input_function({"param": "value"})')
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Function Call")).toBeInTheDocument(); expect(screen.getByText("Function Call")).toBeInTheDocument();
}); });
@ -468,7 +468,7 @@ describe("ResponseDetailView", () => {
render(<ResponseDetailView {...defaultProps} response={mockResponse} />); render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
expect( expect(
screen.getByText("web_search_call(status: completed)"), screen.getByText("web_search_call(status: completed)")
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Function Call")).toBeInTheDocument(); expect(screen.getByText("Function Call")).toBeInTheDocument();
expect(screen.getByText("(Web Search)")).toBeInTheDocument(); expect(screen.getByText("(Web Search)")).toBeInTheDocument();
@ -522,7 +522,7 @@ describe("ResponseDetailView", () => {
render(<ResponseDetailView {...defaultProps} response={mockResponse} />); render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
expect( expect(
screen.getByText("First output Second output"), screen.getByText("First output Second output")
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Assistant")).toBeInTheDocument(); expect(screen.getByText("Assistant")).toBeInTheDocument();
}); });
@ -549,7 +549,7 @@ describe("ResponseDetailView", () => {
render(<ResponseDetailView {...defaultProps} response={mockResponse} />); render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
expect( expect(
screen.getByText('search_function({"query": "test"})'), screen.getByText('search_function({"query": "test"})')
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Function Call")).toBeInTheDocument(); expect(screen.getByText("Function Call")).toBeInTheDocument();
}); });
@ -598,7 +598,7 @@ describe("ResponseDetailView", () => {
render(<ResponseDetailView {...defaultProps} response={mockResponse} />); render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
expect( expect(
screen.getByText("web_search_call(status: completed)"), screen.getByText("web_search_call(status: completed)")
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(/Function Call/)).toBeInTheDocument(); expect(screen.getByText(/Function Call/)).toBeInTheDocument();
expect(screen.getByText("(Web Search)")).toBeInTheDocument(); expect(screen.getByText("(Web Search)")).toBeInTheDocument();
@ -616,7 +616,7 @@ describe("ResponseDetailView", () => {
type: "unknown_type", type: "unknown_type",
custom_field: "custom_value", custom_field: "custom_value",
data: { nested: "object" }, data: { nested: "object" },
} as any, } as unknown,
], ],
input: [], input: [],
}; };
@ -625,7 +625,7 @@ describe("ResponseDetailView", () => {
// Should show JSON stringified content // Should show JSON stringified content
expect( expect(
screen.getByText(/custom_field.*custom_value/), screen.getByText(/custom_field.*custom_value/)
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("(unknown_type)")).toBeInTheDocument(); expect(screen.getByText("(unknown_type)")).toBeInTheDocument();
}); });
@ -666,7 +666,7 @@ describe("ResponseDetailView", () => {
role: "assistant", role: "assistant",
call_id: "call_123", call_id: "call_123",
content: "sunny and warm", content: "sunny and warm",
} as any, // Using any to bypass the type restriction for this test } as unknown, // Using any to bypass the type restriction for this test
], ],
input: [], input: [],
}; };
@ -676,7 +676,7 @@ describe("ResponseDetailView", () => {
// Should show the function call and message as separate items (not grouped) // Should show the function call and message as separate items (not grouped)
expect(screen.getByText("Function Call")).toBeInTheDocument(); expect(screen.getByText("Function Call")).toBeInTheDocument();
expect( expect(
screen.getByText('get_weather({"city": "Tokyo"})'), screen.getByText('get_weather({"city": "Tokyo"})')
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Assistant")).toBeInTheDocument(); expect(screen.getByText("Assistant")).toBeInTheDocument();
expect(screen.getByText("sunny and warm")).toBeInTheDocument(); expect(screen.getByText("sunny and warm")).toBeInTheDocument();
@ -706,7 +706,7 @@ describe("ResponseDetailView", () => {
status: "completed", status: "completed",
call_id: "call_123", call_id: "call_123",
output: "sunny and warm", output: "sunny and warm",
} as any, // Using any to bypass the type restriction for this test } as unknown,
], ],
input: [], input: [],
}; };
@ -717,7 +717,7 @@ describe("ResponseDetailView", () => {
expect(screen.getByText("Function Call")).toBeInTheDocument(); expect(screen.getByText("Function Call")).toBeInTheDocument();
expect(screen.getByText("Arguments")).toBeInTheDocument(); expect(screen.getByText("Arguments")).toBeInTheDocument();
expect( expect(
screen.getByText('get_weather({"city": "Tokyo"})'), screen.getByText('get_weather({"city": "Tokyo"})')
).toBeInTheDocument(); ).toBeInTheDocument();
// Use getAllByText since there are multiple "Output" elements (card title and output label) // Use getAllByText since there are multiple "Output" elements (card title and output label)
const outputElements = screen.getAllByText("Output"); const outputElements = screen.getAllByText("Output");

View file

@ -146,7 +146,7 @@ describe("ResponsesTable", () => {
expect(tableCaption).toBeInTheDocument(); expect(tableCaption).toBeInTheDocument();
if (tableCaption) { if (tableCaption) {
const captionSkeleton = tableCaption.querySelector( const captionSkeleton = tableCaption.querySelector(
'[data-slot="skeleton"]', '[data-slot="skeleton"]'
); );
expect(captionSkeleton).toBeInTheDocument(); expect(captionSkeleton).toBeInTheDocument();
} }
@ -156,7 +156,7 @@ describe("ResponsesTable", () => {
expect(tableBody).toBeInTheDocument(); expect(tableBody).toBeInTheDocument();
if (tableBody) { if (tableBody) {
const bodySkeletons = tableBody.querySelectorAll( const bodySkeletons = tableBody.querySelectorAll(
'[data-slot="skeleton"]', '[data-slot="skeleton"]'
); );
expect(bodySkeletons.length).toBeGreaterThan(0); expect(bodySkeletons.length).toBeGreaterThan(0);
} }
@ -176,14 +176,14 @@ describe("ResponsesTable", () => {
render(<ResponsesTable {...defaultProps} />); render(<ResponsesTable {...defaultProps} />);
expect( expect(
screen.getByText("Unable to load chat completions"), screen.getByText("Unable to load chat completions")
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(errorMessage)).toBeInTheDocument(); expect(screen.getByText(errorMessage)).toBeInTheDocument();
}); });
test.each([{ name: "Error", message: "" }, {}])( test.each([{ name: "Error", message: "" }, {}])(
"renders default error message when error has no message", "renders default error message when error has no message",
(errorObject) => { errorObject => {
mockedUsePagination.mockReturnValue({ mockedUsePagination.mockReturnValue({
data: [], data: [],
status: "error", status: "error",
@ -194,14 +194,14 @@ describe("ResponsesTable", () => {
render(<ResponsesTable {...defaultProps} />); render(<ResponsesTable {...defaultProps} />);
expect( expect(
screen.getByText("Unable to load chat completions"), screen.getByText("Unable to load chat completions")
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(
"An unexpected error occurred while loading the data.", "An unexpected error occurred while loading the data."
), )
).toBeInTheDocument(); ).toBeInTheDocument();
}, }
); );
}); });
@ -275,7 +275,7 @@ describe("ResponsesTable", () => {
// Table caption // Table caption
expect( expect(
screen.getByText("A list of your recent responses."), screen.getByText("A list of your recent responses.")
).toBeInTheDocument(); ).toBeInTheDocument();
// Table headers // Table headers
@ -289,14 +289,14 @@ describe("ResponsesTable", () => {
expect(screen.getByText("Test output")).toBeInTheDocument(); expect(screen.getByText("Test output")).toBeInTheDocument();
expect(screen.getByText("llama-test-model")).toBeInTheDocument(); expect(screen.getByText("llama-test-model")).toBeInTheDocument();
expect( expect(
screen.getByText(new Date(1710000000 * 1000).toLocaleString()), screen.getByText(new Date(1710000000 * 1000).toLocaleString())
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("Another input")).toBeInTheDocument(); expect(screen.getByText("Another input")).toBeInTheDocument();
expect(screen.getByText("Another output")).toBeInTheDocument(); expect(screen.getByText("Another output")).toBeInTheDocument();
expect(screen.getByText("llama-another-model")).toBeInTheDocument(); expect(screen.getByText("llama-another-model")).toBeInTheDocument();
expect( expect(
screen.getByText(new Date(1710001000 * 1000).toLocaleString()), screen.getByText(new Date(1710001000 * 1000).toLocaleString())
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
@ -487,7 +487,7 @@ describe("ResponsesTable", () => {
render(<ResponsesTable {...defaultProps} />); render(<ResponsesTable {...defaultProps} />);
expect( expect(
screen.getByText('search_function({"query": "test"})'), screen.getByText('search_function({"query": "test"})')
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -548,7 +548,7 @@ describe("ResponsesTable", () => {
render(<ResponsesTable {...defaultProps} />); render(<ResponsesTable {...defaultProps} />);
expect( expect(
screen.getByText("web_search_call(status: completed)"), screen.getByText("web_search_call(status: completed)")
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@ -565,7 +565,7 @@ describe("ResponsesTable", () => {
id: "unknown_123", id: "unknown_123",
status: "completed", status: "completed",
custom_field: "custom_value", custom_field: "custom_value",
} as any, } as unknown,
], ],
input: [{ type: "message", content: "input" }], input: [{ type: "message", content: "input" }],
}; };
@ -594,7 +594,7 @@ describe("ResponsesTable", () => {
{ {
type: "unknown_type", type: "unknown_type",
data: "some data", data: "some data",
} as any, } as unknown,
], ],
input: [{ type: "message", content: "input" }], input: [{ type: "message", content: "input" }],
}; };
@ -623,7 +623,7 @@ describe("ResponsesTable", () => {
return typeof text === "string" && text.length > effectiveMaxLength return typeof text === "string" && text.length > effectiveMaxLength
? text.slice(0, effectiveMaxLength) + "..." ? text.slice(0, effectiveMaxLength) + "..."
: text; : text;
}, }
); );
const longInput = const longInput =
@ -665,7 +665,7 @@ describe("ResponsesTable", () => {
// The truncated text should be present for both input and output // The truncated text should be present for both input and output
const truncatedTexts = screen.getAllByText( const truncatedTexts = screen.getAllByText(
longInput.slice(0, 10) + "...", longInput.slice(0, 10) + "..."
); );
expect(truncatedTexts.length).toBe(2); // one for input, one for output expect(truncatedTexts.length).toBe(2); // one for input, one for output
}); });

View file

@ -27,7 +27,7 @@ interface ResponsesTableProps {
* Helper function to convert ResponseListResponse.Data to OpenAIResponse * Helper function to convert ResponseListResponse.Data to OpenAIResponse
*/ */
const convertResponseListData = ( const convertResponseListData = (
responseData: ResponseListResponse.Data, responseData: ResponseListResponse.Data
): OpenAIResponse => { ): OpenAIResponse => {
return { return {
id: responseData.id, id: responseData.id,
@ -56,8 +56,8 @@ function getInputText(response: OpenAIResponse): string {
} }
function getOutputText(response: OpenAIResponse): string { function getOutputText(response: OpenAIResponse): string {
const firstMessage = response.output.find((item) => const firstMessage = response.output.find(item =>
isMessageItem(item as any), isMessageItem(item as Record<string, unknown>)
); );
if (firstMessage) { if (firstMessage) {
const content = extractContentFromItem(firstMessage as MessageItem); const content = extractContentFromItem(firstMessage as MessageItem);
@ -66,15 +66,15 @@ function getOutputText(response: OpenAIResponse): string {
} }
} }
const functionCall = response.output.find((item) => const functionCall = response.output.find(item =>
isFunctionCallItem(item as any), isFunctionCallItem(item as Record<string, unknown>)
); );
if (functionCall) { if (functionCall) {
return formatFunctionCall(functionCall as FunctionCallItem); return formatFunctionCall(functionCall as FunctionCallItem);
} }
const webSearchCall = response.output.find((item) => const webSearchCall = response.output.find(item =>
isWebSearchCallItem(item as any), isWebSearchCallItem(item as Record<string, unknown>)
); );
if (webSearchCall) { if (webSearchCall) {
return formatWebSearchCall(webSearchCall as WebSearchCallItem); return formatWebSearchCall(webSearchCall as WebSearchCallItem);
@ -95,7 +95,7 @@ function extractContentFromItem(item: {
} else if (Array.isArray(item.content)) { } else if (Array.isArray(item.content)) {
const textContent = item.content.find( const textContent = item.content.find(
(c: ResponseInputMessageContent) => (c: ResponseInputMessageContent) =>
c.type === "input_text" || c.type === "output_text", c.type === "input_text" || c.type === "output_text"
); );
return textContent?.text || ""; return textContent?.text || "";
} }
@ -131,14 +131,14 @@ export function ResponsesTable({ paginationOptions }: ResponsesTableProps) {
limit: number; limit: number;
model?: string; model?: string;
order?: string; order?: string;
}, }
) => { ) => {
const response = await client.responses.list({ const response = await client.responses.list({
after: params.after, after: params.after,
limit: params.limit, limit: params.limit,
...(params.model && { model: params.model }), ...(params.model && { model: params.model }),
...(params.order && { order: params.order }), ...(params.order && { order: params.order }),
} as any); } as Parameters<typeof client.responses.list>[0]);
const listResponse = response as ResponseListResponse; const listResponse = response as ResponseListResponse;

View file

@ -29,7 +29,7 @@ export type AnyResponseItem =
| FunctionCallOutputItem; | FunctionCallOutputItem;
export function isMessageInput( export function isMessageInput(
item: ResponseInput, item: ResponseInput
): item is ResponseInput & { type: "message" } { ): item is ResponseInput & { type: "message" } {
return item.type === "message"; return item.type === "message";
} }
@ -39,23 +39,23 @@ export function isMessageItem(item: AnyResponseItem): item is MessageItem {
} }
export function isFunctionCallItem( export function isFunctionCallItem(
item: AnyResponseItem, item: AnyResponseItem
): item is FunctionCallItem { ): item is FunctionCallItem {
return item.type === "function_call" && "name" in item; return item.type === "function_call" && "name" in item;
} }
export function isWebSearchCallItem( export function isWebSearchCallItem(
item: AnyResponseItem, item: AnyResponseItem
): item is WebSearchCallItem { ): item is WebSearchCallItem {
return item.type === "web_search_call"; return item.type === "web_search_call";
} }
export function isFunctionCallOutputItem( export function isFunctionCallOutputItem(
item: AnyResponseItem, item: AnyResponseItem
): item is FunctionCallOutputItem { ): item is FunctionCallOutputItem {
return ( return (
item.type === "function_call_output" && item.type === "function_call_output" &&
"call_id" in item && "call_id" in item &&
typeof (item as any).call_id === "string" typeof (item as Record<string, unknown>).call_id === "string"
); );
} }

View file

@ -1,6 +1,6 @@
"use client" "use client";
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react";
// Configuration constants for the audio analyzer // Configuration constants for the audio analyzer
const AUDIO_CONFIG = { const AUDIO_CONFIG = {
@ -14,12 +14,12 @@ const AUDIO_CONFIG = {
MAX_INTENSITY: 255, // Maximum gray value (brighter) MAX_INTENSITY: 255, // Maximum gray value (brighter)
INTENSITY_RANGE: 155, // MAX_INTENSITY - MIN_INTENSITY INTENSITY_RANGE: 155, // MAX_INTENSITY - MIN_INTENSITY
}, },
} as const } as const;
interface AudioVisualizerProps { interface AudioVisualizerProps {
stream: MediaStream | null stream: MediaStream | null;
isRecording: boolean isRecording: boolean;
onClick: () => void onClick: () => void;
} }
export function AudioVisualizer({ export function AudioVisualizer({
@ -28,91 +28,91 @@ export function AudioVisualizer({
onClick, onClick,
}: AudioVisualizerProps) { }: AudioVisualizerProps) {
// Refs for managing audio context and animation // Refs for managing audio context and animation
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null);
const audioContextRef = useRef<AudioContext | null>(null) const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null) const analyserRef = useRef<AnalyserNode | null>(null);
const animationFrameRef = useRef<number>() const animationFrameRef = useRef<number>();
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null);
// Cleanup function to stop visualization and close audio context // Cleanup function to stop visualization and close audio context
const cleanup = () => { const cleanup = () => {
if (animationFrameRef.current) { if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
} }
if (audioContextRef.current) { if (audioContextRef.current) {
audioContextRef.current.close() audioContextRef.current.close();
}
} }
};
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
return cleanup return cleanup;
}, []) }, []);
// Start or stop visualization based on recording state // Start or stop visualization based on recording state
useEffect(() => { useEffect(() => {
if (stream && isRecording) { if (stream && isRecording) {
startVisualization() startVisualization();
} else { } else {
cleanup() cleanup();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [stream, isRecording]) }, [stream, isRecording]);
// Handle window resize // Handle window resize
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (canvasRef.current && containerRef.current) { if (canvasRef.current && containerRef.current) {
const container = containerRef.current const container = containerRef.current;
const canvas = canvasRef.current const canvas = canvasRef.current;
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1;
// Set canvas size based on container and device pixel ratio // Set canvas size based on container and device pixel ratio
const rect = container.getBoundingClientRect() const rect = container.getBoundingClientRect();
// Account for the 2px total margin (1px on each side) // Account for the 2px total margin (1px on each side)
canvas.width = (rect.width - 2) * dpr canvas.width = (rect.width - 2) * dpr;
canvas.height = (rect.height - 2) * dpr canvas.height = (rect.height - 2) * dpr;
// Scale canvas CSS size to match container minus margins // Scale canvas CSS size to match container minus margins
canvas.style.width = `${rect.width - 2}px` canvas.style.width = `${rect.width - 2}px`;
canvas.style.height = `${rect.height - 2}px` canvas.style.height = `${rect.height - 2}px`;
}
} }
};
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize);
// Initial setup // Initial setup
handleResize() handleResize();
return () => window.removeEventListener("resize", handleResize) return () => window.removeEventListener("resize", handleResize);
}, []) }, []);
// Initialize audio context and start visualization // Initialize audio context and start visualization
const startVisualization = async () => { const startVisualization = async () => {
try { try {
const audioContext = new AudioContext() const audioContext = new AudioContext();
audioContextRef.current = audioContext audioContextRef.current = audioContext;
const analyser = audioContext.createAnalyser() const analyser = audioContext.createAnalyser();
analyser.fftSize = AUDIO_CONFIG.FFT_SIZE analyser.fftSize = AUDIO_CONFIG.FFT_SIZE;
analyser.smoothingTimeConstant = AUDIO_CONFIG.SMOOTHING analyser.smoothingTimeConstant = AUDIO_CONFIG.SMOOTHING;
analyserRef.current = analyser analyserRef.current = analyser;
const source = audioContext.createMediaStreamSource(stream!) const source = audioContext.createMediaStreamSource(stream!);
source.connect(analyser) source.connect(analyser);
draw() draw();
} catch (error) { } catch (error) {
console.error("Error starting visualization:", error) console.error("Error starting visualization:", error);
}
} }
};
// Calculate the color intensity based on bar height // Calculate the color intensity based on bar height
const getBarColor = (normalizedHeight: number) => { const getBarColor = (normalizedHeight: number) => {
const intensity = const intensity =
Math.floor(normalizedHeight * AUDIO_CONFIG.COLOR.INTENSITY_RANGE) + Math.floor(normalizedHeight * AUDIO_CONFIG.COLOR.INTENSITY_RANGE) +
AUDIO_CONFIG.COLOR.MIN_INTENSITY AUDIO_CONFIG.COLOR.MIN_INTENSITY;
return `rgb(${intensity}, ${intensity}, ${intensity})` return `rgb(${intensity}, ${intensity}, ${intensity})`;
} };
// Draw a single bar of the visualizer // Draw a single bar of the visualizer
const drawBar = ( const drawBar = (
@ -123,52 +123,52 @@ export function AudioVisualizer({
height: number, height: number,
color: string color: string
) => { ) => {
ctx.fillStyle = color ctx.fillStyle = color;
// Draw upper bar (above center) // Draw upper bar (above center)
ctx.fillRect(x, centerY - height, width, height) ctx.fillRect(x, centerY - height, width, height);
// Draw lower bar (below center) // Draw lower bar (below center)
ctx.fillRect(x, centerY, width, height) ctx.fillRect(x, centerY, width, height);
} };
// Main drawing function // Main drawing function
const draw = () => { const draw = () => {
if (!isRecording) return if (!isRecording) return;
const canvas = canvasRef.current const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d") const ctx = canvas?.getContext("2d");
if (!canvas || !ctx || !analyserRef.current) return if (!canvas || !ctx || !analyserRef.current) return;
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1;
ctx.scale(dpr, dpr) ctx.scale(dpr, dpr);
const analyser = analyserRef.current const analyser = analyserRef.current;
const bufferLength = analyser.frequencyBinCount const bufferLength = analyser.frequencyBinCount;
const frequencyData = new Uint8Array(bufferLength) const frequencyData = new Uint8Array(bufferLength);
const drawFrame = () => { const drawFrame = () => {
animationFrameRef.current = requestAnimationFrame(drawFrame) animationFrameRef.current = requestAnimationFrame(drawFrame);
// Get current frequency data // Get current frequency data
analyser.getByteFrequencyData(frequencyData) analyser.getByteFrequencyData(frequencyData);
// Clear canvas - use CSS pixels for clearing // Clear canvas - use CSS pixels for clearing
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr) ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
// Calculate dimensions in CSS pixels // Calculate dimensions in CSS pixels
const barWidth = Math.max( const barWidth = Math.max(
AUDIO_CONFIG.MIN_BAR_WIDTH, AUDIO_CONFIG.MIN_BAR_WIDTH,
canvas.width / dpr / bufferLength - AUDIO_CONFIG.BAR_SPACING canvas.width / dpr / bufferLength - AUDIO_CONFIG.BAR_SPACING
) );
const centerY = canvas.height / dpr / 2 const centerY = canvas.height / dpr / 2;
let x = 0 let x = 0;
// Draw each frequency bar // Draw each frequency bar
for (let i = 0; i < bufferLength; i++) { for (let i = 0; i < bufferLength; i++) {
const normalizedHeight = frequencyData[i] / 255 // Convert to 0-1 range const normalizedHeight = frequencyData[i] / 255; // Convert to 0-1 range
const barHeight = Math.max( const barHeight = Math.max(
AUDIO_CONFIG.MIN_BAR_HEIGHT, AUDIO_CONFIG.MIN_BAR_HEIGHT,
normalizedHeight * centerY normalizedHeight * centerY
) );
drawBar( drawBar(
ctx, ctx,
@ -177,14 +177,14 @@ export function AudioVisualizer({
barWidth, barWidth,
barHeight, barHeight,
getBarColor(normalizedHeight) getBarColor(normalizedHeight)
) );
x += barWidth + AUDIO_CONFIG.BAR_SPACING x += barWidth + AUDIO_CONFIG.BAR_SPACING;
}
} }
};
drawFrame() drawFrame();
} };
return ( return (
<div <div
@ -194,5 +194,5 @@ export function AudioVisualizer({
> >
<canvas ref={canvasRef} className="h-full w-full" /> <canvas ref={canvasRef} className="h-full w-full" />
</div> </div>
) );
} }

View file

@ -14,7 +14,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
data-slot="breadcrumb-list" data-slot="breadcrumb-list"
className={cn( className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className, className
)} )}
{...props} {...props}
/> />

View file

@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@ -33,7 +33,7 @@ const buttonVariants = cva(
size: "default", size: "default",
}, },
} }
) );
function Button({ function Button({
className, className,
@ -43,9 +43,9 @@ function Button({
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@ -53,7 +53,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

View file

@ -8,7 +8,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className, className
)} )}
{...props} {...props}
/> />
@ -21,7 +21,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className, className
)} )}
{...props} {...props}
/> />
@ -54,7 +54,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className, className
)} )}
{...props} {...props}
/> />

View file

@ -1,11 +1,11 @@
"use client" "use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({ function Collapsible({
...props ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) { }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} /> return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
} }
function CollapsibleTrigger({ function CollapsibleTrigger({
@ -16,7 +16,7 @@ function CollapsibleTrigger({
data-slot="collapsible-trigger" data-slot="collapsible-trigger"
{...props} {...props}
/> />
) );
} }
function CollapsibleContent({ function CollapsibleContent({
@ -27,7 +27,7 @@ function CollapsibleContent({
data-slot="collapsible-content" data-slot="collapsible-content"
{...props} {...props}
/> />
) );
} }
export { Collapsible, CollapsibleTrigger, CollapsibleContent } export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -1,21 +1,21 @@
"use client" "use client";
import { Check, Copy } from "lucide-react" import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
type CopyButtonProps = { type CopyButtonProps = {
content: string content: string;
copyMessage?: string copyMessage?: string;
} };
export function CopyButton({ content, copyMessage }: CopyButtonProps) { export function CopyButton({ content, copyMessage }: CopyButtonProps) {
const { isCopied, handleCopy } = useCopyToClipboard({ const { isCopied, handleCopy } = useCopyToClipboard({
text: content, text: content,
copyMessage, copyMessage,
}) });
return ( return (
<Button <Button
@ -40,5 +40,5 @@ export function CopyButton({ content, copyMessage }: CopyButtonProps) {
)} )}
/> />
</Button> </Button>
) );
} }

View file

@ -43,7 +43,7 @@ function DropdownMenuContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className, className
)} )}
{...props} {...props}
/> />
@ -75,7 +75,7 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className
)} )}
{...props} {...props}
/> />
@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className
)} )}
checked={checked} checked={checked}
{...props} {...props}
@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className
)} )}
{...props} {...props}
> >
@ -156,7 +156,7 @@ function DropdownMenuLabel({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className, className
)} )}
{...props} {...props}
/> />
@ -185,7 +185,7 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest", "text-muted-foreground ml-auto text-xs tracking-widest",
className, className
)} )}
{...props} {...props}
/> />
@ -212,7 +212,7 @@ function DropdownMenuSubTrigger({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className, className
)} )}
{...props} {...props}
> >
@ -231,7 +231,7 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className, className
)} )}
{...props} {...props}
/> />

View file

@ -1,18 +1,18 @@
"use client" "use client";
import React, { useEffect } from "react" import React, { useEffect } from "react";
import { motion } from "framer-motion" import { motion } from "framer-motion";
import { FileIcon, X } from "lucide-react" import { FileIcon, X } from "lucide-react";
interface FilePreviewProps { interface FilePreviewProps {
file: File file: File;
onRemove?: () => void onRemove?: () => void;
} }
export const FilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>( export const FilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
(props, ref) => { (props, ref) => {
if (props.file.type.startsWith("image/")) { if (props.file.type.startsWith("image/")) {
return <ImageFilePreview {...props} ref={ref} /> return <ImageFilePreview {...props} ref={ref} />;
} }
if ( if (
@ -20,13 +20,13 @@ export const FilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
props.file.name.endsWith(".txt") || props.file.name.endsWith(".txt") ||
props.file.name.endsWith(".md") props.file.name.endsWith(".md")
) { ) {
return <TextFilePreview {...props} ref={ref} /> return <TextFilePreview {...props} ref={ref} />;
} }
return <GenericFilePreview {...props} ref={ref} /> return <GenericFilePreview {...props} ref={ref} />;
} }
) );
FilePreview.displayName = "FilePreview" FilePreview.displayName = "FilePreview";
const ImageFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>( const ImageFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
({ file, onRemove }, ref) => { ({ file, onRemove }, ref) => {
@ -62,23 +62,23 @@ const ImageFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
</button> </button>
) : null} ) : null}
</motion.div> </motion.div>
) );
} }
) );
ImageFilePreview.displayName = "ImageFilePreview" ImageFilePreview.displayName = "ImageFilePreview";
const TextFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>( const TextFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
({ file, onRemove }, ref) => { ({ file, onRemove }, ref) => {
const [preview, setPreview] = React.useState<string>("") const [preview, setPreview] = React.useState<string>("");
useEffect(() => { useEffect(() => {
const reader = new FileReader() const reader = new FileReader();
reader.onload = (e) => { reader.onload = e => {
const text = e.target?.result as string const text = e.target?.result as string;
setPreview(text.slice(0, 50) + (text.length > 50 ? "..." : "")) setPreview(text.slice(0, 50) + (text.length > 50 ? "..." : ""));
} };
reader.readAsText(file) reader.readAsText(file);
}, [file]) }, [file]);
return ( return (
<motion.div <motion.div
@ -111,10 +111,10 @@ const TextFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
</button> </button>
) : null} ) : null}
</motion.div> </motion.div>
) );
} }
) );
TextFilePreview.displayName = "TextFilePreview" TextFilePreview.displayName = "TextFilePreview";
const GenericFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>( const GenericFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
({ file, onRemove }, ref) => { ({ file, onRemove }, ref) => {
@ -147,7 +147,7 @@ const GenericFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
</button> </button>
) : null} ) : null}
</motion.div> </motion.div>
) );
} }
) );
GenericFilePreview.displayName = "GenericFilePreview" GenericFilePreview.displayName = "GenericFilePreview";

View file

@ -11,7 +11,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className, className
)} )}
{...props} {...props}
/> />

View file

@ -1,27 +1,27 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
@ -30,7 +30,7 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@ -47,7 +47,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
@ -82,7 +82,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@ -95,7 +95,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@ -119,7 +119,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@ -132,7 +132,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@ -150,7 +150,7 @@ function SelectScrollUpButton({
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@ -168,7 +168,7 @@ function SelectScrollDownButton({
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@ -182,4 +182,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };

View file

@ -18,7 +18,7 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className, className
)} )}
{...props} {...props}
/> />

View file

@ -37,7 +37,7 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className, className
)} )}
{...props} {...props}
/> />
@ -67,7 +67,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className, className
)} )}
{...props} {...props}
> >

View file

@ -85,12 +85,12 @@ function SidebarProvider({
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open], [setOpenProp, open]
); );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
}, [isMobile, setOpen, setOpenMobile]); }, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
@ -123,7 +123,7 @@ function SidebarProvider({
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
); );
return ( return (
@ -140,7 +140,7 @@ function SidebarProvider({
} }
className={cn( className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className, className
)} )}
{...props} {...props}
> >
@ -171,7 +171,7 @@ function Sidebar({
data-slot="sidebar" data-slot="sidebar"
className={cn( className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className, className
)} )}
{...props} {...props}
> >
@ -223,7 +223,7 @@ function Sidebar({
"group-data-[side=right]:rotate-180", "group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)} )}
/> />
<div <div
@ -237,7 +237,7 @@ function Sidebar({
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className, className
)} )}
{...props} {...props}
> >
@ -267,7 +267,7 @@ function SidebarTrigger({
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn("size-7", className)} className={cn("size-7", className)}
onClick={(event) => { onClick={event => {
onClick?.(event); onClick?.(event);
toggleSidebar(); toggleSidebar();
}} }}
@ -297,7 +297,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className, className
)} )}
{...props} {...props}
/> />
@ -311,7 +311,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
className={cn( className={cn(
"bg-background relative flex w-full flex-1 flex-col", "bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className, className
)} )}
{...props} {...props}
/> />
@ -375,7 +375,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className, className
)} )}
{...props} {...props}
/> />
@ -407,7 +407,7 @@ function SidebarGroupLabel({
className={cn( className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className, className
)} )}
{...props} {...props}
/> />
@ -430,7 +430,7 @@ function SidebarGroupAction({
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden", "after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className, className
)} )}
{...props} {...props}
/> />
@ -492,7 +492,7 @@ const sidebarMenuButtonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
}, }
); );
function SidebarMenuButton({ function SidebarMenuButton({
@ -570,7 +570,7 @@ function SidebarMenuAction({
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
showOnHover && showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className, className
)} )}
{...props} {...props}
/> />
@ -592,7 +592,7 @@ function SidebarMenuBadge({
"peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className, className
)} )}
{...props} {...props}
/> />
@ -645,7 +645,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
className={cn( className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className, className
)} )}
{...props} {...props}
/> />
@ -691,7 +691,7 @@ function SidebarMenuSubButton({
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "md" && "text-sm", size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className, className
)} )}
{...props} {...props}
/> />

View file

@ -1,10 +1,10 @@
"use client" "use client";
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner" import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
} }
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };

View file

@ -45,7 +45,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className, className
)} )}
{...props} {...props}
/> />
@ -58,7 +58,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className, className
)} )}
{...props} {...props}
/> />
@ -71,7 +71,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className, className
)} )}
{...props} {...props}
/> />
@ -84,7 +84,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className, className
)} )}
{...props} {...props}
/> />

View file

@ -47,7 +47,7 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className, className
)} )}
{...props} {...props}
> >

View file

@ -85,7 +85,7 @@ export function VectorStoreDetailView({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{files.map((file) => ( {files.map(file => (
<TableRow key={file.id}> <TableRow key={file.id}>
<TableCell> <TableCell>
<Button <Button

View file

@ -45,7 +45,7 @@ test.describe("LogsTable Scroll and Progressive Loading", () => {
const scrollContainer = page.locator("div.overflow-auto").first(); const scrollContainer = page.locator("div.overflow-auto").first();
// Scroll to near the bottom // Scroll to near the bottom
await scrollContainer.evaluate((element) => { await scrollContainer.evaluate(element => {
element.scrollTop = element.scrollHeight - element.clientHeight - 100; element.scrollTop = element.scrollHeight - element.clientHeight - 100;
}); });

View file

@ -10,7 +10,13 @@ const compat = new FlatCompat({
}); });
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends("next/core-web-vitals", "next/typescript", "prettier"),
...compat.plugins("prettier"),
{
rules: {
"prettier/prettier": "error",
},
},
]; ];
export default eslintConfig; export default eslintConfig;

View file

@ -1,85 +1,85 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react";
import { recordAudio } from "@/lib/audio-utils" import { recordAudio } from "@/lib/audio-utils";
interface UseAudioRecordingOptions { interface UseAudioRecordingOptions {
transcribeAudio?: (blob: Blob) => Promise<string> transcribeAudio?: (blob: Blob) => Promise<string>;
onTranscriptionComplete?: (text: string) => void onTranscriptionComplete?: (text: string) => void;
} }
export function useAudioRecording({ export function useAudioRecording({
transcribeAudio, transcribeAudio,
onTranscriptionComplete, onTranscriptionComplete,
}: UseAudioRecordingOptions) { }: UseAudioRecordingOptions) {
const [isListening, setIsListening] = useState(false) const [isListening, setIsListening] = useState(false);
const [isSpeechSupported, setIsSpeechSupported] = useState(!!transcribeAudio) const [isSpeechSupported, setIsSpeechSupported] = useState(!!transcribeAudio);
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false) const [isTranscribing, setIsTranscribing] = useState(false);
const [audioStream, setAudioStream] = useState<MediaStream | null>(null) const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
const activeRecordingRef = useRef<any>(null) const activeRecordingRef = useRef<any>(null);
useEffect(() => { useEffect(() => {
const checkSpeechSupport = async () => { const checkSpeechSupport = async () => {
const hasMediaDevices = !!( const hasMediaDevices = !!(
navigator.mediaDevices && navigator.mediaDevices.getUserMedia navigator.mediaDevices && navigator.mediaDevices.getUserMedia
) );
setIsSpeechSupported(hasMediaDevices && !!transcribeAudio) setIsSpeechSupported(hasMediaDevices && !!transcribeAudio);
} };
checkSpeechSupport() checkSpeechSupport();
}, [transcribeAudio]) }, [transcribeAudio]);
const stopRecording = async () => { const stopRecording = async () => {
setIsRecording(false) setIsRecording(false);
setIsTranscribing(true) setIsTranscribing(true);
try { try {
// First stop the recording to get the final blob // First stop the recording to get the final blob
recordAudio.stop() recordAudio.stop();
// Wait for the recording promise to resolve with the final blob // Wait for the recording promise to resolve with the final blob
const recording = await activeRecordingRef.current const recording = await activeRecordingRef.current;
if (transcribeAudio) { if (transcribeAudio) {
const text = await transcribeAudio(recording) const text = await transcribeAudio(recording);
onTranscriptionComplete?.(text) onTranscriptionComplete?.(text);
} }
} catch (error) { } catch (error) {
console.error("Error transcribing audio:", error) console.error("Error transcribing audio:", error);
} finally { } finally {
setIsTranscribing(false) setIsTranscribing(false);
setIsListening(false) setIsListening(false);
if (audioStream) { if (audioStream) {
audioStream.getTracks().forEach((track) => track.stop()) audioStream.getTracks().forEach(track => track.stop());
setAudioStream(null) setAudioStream(null);
}
activeRecordingRef.current = null
} }
activeRecordingRef.current = null;
} }
};
const toggleListening = async () => { const toggleListening = async () => {
if (!isListening) { if (!isListening) {
try { try {
setIsListening(true) setIsListening(true);
setIsRecording(true) setIsRecording(true);
// Get audio stream first // Get audio stream first
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
audio: true, audio: true,
}) });
setAudioStream(stream) setAudioStream(stream);
// Start recording with the stream // Start recording with the stream
activeRecordingRef.current = recordAudio(stream) activeRecordingRef.current = recordAudio(stream);
} catch (error) { } catch (error) {
console.error("Error recording audio:", error) console.error("Error recording audio:", error);
setIsListening(false) setIsListening(false);
setIsRecording(false) setIsRecording(false);
if (audioStream) { if (audioStream) {
audioStream.getTracks().forEach((track) => track.stop()) audioStream.getTracks().forEach(track => track.stop());
setAudioStream(null) setAudioStream(null);
} }
} }
} else { } else {
await stopRecording() await stopRecording();
}
} }
};
return { return {
isListening, isListening,
@ -89,5 +89,5 @@ export function useAudioRecording({
audioStream, audioStream,
toggleListening, toggleListening,
stopRecording, stopRecording,
} };
} }

View file

@ -1,67 +1,67 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react";
// How many pixels from the bottom of the container to enable auto-scroll // How many pixels from the bottom of the container to enable auto-scroll
const ACTIVATION_THRESHOLD = 50 const ACTIVATION_THRESHOLD = 50;
// Minimum pixels of scroll-up movement required to disable auto-scroll // Minimum pixels of scroll-up movement required to disable auto-scroll
const MIN_SCROLL_UP_THRESHOLD = 10 const MIN_SCROLL_UP_THRESHOLD = 10;
export function useAutoScroll(dependencies: React.DependencyList) { export function useAutoScroll(dependencies: React.DependencyList) {
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null);
const previousScrollTop = useRef<number | null>(null) const previousScrollTop = useRef<number | null>(null);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true) const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
const scrollToBottom = () => { const scrollToBottom = () => {
if (containerRef.current) { if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
} }
};
const handleScroll = () => { const handleScroll = () => {
if (containerRef.current) { if (containerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = containerRef.current const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const distanceFromBottom = Math.abs( const distanceFromBottom = Math.abs(
scrollHeight - scrollTop - clientHeight scrollHeight - scrollTop - clientHeight
) );
const isScrollingUp = previousScrollTop.current const isScrollingUp = previousScrollTop.current
? scrollTop < previousScrollTop.current ? scrollTop < previousScrollTop.current
: false : false;
const scrollUpDistance = previousScrollTop.current const scrollUpDistance = previousScrollTop.current
? previousScrollTop.current - scrollTop ? previousScrollTop.current - scrollTop
: 0 : 0;
const isDeliberateScrollUp = const isDeliberateScrollUp =
isScrollingUp && scrollUpDistance > MIN_SCROLL_UP_THRESHOLD isScrollingUp && scrollUpDistance > MIN_SCROLL_UP_THRESHOLD;
if (isDeliberateScrollUp) { if (isDeliberateScrollUp) {
setShouldAutoScroll(false) setShouldAutoScroll(false);
} else { } else {
const isScrolledToBottom = distanceFromBottom < ACTIVATION_THRESHOLD const isScrolledToBottom = distanceFromBottom < ACTIVATION_THRESHOLD;
setShouldAutoScroll(isScrolledToBottom) setShouldAutoScroll(isScrolledToBottom);
} }
previousScrollTop.current = scrollTop previousScrollTop.current = scrollTop;
}
} }
};
const handleTouchStart = () => { const handleTouchStart = () => {
setShouldAutoScroll(false) setShouldAutoScroll(false);
} };
useEffect(() => { useEffect(() => {
if (containerRef.current) { if (containerRef.current) {
previousScrollTop.current = containerRef.current.scrollTop previousScrollTop.current = containerRef.current.scrollTop;
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
if (shouldAutoScroll) { if (shouldAutoScroll) {
scrollToBottom() scrollToBottom();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies) }, dependencies);
return { return {
containerRef, containerRef,
@ -69,5 +69,5 @@ export function useAutoScroll(dependencies: React.DependencyList) {
handleScroll, handleScroll,
shouldAutoScroll, shouldAutoScroll,
handleTouchStart, handleTouchStart,
} };
} }

View file

@ -1,10 +1,10 @@
import { useLayoutEffect, useRef } from "react" import { useLayoutEffect, useRef } from "react";
interface UseAutosizeTextAreaProps { interface UseAutosizeTextAreaProps {
ref: React.RefObject<HTMLTextAreaElement | null> ref: React.RefObject<HTMLTextAreaElement | null>;
maxHeight?: number maxHeight?: number;
borderWidth?: number borderWidth?: number;
dependencies: React.DependencyList dependencies: React.DependencyList;
} }
export function useAutosizeTextArea({ export function useAutosizeTextArea({
@ -13,27 +13,27 @@ export function useAutosizeTextArea({
borderWidth = 0, borderWidth = 0,
dependencies, dependencies,
}: UseAutosizeTextAreaProps) { }: UseAutosizeTextAreaProps) {
const originalHeight = useRef<number | null>(null) const originalHeight = useRef<number | null>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!ref.current) return if (!ref.current) return;
const currentRef = ref.current const currentRef = ref.current;
const borderAdjustment = borderWidth * 2 const borderAdjustment = borderWidth * 2;
if (originalHeight.current === null) { if (originalHeight.current === null) {
originalHeight.current = currentRef.scrollHeight - borderAdjustment originalHeight.current = currentRef.scrollHeight - borderAdjustment;
} }
currentRef.style.removeProperty("height") currentRef.style.removeProperty("height");
const scrollHeight = currentRef.scrollHeight const scrollHeight = currentRef.scrollHeight;
// Make sure we don't go over maxHeight // Make sure we don't go over maxHeight
const clampedToMax = Math.min(scrollHeight, maxHeight) const clampedToMax = Math.min(scrollHeight, maxHeight);
// Make sure we don't go less than the original height // Make sure we don't go less than the original height
const clampedToMin = Math.max(clampedToMax, originalHeight.current) const clampedToMin = Math.max(clampedToMax, originalHeight.current);
currentRef.style.height = `${clampedToMin + borderAdjustment}px` currentRef.style.height = `${clampedToMin + borderAdjustment}px`;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxHeight, ref, ...dependencies]) }, [maxHeight, ref, ...dependencies]);
} }

View file

@ -1,36 +1,36 @@
import { useCallback, useRef, useState } from "react" import { useCallback, useRef, useState } from "react";
import { toast } from "sonner" import { toast } from "sonner";
type UseCopyToClipboardProps = { type UseCopyToClipboardProps = {
text: string text: string;
copyMessage?: string copyMessage?: string;
} };
export function useCopyToClipboard({ export function useCopyToClipboard({
text, text,
copyMessage = "Copied to clipboard!", copyMessage = "Copied to clipboard!",
}: UseCopyToClipboardProps) { }: UseCopyToClipboardProps) {
const [isCopied, setIsCopied] = useState(false) const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null) const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleCopy = useCallback(() => { const handleCopy = useCallback(() => {
navigator.clipboard navigator.clipboard
.writeText(text) .writeText(text)
.then(() => { .then(() => {
toast.success(copyMessage) toast.success(copyMessage);
setIsCopied(true) setIsCopied(true);
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = null timeoutRef.current = null;
} }
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
setIsCopied(false) setIsCopied(false);
}, 2000) }, 2000);
}) })
.catch(() => { .catch(() => {
toast.error("Failed to copy to clipboard.") toast.error("Failed to copy to clipboard.");
}) });
}, [text, copyMessage]) }, [text, copyMessage]);
return { isCopied, handleCopy } return { isCopied, handleCopy };
} }

View file

@ -20,7 +20,7 @@ interface UseInfiniteScrollOptions {
*/ */
export function useInfiniteScroll( export function useInfiniteScroll(
onLoadMore: (() => void) | undefined, onLoadMore: (() => void) | undefined,
options: UseInfiniteScrollOptions = {}, options: UseInfiniteScrollOptions = {}
) { ) {
const { enabled = true, threshold = 0.1, rootMargin = "100px" } = options; const { enabled = true, threshold = 0.1, rootMargin = "100px" } = options;
const sentinelRef = useRef<HTMLTableRowElement>(null); const sentinelRef = useRef<HTMLTableRowElement>(null);
@ -29,7 +29,7 @@ export function useInfiniteScroll(
if (!onLoadMore || !enabled) return; if (!onLoadMore || !enabled) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { entries => {
const [entry] = entries; const [entry] = entries;
if (entry.isIntersecting) { if (entry.isIntersecting) {
onLoadMore(); onLoadMore();
@ -38,7 +38,7 @@ export function useInfiniteScroll(
{ {
threshold, threshold,
rootMargin, rootMargin,
}, }
); );
const sentinel = sentinelRef.current; const sentinel = sentinelRef.current;

View file

@ -4,7 +4,7 @@ const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>( const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined, undefined
); );
React.useEffect(() => { React.useEffect(() => {

View file

@ -38,7 +38,7 @@ interface UsePaginationParams<T> extends UsePaginationOptions {
limit: number; limit: number;
model?: string; model?: string;
order?: string; order?: string;
}, }
) => Promise<PaginationResponse<T>>; ) => Promise<PaginationResponse<T>>;
errorMessagePrefix: string; errorMessagePrefix: string;
enabled?: boolean; enabled?: boolean;
@ -81,7 +81,7 @@ export function usePagination<T>({
const fetchLimit = targetRows || limit; const fetchLimit = targetRows || limit;
try { try {
setState((prev) => ({ setState(prev => ({
...prev, ...prev,
status: isInitialLoad ? "loading" : "loading-more", status: isInitialLoad ? "loading" : "loading-more",
error: null, error: null,
@ -94,7 +94,7 @@ export function usePagination<T>({
...(order && { order }), ...(order && { order }),
}); });
setState((prev) => ({ setState(prev => ({
...prev, ...prev,
data: isInitialLoad data: isInitialLoad
? response.data ? response.data
@ -124,14 +124,14 @@ export function usePagination<T>({
? new Error(`${errorMessage} ${err.message}`) ? new Error(`${errorMessage} ${err.message}`)
: new Error(errorMessage); : new Error(errorMessage);
setState((prev) => ({ setState(prev => ({
...prev, ...prev,
error, error,
status: "error", status: "error",
})); }));
} }
}, },
[limit, model, order, fetchFunction, errorMessagePrefix, client, router], [limit, model, order, fetchFunction, errorMessagePrefix, client, router]
); );
/** /**

View file

@ -1,50 +1,50 @@
type RecordAudioType = { type RecordAudioType = {
(stream: MediaStream): Promise<Blob> (stream: MediaStream): Promise<Blob>;
stop: () => void stop: () => void;
currentRecorder?: MediaRecorder currentRecorder?: MediaRecorder;
} };
export const recordAudio = (function (): RecordAudioType { export const recordAudio = (function (): RecordAudioType {
const func = async function recordAudio(stream: MediaStream): Promise<Blob> { const func = async function recordAudio(stream: MediaStream): Promise<Blob> {
try { try {
const mediaRecorder = new MediaRecorder(stream, { const mediaRecorder = new MediaRecorder(stream, {
mimeType: "audio/webm;codecs=opus", mimeType: "audio/webm;codecs=opus",
}) });
const audioChunks: Blob[] = [] const audioChunks: Blob[] = [];
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
mediaRecorder.ondataavailable = (event) => { mediaRecorder.ondataavailable = event => {
if (event.data.size > 0) { if (event.data.size > 0) {
audioChunks.push(event.data) audioChunks.push(event.data);
}
} }
};
mediaRecorder.onstop = () => { mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunks, { type: "audio/webm" }) const audioBlob = new Blob(audioChunks, { type: "audio/webm" });
resolve(audioBlob) resolve(audioBlob);
} };
mediaRecorder.onerror = () => { mediaRecorder.onerror = () => {
reject(new Error("MediaRecorder error occurred")) reject(new Error("MediaRecorder error occurred"));
} };
mediaRecorder.start(1000) mediaRecorder.start(1000);
;(func as RecordAudioType).currentRecorder = mediaRecorder (func as RecordAudioType).currentRecorder = mediaRecorder;
}) });
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred" error instanceof Error ? error.message : "Unknown error occurred";
throw new Error("Failed to start recording: " + errorMessage) throw new Error("Failed to start recording: " + errorMessage);
}
} }
};
;(func as RecordAudioType).stop = () => { (func as RecordAudioType).stop = () => {
const recorder = (func as RecordAudioType).currentRecorder const recorder = (func as RecordAudioType).currentRecorder;
if (recorder && recorder.state !== "inactive") { if (recorder && recorder.state !== "inactive") {
recorder.stop() recorder.stop();
}
delete (func as RecordAudioType).currentRecorder
} }
delete (func as RecordAudioType).currentRecorder;
};
return func as RecordAudioType return func as RecordAudioType;
})() })();

View file

@ -27,19 +27,19 @@ export function validateServerConfig() {
!optionalConfigs.GITHUB_CLIENT_SECRET !optionalConfigs.GITHUB_CLIENT_SECRET
) { ) {
console.log( console.log(
"\n📝 GitHub OAuth not configured (authentication features disabled)", "\n📝 GitHub OAuth not configured (authentication features disabled)"
); );
console.log(" To enable GitHub OAuth:"); console.log(" To enable GitHub OAuth:");
console.log(" 1. Go to https://github.com/settings/applications/new"); console.log(" 1. Go to https://github.com/settings/applications/new");
console.log( console.log(
" 2. Set Application name: Llama Stack UI (or your preferred name)", " 2. Set Application name: Llama Stack UI (or your preferred name)"
); );
console.log(" 3. Set Homepage URL: http://localhost:8322"); console.log(" 3. Set Homepage URL: http://localhost:8322");
console.log( console.log(
" 4. Set Authorization callback URL: http://localhost:8322/api/auth/callback/github", " 4. Set Authorization callback URL: http://localhost:8322/api/auth/callback/github"
); );
console.log( console.log(
" 5. Create the app and copy the Client ID and Client Secret", " 5. Create the app and copy the Client ID and Client Secret"
); );
console.log(" 6. Add them to your .env.local file:"); console.log(" 6. Add them to your .env.local file:");
console.log(" GITHUB_CLIENT_ID=your_client_id"); console.log(" GITHUB_CLIENT_ID=your_client_id");

View file

@ -11,7 +11,7 @@ export interface VectorStoreContentItem {
vector_store_id: string; vector_store_id: string;
file_id: string; file_id: string;
content: VectorStoreContent; content: VectorStoreContent;
metadata: Record<string, any>; metadata: Record<string, unknown>;
embedding?: number[]; embedding?: number[];
} }
@ -32,11 +32,18 @@ export interface VectorStoreListContentsResponse {
export class ContentsAPI { export class ContentsAPI {
constructor(private client: LlamaStackClient) {} constructor(private client: LlamaStackClient) {}
async getFileContents(vectorStoreId: string, fileId: string): Promise<VectorStoreContentsResponse> { async getFileContents(
vectorStoreId: string,
fileId: string
): Promise<VectorStoreContentsResponse> {
return this.client.vectorStores.files.content(vectorStoreId, fileId); return this.client.vectorStores.files.content(vectorStoreId, fileId);
} }
async getContent(vectorStoreId: string, fileId: string, contentId: string): Promise<VectorStoreContentItem> { async getContent(
vectorStoreId: string,
fileId: string,
contentId: string
): Promise<VectorStoreContentItem> {
const contentsResponse = await this.listContents(vectorStoreId, fileId); const contentsResponse = await this.listContents(vectorStoreId, fileId);
const targetContent = contentsResponse.data.find(c => c.id === contentId); const targetContent = contentsResponse.data.find(c => c.id === contentId);
@ -47,16 +54,11 @@ export class ContentsAPI {
return targetContent; return targetContent;
} }
async updateContent( async updateContent(): Promise<VectorStoreContentItem> {
vectorStoreId: string,
fileId: string,
contentId: string,
updates: { content?: string; metadata?: Record<string, any> }
): Promise<VectorStoreContentItem> {
throw new Error("Individual content updates not yet implemented in API"); throw new Error("Individual content updates not yet implemented in API");
} }
async deleteContent(vectorStoreId: string, fileId: string, contentId: string): Promise<VectorStoreContentDeleteResponse> { async deleteContent(): Promise<VectorStoreContentDeleteResponse> {
throw new Error("Individual content deletion not yet implemented in API"); throw new Error("Individual content deletion not yet implemented in API");
} }
@ -70,18 +72,27 @@ export class ContentsAPI {
before?: string; before?: string;
} }
): Promise<VectorStoreListContentsResponse> { ): Promise<VectorStoreListContentsResponse> {
const fileContents = await this.client.vectorStores.files.content(vectorStoreId, fileId); const fileContents = await this.client.vectorStores.files.content(
vectorStoreId,
fileId
);
const contentItems: VectorStoreContentItem[] = []; const contentItems: VectorStoreContentItem[] = [];
fileContents.content.forEach((content, contentIndex) => { fileContents.content.forEach((content, contentIndex) => {
const rawContent = content as any; const rawContent = content as Record<string, unknown>;
// Extract actual fields from the API response // Extract actual fields from the API response
const embedding = rawContent.embedding || undefined; const embedding = rawContent.embedding || undefined;
const created_timestamp = rawContent.created_timestamp || rawContent.created_at || Date.now() / 1000; const created_timestamp =
rawContent.created_timestamp ||
rawContent.created_at ||
Date.now() / 1000;
const chunkMetadata = rawContent.chunk_metadata || {}; const chunkMetadata = rawContent.chunk_metadata || {};
const contentId = rawContent.chunk_metadata?.chunk_id || rawContent.id || `content_${fileId}_${contentIndex}`; const contentId =
const objectType = rawContent.object || 'vector_store.file.content'; rawContent.chunk_metadata?.chunk_id ||
rawContent.id ||
`content_${fileId}_${contentIndex}`;
const objectType = rawContent.object || "vector_store.file.content";
contentItems.push({ contentItems.push({
id: contentId, id: contentId,
object: objectType, object: objectType,
@ -92,7 +103,7 @@ export class ContentsAPI {
embedding: embedding, embedding: embedding,
metadata: { metadata: {
...chunkMetadata, // chunk_metadata fields from API ...chunkMetadata, // chunk_metadata fields from API
content_length: content.type === 'text' ? content.text.length : 0, content_length: content.type === "text" ? content.text.length : 0,
}, },
}); });
}); });
@ -104,7 +115,7 @@ export class ContentsAPI {
} }
return { return {
object: 'list', object: "list",
data: filteredItems, data: filteredItems,
has_more: contentItems.length > (options?.limit || contentItems.length), has_more: contentItems.length > (options?.limit || contentItems.length),
}; };

View file

@ -18,7 +18,7 @@ describe("extractTextFromContentPart", () => {
it("should extract text from an array of text content objects", () => { it("should extract text from an array of text content objects", () => {
const content = [{ type: "text", text: "Which planet do humans live on?" }]; const content = [{ type: "text", text: "Which planet do humans live on?" }];
expect(extractTextFromContentPart(content)).toBe( expect(extractTextFromContentPart(content)).toBe(
"Which planet do humans live on?", "Which planet do humans live on?"
); );
}); });
@ -37,7 +37,7 @@ describe("extractTextFromContentPart", () => {
{ type: "text", text: "It's an image." }, { type: "text", text: "It's an image." },
]; ];
expect(extractTextFromContentPart(content)).toBe( expect(extractTextFromContentPart(content)).toBe(
"Look at this: [Image] It's an image.", "Look at this: [Image] It's an image."
); );
}); });
@ -53,7 +53,7 @@ describe("extractTextFromContentPart", () => {
}); });
it("should handle arrays with plain strings", () => { it("should handle arrays with plain strings", () => {
const content = ["This is", " a test."] as any; const content = ["This is", " a test."] as unknown;
expect(extractTextFromContentPart(content)).toBe("This is a test."); expect(extractTextFromContentPart(content)).toBe("This is a test.");
}); });
@ -65,7 +65,7 @@ describe("extractTextFromContentPart", () => {
null, null,
undefined, undefined,
{ type: "text", noTextProperty: true }, { type: "text", noTextProperty: true },
] as any; ] as unknown;
expect(extractTextFromContentPart(content)).toBe("Valid"); expect(extractTextFromContentPart(content)).toBe("Valid");
}); });
@ -75,15 +75,17 @@ describe("extractTextFromContentPart", () => {
"Just a string.", "Just a string.",
{ type: "image_url", image_url: { url: "http://example.com/image.png" } }, { type: "image_url", image_url: { url: "http://example.com/image.png" } },
{ type: "text", text: "Last part." }, { type: "text", text: "Last part." },
] as any; ] as unknown;
expect(extractTextFromContentPart(content)).toBe( expect(extractTextFromContentPart(content)).toBe(
"First part. Just a string. [Image] Last part.", "First part. Just a string. [Image] Last part."
); );
}); });
}); });
describe("extractDisplayableText (composite function)", () => { describe("extractDisplayableText (composite function)", () => {
const mockFormatToolCallToString = (toolCall: any) => { const mockFormatToolCallToString = (toolCall: {
function?: { name?: string; arguments?: unknown };
}) => {
if (!toolCall || !toolCall.function || !toolCall.function.name) return ""; if (!toolCall || !toolCall.function || !toolCall.function.name) return "";
const args = toolCall.function.arguments const args = toolCall.function.arguments
? JSON.stringify(toolCall.function.arguments) ? JSON.stringify(toolCall.function.arguments)
@ -125,7 +127,7 @@ describe("extractDisplayableText (composite function)", () => {
tool_calls: [toolCall], tool_calls: [toolCall],
}; };
expect(extractDisplayableText(messageWithEffectivelyEmptyContent)).toBe( expect(extractDisplayableText(messageWithEffectivelyEmptyContent)).toBe(
mockFormatToolCallToString(toolCall), mockFormatToolCallToString(toolCall)
); );
const messageWithEmptyContent: ChatMessage = { const messageWithEmptyContent: ChatMessage = {
@ -134,7 +136,7 @@ describe("extractDisplayableText (composite function)", () => {
tool_calls: [toolCall], tool_calls: [toolCall],
}; };
expect(extractDisplayableText(messageWithEmptyContent)).toBe( expect(extractDisplayableText(messageWithEmptyContent)).toBe(
mockFormatToolCallToString(toolCall), mockFormatToolCallToString(toolCall)
); );
}); });
@ -149,7 +151,7 @@ describe("extractDisplayableText (composite function)", () => {
}; };
const expectedToolCallStr = mockFormatToolCallToString(toolCall); const expectedToolCallStr = mockFormatToolCallToString(toolCall);
expect(extractDisplayableText(message)).toBe( expect(extractDisplayableText(message)).toBe(
`The result is: ${expectedToolCallStr}`, `The result is: ${expectedToolCallStr}`
); );
}); });
@ -167,7 +169,7 @@ describe("extractDisplayableText (composite function)", () => {
}; };
const expectedToolCallStr = mockFormatToolCallToString(toolCall); const expectedToolCallStr = mockFormatToolCallToString(toolCall);
expect(extractDisplayableText(message)).toBe( expect(extractDisplayableText(message)).toBe(
`Okay, checking weather for London. ${expectedToolCallStr}`, `Okay, checking weather for London. ${expectedToolCallStr}`
); );
}); });
@ -178,7 +180,7 @@ describe("extractDisplayableText (composite function)", () => {
tool_calls: [], tool_calls: [],
}; };
expect(extractDisplayableText(messageEmptyToolCalls)).toBe( expect(extractDisplayableText(messageEmptyToolCalls)).toBe(
"No tools here.", "No tools here."
); );
const messageUndefinedToolCalls: ChatMessage = { const messageUndefinedToolCalls: ChatMessage = {
@ -187,7 +189,7 @@ describe("extractDisplayableText (composite function)", () => {
tool_calls: undefined, tool_calls: undefined,
}; };
expect(extractDisplayableText(messageUndefinedToolCalls)).toBe( expect(extractDisplayableText(messageUndefinedToolCalls)).toBe(
"Still no tools.", "Still no tools."
); );
}); });
}); });

View file

@ -2,7 +2,7 @@ import { ChatMessage, ChatMessageContentPart } from "@/lib/types";
import { formatToolCallToString } from "@/lib/format-tool-call"; import { formatToolCallToString } from "@/lib/format-tool-call";
export function extractTextFromContentPart( export function extractTextFromContentPart(
content: string | ChatMessageContentPart[] | null | undefined, content: string | ChatMessageContentPart[] | null | undefined
): string { ): string {
if (content === null || content === undefined) { if (content === null || content === undefined) {
return ""; return "";
@ -37,7 +37,7 @@ export function extractTextFromContentPart(
} }
export function extractDisplayableText( export function extractDisplayableText(
message: ChatMessage | undefined | null, message: ChatMessage | undefined | null
): string { ): string {
if (!message) { if (!message) {
return ""; return "";

View file

@ -5,7 +5,9 @@
* with `name` and `arguments`. * with `name` and `arguments`.
* @returns A formatted string or an empty string if data is malformed. * @returns A formatted string or an empty string if data is malformed.
*/ */
export function formatToolCallToString(toolCall: any): string { export function formatToolCallToString(toolCall: {
function?: { name?: string; arguments?: unknown };
}): string {
if ( if (
!toolCall || !toolCall ||
!toolCall.function || !toolCall.function ||
@ -24,7 +26,7 @@ export function formatToolCallToString(toolCall: any): string {
} else { } else {
try { try {
argsString = JSON.stringify(args); argsString = JSON.stringify(args);
} catch (error) { } catch {
return ""; return "";
} }
} }

View file

@ -1,6 +1,6 @@
export function truncateText( export function truncateText(
text: string | null | undefined, text: string | null | undefined,
maxLength: number = 50, maxLength: number = 50
): string { ): string {
if (!text) return "N/A"; if (!text) return "N/A";
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;