mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-08-15 06:00:48 +00:00
fixing linter
Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
This commit is contained in:
parent
82de94a11b
commit
8f918040e5
79 changed files with 2452 additions and 1445 deletions
|
@ -145,6 +145,20 @@ repos:
|
|||
pass_filenames: false
|
||||
require_serial: true
|
||||
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:
|
||||
autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
|
||||
|
|
|
@ -1,3 +1,12 @@
|
|||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
.next
|
||||
node_modules
|
||||
dist
|
||||
*.lock
|
||||
*.log
|
||||
|
||||
# Generated files
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
|
|
@ -1 +1,10 @@
|
|||
{}
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
|
|
|
@ -1,47 +1,45 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { url } = await request.json();
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'URL is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
if (!url || typeof url !== "string") {
|
||||
return NextResponse.json({ error: "URL is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch the URL content
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
let content: string;
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
if (contentType.includes("application/json")) {
|
||||
const json = await response.json();
|
||||
content = JSON.stringify(json, null, 2);
|
||||
} else if (contentType.includes('text/html')) {
|
||||
} else if (contentType.includes("text/html")) {
|
||||
const html = await response.text();
|
||||
// Basic HTML to text conversion - remove tags and decode entities
|
||||
content = html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
} else {
|
||||
content = await response.text();
|
||||
|
@ -49,9 +47,9 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
return NextResponse.json({ content });
|
||||
} catch (error) {
|
||||
console.error('Error fetching URL:', error);
|
||||
console.error("Error fetching URL:", error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch URL content' },
|
||||
{ error: "Failed to fetch URL content" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
|
51
llama_stack/ui/app/api/upload-document/route.ts
Normal file
51
llama_stack/ui/app/api/upload-document/route.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File;
|
||||
const vectorDbId = formData.get("vectorDbId") as string;
|
||||
|
||||
if (!file || !vectorDbId) {
|
||||
return NextResponse.json(
|
||||
{ error: "File and vectorDbId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read file content based on type
|
||||
let content: string;
|
||||
const mimeType = file.type || "application/octet-stream";
|
||||
|
||||
if (mimeType === "text/plain" || mimeType === "text/markdown") {
|
||||
content = await file.text();
|
||||
} else if (mimeType === "application/pdf") {
|
||||
// For PDFs, convert to base64 on the server side
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
content = `data:${mimeType};base64,${base64}`;
|
||||
} else {
|
||||
// Try to read as text
|
||||
content = await file.text();
|
||||
}
|
||||
|
||||
// Return the processed content for the client to send to RagTool
|
||||
return NextResponse.json({
|
||||
content,
|
||||
mimeType,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing file upload:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to process file upload" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
|
@ -47,7 +47,7 @@ async function proxyRequest(request: NextRequest, method: string) {
|
|||
const responseText = await response.text();
|
||||
|
||||
console.log(
|
||||
`Response from FastAPI: ${response.status} ${response.statusText}`,
|
||||
`Response from FastAPI: ${response.status} ${response.statusText}`
|
||||
);
|
||||
|
||||
// Create response with same status and headers
|
||||
|
@ -74,7 +74,7 @@ async function proxyRequest(request: NextRequest, method: string) {
|
|||
backend_url: BACKEND_URL,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,9 +51,9 @@ export default function SignInPage() {
|
|||
onClick={() => {
|
||||
console.log("Signing in with GitHub...");
|
||||
signIn("github", { callbackUrl: "/auth/signin" }).catch(
|
||||
(error) => {
|
||||
error => {
|
||||
console.error("Sign in error:", error);
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="w-full"
|
||||
|
|
|
@ -16,67 +16,96 @@ import { useAuthClient } from "@/hooks/use-auth-client";
|
|||
import type { CompletionCreateParams } from "llama-stack-client/resources/chat/completions";
|
||||
import type { Model } from "llama-stack-client/resources/models";
|
||||
import type { VectorDBListResponse } from "llama-stack-client/resources/vector-dbs";
|
||||
import { VectorDbManager } from "@/components/vector-db/vector-db-manager";
|
||||
import { SessionManager, SessionUtils } from "@/components/chat-playground/session-manager";
|
||||
import { VectorDbManager } from "@/components/vector-db/vector-db-manager-simple";
|
||||
import {
|
||||
SessionManager,
|
||||
SessionUtils,
|
||||
} from "@/components/chat-playground/session-manager";
|
||||
import { DocumentUploader } from "@/components/chat-playground/document-uploader";
|
||||
|
||||
/**
|
||||
* Unified Chat Playground
|
||||
* - Keeps session + system message + VectorDB/RAG & document upload from version B
|
||||
* - Preserves simple message flow & suggestions/append helpers from version A
|
||||
* - Uses a single state source of truth: currentSession
|
||||
*/
|
||||
|
||||
interface ChatSession {
|
||||
id: string;
|
||||
name: string;
|
||||
messages: Message[];
|
||||
selectedModel: string;
|
||||
selectedVectorDb: string;
|
||||
selectedVectorDb: string; // "none" disables RAG
|
||||
systemMessage: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export default function ChatPlaygroundPage() {
|
||||
const [currentSession, setCurrentSession] = useState<ChatSession | null>(null);
|
||||
const [currentSession, setCurrentSession] = useState<ChatSession | null>(
|
||||
null
|
||||
);
|
||||
const [input, setInput] = useState("");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(true);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
|
||||
const [vectorDbs, setVectorDbs] = useState<VectorDBListResponse>([]);
|
||||
const [vectorDbsLoading, setVectorDbsLoading] = useState(true);
|
||||
const [vectorDbsError, setVectorDbsError] = useState<string | null>(null);
|
||||
const client = useAuthClient();
|
||||
|
||||
const client = useAuthClient();
|
||||
const isModelsLoading = modelsLoading ?? true;
|
||||
|
||||
// Load current session on mount
|
||||
// --- Session bootstrapping ---
|
||||
useEffect(() => {
|
||||
const savedSession = SessionUtils.loadCurrentSession();
|
||||
if (savedSession) {
|
||||
setCurrentSession(savedSession);
|
||||
const saved = SessionUtils.loadCurrentSession();
|
||||
if (saved) {
|
||||
setCurrentSession(saved);
|
||||
} else {
|
||||
// Create default session if none exists - will be updated with model when models load
|
||||
const defaultSession = SessionUtils.createDefaultSession();
|
||||
const def = SessionUtils.createDefaultSession();
|
||||
// ensure defaults align with our fields
|
||||
const defaultSession: ChatSession = {
|
||||
...def,
|
||||
selectedModel: "",
|
||||
selectedVectorDb: "none",
|
||||
systemMessage: def.systemMessage || "You are a helpful assistant.",
|
||||
};
|
||||
setCurrentSession(defaultSession);
|
||||
SessionUtils.saveCurrentSession(defaultSession);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save session when it changes
|
||||
// Persist session on change
|
||||
useEffect(() => {
|
||||
if (currentSession) {
|
||||
SessionUtils.saveCurrentSession(currentSession);
|
||||
}
|
||||
if (currentSession) SessionUtils.saveCurrentSession(currentSession);
|
||||
}, [currentSession]);
|
||||
|
||||
// --- Fetch models & vector DBs ---
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
const modelList = await client.models.list();
|
||||
const llmModels = modelList.filter(model => model.model_type === 'llm');
|
||||
setModels(llmModels);
|
||||
if (llmModels.length > 0 && currentSession && !currentSession.selectedModel) {
|
||||
setCurrentSession(prev => prev ? { ...prev, selectedModel: llmModels[0].identifier } : null);
|
||||
const list = await client.models.list();
|
||||
const llms = list.filter(m => m.model_type === "llm");
|
||||
setModels(llms);
|
||||
if (llms.length > 0) {
|
||||
setCurrentSession(prev =>
|
||||
prev && !prev.selectedModel
|
||||
? {
|
||||
...prev,
|
||||
selectedModel: llms[0].identifier,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching models:", err);
|
||||
: prev
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error fetching models:", e);
|
||||
setModelsError("Failed to fetch available models");
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
|
@ -87,10 +116,16 @@ export default function ChatPlaygroundPage() {
|
|||
try {
|
||||
setVectorDbsLoading(true);
|
||||
setVectorDbsError(null);
|
||||
const vectorDbList = await client.vectorDBs.list();
|
||||
setVectorDbs(vectorDbList);
|
||||
} catch (err) {
|
||||
console.error("Error fetching vector DBs:", err);
|
||||
const list = await client.vectorDBs.list();
|
||||
setVectorDbs(list);
|
||||
// default to "none" if not set
|
||||
setCurrentSession(prev =>
|
||||
prev && !prev.selectedVectorDb
|
||||
? { ...prev, selectedVectorDb: "none", updatedAt: Date.now() }
|
||||
: prev
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error fetching vector DBs:", e);
|
||||
setVectorDbsError("Failed to fetch available vector databases");
|
||||
} finally {
|
||||
setVectorDbsLoading(false);
|
||||
|
@ -101,31 +136,46 @@ export default function ChatPlaygroundPage() {
|
|||
fetchVectorDbs();
|
||||
}, [client]);
|
||||
|
||||
// --- Utilities ---
|
||||
const extractTextContent = (content: unknown): string => {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter(item => item && typeof item === 'object' && 'type' in item && item.type === 'text')
|
||||
.map(item => (item && typeof item === 'object' && 'text' in item) ? String(item.text) : '')
|
||||
.join('');
|
||||
.filter(
|
||||
item =>
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"type" in item &&
|
||||
(item as { type: string }).type === "text"
|
||||
)
|
||||
.map(item =>
|
||||
item && typeof item === "object" && "text" in item
|
||||
? String((item as { text: unknown }).text)
|
||||
: ""
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
if (content && typeof content === 'object' && 'type' in content && content.type === 'text' && 'text' in content) {
|
||||
return String(content.text) || '';
|
||||
if (
|
||||
content &&
|
||||
typeof content === "object" &&
|
||||
"type" in content &&
|
||||
(content as { type: string }).type === "text" &&
|
||||
"text" in content
|
||||
) {
|
||||
return String((content as { text: unknown }).text) || "";
|
||||
}
|
||||
return '';
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
// --- Handlers ---
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setInput(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event?: { preventDefault?: () => void }) => {
|
||||
event?.preventDefault?.();
|
||||
if (!input.trim() || !currentSession || !currentSession.selectedModel) return;
|
||||
if (!input.trim() || !currentSession || !currentSession.selectedModel)
|
||||
return;
|
||||
|
||||
// Add user message to chat
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: "user",
|
||||
|
@ -133,14 +183,17 @@ const handleSubmit = async (event?: { preventDefault?: () => void }) => {
|
|||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setCurrentSession(prev => prev ? {
|
||||
setCurrentSession(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
messages: [...prev.messages, userMessage],
|
||||
updatedAt: Date.now()
|
||||
} : null);
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: prev
|
||||
);
|
||||
setInput("");
|
||||
|
||||
// Use the helper function with the content
|
||||
await handleSubmitWithContent(userMessage.content);
|
||||
};
|
||||
|
||||
|
@ -151,8 +204,11 @@ const handleSubmitWithContent = async (content: string) => {
|
|||
try {
|
||||
let enhancedContent = content;
|
||||
|
||||
// If a vector DB is selected, query for relevant context
|
||||
if (currentSession?.selectedVectorDb && currentSession.selectedVectorDb !== "none") {
|
||||
// --- RAG augmentation (optional) ---
|
||||
if (
|
||||
currentSession?.selectedVectorDb &&
|
||||
currentSession.selectedVectorDb !== "none"
|
||||
) {
|
||||
try {
|
||||
const vectorResponse = await client.vectorIo.query({
|
||||
query: content,
|
||||
|
@ -161,35 +217,37 @@ const handleSubmitWithContent = async (content: string) => {
|
|||
|
||||
if (vectorResponse.chunks && vectorResponse.chunks.length > 0) {
|
||||
const context = vectorResponse.chunks
|
||||
.map(chunk => {
|
||||
// Extract text content from the chunk
|
||||
const chunkContent = typeof chunk.content === 'string'
|
||||
.map(chunk =>
|
||||
typeof chunk.content === "string"
|
||||
? chunk.content
|
||||
: extractTextContent(chunk.content);
|
||||
return chunkContent;
|
||||
})
|
||||
.join('\n\n');
|
||||
: extractTextContent(chunk.content)
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
enhancedContent = `Please answer the following query using the context below.\n\nCONTEXT:\n${context}\n\nQUERY:\n${content}`;
|
||||
}
|
||||
} catch (vectorErr) {
|
||||
console.error("Error querying vector DB:", vectorErr);
|
||||
// Continue with original content if vector query fails
|
||||
// proceed without augmentation
|
||||
}
|
||||
}
|
||||
|
||||
const messageParams: CompletionCreateParams["messages"] = [
|
||||
...(currentSession?.systemMessage
|
||||
? [{ role: "system" as const, content: currentSession.systemMessage }]
|
||||
: []),
|
||||
...(currentSession?.messages || []).map(msg => {
|
||||
const msgContent = typeof msg.content === 'string' ? msg.content : extractTextContent(msg.content);
|
||||
if (msg.role === "user") {
|
||||
const msgContent =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: extractTextContent(msg.content);
|
||||
if (msg.role === "user")
|
||||
return { role: "user" as const, content: msgContent };
|
||||
} else if (msg.role === "assistant") {
|
||||
if (msg.role === "assistant")
|
||||
return { role: "assistant" as const, content: msgContent };
|
||||
} else {
|
||||
return { role: "system" as const, content: msgContent };
|
||||
}
|
||||
}),
|
||||
{ role: "user" as const, content: enhancedContent }
|
||||
{ role: "user" as const, content: enhancedContent },
|
||||
];
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
|
@ -205,11 +263,15 @@ const handleSubmitWithContent = async (content: string) => {
|
|||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setCurrentSession(prev => prev ? {
|
||||
setCurrentSession(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
messages: [...prev.messages, assistantMessage],
|
||||
updatedAt: Date.now()
|
||||
} : null);
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: prev
|
||||
);
|
||||
|
||||
let fullContent = "";
|
||||
for await (const chunk of response) {
|
||||
|
@ -221,10 +283,8 @@ const handleSubmitWithContent = async (content: string) => {
|
|||
setCurrentSession(prev => {
|
||||
if (!prev) return null;
|
||||
const newMessages = [...prev.messages];
|
||||
const lastMessage = newMessages[newMessages.length - 1];
|
||||
if (lastMessage.role === "assistant") {
|
||||
lastMessage.content = fullContent;
|
||||
}
|
||||
const last = newMessages[newMessages.length - 1];
|
||||
if (last.role === "assistant") last.content = fullContent;
|
||||
return { ...prev, messages: newMessages, updatedAt: Date.now() };
|
||||
});
|
||||
});
|
||||
|
@ -233,15 +293,21 @@ const handleSubmitWithContent = async (content: string) => {
|
|||
} catch (err) {
|
||||
console.error("Error sending message:", err);
|
||||
setError("Failed to send message. Please try again.");
|
||||
setCurrentSession(prev => prev ? {
|
||||
setCurrentSession(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
messages: prev.messages.slice(0, -1),
|
||||
updatedAt: Date.now()
|
||||
} : null);
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: prev
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- UX helpers ---
|
||||
const suggestions = [
|
||||
"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?",
|
||||
|
@ -255,20 +321,22 @@ const handleSubmitWithContent = async (content: string) => {
|
|||
content: message.content,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
setCurrentSession(prev => prev ? {
|
||||
setCurrentSession(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
messages: [...prev.messages, newMessage],
|
||||
updatedAt: Date.now()
|
||||
} : null);
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: prev
|
||||
);
|
||||
handleSubmitWithContent(newMessage.content);
|
||||
};
|
||||
|
||||
const clearChat = () => {
|
||||
setCurrentSession(prev => prev ? {
|
||||
...prev,
|
||||
messages: [],
|
||||
updatedAt: Date.now()
|
||||
} : null);
|
||||
setCurrentSession(prev =>
|
||||
prev ? { ...prev, messages: [], updatedAt: Date.now() } : prev
|
||||
);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
|
@ -278,13 +346,20 @@ const handleSubmitWithContent = async (content: string) => {
|
|||
};
|
||||
|
||||
const handleNewSession = () => {
|
||||
const defaultModel = currentSession?.selectedModel || (models.length > 0 ? models[0].identifier : "");
|
||||
const defaultVectorDb = currentSession?.selectedVectorDb || "";
|
||||
const defaultModel =
|
||||
currentSession?.selectedModel ||
|
||||
(models.length > 0 ? models[0].identifier : "");
|
||||
const defaultVectorDb = currentSession?.selectedVectorDb || "none";
|
||||
|
||||
const newSession = {
|
||||
const newSession: ChatSession = {
|
||||
...SessionUtils.createDefaultSession(),
|
||||
selectedModel: defaultModel,
|
||||
selectedVectorDb: defaultVectorDb,
|
||||
systemMessage:
|
||||
currentSession?.systemMessage || "You are a helpful assistant.",
|
||||
messages: [],
|
||||
updatedAt: Date.now(),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
setCurrentSession(newSession);
|
||||
SessionUtils.saveCurrentSession(newSession);
|
||||
|
@ -305,84 +380,177 @@ const handleSubmitWithContent = async (content: string) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full max-w-4xl mx-auto">
|
||||
<div className="mb-4 space-y-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Chat Playground</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col h-full w-full max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-3xl font-bold">Chat Playground</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<SessionManager
|
||||
currentSession={currentSession}
|
||||
onSessionChange={handleSessionChange}
|
||||
onNewSession={handleNewSession}
|
||||
/>
|
||||
<Button variant="outline" onClick={clearChat} disabled={isGenerating}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={clearChat}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
Clear Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium text-gray-600">Model:</span>
|
||||
{/* Main Two-Column Layout */}
|
||||
<div className="flex flex-1 gap-6 min-h-0 flex-col lg:flex-row">
|
||||
{/* Left Column - Configuration Panel */}
|
||||
<div className="w-full lg:w-80 lg:flex-shrink-0 space-y-6 p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h2 className="text-lg font-semibold border-b pb-2 text-left">
|
||||
Settings
|
||||
</h2>
|
||||
|
||||
{/* Model Configuration */}
|
||||
<div className="space-y-4 text-left">
|
||||
<h3 className="text-lg font-semibold border-b pb-2 text-left">
|
||||
Model Configuration
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">Model</label>
|
||||
<Select
|
||||
value={currentSession?.selectedModel || ""}
|
||||
onValueChange={(value) => setCurrentSession(prev => prev ? { ...prev, selectedModel: value, updatedAt: Date.now() } : null)}
|
||||
onValueChange={value =>
|
||||
setCurrentSession(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
selectedModel: value,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: prev
|
||||
)
|
||||
}
|
||||
disabled={isModelsLoading || isGenerating}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder={isModelsLoading ? "Loading..." : "Select Model"} />
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isModelsLoading ? "Loading..." : "Select Model"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.identifier} value={model.identifier}>
|
||||
{models.map(model => (
|
||||
<SelectItem
|
||||
key={model.identifier}
|
||||
value={model.identifier}
|
||||
>
|
||||
{model.identifier}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelsError && (
|
||||
<p className="text-destructive text-xs mt-1">{modelsError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium text-gray-600">Vector DB:</span>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">
|
||||
System Message
|
||||
</label>
|
||||
<textarea
|
||||
value={currentSession?.systemMessage || ""}
|
||||
onChange={e =>
|
||||
setCurrentSession(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
systemMessage: e.target.value,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: prev
|
||||
)
|
||||
}
|
||||
placeholder="You are a helpful assistant."
|
||||
disabled={isGenerating}
|
||||
className="w-full h-24 px-3 py-2 text-sm border border-input rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vector Database Configuration */}
|
||||
<div className="space-y-4 text-left">
|
||||
<h3 className="text-lg font-semibold border-b pb-2 text-left">
|
||||
VectorDB Configuration
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">
|
||||
Vector Database
|
||||
</label>
|
||||
<Select
|
||||
value={currentSession?.selectedVectorDb || ""}
|
||||
onValueChange={(value) => setCurrentSession(prev => prev ? { ...prev, selectedVectorDb: value, updatedAt: Date.now() } : null)}
|
||||
value={currentSession?.selectedVectorDb || "none"}
|
||||
onValueChange={value =>
|
||||
setCurrentSession(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
selectedVectorDb: value,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: prev
|
||||
)
|
||||
}
|
||||
disabled={vectorDbsLoading || isGenerating}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder={vectorDbsLoading ? "Loading..." : "None"} />
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
vectorDbsLoading ? "Loading..." : "None (No RAG)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{vectorDbs.map((vectorDb) => (
|
||||
<SelectItem key={vectorDb.identifier} value={vectorDb.identifier}>
|
||||
{vectorDb.identifier}
|
||||
<SelectItem value="none">None (No RAG)</SelectItem>
|
||||
{vectorDbs.map(db => (
|
||||
<SelectItem key={db.identifier} value={db.identifier}>
|
||||
{db.identifier}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{vectorDbsError && (
|
||||
<p className="text-destructive text-xs mt-1">
|
||||
{vectorDbsError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<VectorDbManager
|
||||
client={client}
|
||||
onVectorDbCreated={refreshVectorDbs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modelsError && (
|
||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-destructive text-sm">{modelsError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vectorDbsError && (
|
||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-destructive text-sm">{vectorDbsError}</p>
|
||||
{/* Document Upload Section */}
|
||||
<div className="space-y-4 text-left">
|
||||
<DocumentUploader
|
||||
client={client}
|
||||
selectedVectorDb={currentSession?.selectedVectorDb || "none"}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Column - Chat Interface */}
|
||||
<div className="flex-1 flex flex-col min-h-0 p-4 border border-border rounded-lg bg-background">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
|
@ -398,8 +566,14 @@ const handleSubmitWithContent = async (content: string) => {
|
|||
isGenerating={isGenerating}
|
||||
append={append}
|
||||
suggestions={suggestions}
|
||||
setMessages={(messages) => setCurrentSession(prev => prev ? { ...prev, messages, updatedAt: Date.now() } : null)}
|
||||
setMessages={messages =>
|
||||
setCurrentSession(prev =>
|
||||
prev ? { ...prev, messages, updatedAt: Date.now() } : prev
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -33,12 +33,12 @@ export default function ChatCompletionDetailPage() {
|
|||
} catch (err) {
|
||||
console.error(
|
||||
`Error fetching chat completion detail for ID ${id}:`,
|
||||
err,
|
||||
err
|
||||
);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error("Failed to fetch completion detail"),
|
||||
: new Error("Failed to fetch completion detail")
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
|
|
@ -13,10 +13,10 @@ export default function ResponseDetailPage() {
|
|||
const client = useAuthClient();
|
||||
|
||||
const [responseDetail, setResponseDetail] = useState<OpenAIResponse | null>(
|
||||
null,
|
||||
null
|
||||
);
|
||||
const [inputItems, setInputItems] = useState<InputItemListResponse | null>(
|
||||
null,
|
||||
null
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isLoadingInputItems, setIsLoadingInputItems] = useState<boolean>(true);
|
||||
|
@ -25,7 +25,7 @@ export default function ResponseDetailPage() {
|
|||
|
||||
// Helper function to convert ResponseObject to OpenAIResponse
|
||||
const convertResponseObject = (
|
||||
responseData: ResponseObject,
|
||||
responseData: ResponseObject
|
||||
): OpenAIResponse => {
|
||||
return {
|
||||
id: responseData.id,
|
||||
|
@ -73,12 +73,12 @@ export default function ResponseDetailPage() {
|
|||
} else {
|
||||
console.error(
|
||||
`Error fetching response detail for ID ${id}:`,
|
||||
responseResult.reason,
|
||||
responseResult.reason
|
||||
);
|
||||
setError(
|
||||
responseResult.reason instanceof Error
|
||||
? 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 {
|
||||
console.error(
|
||||
`Error fetching input items for response ID ${id}:`,
|
||||
inputItemsResult.reason,
|
||||
inputItemsResult.reason
|
||||
);
|
||||
setInputItemsError(
|
||||
inputItemsResult.reason instanceof Error
|
||||
? inputItemsResult.reason
|
||||
: new Error("Failed to fetch input items"),
|
||||
: new Error("Failed to fetch input items")
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Unexpected error fetching data for ID ${id}:`, err);
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Unexpected error occurred"),
|
||||
err instanceof Error ? err : new Error("Unexpected error occurred")
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
|
|
@ -18,7 +18,10 @@ import {
|
|||
PropertiesCard,
|
||||
PropertyItem,
|
||||
} 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() {
|
||||
const params = useParams();
|
||||
|
@ -28,13 +31,13 @@ export default function ContentDetailPage() {
|
|||
const contentId = params.contentId as string;
|
||||
const client = useAuthClient();
|
||||
|
||||
const getTextFromContent = (content: any): string => {
|
||||
if (typeof content === 'string') {
|
||||
const getTextFromContent = (content: unknown): string => {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
} else if (content && content.type === 'text') {
|
||||
} else if (content && content.type === "text") {
|
||||
return content.text;
|
||||
}
|
||||
return '';
|
||||
return "";
|
||||
};
|
||||
|
||||
const [store, setStore] = useState<VectorStore | null>(null);
|
||||
|
@ -44,7 +47,9 @@ export default function ContentDetailPage() {
|
|||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedContent, setEditedContent] = useState("");
|
||||
const [editedMetadata, setEditedMetadata] = useState<Record<string, any>>({});
|
||||
const [editedMetadata, setEditedMetadata] = useState<Record<string, unknown>>(
|
||||
{}
|
||||
);
|
||||
const [isEditingEmbedding, setIsEditingEmbedding] = useState(false);
|
||||
const [editedEmbedding, setEditedEmbedding] = useState<number[]>([]);
|
||||
|
||||
|
@ -64,8 +69,13 @@ export default function ContentDetailPage() {
|
|||
setFile(fileResponse as VectorStoreFile);
|
||||
|
||||
const contentsAPI = new ContentsAPI(client);
|
||||
const contentsResponse = await contentsAPI.listContents(vectorStoreId, fileId);
|
||||
const targetContent = contentsResponse.data.find(c => c.id === contentId);
|
||||
const contentsResponse = await contentsAPI.listContents(
|
||||
vectorStoreId,
|
||||
fileId
|
||||
);
|
||||
const targetContent = contentsResponse.data.find(
|
||||
c => c.id === contentId
|
||||
);
|
||||
|
||||
if (targetContent) {
|
||||
setContent(targetContent);
|
||||
|
@ -76,7 +86,9 @@ export default function ContentDetailPage() {
|
|||
throw new Error(`Content ${contentId} not found`);
|
||||
}
|
||||
} 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 {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
@ -88,7 +100,8 @@ export default function ContentDetailPage() {
|
|||
if (!content) return;
|
||||
|
||||
try {
|
||||
const updates: { content?: string; metadata?: Record<string, any> } = {};
|
||||
const updates: { content?: string; metadata?: Record<string, unknown> } =
|
||||
{};
|
||||
|
||||
if (editedContent !== getTextFromContent(content.content)) {
|
||||
updates.content = editedContent;
|
||||
|
@ -100,25 +113,32 @@ export default function ContentDetailPage() {
|
|||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
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);
|
||||
}
|
||||
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to update content:', err);
|
||||
console.error("Failed to update content:", err);
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
const contentsAPI = new ContentsAPI(client);
|
||||
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) {
|
||||
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[] = [
|
||||
{ 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: 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 },
|
||||
];
|
||||
|
||||
|
@ -186,7 +215,7 @@ export default function ContentDetailPage() {
|
|||
{isEditing ? (
|
||||
<textarea
|
||||
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"
|
||||
placeholder="Enter content..."
|
||||
/>
|
||||
|
@ -206,16 +235,23 @@ export default function ContentDetailPage() {
|
|||
<div className="flex gap-2">
|
||||
{isEditingEmbedding ? (
|
||||
<>
|
||||
<Button size="sm" onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsEditingEmbedding(false);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditedEmbedding(content?.embedding || []);
|
||||
setIsEditingEmbedding(false);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
|
@ -237,14 +273,16 @@ export default function ContentDetailPage() {
|
|||
</p>
|
||||
<textarea
|
||||
value={JSON.stringify(editedEmbedding, null, 2)}
|
||||
onChange={(e) => {
|
||||
onChange={e => {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
}}
|
||||
className="w-full h-32 p-3 border rounded-md resize-none font-mono text-xs"
|
||||
placeholder="Enter embedding as JSON array..."
|
||||
|
@ -259,8 +297,15 @@ export default function ContentDetailPage() {
|
|||
</div>
|
||||
<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">
|
||||
[{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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -284,7 +329,7 @@ export default function ContentDetailPage() {
|
|||
<div key={key} className="flex gap-2">
|
||||
<Input
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
onChange={e => {
|
||||
const newMetadata = { ...editedMetadata };
|
||||
delete newMetadata[key];
|
||||
newMetadata[e.target.value] = value;
|
||||
|
@ -294,11 +339,13 @@ export default function ContentDetailPage() {
|
|||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={typeof value === 'string' ? value : JSON.stringify(value)}
|
||||
onChange={(e) => {
|
||||
value={
|
||||
typeof value === "string" ? value : JSON.stringify(value)
|
||||
}
|
||||
onChange={e => {
|
||||
setEditedMetadata({
|
||||
...editedMetadata,
|
||||
[key]: e.target.value
|
||||
[key]: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="Value"
|
||||
|
@ -312,7 +359,7 @@ export default function ContentDetailPage() {
|
|||
onClick={() => {
|
||||
setEditedMetadata({
|
||||
...editedMetadata,
|
||||
['']: ''
|
||||
[""]: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -325,7 +372,7 @@ export default function ContentDetailPage() {
|
|||
<div key={key} className="flex justify-between py-1">
|
||||
<span className="font-medium text-gray-600">{key}:</span>
|
||||
<span className="font-mono text-sm">
|
||||
{typeof value === 'string' ? value : JSON.stringify(value)}
|
||||
{typeof value === "string" ? value : JSON.stringify(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
@ -351,15 +398,15 @@ export default function ContentDetailPage() {
|
|||
value={`${getTextFromContent(content.content).length} chars`}
|
||||
/>
|
||||
{content.metadata.chunk_window && (
|
||||
<PropertyItem
|
||||
label="Position"
|
||||
value={content.metadata.chunk_window}
|
||||
/>
|
||||
<PropertyItem label="Position" value={content.metadata.chunk_window} />
|
||||
)}
|
||||
{file && (
|
||||
<>
|
||||
<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 && (
|
||||
|
|
|
@ -18,7 +18,10 @@ import {
|
|||
PropertiesCard,
|
||||
PropertyItem,
|
||||
} from "@/components/layout/detail-layout";
|
||||
import { PageBreadcrumb, BreadcrumbSegment } from "@/components/layout/page-breadcrumb";
|
||||
import {
|
||||
PageBreadcrumb,
|
||||
BreadcrumbSegment,
|
||||
} from "@/components/layout/page-breadcrumb";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
@ -36,23 +39,21 @@ export default function ContentsListPage() {
|
|||
const fileId = params.fileId as string;
|
||||
const client = useAuthClient();
|
||||
|
||||
const getTextFromContent = (content: any): string => {
|
||||
if (typeof content === 'string') {
|
||||
const getTextFromContent = (content: unknown): string => {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
} else if (content && content.type === 'text') {
|
||||
} else if (content && content.type === "text") {
|
||||
return content.text;
|
||||
}
|
||||
return '';
|
||||
return "";
|
||||
};
|
||||
|
||||
const [store, setStore] = useState<VectorStore | null>(null);
|
||||
const [file, setFile] = useState<VectorStoreFile | null>(null);
|
||||
const [contents, setContents] = useState<VectorStoreContentItem[]>([]);
|
||||
const [isLoadingStore, setIsLoadingStore] = useState(true);
|
||||
const [isLoadingFile, setIsLoadingFile] = useState(true);
|
||||
const [isLoadingContents, setIsLoadingContents] = useState(true);
|
||||
const [errorStore, setErrorStore] = useState<Error | null>(null);
|
||||
const [errorFile, setErrorFile] = useState<Error | null>(null);
|
||||
const [errorContents, setErrorContents] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -65,7 +66,9 @@ export default function ContentsListPage() {
|
|||
const response = await client.vectorStores.retrieve(vectorStoreId);
|
||||
setStore(response as VectorStore);
|
||||
} 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 {
|
||||
setIsLoadingStore(false);
|
||||
}
|
||||
|
@ -80,10 +83,15 @@ export default function ContentsListPage() {
|
|||
setIsLoadingFile(true);
|
||||
setErrorFile(null);
|
||||
try {
|
||||
const response = await client.vectorStores.files.retrieve(vectorStoreId, fileId);
|
||||
const response = await client.vectorStores.files.retrieve(
|
||||
vectorStoreId,
|
||||
fileId
|
||||
);
|
||||
setFile(response as VectorStoreFile);
|
||||
} 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 {
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
|
@ -99,10 +107,16 @@ export default function ContentsListPage() {
|
|||
setErrorContents(null);
|
||||
try {
|
||||
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);
|
||||
} 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 {
|
||||
setIsLoadingContents(false);
|
||||
}
|
||||
|
@ -116,26 +130,36 @@ export default function ContentsListPage() {
|
|||
await contentsAPI.deleteContent(vectorStoreId, fileId, contentId);
|
||||
setContents(contents.filter(content => content.id !== contentId));
|
||||
} catch (err) {
|
||||
console.error('Failed to delete content:', err);
|
||||
console.error("Failed to delete content:", err);
|
||||
}
|
||||
};
|
||||
|
||||
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 breadcrumbSegments: BreadcrumbSegment[] = [
|
||||
{ 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: fileId, href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}` },
|
||||
{
|
||||
label: fileId,
|
||||
href: `/logs/vector-stores/${vectorStoreId}/files/${fileId}`,
|
||||
},
|
||||
{ label: "Contents" },
|
||||
];
|
||||
|
||||
if (errorStore) {
|
||||
return <DetailErrorView title={title} id={vectorStoreId} error={errorStore} />;
|
||||
return (
|
||||
<DetailErrorView title={title} id={vectorStoreId} error={errorStore} />
|
||||
);
|
||||
}
|
||||
if (isLoadingStore) {
|
||||
return <DetailLoadingView title={title} />;
|
||||
|
@ -175,7 +199,7 @@ export default function ContentsListPage() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{contents.map((content) => (
|
||||
{contents.map(content => (
|
||||
<TableRow key={content.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Button
|
||||
|
@ -189,7 +213,10 @@ export default function ContentsListPage() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
|
@ -197,12 +224,25 @@ export default function ContentsListPage() {
|
|||
<TableCell className="text-xs text-gray-500">
|
||||
{content.embedding && content.embedding.length > 0 ? (
|
||||
<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(', ')}...]`}>
|
||||
[{content.embedding.slice(0, 3).map(v => v.toFixed(3)).join(', ')}...] ({content.embedding.length}D)
|
||||
<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(", ")}...]`}
|
||||
>
|
||||
[
|
||||
{content.embedding
|
||||
.slice(0, 3)
|
||||
.map(v => v.toFixed(3))
|
||||
.join(", ")}
|
||||
...] ({content.embedding.length}D)
|
||||
</span>
|
||||
</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 className="text-xs text-gray-500">
|
||||
|
@ -211,7 +251,9 @@ export default function ContentsListPage() {
|
|||
: `${content.metadata.content_length || 0} chars`}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{new Date(content.created_timestamp * 1000).toLocaleString()}
|
||||
{new Date(
|
||||
content.created_timestamp * 1000
|
||||
).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
|
|
|
@ -4,9 +4,12 @@ import { useEffect, useState } from "react";
|
|||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuthClient } from "@/hooks/use-auth-client";
|
||||
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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { List } from "lucide-react";
|
||||
import {
|
||||
|
@ -17,7 +20,10 @@ import {
|
|||
PropertiesCard,
|
||||
PropertyItem,
|
||||
} 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() {
|
||||
const params = useParams();
|
||||
|
@ -46,7 +52,9 @@ export default function FileDetailPage() {
|
|||
const response = await client.vectorStores.retrieve(vectorStoreId);
|
||||
setStore(response as VectorStore);
|
||||
} 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 {
|
||||
setIsLoadingStore(false);
|
||||
}
|
||||
|
@ -61,10 +69,15 @@ export default function FileDetailPage() {
|
|||
setIsLoadingFile(true);
|
||||
setErrorFile(null);
|
||||
try {
|
||||
const response = await client.vectorStores.files.retrieve(vectorStoreId, fileId);
|
||||
const response = await client.vectorStores.files.retrieve(
|
||||
vectorStoreId,
|
||||
fileId
|
||||
);
|
||||
setFile(response as VectorStoreFile);
|
||||
} 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 {
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
|
@ -79,10 +92,15 @@ export default function FileDetailPage() {
|
|||
setIsLoadingContents(true);
|
||||
setErrorContents(null);
|
||||
try {
|
||||
const response = await client.vectorStores.files.content(vectorStoreId, fileId);
|
||||
const response = await client.vectorStores.files.content(
|
||||
vectorStoreId,
|
||||
fileId
|
||||
);
|
||||
setContents(response);
|
||||
} 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 {
|
||||
setIsLoadingContents(false);
|
||||
}
|
||||
|
@ -91,20 +109,27 @@ export default function FileDetailPage() {
|
|||
}, [vectorStoreId, fileId, client]);
|
||||
|
||||
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 breadcrumbSegments: BreadcrumbSegment[] = [
|
||||
{ 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: fileId },
|
||||
];
|
||||
|
||||
if (errorStore) {
|
||||
return <DetailErrorView title={title} id={vectorStoreId} error={errorStore} />;
|
||||
return (
|
||||
<DetailErrorView title={title} id={vectorStoreId} error={errorStore} />
|
||||
);
|
||||
}
|
||||
if (isLoadingStore) {
|
||||
return <DetailLoadingView title={title} />;
|
||||
|
@ -136,19 +161,29 @@ export default function FileDetailPage() {
|
|||
<h3 className="text-lg font-medium mb-2">File Details</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">Created:</span>
|
||||
<span className="ml-2">{new Date(file.created_at * 1000).toLocaleString()}</span>
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||
Created:
|
||||
</span>
|
||||
<span className="ml-2">
|
||||
{new Date(file.created_at * 1000).toLocaleString()}
|
||||
</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -166,9 +201,7 @@ export default function FileDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 italic text-sm">
|
||||
File not found.
|
||||
</p>
|
||||
<p className="text-gray-500 italic text-sm">File not found.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
@ -192,16 +225,27 @@ export default function FileDetailPage() {
|
|||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">Total Characters:</span>
|
||||
<span className="ml-2">{contents.content.reduce((total, item) => total + item.text.length, 0)}</span>
|
||||
<span className="font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Characters:
|
||||
</span>
|
||||
<span className="ml-2">
|
||||
{contents.content.reduce(
|
||||
(total, item) => total + item.text.length,
|
||||
0
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100 line-clamp-3">
|
||||
{contents.content[0]?.text.substring(0, 200)}...
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useAuthClient } from "@/hooks/use-auth-client";
|
||||
import type { VectorStore } from "llama-stack-client/resources/vector-stores/vector-stores";
|
||||
import type { VectorStoreFile } from "llama-stack-client/resources/vector-stores/files";
|
||||
|
@ -11,7 +11,6 @@ export default function VectorStoreDetailPage() {
|
|||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const client = useAuthClient();
|
||||
const router = useRouter();
|
||||
|
||||
const [store, setStore] = useState<VectorStore | null>(null);
|
||||
const [files, setFiles] = useState<VectorStoreFile[]>([]);
|
||||
|
@ -34,9 +33,7 @@ export default function VectorStoreDetailPage() {
|
|||
setStore(response as VectorStore);
|
||||
} catch (err) {
|
||||
setErrorStore(
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error("Failed to load vector store."),
|
||||
err instanceof Error ? err : new Error("Failed to load vector store.")
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingStore(false);
|
||||
|
@ -55,18 +52,18 @@ export default function VectorStoreDetailPage() {
|
|||
setIsLoadingFiles(true);
|
||||
setErrorFiles(null);
|
||||
try {
|
||||
const result = await client.vectorStores.files.list(id as any);
|
||||
setFiles((result as any).data);
|
||||
const result = await client.vectorStores.files.list(id);
|
||||
setFiles((result as { data: VectorStoreFile[] }).data);
|
||||
} catch (err) {
|
||||
setErrorFiles(
|
||||
err instanceof Error ? err : new Error("Failed to load files."),
|
||||
err instanceof Error ? err : new Error("Failed to load files.")
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
};
|
||||
fetchFiles();
|
||||
}, [id]);
|
||||
}, [id, client.vectorStores.files]);
|
||||
|
||||
return (
|
||||
<VectorStoreDetailView
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useAuthClient } from "@/hooks/use-auth-client";
|
||||
import type {
|
||||
ListVectorStoresResponse,
|
||||
VectorStore,
|
||||
|
@ -12,7 +11,6 @@ import { Button } from "@/components/ui/button";
|
|||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
|
@ -21,7 +19,6 @@ import {
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function VectorStoresPage() {
|
||||
const client = useAuthClient();
|
||||
const router = useRouter();
|
||||
const {
|
||||
data: stores,
|
||||
|
@ -37,7 +34,7 @@ export default function VectorStoresPage() {
|
|||
after: params.after,
|
||||
limit: params.limit,
|
||||
order: params.order,
|
||||
} as any);
|
||||
} as Parameters<typeof client.vectorStores.list>[0]);
|
||||
return response as ListVectorStoresResponse;
|
||||
},
|
||||
errorMessagePrefix: "vector stores",
|
||||
|
@ -88,7 +85,7 @@ export default function VectorStoresPage() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stores.map((store) => {
|
||||
{stores.map(store => {
|
||||
const fileCounts = store.file_counts;
|
||||
const metadata = store.metadata || {};
|
||||
const providerId = metadata.provider_id ?? "";
|
||||
|
|
|
@ -14,7 +14,7 @@ describe("ChatCompletionDetailView", () => {
|
|||
isLoading={true}
|
||||
error={null}
|
||||
id="test-id"
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
// Use the data-slot attribute for Skeletons
|
||||
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
|
||||
|
@ -28,10 +28,10 @@ describe("ChatCompletionDetailView", () => {
|
|||
isLoading={false}
|
||||
error={{ name: "Error", message: "Network Error" }}
|
||||
id="err-id"
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Error loading details for ID err-id: Network Error/),
|
||||
screen.getByText(/Error loading details for ID err-id: Network Error/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
@ -42,11 +42,11 @@ describe("ChatCompletionDetailView", () => {
|
|||
isLoading={false}
|
||||
error={{ name: "Error", message: "" }}
|
||||
id="err-id"
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
// Use regex to match the error message regardless of whitespace
|
||||
expect(
|
||||
screen.getByText(/Error loading details for ID\s*err-id\s*:/),
|
||||
screen.getByText(/Error loading details for ID\s*err-id\s*:/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
@ -57,11 +57,11 @@ describe("ChatCompletionDetailView", () => {
|
|||
isLoading={false}
|
||||
error={{} as Error}
|
||||
id="err-id"
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
// Use regex to match the error message regardless of whitespace
|
||||
expect(
|
||||
screen.getByText(/Error loading details for ID\s*err-id\s*:/),
|
||||
screen.getByText(/Error loading details for ID\s*err-id\s*:/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
@ -72,10 +72,10 @@ describe("ChatCompletionDetailView", () => {
|
|||
isLoading={false}
|
||||
error={null}
|
||||
id="notfound-id"
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No details found for ID: notfound-id."),
|
||||
screen.getByText("No details found for ID: notfound-id.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
@ -100,7 +100,7 @@ describe("ChatCompletionDetailView", () => {
|
|||
isLoading={false}
|
||||
error={null}
|
||||
id={mockCompletion.id}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
// Input
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
|
@ -112,7 +112,7 @@ describe("ChatCompletionDetailView", () => {
|
|||
expect(screen.getByText("Properties")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created:")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString()),
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString())
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ID:")).toBeInTheDocument();
|
||||
expect(screen.getByText("comp_123")).toBeInTheDocument();
|
||||
|
@ -150,7 +150,7 @@ describe("ChatCompletionDetailView", () => {
|
|||
isLoading={false}
|
||||
error={null}
|
||||
id={mockCompletion.id}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
// Output should include the tool call block (should be present twice: input and output)
|
||||
const toolCallLabels = screen.getAllByText("Tool Call");
|
||||
|
@ -178,13 +178,13 @@ describe("ChatCompletionDetailView", () => {
|
|||
isLoading={false}
|
||||
error={null}
|
||||
id={mockCompletion.id}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
// Input section should be present but empty
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
// Output section should show fallback message
|
||||
expect(
|
||||
screen.getByText("No message found in assistant's choice."),
|
||||
screen.getByText("No message found in assistant's choice.")
|
||||
).toBeInTheDocument();
|
||||
// Properties should show N/A for finish reason
|
||||
expect(screen.getByText("Finish Reason:")).toBeInTheDocument();
|
||||
|
|
|
@ -53,14 +53,14 @@ export function ChatCompletionDetailView({
|
|||
{completion.choices?.[0]?.message?.tool_calls &&
|
||||
Array.isArray(completion.choices[0].message.tool_calls) &&
|
||||
!completion.input_messages?.some(
|
||||
(im) =>
|
||||
im =>
|
||||
im.role === "assistant" &&
|
||||
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(
|
||||
(toolCall: any, index: number) => {
|
||||
(toolCall: { function?: { name?: string } }, index: number) => {
|
||||
const assistantToolCallMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
tool_calls: [toolCall],
|
||||
|
@ -72,7 +72,7 @@ export function ChatCompletionDetailView({
|
|||
message={assistantToolCallMessage}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}
|
||||
)
|
||||
: null}
|
||||
</CardContent>
|
||||
|
@ -89,7 +89,7 @@ export function ChatCompletionDetailView({
|
|||
/>
|
||||
) : (
|
||||
<p className="text-gray-500 italic text-sm">
|
||||
No message found in assistant's choice.
|
||||
No message found in assistant's choice.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
@ -120,13 +120,18 @@ export function ChatCompletionDetailView({
|
|||
value={
|
||||
<div>
|
||||
<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}>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{toolCall.function?.name || "N/A"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -83,7 +83,7 @@ describe("ChatCompletionsTable", () => {
|
|||
// Default pass-through implementations
|
||||
truncateText.mockImplementation((text: string | undefined) => text);
|
||||
extractTextFromContentPart.mockImplementation((content: unknown) =>
|
||||
typeof content === "string" ? content : "extracted text",
|
||||
typeof content === "string" ? content : "extracted text"
|
||||
);
|
||||
extractDisplayableText.mockImplementation((message: unknown) => {
|
||||
const msg = message as { content?: string };
|
||||
|
@ -138,7 +138,7 @@ describe("ChatCompletionsTable", () => {
|
|||
if (row) {
|
||||
fireEvent.click(row);
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
"/logs/chat-completions/completion_123",
|
||||
"/logs/chat-completions/completion_123"
|
||||
);
|
||||
} else {
|
||||
throw new Error('Row with "Test prompt" not found for router mock test.');
|
||||
|
@ -162,7 +162,7 @@ describe("ChatCompletionsTable", () => {
|
|||
expect(tableCaption).toBeInTheDocument();
|
||||
if (tableCaption) {
|
||||
const captionSkeleton = tableCaption.querySelector(
|
||||
'[data-slot="skeleton"]',
|
||||
'[data-slot="skeleton"]'
|
||||
);
|
||||
expect(captionSkeleton).toBeInTheDocument();
|
||||
}
|
||||
|
@ -172,7 +172,7 @@ describe("ChatCompletionsTable", () => {
|
|||
expect(tableBody).toBeInTheDocument();
|
||||
if (tableBody) {
|
||||
const bodySkeletons = tableBody.querySelectorAll(
|
||||
'[data-slot="skeleton"]',
|
||||
'[data-slot="skeleton"]'
|
||||
);
|
||||
expect(bodySkeletons.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
@ -192,14 +192,14 @@ describe("ChatCompletionsTable", () => {
|
|||
|
||||
render(<ChatCompletionsTable {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText("Unable to load chat completions"),
|
||||
screen.getByText("Unable to load chat completions")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([{ name: "Error", message: "" }, {}])(
|
||||
"renders default error message when error has no message",
|
||||
(errorObject) => {
|
||||
errorObject => {
|
||||
mockedUsePagination.mockReturnValue({
|
||||
data: [],
|
||||
status: "error",
|
||||
|
@ -210,14 +210,14 @@ describe("ChatCompletionsTable", () => {
|
|||
|
||||
render(<ChatCompletionsTable {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText("Unable to load chat completions"),
|
||||
screen.getByText("Unable to load chat completions")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"An unexpected error occurred while loading the data.",
|
||||
),
|
||||
"An unexpected error occurred while loading the data."
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -225,7 +225,7 @@ describe("ChatCompletionsTable", () => {
|
|||
test('renders "No chat completions found." and no table when data array is empty', () => {
|
||||
render(<ChatCompletionsTable {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText("No chat completions found."),
|
||||
screen.getByText("No chat completions found.")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Ensure that the table structure is NOT rendered in the empty state
|
||||
|
@ -292,7 +292,7 @@ describe("ChatCompletionsTable", () => {
|
|||
|
||||
// Table caption
|
||||
expect(
|
||||
screen.getByText("A list of your recent chat completions."),
|
||||
screen.getByText("A list of your recent chat completions.")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Table headers
|
||||
|
@ -306,14 +306,14 @@ describe("ChatCompletionsTable", () => {
|
|||
expect(screen.getByText("Test output")).toBeInTheDocument();
|
||||
expect(screen.getByText("llama-test-model")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString()),
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString())
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Another input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Another output")).toBeInTheDocument();
|
||||
expect(screen.getByText("llama-another-model")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710001000 * 1000).toLocaleString()),
|
||||
screen.getByText(new Date(1710001000 * 1000).toLocaleString())
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -328,7 +328,7 @@ describe("ChatCompletionsTable", () => {
|
|||
return typeof text === "string" && text.length > effectiveMaxLength
|
||||
? text.slice(0, effectiveMaxLength) + "..."
|
||||
: text;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const longInput =
|
||||
|
@ -368,7 +368,7 @@ describe("ChatCompletionsTable", () => {
|
|||
|
||||
// The truncated text should be present for both input and output
|
||||
const truncatedTexts = screen.getAllByText(
|
||||
longInput.slice(0, 10) + "...",
|
||||
longInput.slice(0, 10) + "..."
|
||||
);
|
||||
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
|
||||
expect(screen.getByText("Extracted input")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Extracted output from assistant"),
|
||||
screen.getByText("Extracted output from assistant")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -38,14 +38,14 @@ export function ChatCompletionsTable({
|
|||
limit: number;
|
||||
model?: string;
|
||||
order?: string;
|
||||
},
|
||||
}
|
||||
) => {
|
||||
const response = await client.chat.completions.list({
|
||||
after: params.after,
|
||||
limit: params.limit,
|
||||
...(params.model && { model: params.model }),
|
||||
...(params.order && { order: params.order }),
|
||||
} as any);
|
||||
} as Parameters<typeof client.chat.completions.list>[0]);
|
||||
|
||||
return response as ListChatCompletionsResponse;
|
||||
};
|
||||
|
|
|
@ -37,7 +37,11 @@ export function ChatMessageItem({ message }: ChatMessageItemProps) {
|
|||
) {
|
||||
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 toolCallContent = (
|
||||
<ToolCallBlock>
|
||||
|
@ -51,7 +55,8 @@ export function ChatMessageItem({ message }: ChatMessageItemProps) {
|
|||
content={toolCallContent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { motion } from "framer-motion"
|
||||
import { Ban, ChevronRight, Code2, Loader2, Terminal } from "lucide-react"
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { motion } from "framer-motion";
|
||||
import { Ban, ChevronRight, Code2, Loader2, Terminal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { FilePreview } from "@/components/ui/file-preview"
|
||||
import { MarkdownRenderer } from "@/components/chat-playground/markdown-renderer"
|
||||
} from "@/components/ui/collapsible";
|
||||
import { FilePreview } from "@/components/ui/file-preview";
|
||||
import { MarkdownRenderer } from "@/components/chat-playground/markdown-renderer";
|
||||
|
||||
const chatBubbleVariants = cva(
|
||||
"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 {
|
||||
name?: string
|
||||
contentType?: string
|
||||
url: string
|
||||
name?: string;
|
||||
contentType?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PartialToolCall {
|
||||
state: "partial-call"
|
||||
toolName: string
|
||||
state: "partial-call";
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
state: "call"
|
||||
toolName: string
|
||||
state: "call";
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
interface ToolResult {
|
||||
state: "result"
|
||||
toolName: string
|
||||
state: "result";
|
||||
toolName: string;
|
||||
result: {
|
||||
__cancelled?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
__cancelled?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type ToolInvocation = PartialToolCall | ToolCall | ToolResult
|
||||
type ToolInvocation = PartialToolCall | ToolCall | ToolResult;
|
||||
|
||||
interface ReasoningPart {
|
||||
type: "reasoning"
|
||||
reasoning: string
|
||||
type: "reasoning";
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
interface ToolInvocationPart {
|
||||
type: "tool-invocation"
|
||||
toolInvocation: ToolInvocation
|
||||
type: "tool-invocation";
|
||||
toolInvocation: ToolInvocation;
|
||||
}
|
||||
|
||||
interface TextPart {
|
||||
type: "text"
|
||||
text: string
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
// For compatibility with AI SDK types, not used
|
||||
interface SourcePart {
|
||||
type: "source"
|
||||
source?: any
|
||||
type: "source";
|
||||
source?: unknown;
|
||||
}
|
||||
|
||||
interface FilePart {
|
||||
type: "file"
|
||||
mimeType: string
|
||||
data: string
|
||||
type: "file";
|
||||
mimeType: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface StepStartPart {
|
||||
type: "step-start"
|
||||
type: "step-start";
|
||||
}
|
||||
|
||||
type MessagePart =
|
||||
|
@ -120,22 +120,22 @@ type MessagePart =
|
|||
| ToolInvocationPart
|
||||
| SourcePart
|
||||
| FilePart
|
||||
| StepStartPart
|
||||
| StepStartPart;
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
role: "user" | "assistant" | (string & {})
|
||||
content: string
|
||||
createdAt?: Date
|
||||
experimental_attachments?: Attachment[]
|
||||
toolInvocations?: ToolInvocation[]
|
||||
parts?: MessagePart[]
|
||||
id: string;
|
||||
role: "user" | "assistant" | (string & {});
|
||||
content: string;
|
||||
createdAt?: Date;
|
||||
experimental_attachments?: Attachment[];
|
||||
toolInvocations?: ToolInvocation[];
|
||||
parts?: MessagePart[];
|
||||
}
|
||||
|
||||
export interface ChatMessageProps extends Message {
|
||||
showTimeStamp?: boolean
|
||||
animation?: Animation
|
||||
actions?: React.ReactNode
|
||||
showTimeStamp?: boolean;
|
||||
animation?: Animation;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
@ -150,21 +150,23 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||
parts,
|
||||
}) => {
|
||||
const files = useMemo(() => {
|
||||
return experimental_attachments?.map((attachment) => {
|
||||
const dataArray = dataUrlToUint8Array(attachment.url)
|
||||
return experimental_attachments?.map(attachment => {
|
||||
const dataArray = dataUrlToUint8Array(attachment.url);
|
||||
const file = new File([dataArray], attachment.name ?? "Unknown", {
|
||||
type: attachment.contentType,
|
||||
})
|
||||
return file
|
||||
})
|
||||
}, [experimental_attachments])
|
||||
});
|
||||
return file;
|
||||
});
|
||||
}, [experimental_attachments]);
|
||||
|
||||
const isUser = role === "user"
|
||||
const isUser = role === "user";
|
||||
|
||||
const formattedTime = createdAt ? new Date(createdAt).toLocaleTimeString("en-US", {
|
||||
const formattedTime = createdAt
|
||||
? new Date(createdAt).toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) : undefined
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
|
@ -174,7 +176,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||
{files ? (
|
||||
<div className="mb-1 flex flex-wrap gap-2">
|
||||
{files.map((file, index) => {
|
||||
return <FilePreview file={file} key={index} />
|
||||
return <FilePreview file={file} key={index} />;
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
@ -195,7 +197,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (parts && parts.length > 0) {
|
||||
|
@ -230,23 +232,23 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} 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") {
|
||||
return (
|
||||
<ToolCall
|
||||
key={`tool-${index}`}
|
||||
toolInvocations={[part.toolInvocation]}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
return null
|
||||
})
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
if (toolInvocations && toolInvocations.length > 0) {
|
||||
return <ToolCall toolInvocations={toolInvocations} />
|
||||
return <ToolCall toolInvocations={toolInvocations} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -272,17 +274,17 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function dataUrlToUint8Array(data: string) {
|
||||
const base64 = data.split(",")[1]
|
||||
const buf = Buffer.from(base64, "base64")
|
||||
return new Uint8Array(buf)
|
||||
const base64 = data.split(",")[1];
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
const ReasoningBlock = ({ part }: { part: ReasoningPart }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-col items-start sm:max-w-[70%]">
|
||||
|
@ -319,20 +321,20 @@ const ReasoningBlock = ({ part }: { part: ReasoningPart }) => {
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function ToolCall({
|
||||
toolInvocations,
|
||||
}: Pick<ChatMessageProps, "toolInvocations">) {
|
||||
if (!toolInvocations?.length) return null
|
||||
if (!toolInvocations?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{toolInvocations.map((invocation, index) => {
|
||||
const isCancelled =
|
||||
invocation.state === "result" &&
|
||||
invocation.result.__cancelled === true
|
||||
invocation.result.__cancelled === true;
|
||||
|
||||
if (isCancelled) {
|
||||
return (
|
||||
|
@ -350,7 +352,7 @@ function ToolCall({
|
|||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
switch (invocation.state) {
|
||||
|
@ -373,7 +375,7 @@ function ToolCall({
|
|||
</span>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
case "result":
|
||||
return (
|
||||
<div
|
||||
|
@ -395,11 +397,11 @@ function ToolCall({
|
|||
{JSON.stringify(invocation.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
default:
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
|
@ -6,48 +6,48 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
} from "react"
|
||||
import { ArrowDown, ThumbsDown, ThumbsUp } from "lucide-react"
|
||||
} from "react";
|
||||
import { ArrowDown, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAutoScroll } from "@/hooks/use-auto-scroll"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { type Message } from "@/components/chat-playground/chat-message"
|
||||
import { CopyButton } from "@/components/ui/copy-button"
|
||||
import { MessageInput } from "@/components/chat-playground/message-input"
|
||||
import { MessageList } from "@/components/chat-playground/message-list"
|
||||
import { PromptSuggestions } from "@/components/chat-playground/prompt-suggestions"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAutoScroll } from "@/hooks/use-auto-scroll";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type Message } from "@/components/chat-playground/chat-message";
|
||||
import { CopyButton } from "@/components/ui/copy-button";
|
||||
import { MessageInput } from "@/components/chat-playground/message-input";
|
||||
import { MessageList } from "@/components/chat-playground/message-list";
|
||||
import { PromptSuggestions } from "@/components/chat-playground/prompt-suggestions";
|
||||
|
||||
interface ChatPropsBase {
|
||||
handleSubmit: (
|
||||
event?: { preventDefault?: () => void },
|
||||
options?: { experimental_attachments?: FileList }
|
||||
) => void
|
||||
messages: Array<Message>
|
||||
input: string
|
||||
className?: string
|
||||
handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement>
|
||||
isGenerating: boolean
|
||||
stop?: () => void
|
||||
) => void;
|
||||
messages: Array<Message>;
|
||||
input: string;
|
||||
className?: string;
|
||||
handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement>;
|
||||
isGenerating: boolean;
|
||||
stop?: () => void;
|
||||
onRateResponse?: (
|
||||
messageId: string,
|
||||
rating: "thumbs-up" | "thumbs-down"
|
||||
) => void
|
||||
setMessages?: (messages: any[]) => void
|
||||
transcribeAudio?: (blob: Blob) => Promise<string>
|
||||
) => void;
|
||||
setMessages?: (messages: Message[]) => void;
|
||||
transcribeAudio?: (blob: Blob) => Promise<string>;
|
||||
}
|
||||
|
||||
interface ChatPropsWithoutSuggestions extends ChatPropsBase {
|
||||
append?: never
|
||||
suggestions?: never
|
||||
append?: never;
|
||||
suggestions?: never;
|
||||
}
|
||||
|
||||
interface ChatPropsWithSuggestions extends ChatPropsBase {
|
||||
append: (message: { role: "user"; content: string }) => void
|
||||
suggestions: string[]
|
||||
append: (message: { role: "user"; content: string }) => void;
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
type ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions
|
||||
type ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions;
|
||||
|
||||
export function Chat({
|
||||
messages,
|
||||
|
@ -63,34 +63,34 @@ export function Chat({
|
|||
setMessages,
|
||||
transcribeAudio,
|
||||
}: ChatProps) {
|
||||
const lastMessage = messages.at(-1)
|
||||
const isEmpty = messages.length === 0
|
||||
const isTyping = lastMessage?.role === "user"
|
||||
const lastMessage = messages.at(-1);
|
||||
const isEmpty = messages.length === 0;
|
||||
const isTyping = lastMessage?.role === "user";
|
||||
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
// Enhanced stop function that marks pending tool calls as cancelled
|
||||
const handleStop = useCallback(() => {
|
||||
stop?.()
|
||||
stop?.();
|
||||
|
||||
if (!setMessages) return
|
||||
if (!setMessages) return;
|
||||
|
||||
const latestMessages = [...messagesRef.current]
|
||||
const latestMessages = [...messagesRef.current];
|
||||
const lastAssistantMessage = latestMessages.findLast(
|
||||
(m) => m.role === "assistant"
|
||||
)
|
||||
m => m.role === "assistant"
|
||||
);
|
||||
|
||||
if (!lastAssistantMessage) return
|
||||
if (!lastAssistantMessage) return;
|
||||
|
||||
let needsUpdate = false
|
||||
let updatedMessage = { ...lastAssistantMessage }
|
||||
let needsUpdate = false;
|
||||
let updatedMessage = { ...lastAssistantMessage };
|
||||
|
||||
if (lastAssistantMessage.toolInvocations) {
|
||||
const updatedToolInvocations = lastAssistantMessage.toolInvocations.map(
|
||||
(toolInvocation) => {
|
||||
toolInvocation => {
|
||||
if (toolInvocation.state === "call") {
|
||||
needsUpdate = true
|
||||
needsUpdate = true;
|
||||
return {
|
||||
...toolInvocation,
|
||||
state: "result",
|
||||
|
@ -98,28 +98,32 @@ export function Chat({
|
|||
content: "Tool execution was cancelled",
|
||||
__cancelled: true, // Special marker to indicate cancellation
|
||||
},
|
||||
} as const
|
||||
} as const;
|
||||
}
|
||||
return toolInvocation
|
||||
return toolInvocation;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
updatedMessage = {
|
||||
...updatedMessage,
|
||||
toolInvocations: updatedToolInvocations,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
part.type === "tool-invocation" &&
|
||||
part.toolInvocation &&
|
||||
part.toolInvocation.state === "call"
|
||||
) {
|
||||
needsUpdate = true
|
||||
needsUpdate = true;
|
||||
return {
|
||||
...part,
|
||||
toolInvocation: {
|
||||
|
@ -130,29 +134,30 @@ export function Chat({
|
|||
__cancelled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return part;
|
||||
}
|
||||
return part
|
||||
})
|
||||
);
|
||||
|
||||
if (needsUpdate) {
|
||||
updatedMessage = {
|
||||
...updatedMessage,
|
||||
parts: updatedParts,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
const messageIndex = latestMessages.findIndex(
|
||||
(m) => m.id === lastAssistantMessage.id
|
||||
)
|
||||
m => m.id === lastAssistantMessage.id
|
||||
);
|
||||
if (messageIndex !== -1) {
|
||||
latestMessages[messageIndex] = updatedMessage
|
||||
setMessages(latestMessages)
|
||||
latestMessages[messageIndex] = updatedMessage;
|
||||
setMessages(latestMessages);
|
||||
}
|
||||
}
|
||||
}, [stop, setMessages, messagesRef])
|
||||
}, [stop, setMessages, messagesRef]);
|
||||
|
||||
const messageOptions = useCallback(
|
||||
(message: Message) => ({
|
||||
|
@ -189,7 +194,7 @@ export function Chat({
|
|||
),
|
||||
}),
|
||||
[onRateResponse]
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatContainer className={className}>
|
||||
|
@ -198,7 +203,7 @@ export function Chat({
|
|||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="max-w-4xl mx-auto w-full">
|
||||
<PromptSuggestions
|
||||
label="Try these prompts ✨"
|
||||
label="Try asking a question ✨"
|
||||
append={append}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
|
@ -239,15 +244,15 @@ export function Chat({
|
|||
</div>
|
||||
</div>
|
||||
</ChatContainer>
|
||||
)
|
||||
);
|
||||
}
|
||||
Chat.displayName = "Chat"
|
||||
Chat.displayName = "Chat";
|
||||
|
||||
export function ChatMessages({
|
||||
messages,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
messages: Message[]
|
||||
messages: Message[];
|
||||
}>) {
|
||||
const {
|
||||
containerRef,
|
||||
|
@ -255,7 +260,7 @@ export function ChatMessages({
|
|||
handleScroll,
|
||||
shouldAutoScroll,
|
||||
handleTouchStart,
|
||||
} = useAutoScroll([messages])
|
||||
} = useAutoScroll([messages]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -264,7 +269,7 @@ export function ChatMessages({
|
|||
onScroll={handleScroll}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto w-full [grid-column:1/1] [grid-row:1/1]">
|
||||
<div className="max-w-full [grid-column:1/1] [grid-row:1/1]">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
@ -283,7 +288,7 @@ export function ChatMessages({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatContainer = forwardRef<
|
||||
|
@ -296,56 +301,56 @@ export const ChatContainer = forwardRef<
|
|||
className={cn("flex flex-col max-h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
ChatContainer.displayName = "ChatContainer"
|
||||
);
|
||||
});
|
||||
ChatContainer.displayName = "ChatContainer";
|
||||
|
||||
interface ChatFormProps {
|
||||
className?: string
|
||||
isPending: boolean
|
||||
className?: string;
|
||||
isPending: boolean;
|
||||
handleSubmit: (
|
||||
event?: { preventDefault?: () => void },
|
||||
options?: { experimental_attachments?: FileList }
|
||||
) => void
|
||||
) => void;
|
||||
children: (props: {
|
||||
files: File[] | null
|
||||
setFiles: React.Dispatch<React.SetStateAction<File[] | null>>
|
||||
}) => ReactElement
|
||||
files: File[] | null;
|
||||
setFiles: React.Dispatch<React.SetStateAction<File[] | null>>;
|
||||
}) => ReactElement;
|
||||
}
|
||||
|
||||
export const ChatForm = forwardRef<HTMLFormElement, ChatFormProps>(
|
||||
({ children, handleSubmit, isPending, className }, ref) => {
|
||||
const [files, setFiles] = useState<File[] | null>(null)
|
||||
const [files, setFiles] = useState<File[] | null>(null);
|
||||
|
||||
const onSubmit = (event: React.FormEvent) => {
|
||||
// if (isPending) {
|
||||
// event.preventDefault()
|
||||
// return
|
||||
// }
|
||||
if (isPending) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files) {
|
||||
handleSubmit(event)
|
||||
return
|
||||
handleSubmit(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileList = createFileList(files)
|
||||
handleSubmit(event, { experimental_attachments: fileList })
|
||||
setFiles(null)
|
||||
}
|
||||
const fileList = createFileList(files);
|
||||
handleSubmit(event, { experimental_attachments: fileList });
|
||||
setFiles(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<form ref={ref} onSubmit={onSubmit} className={className}>
|
||||
{children({ files, setFiles })}
|
||||
</form>
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
ChatForm.displayName = "ChatForm"
|
||||
);
|
||||
ChatForm.displayName = "ChatForm";
|
||||
|
||||
function createFileList(files: File[] | FileList): FileList {
|
||||
const dataTransfer = new DataTransfer()
|
||||
const dataTransfer = new DataTransfer();
|
||||
for (const file of Array.from(files)) {
|
||||
dataTransfer.items.add(file)
|
||||
dataTransfer.items.add(file);
|
||||
}
|
||||
return dataTransfer.files
|
||||
return dataTransfer.files;
|
||||
}
|
||||
|
|
355
llama_stack/ui/components/chat-playground/document-uploader.tsx
Normal file
355
llama_stack/ui/components/chat-playground/document-uploader.tsx
Normal file
|
@ -0,0 +1,355 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type LlamaStackClient from "llama-stack-client";
|
||||
|
||||
interface DocumentUploaderProps {
|
||||
client: LlamaStackClient;
|
||||
selectedVectorDb: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface UploadState {
|
||||
isUploading: boolean;
|
||||
uploadProgress: string;
|
||||
uploadError: string | null;
|
||||
}
|
||||
|
||||
export function DocumentUploader({
|
||||
client,
|
||||
selectedVectorDb,
|
||||
disabled,
|
||||
}: DocumentUploaderProps) {
|
||||
const [uploadState, setUploadState] = useState<UploadState>({
|
||||
isUploading: false,
|
||||
uploadProgress: "",
|
||||
uploadError: null,
|
||||
});
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] || null;
|
||||
setSelectedFile(file);
|
||||
if (file) {
|
||||
console.log("File selected:", file.name, file.size, "bytes");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
console.log("Upload button clicked");
|
||||
console.log("Files:", fileInputRef.current?.files?.length);
|
||||
console.log("Selected Vector DB:", selectedVectorDb);
|
||||
|
||||
if (
|
||||
!fileInputRef.current?.files?.length ||
|
||||
!selectedVectorDb ||
|
||||
selectedVectorDb === "none"
|
||||
) {
|
||||
console.log("Upload blocked: missing file or vector DB");
|
||||
setUploadState({
|
||||
isUploading: false,
|
||||
uploadProgress: "",
|
||||
uploadError:
|
||||
"Please select a file and ensure a Vector Database is selected",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileInputRef.current.files[0];
|
||||
console.log("Starting upload for:", file.name);
|
||||
setUploadState({
|
||||
isUploading: true,
|
||||
uploadProgress: `Uploading ${file.name}...`,
|
||||
uploadError: null,
|
||||
});
|
||||
|
||||
try {
|
||||
// Determine MIME type
|
||||
let mimeType = file.type;
|
||||
if (!mimeType) {
|
||||
// Fallback based on file extension
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
switch (extension) {
|
||||
case "pdf":
|
||||
mimeType = "application/pdf";
|
||||
break;
|
||||
case "txt":
|
||||
mimeType = "text/plain";
|
||||
break;
|
||||
case "md":
|
||||
mimeType = "text/markdown";
|
||||
break;
|
||||
default:
|
||||
mimeType = "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
setUploadState({
|
||||
isUploading: true,
|
||||
uploadProgress: `Reading ${file.name}...`,
|
||||
uploadError: null,
|
||||
});
|
||||
|
||||
// Use server-side file processing API for better efficiency
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("vectorDbId", selectedVectorDb);
|
||||
|
||||
setUploadState({
|
||||
isUploading: true,
|
||||
uploadProgress: `Processing ${file.name}...`,
|
||||
uploadError: null,
|
||||
});
|
||||
|
||||
const uploadResponse = await fetch("/api/upload-document", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
|
||||
}
|
||||
|
||||
const { content, mimeType: processedMimeType } =
|
||||
await uploadResponse.json();
|
||||
|
||||
// Use RagTool to insert the document
|
||||
console.log("Calling RagTool.insert with:", {
|
||||
vector_db_id: selectedVectorDb,
|
||||
chunk_size_in_tokens: 512,
|
||||
content_type: typeof content,
|
||||
content_length: typeof content === "string" ? content.length : "N/A",
|
||||
mime_type: processedMimeType,
|
||||
});
|
||||
|
||||
await client.toolRuntime.ragTool.insert({
|
||||
vector_db_id: selectedVectorDb,
|
||||
chunk_size_in_tokens: 512,
|
||||
documents: [
|
||||
{
|
||||
document_id: `file-${Date.now()}-${file.name}`,
|
||||
content: content,
|
||||
mime_type: processedMimeType,
|
||||
metadata: {
|
||||
source: file.name,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
file_size: file.size,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.log("RagTool.insert completed successfully");
|
||||
|
||||
const truncatedName =
|
||||
file.name.length > 20 ? file.name.substring(0, 20) + "..." : file.name;
|
||||
setUploadState({
|
||||
isUploading: false,
|
||||
uploadProgress: `Successfully uploaded ${truncatedName}`,
|
||||
uploadError: null,
|
||||
});
|
||||
|
||||
// Clear file input and selected file state
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
setSelectedFile(null);
|
||||
} catch (err) {
|
||||
console.error("Error uploading file:", err);
|
||||
setUploadState({
|
||||
isUploading: false,
|
||||
uploadProgress: "",
|
||||
uploadError:
|
||||
err instanceof Error ? err.message : "Failed to upload file",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlUpload = async () => {
|
||||
if (!urlInput.trim() || !selectedVectorDb || selectedVectorDb === "none")
|
||||
return;
|
||||
|
||||
setUploadState({
|
||||
isUploading: true,
|
||||
uploadProgress: `Fetching content from ${urlInput}...`,
|
||||
uploadError: null,
|
||||
});
|
||||
|
||||
try {
|
||||
// Determine MIME type from URL
|
||||
let mimeType = "text/html";
|
||||
const url = urlInput.toLowerCase();
|
||||
if (url.endsWith(".pdf")) {
|
||||
mimeType = "application/pdf";
|
||||
} else if (url.endsWith(".txt")) {
|
||||
mimeType = "text/plain";
|
||||
} else if (url.endsWith(".md")) {
|
||||
mimeType = "text/markdown";
|
||||
}
|
||||
|
||||
setUploadState({
|
||||
isUploading: true,
|
||||
uploadProgress: `Processing content from ${urlInput}...`,
|
||||
uploadError: null,
|
||||
});
|
||||
|
||||
// Use RagTool to insert the document from URL
|
||||
await client.toolRuntime.ragTool.insert({
|
||||
vector_db_id: selectedVectorDb,
|
||||
chunk_size_in_tokens: 512,
|
||||
documents: [
|
||||
{
|
||||
document_id: `url-${Date.now()}-${encodeURIComponent(urlInput)}`,
|
||||
content: urlInput,
|
||||
mime_type: mimeType,
|
||||
metadata: {
|
||||
source: urlInput,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const truncatedUrl =
|
||||
urlInput.length > 30 ? urlInput.substring(0, 30) + "..." : urlInput;
|
||||
setUploadState({
|
||||
isUploading: false,
|
||||
uploadProgress: `Successfully processed ${truncatedUrl}`,
|
||||
uploadError: null,
|
||||
});
|
||||
|
||||
setUrlInput("");
|
||||
} catch (err) {
|
||||
console.error("Error uploading URL:", err);
|
||||
setUploadState({
|
||||
isUploading: false,
|
||||
uploadProgress: "",
|
||||
uploadError:
|
||||
err instanceof Error ? err.message : "Failed to process URL content",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const clearStatus = () => {
|
||||
setUploadState({
|
||||
isUploading: false,
|
||||
uploadProgress: "",
|
||||
uploadError: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (!selectedVectorDb || selectedVectorDb === "none") {
|
||||
return (
|
||||
<div className="text-sm text-gray-500 text-center py-4">
|
||||
Select a Vector Database to upload documents
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-md font-medium">Upload Documents</h3>
|
||||
|
||||
{uploadState.uploadError && (
|
||||
<div className="p-2 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-foreground">{uploadState.uploadError}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearStatus}
|
||||
className="mt-1 h-6 px-2 text-xs"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState.uploadProgress && (
|
||||
<div className="p-2 bg-muted border border-border rounded-md">
|
||||
<p className="text-sm text-foreground">
|
||||
{uploadState.uploadProgress}
|
||||
</p>
|
||||
{uploadState.isUploading && (
|
||||
<div className="mt-2 w-full bg-secondary rounded-full h-1">
|
||||
<div
|
||||
className="bg-primary h-1 rounded-full animate-pulse"
|
||||
style={{ width: "60%" }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium block">Upload File</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.pdf,.md"
|
||||
onChange={handleFileSelect}
|
||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||
disabled={disabled || uploadState.isUploading}
|
||||
/>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="p-2 bg-muted border border-border rounded-md">
|
||||
<p className="text-sm text-foreground">
|
||||
Selected:{" "}
|
||||
<span className="font-medium">
|
||||
{selectedFile.name.length > 25
|
||||
? selectedFile.name.substring(0, 25) + "..."
|
||||
: selectedFile.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleFileUpload}
|
||||
disabled={disabled || !selectedFile || uploadState.isUploading}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{uploadState.isUploading
|
||||
? "Uploading..."
|
||||
: selectedFile
|
||||
? "Upload File"
|
||||
: "Upload File"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* URL Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium block">Or Enter URL</label>
|
||||
<Input
|
||||
value={urlInput}
|
||||
onChange={e => setUrlInput(e.target.value)}
|
||||
placeholder="https://example.com/document.pdf"
|
||||
disabled={disabled || uploadState.isUploading}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUrlUpload}
|
||||
disabled={disabled || !urlInput.trim() || uploadState.isUploading}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{uploadState.isUploading ? "Processing..." : "Process URL"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
Supported formats: PDF, TXT, MD files and web URLs
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { X } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface InterruptPromptProps {
|
||||
isOpen: boolean
|
||||
close: () => void
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function InterruptPrompt({ isOpen, close }: InterruptPromptProps) {
|
||||
|
@ -37,5 +37,5 @@ export function InterruptPrompt({ isOpen, close }: InterruptPromptProps) {
|
|||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import React, { Suspense, useEffect, useState } from "react"
|
||||
import Markdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CopyButton } from "@/components/ui/copy-button"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CopyButton } from "@/components/ui/copy-button";
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
children: string
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ children }: MarkdownRendererProps) {
|
||||
|
@ -16,34 +16,34 @@ export function MarkdownRenderer({ children }: MarkdownRendererProps) {
|
|||
{children}
|
||||
</Markdown>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface HighlightedPre extends React.HTMLAttributes<HTMLPreElement> {
|
||||
children: string
|
||||
language: string
|
||||
children: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const HighlightedPre = React.memo(
|
||||
({ children, language, ...props }: HighlightedPre) => {
|
||||
const [tokens, setTokens] = useState<any[] | null>(null)
|
||||
const [isSupported, setIsSupported] = useState(false)
|
||||
const [tokens, setTokens] = useState<unknown[] | null>(null);
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
let mounted = true;
|
||||
|
||||
const loadAndHighlight = async () => {
|
||||
try {
|
||||
const { codeToTokens, bundledLanguages } = await import("shiki")
|
||||
const { codeToTokens, bundledLanguages } = await import("shiki");
|
||||
|
||||
if (!mounted) return
|
||||
if (!mounted) return;
|
||||
|
||||
if (!(language in bundledLanguages)) {
|
||||
setIsSupported(false)
|
||||
return
|
||||
setIsSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSupported(true)
|
||||
setIsSupported(true);
|
||||
|
||||
const { tokens: highlightedTokens } = await codeToTokens(children, {
|
||||
lang: language as keyof typeof bundledLanguages,
|
||||
|
@ -52,31 +52,31 @@ const HighlightedPre = React.memo(
|
|||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
setTokens(highlightedTokens)
|
||||
setTokens(highlightedTokens);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setIsSupported(false)
|
||||
}
|
||||
setIsSupported(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadAndHighlight()
|
||||
loadAndHighlight();
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [children, language])
|
||||
mounted = false;
|
||||
};
|
||||
}, [children, language]);
|
||||
|
||||
if (!isSupported) {
|
||||
return <pre {...props}>{children}</pre>
|
||||
return <pre {...props}>{children}</pre>;
|
||||
}
|
||||
|
||||
if (!tokens) {
|
||||
return <pre {...props}>{children}</pre>
|
||||
return <pre {...props}>{children}</pre>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -89,7 +89,7 @@ const HighlightedPre = React.memo(
|
|||
const style =
|
||||
typeof token.htmlStyle === "string"
|
||||
? undefined
|
||||
: token.htmlStyle
|
||||
: token.htmlStyle;
|
||||
|
||||
return (
|
||||
<span
|
||||
|
@ -99,7 +99,7 @@ const HighlightedPre = React.memo(
|
|||
>
|
||||
{token.content}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
{lineIndex !== tokens.length - 1 && "\n"}
|
||||
|
@ -107,15 +107,15 @@ const HighlightedPre = React.memo(
|
|||
))}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
HighlightedPre.displayName = "HighlightedCode"
|
||||
);
|
||||
HighlightedPre.displayName = "HighlightedCode";
|
||||
|
||||
interface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
language: string
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const CodeBlock = ({
|
||||
|
@ -127,12 +127,12 @@ const CodeBlock = ({
|
|||
const code =
|
||||
typeof children === "string"
|
||||
? children
|
||||
: childrenTakeAllStringContents(children)
|
||||
: childrenTakeAllStringContents(children);
|
||||
|
||||
const preClass = cn(
|
||||
"overflow-x-scroll rounded-md border bg-background/50 p-4 font-mono text-sm [scrollbar-width:none]",
|
||||
className
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group/code relative mb-4">
|
||||
|
@ -152,27 +152,27 @@ const CodeBlock = ({
|
|||
<CopyButton content={code} copyMessage="Copied code to clipboard" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function childrenTakeAllStringContents(element: any): string {
|
||||
function childrenTakeAllStringContents(element: unknown): string {
|
||||
if (typeof element === "string") {
|
||||
return element
|
||||
return element;
|
||||
}
|
||||
|
||||
if (element?.props?.children) {
|
||||
let children = element.props.children
|
||||
const children = element.props.children;
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children
|
||||
.map((child) => childrenTakeAllStringContents(child))
|
||||
.join("")
|
||||
.map(child => childrenTakeAllStringContents(child))
|
||||
.join("");
|
||||
} else {
|
||||
return childrenTakeAllStringContents(children)
|
||||
return childrenTakeAllStringContents(children);
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
return "";
|
||||
}
|
||||
|
||||
const COMPONENTS = {
|
||||
|
@ -184,8 +184,14 @@ const COMPONENTS = {
|
|||
strong: withClass("strong", "font-semibold"),
|
||||
a: withClass("a", "text-primary underline underline-offset-2"),
|
||||
blockquote: withClass("blockquote", "border-l-2 border-primary pl-4"),
|
||||
code: ({ children, className, node, ...rest }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || "")
|
||||
code: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return match ? (
|
||||
<CodeBlock className={className} language={match[1]} {...rest}>
|
||||
{children}
|
||||
|
@ -199,9 +205,9 @@ const COMPONENTS = {
|
|||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
);
|
||||
},
|
||||
pre: ({ children }: any) => children,
|
||||
pre: ({ children }: { children: React.ReactNode }) => children,
|
||||
ol: withClass("ol", "list-decimal space-y-2 pl-6"),
|
||||
ul: withClass("ul", "list-disc space-y-2 pl-6"),
|
||||
li: withClass("li", "my-1.5"),
|
||||
|
@ -220,14 +226,14 @@ const COMPONENTS = {
|
|||
tr: withClass("tr", "m-0 border-t p-0 even:bg-muted"),
|
||||
p: withClass("p", "whitespace-pre-wrap"),
|
||||
hr: withClass("hr", "border-foreground/20"),
|
||||
}
|
||||
};
|
||||
|
||||
function withClass(Tag: keyof JSX.IntrinsicElements, classes: string) {
|
||||
const Component = ({ node, ...props }: any) => (
|
||||
const Component = ({ ...props }: Record<string, unknown>) => (
|
||||
<Tag className={classes} {...props} />
|
||||
)
|
||||
Component.displayName = Tag
|
||||
return Component
|
||||
);
|
||||
Component.displayName = Tag;
|
||||
return Component;
|
||||
}
|
||||
|
||||
export default MarkdownRenderer
|
||||
export default MarkdownRenderer;
|
||||
|
|
|
@ -1,41 +1,41 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ArrowUp, Info, Loader2, Mic, Paperclip, Square } from "lucide-react"
|
||||
import { omit } from "remeda"
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowUp, Info, Loader2, Mic, Paperclip, Square } from "lucide-react";
|
||||
import { omit } from "remeda";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAudioRecording } from "@/hooks/use-audio-recording"
|
||||
import { useAutosizeTextArea } from "@/hooks/use-autosize-textarea"
|
||||
import { AudioVisualizer } from "@/components/ui/audio-visualizer"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FilePreview } from "@/components/ui/file-preview"
|
||||
import { InterruptPrompt } from "@/components/chat-playground/interrupt-prompt"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAudioRecording } from "@/hooks/use-audio-recording";
|
||||
import { useAutosizeTextArea } from "@/hooks/use-autosize-textarea";
|
||||
import { AudioVisualizer } from "@/components/ui/audio-visualizer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FilePreview } from "@/components/ui/file-preview";
|
||||
import { InterruptPrompt } from "@/components/chat-playground/interrupt-prompt";
|
||||
|
||||
interface MessageInputBaseProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
value: string
|
||||
submitOnEnter?: boolean
|
||||
stop?: () => void
|
||||
isGenerating: boolean
|
||||
enableInterrupt?: boolean
|
||||
transcribeAudio?: (blob: Blob) => Promise<string>
|
||||
value: string;
|
||||
submitOnEnter?: boolean;
|
||||
stop?: () => void;
|
||||
isGenerating: boolean;
|
||||
enableInterrupt?: boolean;
|
||||
transcribeAudio?: (blob: Blob) => Promise<string>;
|
||||
}
|
||||
|
||||
interface MessageInputWithoutAttachmentProps extends MessageInputBaseProps {
|
||||
allowAttachments?: false
|
||||
allowAttachments?: false;
|
||||
}
|
||||
|
||||
interface MessageInputWithAttachmentsProps extends MessageInputBaseProps {
|
||||
allowAttachments: true
|
||||
files: File[] | null
|
||||
setFiles: React.Dispatch<React.SetStateAction<File[] | null>>
|
||||
allowAttachments: true;
|
||||
files: File[] | null;
|
||||
setFiles: React.Dispatch<React.SetStateAction<File[] | null>>;
|
||||
}
|
||||
|
||||
type MessageInputProps =
|
||||
| MessageInputWithoutAttachmentProps
|
||||
| MessageInputWithAttachmentsProps
|
||||
| MessageInputWithAttachmentsProps;
|
||||
|
||||
export function MessageInput({
|
||||
placeholder = "Ask AI...",
|
||||
|
@ -48,8 +48,8 @@ export function MessageInput({
|
|||
transcribeAudio,
|
||||
...props
|
||||
}: MessageInputProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [showInterruptPrompt, setShowInterruptPrompt] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showInterruptPrompt, setShowInterruptPrompt] = useState(false);
|
||||
|
||||
const {
|
||||
isListening,
|
||||
|
@ -61,123 +61,124 @@ export function MessageInput({
|
|||
stopRecording,
|
||||
} = useAudioRecording({
|
||||
transcribeAudio,
|
||||
onTranscriptionComplete: (text) => {
|
||||
props.onChange?.({ target: { value: text } } as any)
|
||||
onTranscriptionComplete: text => {
|
||||
props.onChange?.({
|
||||
target: { value: text },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGenerating) {
|
||||
setShowInterruptPrompt(false)
|
||||
setShowInterruptPrompt(false);
|
||||
}
|
||||
}, [isGenerating])
|
||||
}, [isGenerating]);
|
||||
|
||||
const addFiles = (files: File[] | null) => {
|
||||
if (props.allowAttachments) {
|
||||
props.setFiles((currentFiles) => {
|
||||
props.setFiles(currentFiles => {
|
||||
if (currentFiles === null) {
|
||||
return files
|
||||
return files;
|
||||
}
|
||||
|
||||
if (files === null) {
|
||||
return currentFiles
|
||||
return currentFiles;
|
||||
}
|
||||
|
||||
return [...currentFiles, ...files]
|
||||
})
|
||||
}
|
||||
return [...currentFiles, ...files];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (event: React.DragEvent) => {
|
||||
if (props.allowAttachments !== true) return
|
||||
event.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
if (props.allowAttachments !== true) return;
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const onDragLeave = (event: React.DragEvent) => {
|
||||
if (props.allowAttachments !== true) return
|
||||
event.preventDefault()
|
||||
setIsDragging(false)
|
||||
}
|
||||
if (props.allowAttachments !== true) return;
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const onDrop = (event: React.DragEvent) => {
|
||||
setIsDragging(false)
|
||||
if (props.allowAttachments !== true) return
|
||||
event.preventDefault()
|
||||
const dataTransfer = event.dataTransfer
|
||||
setIsDragging(false);
|
||||
if (props.allowAttachments !== true) return;
|
||||
event.preventDefault();
|
||||
const dataTransfer = event.dataTransfer;
|
||||
if (dataTransfer.files.length) {
|
||||
addFiles(Array.from(dataTransfer.files))
|
||||
}
|
||||
addFiles(Array.from(dataTransfer.files));
|
||||
}
|
||||
};
|
||||
|
||||
const onPaste = (event: React.ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const text = event.clipboardData.getData("text")
|
||||
const text = event.clipboardData.getData("text");
|
||||
if (text && text.length > 500 && props.allowAttachments) {
|
||||
event.preventDefault()
|
||||
const blob = new Blob([text], { type: "text/plain" })
|
||||
event.preventDefault();
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
const file = new File([blob], "Pasted text", {
|
||||
type: "text/plain",
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
addFiles([file])
|
||||
return
|
||||
});
|
||||
addFiles([file]);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file) => file !== null)
|
||||
.map(item => item.getAsFile())
|
||||
.filter(file => file !== null);
|
||||
|
||||
if (props.allowAttachments && files.length > 0) {
|
||||
addFiles(files)
|
||||
}
|
||||
addFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (submitOnEnter && event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
event.preventDefault();
|
||||
|
||||
if (isGenerating && stop && enableInterrupt) {
|
||||
if (showInterruptPrompt) {
|
||||
stop()
|
||||
setShowInterruptPrompt(false)
|
||||
event.currentTarget.form?.requestSubmit()
|
||||
stop();
|
||||
setShowInterruptPrompt(false);
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
} else if (
|
||||
props.value ||
|
||||
(props.allowAttachments && props.files?.length)
|
||||
) {
|
||||
setShowInterruptPrompt(true)
|
||||
return
|
||||
setShowInterruptPrompt(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
event.currentTarget.form?.requestSubmit()
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
|
||||
onKeyDownProp?.(event)
|
||||
}
|
||||
onKeyDownProp?.(event);
|
||||
};
|
||||
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [textAreaHeight, setTextAreaHeight] = useState<number>(0)
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [textAreaHeight, setTextAreaHeight] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (textAreaRef.current) {
|
||||
setTextAreaHeight(textAreaRef.current.offsetHeight)
|
||||
setTextAreaHeight(textAreaRef.current.offsetHeight);
|
||||
}
|
||||
}, [props.value])
|
||||
}, [props.value]);
|
||||
|
||||
const showFileList =
|
||||
props.allowAttachments && props.files && props.files.length > 0
|
||||
|
||||
props.allowAttachments && props.files && props.files.length > 0;
|
||||
|
||||
useAutosizeTextArea({
|
||||
ref: textAreaRef,
|
||||
maxHeight: 240,
|
||||
borderWidth: 1,
|
||||
dependencies: [props.value, showFileList],
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<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="flex space-x-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{props.files?.map((file) => {
|
||||
{props.files?.map(file => {
|
||||
return (
|
||||
<FilePreview
|
||||
key={file.name + String(file.lastModified)}
|
||||
file={file}
|
||||
onRemove={() => {
|
||||
props.setFiles((files) => {
|
||||
if (!files) return null
|
||||
props.setFiles(files => {
|
||||
if (!files) return null;
|
||||
|
||||
const filtered = Array.from(files).filter(
|
||||
(f) => f !== file
|
||||
)
|
||||
if (filtered.length === 0) return null
|
||||
return filtered
|
||||
})
|
||||
f => f !== file
|
||||
);
|
||||
if (filtered.length === 0) return null;
|
||||
return filtered;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
@ -256,8 +257,8 @@ export function MessageInput({
|
|||
aria-label="Attach a file"
|
||||
disabled={true}
|
||||
onClick={async () => {
|
||||
const files = await showFileUploadDialog()
|
||||
addFiles(files)
|
||||
const files = await showFileUploadDialog();
|
||||
addFiles(files);
|
||||
}}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
|
@ -308,12 +309,12 @@ export function MessageInput({
|
|||
onStopRecording={stopRecording}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
MessageInput.displayName = "MessageInput"
|
||||
MessageInput.displayName = "MessageInput";
|
||||
|
||||
interface FileUploadOverlayProps {
|
||||
isDragging: boolean
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
function FileUploadOverlay({ isDragging }: FileUploadOverlayProps) {
|
||||
|
@ -333,29 +334,29 @@ function FileUploadOverlay({ isDragging }: FileUploadOverlayProps) {
|
|||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function showFileUploadDialog() {
|
||||
const input = document.createElement("input")
|
||||
const input = document.createElement("input");
|
||||
|
||||
input.type = "file"
|
||||
input.multiple = true
|
||||
input.accept = "*/*"
|
||||
input.click()
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = "*/*";
|
||||
input.click();
|
||||
|
||||
return new Promise<File[] | null>((resolve) => {
|
||||
input.onchange = (e) => {
|
||||
const files = (e.currentTarget as HTMLInputElement).files
|
||||
return new Promise<File[] | null>(resolve => {
|
||||
input.onchange = e => {
|
||||
const files = (e.currentTarget as HTMLInputElement).files;
|
||||
|
||||
if (files) {
|
||||
resolve(Array.from(files))
|
||||
return
|
||||
resolve(Array.from(files));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function TranscribingOverlay() {
|
||||
|
@ -385,12 +386,12 @@ function TranscribingOverlay() {
|
|||
Transcribing audio...
|
||||
</p>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface RecordingPromptProps {
|
||||
isVisible: boolean
|
||||
onStopRecording: () => void
|
||||
isVisible: boolean;
|
||||
onStopRecording: () => void;
|
||||
}
|
||||
|
||||
function RecordingPrompt({ isVisible, onStopRecording }: RecordingPromptProps) {
|
||||
|
@ -418,15 +419,15 @@ function RecordingPrompt({ isVisible, onStopRecording }: RecordingPromptProps) {
|
|||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface RecordingControlsProps {
|
||||
isRecording: boolean
|
||||
isTranscribing: boolean
|
||||
audioStream: MediaStream | null
|
||||
textAreaHeight: number
|
||||
onStopRecording: () => void
|
||||
isRecording: boolean;
|
||||
isTranscribing: boolean;
|
||||
audioStream: MediaStream | null;
|
||||
textAreaHeight: number;
|
||||
onStopRecording: () => void;
|
||||
}
|
||||
|
||||
function RecordingControls({
|
||||
|
@ -448,7 +449,7 @@ function RecordingControls({
|
|||
onClick={onStopRecording}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isTranscribing) {
|
||||
|
@ -459,8 +460,8 @@ function RecordingControls({
|
|||
>
|
||||
<TranscribingOverlay />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -2,18 +2,18 @@ import {
|
|||
ChatMessage,
|
||||
type ChatMessageProps,
|
||||
type Message,
|
||||
} from "@/components/chat-playground/chat-message"
|
||||
import { TypingIndicator } from "@/components/chat-playground/typing-indicator"
|
||||
} from "@/components/chat-playground/chat-message";
|
||||
import { TypingIndicator } from "@/components/chat-playground/typing-indicator";
|
||||
|
||||
type AdditionalMessageOptions = Omit<ChatMessageProps, keyof Message>
|
||||
type AdditionalMessageOptions = Omit<ChatMessageProps, keyof Message>;
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[]
|
||||
showTimeStamps?: boolean
|
||||
isTyping?: boolean
|
||||
messages: Message[];
|
||||
showTimeStamps?: boolean;
|
||||
isTyping?: boolean;
|
||||
messageOptions?:
|
||||
| AdditionalMessageOptions
|
||||
| ((message: Message) => AdditionalMessageOptions)
|
||||
| ((message: Message) => AdditionalMessageOptions);
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
|
@ -28,7 +28,7 @@ export function MessageList({
|
|||
const additionalOptions =
|
||||
typeof messageOptions === "function"
|
||||
? messageOptions(message)
|
||||
: messageOptions
|
||||
: messageOptions;
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
|
@ -37,9 +37,9 @@ export function MessageList({
|
|||
{...message}
|
||||
{...additionalOptions}
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
{isTyping && <TypingIndicator />}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
interface PromptSuggestionsProps {
|
||||
label: string
|
||||
append: (message: { role: "user"; content: string }) => void
|
||||
suggestions: string[]
|
||||
label: string;
|
||||
append: (message: { role: "user"; content: string }) => void;
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export function PromptSuggestions({
|
||||
|
@ -13,7 +13,7 @@ export function PromptSuggestions({
|
|||
<div className="space-y-6">
|
||||
<h2 className="text-center text-2xl font-bold">{label}</h2>
|
||||
<div className="flex gap-6 text-sm">
|
||||
{suggestions.map((suggestion) => (
|
||||
{suggestions.map(suggestion => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => append({ role: "user", content: suggestion })}
|
||||
|
@ -24,5 +24,5 @@ export function PromptSuggestions({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ interface ChatSession {
|
|||
messages: Message[];
|
||||
selectedModel: string;
|
||||
selectedVectorDb: string;
|
||||
systemMessage: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
@ -29,10 +30,13 @@ interface SessionManagerProps {
|
|||
onNewSession: () => void;
|
||||
}
|
||||
|
||||
const SESSIONS_STORAGE_KEY = 'chat-playground-sessions';
|
||||
const CURRENT_SESSION_KEY = 'chat-playground-current-session';
|
||||
const SESSIONS_STORAGE_KEY = "chat-playground-sessions";
|
||||
const CURRENT_SESSION_KEY = "chat-playground-current-session";
|
||||
|
||||
export function SessionManager({ currentSession, onSessionChange, onNewSession }: SessionManagerProps) {
|
||||
export function SessionManager({
|
||||
currentSession,
|
||||
onSessionChange,
|
||||
}: SessionManagerProps) {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newSessionName, setNewSessionName] = useState("");
|
||||
|
@ -56,13 +60,16 @@ export function SessionManager({ currentSession, onSessionChange, onNewSession }
|
|||
};
|
||||
|
||||
const createNewSession = () => {
|
||||
const sessionName = newSessionName.trim() || `Session ${sessions.length + 1}`;
|
||||
const sessionName =
|
||||
newSessionName.trim() || `Session ${sessions.length + 1}`;
|
||||
const newSession: ChatSession = {
|
||||
id: Date.now().toString(),
|
||||
name: sessionName,
|
||||
messages: [],
|
||||
selectedModel: currentSession?.selectedModel || "",
|
||||
selectedVectorDb: currentSession?.selectedVectorDb || "",
|
||||
systemMessage:
|
||||
currentSession?.systemMessage || "You are a helpful assistant.",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
@ -85,48 +92,27 @@ export function SessionManager({ currentSession, onSessionChange, onNewSession }
|
|||
}
|
||||
};
|
||||
|
||||
// These functions are available for future use but not currently implemented in UI
|
||||
// const deleteSession = (sessionId: string) => {
|
||||
// const updatedSessions = sessions.filter(s => s.id !== sessionId);
|
||||
// saveSessions(updatedSessions);
|
||||
|
||||
// // If we deleted the current session, switch to the first available or create new
|
||||
// if (currentSession?.id === sessionId) {
|
||||
// if (updatedSessions.length > 0) {
|
||||
// switchToSession(updatedSessions[0].id);
|
||||
// } else {
|
||||
// localStorage.removeItem(CURRENT_SESSION_KEY);
|
||||
// onNewSession();
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
|
||||
// const renameSession = (sessionId: string, newName: string) => {
|
||||
// const updatedSessions = sessions.map(session =>
|
||||
// session.id === sessionId
|
||||
// ? { ...session, name: newName, updatedAt: Date.now() }
|
||||
// : session
|
||||
// );
|
||||
// saveSessions(updatedSessions);
|
||||
|
||||
// if (currentSession?.id === sessionId) {
|
||||
// onSessionChange({ ...currentSession, name: newName });
|
||||
// }
|
||||
// };
|
||||
|
||||
// Update current session in the sessions list
|
||||
useEffect(() => {
|
||||
if (currentSession) {
|
||||
const updatedSessions = sessions.map(session =>
|
||||
setSessions(prevSessions => {
|
||||
const updatedSessions = prevSessions.map(session =>
|
||||
session.id === currentSession.id ? currentSession : session
|
||||
);
|
||||
|
||||
// Add session if it doesn't exist
|
||||
if (!sessions.find(s => s.id === currentSession.id)) {
|
||||
if (!prevSessions.find(s => s.id === currentSession.id)) {
|
||||
updatedSessions.push(currentSession);
|
||||
}
|
||||
|
||||
saveSessions(updatedSessions);
|
||||
// Save to localStorage
|
||||
localStorage.setItem(
|
||||
SESSIONS_STORAGE_KEY,
|
||||
JSON.stringify(updatedSessions)
|
||||
);
|
||||
|
||||
return updatedSessions;
|
||||
});
|
||||
}
|
||||
}, [currentSession]);
|
||||
|
||||
|
@ -141,7 +127,7 @@ export function SessionManager({ currentSession, onSessionChange, onNewSession }
|
|||
<SelectValue placeholder="Select Session" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sessions.map((session) => (
|
||||
{sessions.map(session => (
|
||||
<SelectItem key={session.id} value={session.id}>
|
||||
{session.name}
|
||||
</SelectItem>
|
||||
|
@ -164,12 +150,12 @@ export function SessionManager({ currentSession, onSessionChange, onNewSession }
|
|||
|
||||
<Input
|
||||
value={newSessionName}
|
||||
onChange={(e) => setNewSessionName(e.target.value)}
|
||||
onChange={e => setNewSessionName(e.target.value)}
|
||||
placeholder="Session name (optional)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") {
|
||||
createNewSession();
|
||||
} else if (e.key === 'Escape') {
|
||||
} else if (e.key === "Escape") {
|
||||
setShowCreateForm(false);
|
||||
setNewSessionName("");
|
||||
}
|
||||
|
@ -197,7 +183,8 @@ export function SessionManager({ currentSession, onSessionChange, onNewSession }
|
|||
{currentSession && sessions.length > 1 && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
{sessions.length} sessions • Current: {currentSession.name}
|
||||
{currentSession.messages.length > 0 && ` • ${currentSession.messages.length} messages`}
|
||||
{currentSession.messages.length > 0 &&
|
||||
` • ${currentSession.messages.length} messages`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -237,19 +224,27 @@ export const SessionUtils = {
|
|||
if (existingIndex >= 0) {
|
||||
sessions[existingIndex] = { ...session, updatedAt: Date.now() };
|
||||
} else {
|
||||
sessions.push({ ...session, createdAt: Date.now(), updatedAt: Date.now() });
|
||||
sessions.push({
|
||||
...session,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(sessions));
|
||||
localStorage.setItem(CURRENT_SESSION_KEY, session.id);
|
||||
},
|
||||
|
||||
createDefaultSession: (inheritModel?: string, inheritVectorDb?: string): ChatSession => ({
|
||||
createDefaultSession: (
|
||||
inheritModel?: string,
|
||||
inheritVectorDb?: string
|
||||
): ChatSession => ({
|
||||
id: Date.now().toString(),
|
||||
name: "Default Session",
|
||||
messages: [],
|
||||
selectedModel: inheritModel || "",
|
||||
selectedVectorDb: inheritVectorDb || "",
|
||||
systemMessage: "You are a helpful assistant.",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Dot } from "lucide-react"
|
||||
import { Dot } from "lucide-react";
|
||||
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
|
@ -11,5 +11,5 @@ export function TypingIndicator() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -72,14 +72,15 @@ export function AppSidebar() {
|
|||
className={cn(
|
||||
"justify-start",
|
||||
pathname.startsWith(chatPlaygroundItem.url) &&
|
||||
"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={chatPlaygroundItem.url}>
|
||||
<chatPlaygroundItem.icon
|
||||
className={cn(
|
||||
pathname.startsWith(chatPlaygroundItem.url) && "text-gray-900 dark:text-gray-100",
|
||||
"mr-2 h-4 w-4",
|
||||
pathname.startsWith(chatPlaygroundItem.url) &&
|
||||
"text-gray-900 dark:text-gray-100",
|
||||
"mr-2 h-4 w-4"
|
||||
)}
|
||||
/>
|
||||
<span>{chatPlaygroundItem.title}</span>
|
||||
|
@ -95,7 +96,7 @@ export function AppSidebar() {
|
|||
<SidebarGroupLabel>Logs</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{logItems.map((item) => {
|
||||
{logItems.map(item => {
|
||||
const isActive = pathname.startsWith(item.url);
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
|
@ -104,14 +105,14 @@ export function AppSidebar() {
|
|||
className={cn(
|
||||
"justify-start",
|
||||
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}>
|
||||
<item.icon
|
||||
className={cn(
|
||||
isActive && "text-gray-900 dark:text-gray-100",
|
||||
"mr-2 h-4 w-4",
|
||||
"mr-2 h-4 w-4"
|
||||
)}
|
||||
/>
|
||||
<span>{item.title}</span>
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function DetailLoadingView({ title }: { title: string }) {
|
||||
export function DetailLoadingView() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-8 w-3/4 mb-6" /> {/* Title Skeleton */}
|
||||
|
|
|
@ -67,7 +67,7 @@ describe("LogsTable Viewport Loading", () => {
|
|||
() => {
|
||||
expect(mockLoadMore).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 300 },
|
||||
{ timeout: 300 }
|
||||
);
|
||||
|
||||
expect(mockLoadMore).toHaveBeenCalledTimes(1);
|
||||
|
@ -81,11 +81,11 @@ describe("LogsTable Viewport Loading", () => {
|
|||
{...defaultProps}
|
||||
status="loading-more"
|
||||
onLoadMore={mockLoadMore}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for possible triggers
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
expect(mockLoadMore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
@ -94,15 +94,11 @@ describe("LogsTable Viewport Loading", () => {
|
|||
const mockLoadMore = jest.fn();
|
||||
|
||||
render(
|
||||
<LogsTable
|
||||
{...defaultProps}
|
||||
status="loading"
|
||||
onLoadMore={mockLoadMore}
|
||||
/>,
|
||||
<LogsTable {...defaultProps} status="loading" onLoadMore={mockLoadMore} />
|
||||
);
|
||||
|
||||
// Wait for possible triggers
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
expect(mockLoadMore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
@ -111,18 +107,18 @@ describe("LogsTable Viewport Loading", () => {
|
|||
const mockLoadMore = jest.fn();
|
||||
|
||||
render(
|
||||
<LogsTable {...defaultProps} hasMore={false} onLoadMore={mockLoadMore} />,
|
||||
<LogsTable {...defaultProps} hasMore={false} onLoadMore={mockLoadMore} />
|
||||
);
|
||||
|
||||
// Wait for possible triggers
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
expect(mockLoadMore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sentinel element should not be rendered when loading", () => {
|
||||
const { container } = render(
|
||||
<LogsTable {...defaultProps} status="loading-more" />,
|
||||
<LogsTable {...defaultProps} status="loading-more" />
|
||||
);
|
||||
|
||||
// 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", () => {
|
||||
const { container } = render(
|
||||
<LogsTable {...defaultProps} hasMore={true} status="idle" />,
|
||||
<LogsTable {...defaultProps} hasMore={true} status="idle" />
|
||||
);
|
||||
|
||||
// Check that sentinel row exists
|
||||
|
|
|
@ -70,7 +70,7 @@ describe("LogsTable", () => {
|
|||
describe("Loading State", () => {
|
||||
test("renders skeleton UI when isLoading is true", () => {
|
||||
const { container } = render(
|
||||
<LogsTable {...defaultProps} status="loading" />,
|
||||
<LogsTable {...defaultProps} status="loading" />
|
||||
);
|
||||
|
||||
// Check for skeleton in the table caption
|
||||
|
@ -78,7 +78,7 @@ describe("LogsTable", () => {
|
|||
expect(tableCaption).toBeInTheDocument();
|
||||
if (tableCaption) {
|
||||
const captionSkeleton = tableCaption.querySelector(
|
||||
'[data-slot="skeleton"]',
|
||||
'[data-slot="skeleton"]'
|
||||
);
|
||||
expect(captionSkeleton).toBeInTheDocument();
|
||||
}
|
||||
|
@ -88,7 +88,7 @@ describe("LogsTable", () => {
|
|||
expect(tableBody).toBeInTheDocument();
|
||||
if (tableBody) {
|
||||
const bodySkeletons = tableBody.querySelectorAll(
|
||||
'[data-slot="skeleton"]',
|
||||
'[data-slot="skeleton"]'
|
||||
);
|
||||
expect(bodySkeletons.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
@ -102,7 +102,7 @@ describe("LogsTable", () => {
|
|||
|
||||
test("renders correct number of skeleton rows", () => {
|
||||
const { container } = render(
|
||||
<LogsTable {...defaultProps} status="loading" />,
|
||||
<LogsTable {...defaultProps} status="loading" />
|
||||
);
|
||||
|
||||
const skeletonRows = container.querySelectorAll("tbody tr");
|
||||
|
@ -118,10 +118,10 @@ describe("LogsTable", () => {
|
|||
{...defaultProps}
|
||||
status="error"
|
||||
error={{ name: "Error", message: errorMessage } as Error}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Unable to load chat completions"),
|
||||
screen.getByText("Unable to load chat completions")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
@ -132,29 +132,25 @@ describe("LogsTable", () => {
|
|||
{...defaultProps}
|
||||
status="error"
|
||||
error={{ name: "Error", message: "" } as Error}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Unable to load chat completions"),
|
||||
screen.getByText("Unable to load chat completions")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"An unexpected error occurred while loading the data.",
|
||||
),
|
||||
screen.getByText("An unexpected error occurred while loading the data.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders default error message when error prop is an object without message", () => {
|
||||
render(
|
||||
<LogsTable {...defaultProps} status="error" error={{} as Error} />,
|
||||
<LogsTable {...defaultProps} status="error" error={{} as Error} />
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Unable to load chat completions"),
|
||||
screen.getByText("Unable to load chat completions")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"An unexpected error occurred while loading the data.",
|
||||
),
|
||||
screen.getByText("An unexpected error occurred while loading the data.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
@ -164,7 +160,7 @@ describe("LogsTable", () => {
|
|||
{...defaultProps}
|
||||
status="error"
|
||||
error={{ name: "Error", message: "Test error" } as Error}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
const table = screen.queryByRole("table");
|
||||
expect(table).not.toBeInTheDocument();
|
||||
|
@ -178,7 +174,7 @@ describe("LogsTable", () => {
|
|||
{...defaultProps}
|
||||
data={[]}
|
||||
emptyMessage="Custom empty message"
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Custom empty message")).toBeInTheDocument();
|
||||
|
||||
|
@ -214,7 +210,7 @@ describe("LogsTable", () => {
|
|||
{...defaultProps}
|
||||
data={mockData}
|
||||
caption="Custom table caption"
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
// Table caption
|
||||
|
@ -311,8 +307,8 @@ describe("LogsTable", () => {
|
|||
// Verify truncated text is displayed
|
||||
const truncatedTexts = screen.getAllByText("This is a ...");
|
||||
expect(truncatedTexts).toHaveLength(2); // one for input, one for output
|
||||
truncatedTexts.forEach((textElement) =>
|
||||
expect(textElement).toBeInTheDocument(),
|
||||
truncatedTexts.forEach(textElement =>
|
||||
expect(textElement).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -332,12 +328,12 @@ describe("LogsTable", () => {
|
|||
|
||||
// Model name should not be passed to truncateText
|
||||
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
|
||||
expect(
|
||||
screen.getByText("very-long-model-name-that-should-not-be-truncated"),
|
||||
screen.getByText("very-long-model-name-that-should-not-be-truncated")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -142,7 +142,7 @@ export function LogsTable({
|
|||
<Table>
|
||||
<TableCaption className="sr-only">{caption}</TableCaption>
|
||||
<TableBody>
|
||||
{data.map((row) => (
|
||||
{data.map(row => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
onClick={() => router.push(row.detailPath)}
|
||||
|
|
|
@ -22,7 +22,7 @@ export function GroupedItemsDisplay({
|
|||
|
||||
return (
|
||||
<>
|
||||
{groupedItems.map((groupedItem) => {
|
||||
{groupedItems.map(groupedItem => {
|
||||
// If this is a function call with an output, render the grouped component
|
||||
if (
|
||||
groupedItem.outputItem &&
|
||||
|
|
|
@ -18,7 +18,7 @@ export interface GroupedItem {
|
|||
* @returns Array of grouped items with their outputs
|
||||
*/
|
||||
export function useFunctionCallGrouping(
|
||||
items: AnyResponseItem[],
|
||||
items: AnyResponseItem[]
|
||||
): GroupedItem[] {
|
||||
return useMemo(() => {
|
||||
const groupedItems: GroupedItem[] = [];
|
||||
|
|
|
@ -52,7 +52,7 @@ export function ItemRenderer({
|
|||
// Fallback to generic item for unknown types
|
||||
return (
|
||||
<GenericItemComponent
|
||||
item={item as any}
|
||||
item={item as Record<string, unknown>}
|
||||
index={index}
|
||||
keyPrefix={keyPrefix}
|
||||
/>
|
||||
|
|
|
@ -20,7 +20,7 @@ export function MessageItemComponent({
|
|||
content = item.content;
|
||||
} else if (Array.isArray(item.content)) {
|
||||
content = item.content
|
||||
.map((c) => {
|
||||
.map(c => {
|
||||
return c.type === "input_text" || c.type === "output_text"
|
||||
? c.text
|
||||
: JSON.stringify(c);
|
||||
|
|
|
@ -18,7 +18,7 @@ describe("ResponseDetailView", () => {
|
|||
describe("Loading State", () => {
|
||||
test("renders loading skeleton when isLoading is true", () => {
|
||||
const { container } = render(
|
||||
<ResponseDetailView {...defaultProps} isLoading={true} />,
|
||||
<ResponseDetailView {...defaultProps} isLoading={true} />
|
||||
);
|
||||
|
||||
// Check for skeleton elements
|
||||
|
@ -36,13 +36,13 @@ describe("ResponseDetailView", () => {
|
|||
<ResponseDetailView
|
||||
{...defaultProps}
|
||||
error={{ name: "Error", message: errorMessage }}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Responses Details")).toBeInTheDocument();
|
||||
// The error message is split across elements, so we check for parts
|
||||
expect(
|
||||
screen.getByText(/Error loading details for ID/),
|
||||
screen.getByText(/Error loading details for ID/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/test_id/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Network Error/)).toBeInTheDocument();
|
||||
|
@ -53,11 +53,11 @@ describe("ResponseDetailView", () => {
|
|||
<ResponseDetailView
|
||||
{...defaultProps}
|
||||
error={{ name: "Error", message: "" }}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Error loading details for ID/),
|
||||
screen.getByText(/Error loading details for ID/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/test_id/)).toBeInTheDocument();
|
||||
});
|
||||
|
@ -124,14 +124,14 @@ describe("ResponseDetailView", () => {
|
|||
// Check properties - use regex to handle text split across elements
|
||||
expect(screen.getByText(/Created/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString()),
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString())
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check for the specific ID label (not Previous Response ID)
|
||||
expect(
|
||||
screen.getByText((content, element) => {
|
||||
return element?.tagName === "STRONG" && content === "ID:";
|
||||
}),
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("resp_123")).toBeInTheDocument();
|
||||
|
||||
|
@ -166,7 +166,7 @@ describe("ResponseDetailView", () => {
|
|||
};
|
||||
|
||||
render(
|
||||
<ResponseDetailView {...defaultProps} response={minimalResponse} />,
|
||||
<ResponseDetailView {...defaultProps} response={minimalResponse} />
|
||||
);
|
||||
|
||||
// Should show required properties
|
||||
|
@ -179,7 +179,7 @@ describe("ResponseDetailView", () => {
|
|||
expect(screen.queryByText("Top P")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Parallel Tool Calls")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Previous Response ID"),
|
||||
screen.queryByText("Previous Response ID")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
@ -196,7 +196,7 @@ describe("ResponseDetailView", () => {
|
|||
|
||||
// The error is shown in the properties sidebar, not as a separate "Error" label
|
||||
expect(
|
||||
screen.getByText("invalid_request: The request was invalid"),
|
||||
screen.getByText("invalid_request: The request was invalid")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -218,7 +218,7 @@ describe("ResponseDetailView", () => {
|
|||
{...defaultProps}
|
||||
response={mockResponse}
|
||||
isLoadingInputItems={true}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for skeleton loading in input items section
|
||||
|
@ -227,7 +227,7 @@ describe("ResponseDetailView", () => {
|
|||
{...defaultProps}
|
||||
response={mockResponse}
|
||||
isLoadingInputItems={true}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
|
||||
|
@ -243,16 +243,16 @@ describe("ResponseDetailView", () => {
|
|||
name: "Error",
|
||||
message: "Failed to load input items",
|
||||
}}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Error loading input items: Failed to load input items",
|
||||
),
|
||||
"Error loading input items: Failed to load input items"
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Falling back to response input data."),
|
||||
screen.getByText("Falling back to response input data.")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should still show fallback input data
|
||||
|
@ -276,7 +276,7 @@ describe("ResponseDetailView", () => {
|
|||
{...defaultProps}
|
||||
response={mockResponse}
|
||||
inputItems={mockInputItems}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show input items data, not response.input
|
||||
|
@ -295,7 +295,7 @@ describe("ResponseDetailView", () => {
|
|||
{...defaultProps}
|
||||
response={mockResponse}
|
||||
inputItems={emptyInputItems}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show fallback input data
|
||||
|
@ -313,7 +313,7 @@ describe("ResponseDetailView", () => {
|
|||
{...defaultProps}
|
||||
response={responseWithoutInput}
|
||||
inputItems={null}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("No input data available.")).toBeInTheDocument();
|
||||
|
@ -443,7 +443,7 @@ describe("ResponseDetailView", () => {
|
|||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('input_function({"param": "value"})'),
|
||||
screen.getByText('input_function({"param": "value"})')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||
});
|
||||
|
@ -468,7 +468,7 @@ describe("ResponseDetailView", () => {
|
|||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("web_search_call(status: completed)"),
|
||||
screen.getByText("web_search_call(status: completed)")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||
expect(screen.getByText("(Web Search)")).toBeInTheDocument();
|
||||
|
@ -522,7 +522,7 @@ describe("ResponseDetailView", () => {
|
|||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("First output Second output"),
|
||||
screen.getByText("First output Second output")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
});
|
||||
|
@ -549,7 +549,7 @@ describe("ResponseDetailView", () => {
|
|||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('search_function({"query": "test"})'),
|
||||
screen.getByText('search_function({"query": "test"})')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||
});
|
||||
|
@ -598,7 +598,7 @@ describe("ResponseDetailView", () => {
|
|||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("web_search_call(status: completed)"),
|
||||
screen.getByText("web_search_call(status: completed)")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Function Call/)).toBeInTheDocument();
|
||||
expect(screen.getByText("(Web Search)")).toBeInTheDocument();
|
||||
|
@ -616,7 +616,7 @@ describe("ResponseDetailView", () => {
|
|||
type: "unknown_type",
|
||||
custom_field: "custom_value",
|
||||
data: { nested: "object" },
|
||||
} as any,
|
||||
} as unknown,
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
@ -625,7 +625,7 @@ describe("ResponseDetailView", () => {
|
|||
|
||||
// Should show JSON stringified content
|
||||
expect(
|
||||
screen.getByText(/custom_field.*custom_value/),
|
||||
screen.getByText(/custom_field.*custom_value/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("(unknown_type)")).toBeInTheDocument();
|
||||
});
|
||||
|
@ -666,7 +666,7 @@ describe("ResponseDetailView", () => {
|
|||
role: "assistant",
|
||||
call_id: "call_123",
|
||||
content: "sunny and warm",
|
||||
} as any, // Using any to bypass the type restriction for this test
|
||||
} as unknown, // Using unknown to bypass the type restriction for this test
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
@ -676,7 +676,7 @@ describe("ResponseDetailView", () => {
|
|||
// Should show the function call and message as separate items (not grouped)
|
||||
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('get_weather({"city": "Tokyo"})'),
|
||||
screen.getByText('get_weather({"city": "Tokyo"})')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByText("sunny and warm")).toBeInTheDocument();
|
||||
|
@ -706,7 +706,7 @@ describe("ResponseDetailView", () => {
|
|||
status: "completed",
|
||||
call_id: "call_123",
|
||||
output: "sunny and warm",
|
||||
} as any, // Using any to bypass the type restriction for this test
|
||||
} as unknown, // Using unknown to bypass the type restriction for this test
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
@ -717,7 +717,7 @@ describe("ResponseDetailView", () => {
|
|||
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||
expect(screen.getByText("Arguments")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('get_weather({"city": "Tokyo"})'),
|
||||
screen.getByText('get_weather({"city": "Tokyo"})')
|
||||
).toBeInTheDocument();
|
||||
// Use getAllByText since there are multiple "Output" elements (card title and output label)
|
||||
const outputElements = screen.getAllByText("Output");
|
||||
|
|
|
@ -146,7 +146,7 @@ describe("ResponsesTable", () => {
|
|||
expect(tableCaption).toBeInTheDocument();
|
||||
if (tableCaption) {
|
||||
const captionSkeleton = tableCaption.querySelector(
|
||||
'[data-slot="skeleton"]',
|
||||
'[data-slot="skeleton"]'
|
||||
);
|
||||
expect(captionSkeleton).toBeInTheDocument();
|
||||
}
|
||||
|
@ -156,7 +156,7 @@ describe("ResponsesTable", () => {
|
|||
expect(tableBody).toBeInTheDocument();
|
||||
if (tableBody) {
|
||||
const bodySkeletons = tableBody.querySelectorAll(
|
||||
'[data-slot="skeleton"]',
|
||||
'[data-slot="skeleton"]'
|
||||
);
|
||||
expect(bodySkeletons.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
@ -176,14 +176,14 @@ describe("ResponsesTable", () => {
|
|||
|
||||
render(<ResponsesTable {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText("Unable to load chat completions"),
|
||||
screen.getByText("Unable to load chat completions")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([{ name: "Error", message: "" }, {}])(
|
||||
"renders default error message when error has no message",
|
||||
(errorObject) => {
|
||||
errorObject => {
|
||||
mockedUsePagination.mockReturnValue({
|
||||
data: [],
|
||||
status: "error",
|
||||
|
@ -194,14 +194,14 @@ describe("ResponsesTable", () => {
|
|||
|
||||
render(<ResponsesTable {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText("Unable to load chat completions"),
|
||||
screen.getByText("Unable to load chat completions")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"An unexpected error occurred while loading the data.",
|
||||
),
|
||||
"An unexpected error occurred while loading the data."
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -275,7 +275,7 @@ describe("ResponsesTable", () => {
|
|||
|
||||
// Table caption
|
||||
expect(
|
||||
screen.getByText("A list of your recent responses."),
|
||||
screen.getByText("A list of your recent responses.")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Table headers
|
||||
|
@ -289,14 +289,14 @@ describe("ResponsesTable", () => {
|
|||
expect(screen.getByText("Test output")).toBeInTheDocument();
|
||||
expect(screen.getByText("llama-test-model")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString()),
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString())
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Another input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Another output")).toBeInTheDocument();
|
||||
expect(screen.getByText("llama-another-model")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710001000 * 1000).toLocaleString()),
|
||||
screen.getByText(new Date(1710001000 * 1000).toLocaleString())
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -487,7 +487,7 @@ describe("ResponsesTable", () => {
|
|||
|
||||
render(<ResponsesTable {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText('search_function({"query": "test"})'),
|
||||
screen.getByText('search_function({"query": "test"})')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
@ -548,7 +548,7 @@ describe("ResponsesTable", () => {
|
|||
|
||||
render(<ResponsesTable {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText("web_search_call(status: completed)"),
|
||||
screen.getByText("web_search_call(status: completed)")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
@ -565,7 +565,7 @@ describe("ResponsesTable", () => {
|
|||
id: "unknown_123",
|
||||
status: "completed",
|
||||
custom_field: "custom_value",
|
||||
} as any,
|
||||
} as unknown,
|
||||
],
|
||||
input: [{ type: "message", content: "input" }],
|
||||
};
|
||||
|
@ -594,7 +594,7 @@ describe("ResponsesTable", () => {
|
|||
{
|
||||
type: "unknown_type",
|
||||
data: "some data",
|
||||
} as any,
|
||||
} as unknown,
|
||||
],
|
||||
input: [{ type: "message", content: "input" }],
|
||||
};
|
||||
|
@ -623,7 +623,7 @@ describe("ResponsesTable", () => {
|
|||
return typeof text === "string" && text.length > effectiveMaxLength
|
||||
? text.slice(0, effectiveMaxLength) + "..."
|
||||
: text;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const longInput =
|
||||
|
@ -665,7 +665,7 @@ describe("ResponsesTable", () => {
|
|||
|
||||
// The truncated text should be present for both input and output
|
||||
const truncatedTexts = screen.getAllByText(
|
||||
longInput.slice(0, 10) + "...",
|
||||
longInput.slice(0, 10) + "..."
|
||||
);
|
||||
expect(truncatedTexts.length).toBe(2); // one for input, one for output
|
||||
});
|
||||
|
|
|
@ -27,7 +27,7 @@ interface ResponsesTableProps {
|
|||
* Helper function to convert ResponseListResponse.Data to OpenAIResponse
|
||||
*/
|
||||
const convertResponseListData = (
|
||||
responseData: ResponseListResponse.Data,
|
||||
responseData: ResponseListResponse.Data
|
||||
): OpenAIResponse => {
|
||||
return {
|
||||
id: responseData.id,
|
||||
|
@ -56,8 +56,8 @@ function getInputText(response: OpenAIResponse): string {
|
|||
}
|
||||
|
||||
function getOutputText(response: OpenAIResponse): string {
|
||||
const firstMessage = response.output.find((item) =>
|
||||
isMessageItem(item as any),
|
||||
const firstMessage = response.output.find(item =>
|
||||
isMessageItem(item as Record<string, unknown>)
|
||||
);
|
||||
if (firstMessage) {
|
||||
const content = extractContentFromItem(firstMessage as MessageItem);
|
||||
|
@ -66,15 +66,15 @@ function getOutputText(response: OpenAIResponse): string {
|
|||
}
|
||||
}
|
||||
|
||||
const functionCall = response.output.find((item) =>
|
||||
isFunctionCallItem(item as any),
|
||||
const functionCall = response.output.find(item =>
|
||||
isFunctionCallItem(item as Record<string, unknown>)
|
||||
);
|
||||
if (functionCall) {
|
||||
return formatFunctionCall(functionCall as FunctionCallItem);
|
||||
}
|
||||
|
||||
const webSearchCall = response.output.find((item) =>
|
||||
isWebSearchCallItem(item as any),
|
||||
const webSearchCall = response.output.find(item =>
|
||||
isWebSearchCallItem(item as Record<string, unknown>)
|
||||
);
|
||||
if (webSearchCall) {
|
||||
return formatWebSearchCall(webSearchCall as WebSearchCallItem);
|
||||
|
@ -95,7 +95,7 @@ function extractContentFromItem(item: {
|
|||
} else if (Array.isArray(item.content)) {
|
||||
const textContent = item.content.find(
|
||||
(c: ResponseInputMessageContent) =>
|
||||
c.type === "input_text" || c.type === "output_text",
|
||||
c.type === "input_text" || c.type === "output_text"
|
||||
);
|
||||
return textContent?.text || "";
|
||||
}
|
||||
|
@ -131,14 +131,14 @@ export function ResponsesTable({ paginationOptions }: ResponsesTableProps) {
|
|||
limit: number;
|
||||
model?: string;
|
||||
order?: string;
|
||||
},
|
||||
}
|
||||
) => {
|
||||
const response = await client.responses.list({
|
||||
after: params.after,
|
||||
limit: params.limit,
|
||||
...(params.model && { model: params.model }),
|
||||
...(params.order && { order: params.order }),
|
||||
} as any);
|
||||
} as Parameters<typeof client.responses.list>[0]);
|
||||
|
||||
const listResponse = response as ResponseListResponse;
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ export type AnyResponseItem =
|
|||
| FunctionCallOutputItem;
|
||||
|
||||
export function isMessageInput(
|
||||
item: ResponseInput,
|
||||
item: ResponseInput
|
||||
): item is ResponseInput & { type: "message" } {
|
||||
return item.type === "message";
|
||||
}
|
||||
|
@ -39,23 +39,23 @@ export function isMessageItem(item: AnyResponseItem): item is MessageItem {
|
|||
}
|
||||
|
||||
export function isFunctionCallItem(
|
||||
item: AnyResponseItem,
|
||||
item: AnyResponseItem
|
||||
): item is FunctionCallItem {
|
||||
return item.type === "function_call" && "name" in item;
|
||||
}
|
||||
|
||||
export function isWebSearchCallItem(
|
||||
item: AnyResponseItem,
|
||||
item: AnyResponseItem
|
||||
): item is WebSearchCallItem {
|
||||
return item.type === "web_search_call";
|
||||
}
|
||||
|
||||
export function isFunctionCallOutputItem(
|
||||
item: AnyResponseItem,
|
||||
item: AnyResponseItem
|
||||
): item is FunctionCallOutputItem {
|
||||
return (
|
||||
item.type === "function_call_output" &&
|
||||
"call_id" in item &&
|
||||
typeof (item as any).call_id === "string"
|
||||
typeof (item as Record<string, unknown>).call_id === "string"
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Configuration constants for the audio analyzer
|
||||
const AUDIO_CONFIG = {
|
||||
|
@ -14,12 +14,12 @@ const AUDIO_CONFIG = {
|
|||
MAX_INTENSITY: 255, // Maximum gray value (brighter)
|
||||
INTENSITY_RANGE: 155, // MAX_INTENSITY - MIN_INTENSITY
|
||||
},
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
interface AudioVisualizerProps {
|
||||
stream: MediaStream | null
|
||||
isRecording: boolean
|
||||
onClick: () => void
|
||||
stream: MediaStream | null;
|
||||
isRecording: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function AudioVisualizer({
|
||||
|
@ -28,91 +28,91 @@ export function AudioVisualizer({
|
|||
onClick,
|
||||
}: AudioVisualizerProps) {
|
||||
// Refs for managing audio context and animation
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const audioContextRef = useRef<AudioContext | null>(null)
|
||||
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||
const animationFrameRef = useRef<number>()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const animationFrameRef = useRef<number>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Cleanup function to stop visualization and close audio context
|
||||
const cleanup = () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close()
|
||||
}
|
||||
audioContextRef.current.close();
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return cleanup
|
||||
}, [])
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
// Start or stop visualization based on recording state
|
||||
useEffect(() => {
|
||||
if (stream && isRecording) {
|
||||
startVisualization()
|
||||
startVisualization();
|
||||
} else {
|
||||
cleanup()
|
||||
cleanup();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stream, isRecording])
|
||||
}, [stream, isRecording]);
|
||||
|
||||
// Handle window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (canvasRef.current && containerRef.current) {
|
||||
const container = containerRef.current
|
||||
const canvas = canvasRef.current
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// 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)
|
||||
canvas.width = (rect.width - 2) * dpr
|
||||
canvas.height = (rect.height - 2) * dpr
|
||||
canvas.width = (rect.width - 2) * dpr;
|
||||
canvas.height = (rect.height - 2) * dpr;
|
||||
|
||||
// Scale canvas CSS size to match container minus margins
|
||||
canvas.style.width = `${rect.width - 2}px`
|
||||
canvas.style.height = `${rect.height - 2}px`
|
||||
}
|
||||
canvas.style.width = `${rect.width - 2}px`;
|
||||
canvas.style.height = `${rect.height - 2}px`;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize)
|
||||
window.addEventListener("resize", handleResize);
|
||||
// Initial setup
|
||||
handleResize()
|
||||
handleResize();
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize)
|
||||
}, [])
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
// Initialize audio context and start visualization
|
||||
const startVisualization = async () => {
|
||||
try {
|
||||
const audioContext = new AudioContext()
|
||||
audioContextRef.current = audioContext
|
||||
const audioContext = new AudioContext();
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
const analyser = audioContext.createAnalyser()
|
||||
analyser.fftSize = AUDIO_CONFIG.FFT_SIZE
|
||||
analyser.smoothingTimeConstant = AUDIO_CONFIG.SMOOTHING
|
||||
analyserRef.current = analyser
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = AUDIO_CONFIG.FFT_SIZE;
|
||||
analyser.smoothingTimeConstant = AUDIO_CONFIG.SMOOTHING;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const source = audioContext.createMediaStreamSource(stream!)
|
||||
source.connect(analyser)
|
||||
const source = audioContext.createMediaStreamSource(stream!);
|
||||
source.connect(analyser);
|
||||
|
||||
draw()
|
||||
draw();
|
||||
} catch (error) {
|
||||
console.error("Error starting visualization:", error)
|
||||
}
|
||||
console.error("Error starting visualization:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate the color intensity based on bar height
|
||||
const getBarColor = (normalizedHeight: number) => {
|
||||
const intensity =
|
||||
Math.floor(normalizedHeight * AUDIO_CONFIG.COLOR.INTENSITY_RANGE) +
|
||||
AUDIO_CONFIG.COLOR.MIN_INTENSITY
|
||||
return `rgb(${intensity}, ${intensity}, ${intensity})`
|
||||
}
|
||||
AUDIO_CONFIG.COLOR.MIN_INTENSITY;
|
||||
return `rgb(${intensity}, ${intensity}, ${intensity})`;
|
||||
};
|
||||
|
||||
// Draw a single bar of the visualizer
|
||||
const drawBar = (
|
||||
|
@ -123,52 +123,52 @@ export function AudioVisualizer({
|
|||
height: number,
|
||||
color: string
|
||||
) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.fillStyle = color;
|
||||
// Draw upper bar (above center)
|
||||
ctx.fillRect(x, centerY - height, width, height)
|
||||
ctx.fillRect(x, centerY - height, width, height);
|
||||
// Draw lower bar (below center)
|
||||
ctx.fillRect(x, centerY, width, height)
|
||||
}
|
||||
ctx.fillRect(x, centerY, width, height);
|
||||
};
|
||||
|
||||
// Main drawing function
|
||||
const draw = () => {
|
||||
if (!isRecording) return
|
||||
if (!isRecording) return;
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext("2d")
|
||||
if (!canvas || !ctx || !analyserRef.current) return
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx || !analyserRef.current) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
ctx.scale(dpr, dpr)
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const analyser = analyserRef.current
|
||||
const bufferLength = analyser.frequencyBinCount
|
||||
const frequencyData = new Uint8Array(bufferLength)
|
||||
const analyser = analyserRef.current;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const frequencyData = new Uint8Array(bufferLength);
|
||||
|
||||
const drawFrame = () => {
|
||||
animationFrameRef.current = requestAnimationFrame(drawFrame)
|
||||
animationFrameRef.current = requestAnimationFrame(drawFrame);
|
||||
|
||||
// Get current frequency data
|
||||
analyser.getByteFrequencyData(frequencyData)
|
||||
analyser.getByteFrequencyData(frequencyData);
|
||||
|
||||
// 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
|
||||
const barWidth = Math.max(
|
||||
AUDIO_CONFIG.MIN_BAR_WIDTH,
|
||||
canvas.width / dpr / bufferLength - AUDIO_CONFIG.BAR_SPACING
|
||||
)
|
||||
const centerY = canvas.height / dpr / 2
|
||||
let x = 0
|
||||
);
|
||||
const centerY = canvas.height / dpr / 2;
|
||||
let x = 0;
|
||||
|
||||
// Draw each frequency bar
|
||||
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(
|
||||
AUDIO_CONFIG.MIN_BAR_HEIGHT,
|
||||
normalizedHeight * centerY
|
||||
)
|
||||
);
|
||||
|
||||
drawBar(
|
||||
ctx,
|
||||
|
@ -177,14 +177,14 @@ export function AudioVisualizer({
|
|||
barWidth,
|
||||
barHeight,
|
||||
getBarColor(normalizedHeight)
|
||||
)
|
||||
);
|
||||
|
||||
x += barWidth + AUDIO_CONFIG.BAR_SPACING
|
||||
}
|
||||
x += barWidth + AUDIO_CONFIG.BAR_SPACING;
|
||||
}
|
||||
};
|
||||
|
||||
drawFrame()
|
||||
}
|
||||
drawFrame();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -194,5 +194,5 @@ export function AudioVisualizer({
|
|||
>
|
||||
<canvas ref={canvasRef} className="h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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",
|
||||
|
@ -33,7 +33,7 @@ const buttonVariants = cva(
|
|||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
|
@ -43,9 +43,9 @@ function Button({
|
|||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
@ -53,7 +53,7 @@ function Button({
|
|||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
|
|
@ -8,7 +8,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -21,7 +21,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||
data-slot="card-header"
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -54,7 +54,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -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({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
|
@ -16,7 +16,7 @@ function CollapsibleTrigger({
|
|||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
|
@ -27,7 +27,7 @@ function CollapsibleContent({
|
|||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
|
|
@ -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 { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type CopyButtonProps = {
|
||||
content: string
|
||||
copyMessage?: string
|
||||
}
|
||||
content: string;
|
||||
copyMessage?: string;
|
||||
};
|
||||
|
||||
export function CopyButton({ content, copyMessage }: CopyButtonProps) {
|
||||
const { isCopied, handleCopy } = useCopyToClipboard({
|
||||
text: content,
|
||||
copyMessage,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
@ -40,5 +40,5 @@ export function CopyButton({ content, copyMessage }: CopyButtonProps) {
|
|||
)}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ function DropdownMenuContent({
|
|||
sideOffset={sideOffset}
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -75,7 +75,7 @@ function DropdownMenuItem({
|
|||
data-variant={variant}
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
|
|||
data-slot="dropdown-menu-checkbox-item"
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
|
@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
|
|||
data-slot="dropdown-menu-radio-item"
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
@ -156,7 +156,7 @@ function DropdownMenuLabel({
|
|||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -185,7 +185,7 @@ function DropdownMenuShortcut({
|
|||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -212,7 +212,7 @@ function DropdownMenuSubTrigger({
|
|||
data-inset={inset}
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
@ -231,7 +231,7 @@ function DropdownMenuSubContent({
|
|||
data-slot="dropdown-menu-sub-content"
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { FileIcon, X } from "lucide-react"
|
||||
import React, { useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { FileIcon, X } from "lucide-react";
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: File
|
||||
onRemove?: () => void
|
||||
file: File;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export const FilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
||||
(props, ref) => {
|
||||
if (props.file.type.startsWith("image/")) {
|
||||
return <ImageFilePreview {...props} ref={ref} />
|
||||
return <ImageFilePreview {...props} ref={ref} />;
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -20,13 +20,13 @@ export const FilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
|||
props.file.name.endsWith(".txt") ||
|
||||
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>(
|
||||
({ file, onRemove }, ref) => {
|
||||
|
@ -62,23 +62,23 @@ const ImageFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
|||
</button>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
ImageFilePreview.displayName = "ImageFilePreview"
|
||||
);
|
||||
ImageFilePreview.displayName = "ImageFilePreview";
|
||||
|
||||
const TextFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
||||
({ file, onRemove }, ref) => {
|
||||
const [preview, setPreview] = React.useState<string>("")
|
||||
const [preview, setPreview] = React.useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string
|
||||
setPreview(text.slice(0, 50) + (text.length > 50 ? "..." : ""))
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [file])
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const text = e.target?.result as string;
|
||||
setPreview(text.slice(0, 50) + (text.length > 50 ? "..." : ""));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
@ -111,10 +111,10 @@ const TextFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
|||
</button>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
TextFilePreview.displayName = "TextFilePreview"
|
||||
);
|
||||
TextFilePreview.displayName = "TextFilePreview";
|
||||
|
||||
const GenericFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
||||
({ file, onRemove }, ref) => {
|
||||
|
@ -147,7 +147,7 @@ const GenericFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
|||
</button>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
GenericFilePreview.displayName = "GenericFilePreview"
|
||||
);
|
||||
GenericFilePreview.displayName = "GenericFilePreview";
|
||||
|
|
|
@ -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",
|
||||
"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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -1,27 +1,27 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
|
@ -30,7 +30,7 @@ function SelectTrigger({
|
|||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
|
@ -47,7 +47,7 @@ function SelectTrigger({
|
|||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
|
@ -82,7 +82,7 @@ function SelectContent({
|
|||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
|
@ -95,7 +95,7 @@ function SelectLabel({
|
|||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
|
@ -119,7 +119,7 @@ function SelectItem({
|
|||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
|
@ -132,7 +132,7 @@ function SelectSeparator({
|
|||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
|
@ -150,7 +150,7 @@ function SelectScrollUpButton({
|
|||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
|
@ -168,7 +168,7 @@ function SelectScrollDownButton({
|
|||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
|
@ -182,4 +182,4 @@ export {
|
|||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
};
|
||||
|
|
|
@ -18,7 +18,7 @@ function Separator({
|
|||
orientation={orientation}
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -37,7 +37,7 @@ function SheetOverlay({
|
|||
data-slot="sheet-overlay"
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...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",
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
@ -85,12 +85,12 @@ function SidebarProvider({
|
|||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
[setOpenProp, open]
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
|
@ -123,7 +123,7 @@ function SidebarProvider({
|
|||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -140,7 +140,7 @@ function SidebarProvider({
|
|||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
@ -171,7 +171,7 @@ function Sidebar({
|
|||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
@ -223,7 +223,7 @@ function Sidebar({
|
|||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "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
|
||||
|
@ -237,7 +237,7 @@ function Sidebar({
|
|||
variant === "floating" || variant === "inset"
|
||||
? "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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
@ -267,7 +267,7 @@ function SidebarTrigger({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick={event => {
|
||||
onClick?.(event);
|
||||
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",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -311,7 +311,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||
className={cn(
|
||||
"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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -375,7 +375,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -407,7 +407,7 @@ function SidebarGroupLabel({
|
|||
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",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -430,7 +430,7 @@ function SidebarGroupAction({
|
|||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -492,7 +492,7 @@ const sidebarMenuButtonVariants = cva(
|
|||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
|
@ -570,7 +570,7 @@ function SidebarMenuAction({
|
|||
"group-data-[collapsible=icon]:hidden",
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -592,7 +592,7 @@ function SidebarMenuBadge({
|
|||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -645,7 +645,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
|||
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",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -691,7 +691,7 @@ function SidebarMenuSubButton({
|
|||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
|
@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
export { Toaster };
|
||||
|
|
|
@ -45,7 +45,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
|||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -58,7 +58,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -71,7 +71,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|||
data-slot="table-head"
|
||||
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]",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -84,7 +84,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -47,7 +47,7 @@ function TooltipContent({
|
|||
sideOffset={sideOffset}
|
||||
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",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
|
200
llama_stack/ui/components/vector-db/vector-db-manager-simple.tsx
Normal file
200
llama_stack/ui/components/vector-db/vector-db-manager-simple.tsx
Normal file
|
@ -0,0 +1,200 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type LlamaStackClient from "llama-stack-client";
|
||||
|
||||
interface VectorDbManagerProps {
|
||||
client: LlamaStackClient;
|
||||
onVectorDbCreated: () => void;
|
||||
}
|
||||
|
||||
export function VectorDbManager({
|
||||
client,
|
||||
onVectorDbCreated,
|
||||
}: VectorDbManagerProps) {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
vectorDbId: "",
|
||||
embeddingModel: "all-MiniLM-L6-v2",
|
||||
embeddingDimension: "384",
|
||||
});
|
||||
|
||||
const handleCreateVectorDb = async () => {
|
||||
if (!formData.vectorDbId.trim()) {
|
||||
setCreateError("Vector DB ID is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
|
||||
try {
|
||||
// Get available providers to find a vector_io provider
|
||||
const providers = await client.providers.list();
|
||||
const vectorIoProvider = providers.find(p => p.api === "vector_io");
|
||||
|
||||
if (!vectorIoProvider) {
|
||||
throw new Error("No vector_io provider found");
|
||||
}
|
||||
|
||||
await client.vectorDBs.register({
|
||||
vector_db_id: formData.vectorDbId.trim(),
|
||||
embedding_model: formData.embeddingModel,
|
||||
embedding_dimension: parseInt(formData.embeddingDimension),
|
||||
provider_id: vectorIoProvider.provider_id,
|
||||
});
|
||||
|
||||
// Reset form and close
|
||||
setFormData({
|
||||
vectorDbId: "",
|
||||
embeddingModel: "all-MiniLM-L6-v2",
|
||||
embeddingDimension: "384",
|
||||
});
|
||||
setShowCreateForm(false);
|
||||
|
||||
// Refresh the vector DB list
|
||||
onVectorDbCreated();
|
||||
} catch (err) {
|
||||
console.error("Error creating vector DB:", err);
|
||||
setCreateError(
|
||||
err instanceof Error ? err.message : "Failed to create vector database"
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowCreateForm(false);
|
||||
setCreateError(null);
|
||||
setFormData({
|
||||
vectorDbId: "",
|
||||
embeddingModel: "all-MiniLM-L6-v2",
|
||||
embeddingDimension: "384",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{!showCreateForm ? (
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full"
|
||||
>
|
||||
+ Create Vector DB
|
||||
</Button>
|
||||
) : (
|
||||
<Card className="absolute top-full right-0 mt-2 p-4 space-y-4 w-80 z-50 bg-background border shadow-lg">
|
||||
<h3 className="text-lg font-semibold">Create Vector Database</h3>
|
||||
|
||||
{createError && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-destructive text-sm">{createError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1">
|
||||
Vector DB ID *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.vectorDbId}
|
||||
onChange={e =>
|
||||
setFormData({ ...formData, vectorDbId: e.target.value })
|
||||
}
|
||||
placeholder="Enter unique vector DB identifier"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1">
|
||||
Embedding Model
|
||||
</label>
|
||||
<Select
|
||||
value={formData.embeddingModel}
|
||||
onValueChange={value =>
|
||||
setFormData({ ...formData, embeddingModel: value })
|
||||
}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all-MiniLM-L6-v2">
|
||||
all-MiniLM-L6-v2
|
||||
</SelectItem>
|
||||
<SelectItem value="text-embedding-ada-002">
|
||||
text-embedding-ada-002
|
||||
</SelectItem>
|
||||
<SelectItem value="text-embedding-3-small">
|
||||
text-embedding-3-small
|
||||
</SelectItem>
|
||||
<SelectItem value="text-embedding-3-large">
|
||||
text-embedding-3-large
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1">
|
||||
Embedding Dimension
|
||||
</label>
|
||||
<Select
|
||||
value={formData.embeddingDimension}
|
||||
onValueChange={value =>
|
||||
setFormData({ ...formData, embeddingDimension: value })
|
||||
}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="384">384 (all-MiniLM-L6-v2)</SelectItem>
|
||||
<SelectItem value="1536">1536 (ada-002, 3-small)</SelectItem>
|
||||
<SelectItem value="3072">3072 (3-large)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleCreateVectorDb}
|
||||
disabled={isCreating}
|
||||
className="flex-1"
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isCreating}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -24,7 +24,10 @@ interface UploadState {
|
|||
uploadError: string | null;
|
||||
}
|
||||
|
||||
export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerProps) {
|
||||
export function VectorDbManager({
|
||||
client,
|
||||
onVectorDbCreated,
|
||||
}: VectorDbManagerProps) {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
@ -78,7 +81,9 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
onVectorDbCreated();
|
||||
} catch (err) {
|
||||
console.error("Error creating vector DB:", err);
|
||||
setCreateError(err instanceof Error ? err.message : "Failed to create vector database");
|
||||
setCreateError(
|
||||
err instanceof Error ? err.message : "Failed to create vector database"
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
|
@ -105,13 +110,17 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
const chunks: string[] = [];
|
||||
|
||||
for (let i = 0; i < words.length; i += chunkSize) {
|
||||
chunks.push(words.slice(i, i + chunkSize).join(' '));
|
||||
chunks.push(words.slice(i, i + chunkSize).join(" "));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
};
|
||||
|
||||
const ingestDocument = async (content: string, documentId: string, vectorDbId: string) => {
|
||||
const ingestDocument = async (
|
||||
content: string,
|
||||
documentId: string,
|
||||
vectorDbId: string
|
||||
) => {
|
||||
const chunks = chunkText(content);
|
||||
|
||||
const vectorChunks = chunks.map((chunk, index) => ({
|
||||
|
@ -165,7 +174,8 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
setUploadState({
|
||||
isUploading: false,
|
||||
uploadProgress: "",
|
||||
uploadError: err instanceof Error ? err.message : "Failed to upload file",
|
||||
uploadError:
|
||||
err instanceof Error ? err.message : "Failed to upload file",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -181,8 +191,8 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
|
||||
try {
|
||||
const response = await fetch(`/api/fetch-url`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: urlInput }),
|
||||
});
|
||||
|
||||
|
@ -212,7 +222,8 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
setUploadState({
|
||||
isUploading: false,
|
||||
uploadProgress: "",
|
||||
uploadError: err instanceof Error ? err.message : "Failed to fetch URL content",
|
||||
uploadError:
|
||||
err instanceof Error ? err.message : "Failed to fetch URL content",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -239,13 +250,17 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
|
||||
{uploadState.uploadError && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-destructive text-sm">{uploadState.uploadError}</p>
|
||||
<p className="text-destructive text-sm">
|
||||
{uploadState.uploadError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState.uploadProgress && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p className="text-blue-700 text-sm">{uploadState.uploadProgress}</p>
|
||||
<p className="text-blue-700 text-sm">
|
||||
{uploadState.uploadProgress}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -256,7 +271,9 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
</label>
|
||||
<Input
|
||||
value={formData.vectorDbId}
|
||||
onChange={(e) => setFormData({ ...formData, vectorDbId: e.target.value })}
|
||||
onChange={e =>
|
||||
setFormData({ ...formData, vectorDbId: e.target.value })
|
||||
}
|
||||
placeholder="Enter unique vector DB identifier"
|
||||
disabled={isCreating || uploadState.isUploading}
|
||||
/>
|
||||
|
@ -268,17 +285,27 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
</label>
|
||||
<Select
|
||||
value={formData.embeddingModel}
|
||||
onValueChange={(value) => setFormData({ ...formData, embeddingModel: value })}
|
||||
onValueChange={value =>
|
||||
setFormData({ ...formData, embeddingModel: value })
|
||||
}
|
||||
disabled={isCreating || uploadState.isUploading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all-MiniLM-L6-v2">all-MiniLM-L6-v2</SelectItem>
|
||||
<SelectItem value="text-embedding-ada-002">text-embedding-ada-002</SelectItem>
|
||||
<SelectItem value="text-embedding-3-small">text-embedding-3-small</SelectItem>
|
||||
<SelectItem value="text-embedding-3-large">text-embedding-3-large</SelectItem>
|
||||
<SelectItem value="all-MiniLM-L6-v2">
|
||||
all-MiniLM-L6-v2
|
||||
</SelectItem>
|
||||
<SelectItem value="text-embedding-ada-002">
|
||||
text-embedding-ada-002
|
||||
</SelectItem>
|
||||
<SelectItem value="text-embedding-3-small">
|
||||
text-embedding-3-small
|
||||
</SelectItem>
|
||||
<SelectItem value="text-embedding-3-large">
|
||||
text-embedding-3-large
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
@ -289,7 +316,9 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
</label>
|
||||
<Select
|
||||
value={formData.embeddingDimension}
|
||||
onValueChange={(value) => setFormData({ ...formData, embeddingDimension: value })}
|
||||
onValueChange={value =>
|
||||
setFormData({ ...formData, embeddingDimension: value })
|
||||
}
|
||||
disabled={isCreating || uploadState.isUploading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
|
@ -339,7 +368,11 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
/>
|
||||
<Button
|
||||
onClick={() => handleFileUpload(formData.vectorDbId)}
|
||||
disabled={!formData.vectorDbId || !fileInputRef.current?.files?.length || uploadState.isUploading}
|
||||
disabled={
|
||||
!formData.vectorDbId ||
|
||||
!fileInputRef.current?.files?.length ||
|
||||
uploadState.isUploading
|
||||
}
|
||||
className="mt-2 w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
@ -354,13 +387,17 @@ export function VectorDbManager({ client, onVectorDbCreated }: VectorDbManagerPr
|
|||
</label>
|
||||
<Input
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onChange={e => setUrlInput(e.target.value)}
|
||||
placeholder="https://example.com/article"
|
||||
disabled={!formData.vectorDbId || uploadState.isUploading}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleUrlUpload(formData.vectorDbId)}
|
||||
disabled={!formData.vectorDbId || !urlInput.trim() || uploadState.isUploading}
|
||||
disabled={
|
||||
!formData.vectorDbId ||
|
||||
!urlInput.trim() ||
|
||||
uploadState.isUploading
|
||||
}
|
||||
className="mt-2 w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
@ -85,7 +85,7 @@ export function VectorStoreDetailView({
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => (
|
||||
{files.map(file => (
|
||||
<TableRow key={file.id}>
|
||||
<TableCell>
|
||||
<Button
|
||||
|
|
|
@ -45,7 +45,7 @@ test.describe("LogsTable Scroll and Progressive Loading", () => {
|
|||
const scrollContainer = page.locator("div.overflow-auto").first();
|
||||
|
||||
// Scroll to near the bottom
|
||||
await scrollContainer.evaluate((element) => {
|
||||
await scrollContainer.evaluate(element => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight - 100;
|
||||
});
|
||||
|
||||
|
|
|
@ -10,7 +10,13 @@ const compat = new FlatCompat({
|
|||
});
|
||||
|
||||
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;
|
||||
|
|
|
@ -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 {
|
||||
transcribeAudio?: (blob: Blob) => Promise<string>
|
||||
onTranscriptionComplete?: (text: string) => void
|
||||
transcribeAudio?: (blob: Blob) => Promise<string>;
|
||||
onTranscriptionComplete?: (text: string) => void;
|
||||
}
|
||||
|
||||
export function useAudioRecording({
|
||||
transcribeAudio,
|
||||
onTranscriptionComplete,
|
||||
}: UseAudioRecordingOptions) {
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const [isSpeechSupported, setIsSpeechSupported] = useState(!!transcribeAudio)
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [isTranscribing, setIsTranscribing] = useState(false)
|
||||
const [audioStream, setAudioStream] = useState<MediaStream | null>(null)
|
||||
const activeRecordingRef = useRef<any>(null)
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isSpeechSupported, setIsSpeechSupported] = useState(!!transcribeAudio);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isTranscribing, setIsTranscribing] = useState(false);
|
||||
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
|
||||
const activeRecordingRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSpeechSupport = async () => {
|
||||
const hasMediaDevices = !!(
|
||||
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
||||
)
|
||||
setIsSpeechSupported(hasMediaDevices && !!transcribeAudio)
|
||||
}
|
||||
);
|
||||
setIsSpeechSupported(hasMediaDevices && !!transcribeAudio);
|
||||
};
|
||||
|
||||
checkSpeechSupport()
|
||||
}, [transcribeAudio])
|
||||
checkSpeechSupport();
|
||||
}, [transcribeAudio]);
|
||||
|
||||
const stopRecording = async () => {
|
||||
setIsRecording(false)
|
||||
setIsTranscribing(true)
|
||||
setIsRecording(false);
|
||||
setIsTranscribing(true);
|
||||
try {
|
||||
// First stop the recording to get the final blob
|
||||
recordAudio.stop()
|
||||
recordAudio.stop();
|
||||
// Wait for the recording promise to resolve with the final blob
|
||||
const recording = await activeRecordingRef.current
|
||||
const recording = await activeRecordingRef.current;
|
||||
if (transcribeAudio) {
|
||||
const text = await transcribeAudio(recording)
|
||||
onTranscriptionComplete?.(text)
|
||||
const text = await transcribeAudio(recording);
|
||||
onTranscriptionComplete?.(text);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transcribing audio:", error)
|
||||
console.error("Error transcribing audio:", error);
|
||||
} finally {
|
||||
setIsTranscribing(false)
|
||||
setIsListening(false)
|
||||
setIsTranscribing(false);
|
||||
setIsListening(false);
|
||||
if (audioStream) {
|
||||
audioStream.getTracks().forEach((track) => track.stop())
|
||||
setAudioStream(null)
|
||||
}
|
||||
activeRecordingRef.current = null
|
||||
audioStream.getTracks().forEach(track => track.stop());
|
||||
setAudioStream(null);
|
||||
}
|
||||
activeRecordingRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleListening = async () => {
|
||||
if (!isListening) {
|
||||
try {
|
||||
setIsListening(true)
|
||||
setIsRecording(true)
|
||||
setIsListening(true);
|
||||
setIsRecording(true);
|
||||
// Get audio stream first
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
})
|
||||
setAudioStream(stream)
|
||||
});
|
||||
setAudioStream(stream);
|
||||
|
||||
// Start recording with the stream
|
||||
activeRecordingRef.current = recordAudio(stream)
|
||||
activeRecordingRef.current = recordAudio(stream);
|
||||
} catch (error) {
|
||||
console.error("Error recording audio:", error)
|
||||
setIsListening(false)
|
||||
setIsRecording(false)
|
||||
console.error("Error recording audio:", error);
|
||||
setIsListening(false);
|
||||
setIsRecording(false);
|
||||
if (audioStream) {
|
||||
audioStream.getTracks().forEach((track) => track.stop())
|
||||
setAudioStream(null)
|
||||
audioStream.getTracks().forEach(track => track.stop());
|
||||
setAudioStream(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await stopRecording()
|
||||
}
|
||||
await stopRecording();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isListening,
|
||||
|
@ -89,5 +89,5 @@ export function useAudioRecording({
|
|||
audioStream,
|
||||
toggleListening,
|
||||
stopRecording,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
const ACTIVATION_THRESHOLD = 50
|
||||
const ACTIVATION_THRESHOLD = 50;
|
||||
// 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) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const previousScrollTop = useRef<number | null>(null)
|
||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(true)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const previousScrollTop = useRef<number | null>(null);
|
||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
}
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (containerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
|
||||
const distanceFromBottom = Math.abs(
|
||||
scrollHeight - scrollTop - clientHeight
|
||||
)
|
||||
);
|
||||
|
||||
const isScrollingUp = previousScrollTop.current
|
||||
? scrollTop < previousScrollTop.current
|
||||
: false
|
||||
: false;
|
||||
|
||||
const scrollUpDistance = previousScrollTop.current
|
||||
? previousScrollTop.current - scrollTop
|
||||
: 0
|
||||
: 0;
|
||||
|
||||
const isDeliberateScrollUp =
|
||||
isScrollingUp && scrollUpDistance > MIN_SCROLL_UP_THRESHOLD
|
||||
isScrollingUp && scrollUpDistance > MIN_SCROLL_UP_THRESHOLD;
|
||||
|
||||
if (isDeliberateScrollUp) {
|
||||
setShouldAutoScroll(false)
|
||||
setShouldAutoScroll(false);
|
||||
} else {
|
||||
const isScrolledToBottom = distanceFromBottom < ACTIVATION_THRESHOLD
|
||||
setShouldAutoScroll(isScrolledToBottom)
|
||||
const isScrolledToBottom = distanceFromBottom < ACTIVATION_THRESHOLD;
|
||||
setShouldAutoScroll(isScrolledToBottom);
|
||||
}
|
||||
|
||||
previousScrollTop.current = scrollTop
|
||||
}
|
||||
previousScrollTop.current = scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = () => {
|
||||
setShouldAutoScroll(false)
|
||||
}
|
||||
setShouldAutoScroll(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
previousScrollTop.current = containerRef.current.scrollTop
|
||||
previousScrollTop.current = containerRef.current.scrollTop;
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoScroll) {
|
||||
scrollToBottom()
|
||||
scrollToBottom();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, dependencies)
|
||||
}, dependencies);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
|
@ -69,5 +69,5 @@ export function useAutoScroll(dependencies: React.DependencyList) {
|
|||
handleScroll,
|
||||
shouldAutoScroll,
|
||||
handleTouchStart,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { useLayoutEffect, useRef } from "react"
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
|
||||
interface UseAutosizeTextAreaProps {
|
||||
ref: React.RefObject<HTMLTextAreaElement | null>
|
||||
maxHeight?: number
|
||||
borderWidth?: number
|
||||
dependencies: React.DependencyList
|
||||
ref: React.RefObject<HTMLTextAreaElement | null>;
|
||||
maxHeight?: number;
|
||||
borderWidth?: number;
|
||||
dependencies: React.DependencyList;
|
||||
}
|
||||
|
||||
export function useAutosizeTextArea({
|
||||
|
@ -13,27 +13,27 @@ export function useAutosizeTextArea({
|
|||
borderWidth = 0,
|
||||
dependencies,
|
||||
}: UseAutosizeTextAreaProps) {
|
||||
const originalHeight = useRef<number | null>(null)
|
||||
const originalHeight = useRef<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!ref.current) return
|
||||
if (!ref.current) return;
|
||||
|
||||
const currentRef = ref.current
|
||||
const borderAdjustment = borderWidth * 2
|
||||
const currentRef = ref.current;
|
||||
const borderAdjustment = borderWidth * 2;
|
||||
|
||||
if (originalHeight.current === null) {
|
||||
originalHeight.current = currentRef.scrollHeight - borderAdjustment
|
||||
originalHeight.current = currentRef.scrollHeight - borderAdjustment;
|
||||
}
|
||||
|
||||
currentRef.style.removeProperty("height")
|
||||
const scrollHeight = currentRef.scrollHeight
|
||||
currentRef.style.removeProperty("height");
|
||||
const scrollHeight = currentRef.scrollHeight;
|
||||
|
||||
// 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
|
||||
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
|
||||
}, [maxHeight, ref, ...dependencies])
|
||||
}, [maxHeight, ref, ...dependencies]);
|
||||
}
|
||||
|
|
|
@ -1,36 +1,36 @@
|
|||
import { useCallback, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type UseCopyToClipboardProps = {
|
||||
text: string
|
||||
copyMessage?: string
|
||||
}
|
||||
text: string;
|
||||
copyMessage?: string;
|
||||
};
|
||||
|
||||
export function useCopyToClipboard({
|
||||
text,
|
||||
copyMessage = "Copied to clipboard!",
|
||||
}: UseCopyToClipboardProps) {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
toast.success(copyMessage)
|
||||
setIsCopied(true)
|
||||
toast.success(copyMessage);
|
||||
setIsCopied(true);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, 2000)
|
||||
setIsCopied(false);
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to copy to clipboard.")
|
||||
})
|
||||
}, [text, copyMessage])
|
||||
toast.error("Failed to copy to clipboard.");
|
||||
});
|
||||
}, [text, copyMessage]);
|
||||
|
||||
return { isCopied, handleCopy }
|
||||
return { isCopied, handleCopy };
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ interface UseInfiniteScrollOptions {
|
|||
*/
|
||||
export function useInfiniteScroll(
|
||||
onLoadMore: (() => void) | undefined,
|
||||
options: UseInfiniteScrollOptions = {},
|
||||
options: UseInfiniteScrollOptions = {}
|
||||
) {
|
||||
const { enabled = true, threshold = 0.1, rootMargin = "100px" } = options;
|
||||
const sentinelRef = useRef<HTMLTableRowElement>(null);
|
||||
|
@ -29,7 +29,7 @@ export function useInfiniteScroll(
|
|||
if (!onLoadMore || !enabled) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting) {
|
||||
onLoadMore();
|
||||
|
@ -38,7 +38,7 @@ export function useInfiniteScroll(
|
|||
{
|
||||
threshold,
|
||||
rootMargin,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const sentinel = sentinelRef.current;
|
||||
|
|
|
@ -4,7 +4,7 @@ const MOBILE_BREAKPOINT = 768;
|
|||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
|
@ -38,7 +38,7 @@ interface UsePaginationParams<T> extends UsePaginationOptions {
|
|||
limit: number;
|
||||
model?: string;
|
||||
order?: string;
|
||||
},
|
||||
}
|
||||
) => Promise<PaginationResponse<T>>;
|
||||
errorMessagePrefix: string;
|
||||
enabled?: boolean;
|
||||
|
@ -81,7 +81,7 @@ export function usePagination<T>({
|
|||
const fetchLimit = targetRows || limit;
|
||||
|
||||
try {
|
||||
setState((prev) => ({
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: isInitialLoad ? "loading" : "loading-more",
|
||||
error: null,
|
||||
|
@ -94,7 +94,7 @@ export function usePagination<T>({
|
|||
...(order && { order }),
|
||||
});
|
||||
|
||||
setState((prev) => ({
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
data: isInitialLoad
|
||||
? response.data
|
||||
|
@ -124,14 +124,14 @@ export function usePagination<T>({
|
|||
? new Error(`${errorMessage} ${err.message}`)
|
||||
: new Error(errorMessage);
|
||||
|
||||
setState((prev) => ({
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error,
|
||||
status: "error",
|
||||
}));
|
||||
}
|
||||
},
|
||||
[limit, model, order, fetchFunction, errorMessagePrefix, client, router],
|
||||
[limit, model, order, fetchFunction, errorMessagePrefix, client, router]
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,50 +1,50 @@
|
|||
type RecordAudioType = {
|
||||
(stream: MediaStream): Promise<Blob>
|
||||
stop: () => void
|
||||
currentRecorder?: MediaRecorder
|
||||
}
|
||||
(stream: MediaStream): Promise<Blob>;
|
||||
stop: () => void;
|
||||
currentRecorder?: MediaRecorder;
|
||||
};
|
||||
|
||||
export const recordAudio = (function (): RecordAudioType {
|
||||
const func = async function recordAudio(stream: MediaStream): Promise<Blob> {
|
||||
try {
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: "audio/webm;codecs=opus",
|
||||
})
|
||||
const audioChunks: Blob[] = []
|
||||
});
|
||||
const audioChunks: Blob[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data)
|
||||
}
|
||||
audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: "audio/webm" })
|
||||
resolve(audioBlob)
|
||||
}
|
||||
const audioBlob = new Blob(audioChunks, { type: "audio/webm" });
|
||||
resolve(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = () => {
|
||||
reject(new Error("MediaRecorder error occurred"))
|
||||
}
|
||||
reject(new Error("MediaRecorder error occurred"));
|
||||
};
|
||||
|
||||
mediaRecorder.start(1000)
|
||||
;(func as RecordAudioType).currentRecorder = mediaRecorder
|
||||
})
|
||||
mediaRecorder.start(1000);
|
||||
(func as RecordAudioType).currentRecorder = mediaRecorder;
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred"
|
||||
throw new Error("Failed to start recording: " + errorMessage)
|
||||
}
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
throw new Error("Failed to start recording: " + errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
;(func as RecordAudioType).stop = () => {
|
||||
const recorder = (func as RecordAudioType).currentRecorder
|
||||
(func as RecordAudioType).stop = () => {
|
||||
const recorder = (func as RecordAudioType).currentRecorder;
|
||||
if (recorder && recorder.state !== "inactive") {
|
||||
recorder.stop()
|
||||
}
|
||||
delete (func as RecordAudioType).currentRecorder
|
||||
recorder.stop();
|
||||
}
|
||||
delete (func as RecordAudioType).currentRecorder;
|
||||
};
|
||||
|
||||
return func as RecordAudioType
|
||||
})()
|
||||
return func as RecordAudioType;
|
||||
})();
|
||||
|
|
|
@ -27,19 +27,19 @@ export function validateServerConfig() {
|
|||
!optionalConfigs.GITHUB_CLIENT_SECRET
|
||||
) {
|
||||
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(" 1. Go to https://github.com/settings/applications/new");
|
||||
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(
|
||||
" 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(
|
||||
" 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(" GITHUB_CLIENT_ID=your_client_id");
|
||||
|
|
|
@ -11,7 +11,7 @@ export interface VectorStoreContentItem {
|
|||
vector_store_id: string;
|
||||
file_id: string;
|
||||
content: VectorStoreContent;
|
||||
metadata: Record<string, any>;
|
||||
metadata: Record<string, unknown>;
|
||||
embedding?: number[];
|
||||
}
|
||||
|
||||
|
@ -32,11 +32,18 @@ export interface VectorStoreListContentsResponse {
|
|||
export class ContentsAPI {
|
||||
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);
|
||||
}
|
||||
|
||||
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 targetContent = contentsResponse.data.find(c => c.id === contentId);
|
||||
|
||||
|
@ -47,16 +54,11 @@ export class ContentsAPI {
|
|||
return targetContent;
|
||||
}
|
||||
|
||||
async updateContent(
|
||||
vectorStoreId: string,
|
||||
fileId: string,
|
||||
contentId: string,
|
||||
updates: { content?: string; metadata?: Record<string, any> }
|
||||
): Promise<VectorStoreContentItem> {
|
||||
async updateContent(): Promise<VectorStoreContentItem> {
|
||||
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");
|
||||
}
|
||||
|
||||
|
@ -70,18 +72,27 @@ export class ContentsAPI {
|
|||
before?: string;
|
||||
}
|
||||
): 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[] = [];
|
||||
|
||||
fileContents.content.forEach((content, contentIndex) => {
|
||||
const rawContent = content as any;
|
||||
const rawContent = content as Record<string, unknown>;
|
||||
|
||||
// Extract actual fields from the API response
|
||||
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 contentId = rawContent.chunk_metadata?.chunk_id || rawContent.id || `content_${fileId}_${contentIndex}`;
|
||||
const objectType = rawContent.object || 'vector_store.file.content';
|
||||
const contentId =
|
||||
rawContent.chunk_metadata?.chunk_id ||
|
||||
rawContent.id ||
|
||||
`content_${fileId}_${contentIndex}`;
|
||||
const objectType = rawContent.object || "vector_store.file.content";
|
||||
contentItems.push({
|
||||
id: contentId,
|
||||
object: objectType,
|
||||
|
@ -92,7 +103,7 @@ export class ContentsAPI {
|
|||
embedding: embedding,
|
||||
metadata: {
|
||||
...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 {
|
||||
object: 'list',
|
||||
object: "list",
|
||||
data: filteredItems,
|
||||
has_more: contentItems.length > (options?.limit || contentItems.length),
|
||||
};
|
||||
|
|
|
@ -18,7 +18,7 @@ describe("extractTextFromContentPart", () => {
|
|||
it("should extract text from an array of text content objects", () => {
|
||||
const content = [{ type: "text", text: "Which planet do humans live on?" }];
|
||||
expect(extractTextFromContentPart(content)).toBe(
|
||||
"Which planet do humans live on?",
|
||||
"Which planet do humans live on?"
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -37,7 +37,7 @@ describe("extractTextFromContentPart", () => {
|
|||
{ type: "text", text: "It's an image." },
|
||||
];
|
||||
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", () => {
|
||||
const content = ["This is", " a test."] as any;
|
||||
const content = ["This is", " a test."] as unknown;
|
||||
expect(extractTextFromContentPart(content)).toBe("This is a test.");
|
||||
});
|
||||
|
||||
|
@ -65,7 +65,7 @@ describe("extractTextFromContentPart", () => {
|
|||
null,
|
||||
undefined,
|
||||
{ type: "text", noTextProperty: true },
|
||||
] as any;
|
||||
] as unknown;
|
||||
expect(extractTextFromContentPart(content)).toBe("Valid");
|
||||
});
|
||||
|
||||
|
@ -75,15 +75,17 @@ describe("extractTextFromContentPart", () => {
|
|||
"Just a string.",
|
||||
{ type: "image_url", image_url: { url: "http://example.com/image.png" } },
|
||||
{ type: "text", text: "Last part." },
|
||||
] as any;
|
||||
] as unknown;
|
||||
expect(extractTextFromContentPart(content)).toBe(
|
||||
"First part. Just a string. [Image] Last part.",
|
||||
"First part. Just a string. [Image] Last part."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDisplayableText (composite function)", () => {
|
||||
const mockFormatToolCallToString = (toolCall: any) => {
|
||||
const mockFormatToolCallToString = (toolCall: {
|
||||
function?: { name?: string; arguments?: unknown };
|
||||
}) => {
|
||||
if (!toolCall || !toolCall.function || !toolCall.function.name) return "";
|
||||
const args = toolCall.function.arguments
|
||||
? JSON.stringify(toolCall.function.arguments)
|
||||
|
@ -125,7 +127,7 @@ describe("extractDisplayableText (composite function)", () => {
|
|||
tool_calls: [toolCall],
|
||||
};
|
||||
expect(extractDisplayableText(messageWithEffectivelyEmptyContent)).toBe(
|
||||
mockFormatToolCallToString(toolCall),
|
||||
mockFormatToolCallToString(toolCall)
|
||||
);
|
||||
|
||||
const messageWithEmptyContent: ChatMessage = {
|
||||
|
@ -134,7 +136,7 @@ describe("extractDisplayableText (composite function)", () => {
|
|||
tool_calls: [toolCall],
|
||||
};
|
||||
expect(extractDisplayableText(messageWithEmptyContent)).toBe(
|
||||
mockFormatToolCallToString(toolCall),
|
||||
mockFormatToolCallToString(toolCall)
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -149,7 +151,7 @@ describe("extractDisplayableText (composite function)", () => {
|
|||
};
|
||||
const expectedToolCallStr = mockFormatToolCallToString(toolCall);
|
||||
expect(extractDisplayableText(message)).toBe(
|
||||
`The result is: ${expectedToolCallStr}`,
|
||||
`The result is: ${expectedToolCallStr}`
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -167,7 +169,7 @@ describe("extractDisplayableText (composite function)", () => {
|
|||
};
|
||||
const expectedToolCallStr = mockFormatToolCallToString(toolCall);
|
||||
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: [],
|
||||
};
|
||||
expect(extractDisplayableText(messageEmptyToolCalls)).toBe(
|
||||
"No tools here.",
|
||||
"No tools here."
|
||||
);
|
||||
|
||||
const messageUndefinedToolCalls: ChatMessage = {
|
||||
|
@ -187,7 +189,7 @@ describe("extractDisplayableText (composite function)", () => {
|
|||
tool_calls: undefined,
|
||||
};
|
||||
expect(extractDisplayableText(messageUndefinedToolCalls)).toBe(
|
||||
"Still no tools.",
|
||||
"Still no tools."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@ import { ChatMessage, ChatMessageContentPart } from "@/lib/types";
|
|||
import { formatToolCallToString } from "@/lib/format-tool-call";
|
||||
|
||||
export function extractTextFromContentPart(
|
||||
content: string | ChatMessageContentPart[] | null | undefined,
|
||||
content: string | ChatMessageContentPart[] | null | undefined
|
||||
): string {
|
||||
if (content === null || content === undefined) {
|
||||
return "";
|
||||
|
@ -37,7 +37,7 @@ export function extractTextFromContentPart(
|
|||
}
|
||||
|
||||
export function extractDisplayableText(
|
||||
message: ChatMessage | undefined | null,
|
||||
message: ChatMessage | undefined | null
|
||||
): string {
|
||||
if (!message) {
|
||||
return "";
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
* with `name` and `arguments`.
|
||||
* @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 (
|
||||
!toolCall ||
|
||||
!toolCall.function ||
|
||||
|
@ -24,7 +26,7 @@ export function formatToolCallToString(toolCall: any): string {
|
|||
} else {
|
||||
try {
|
||||
argsString = JSON.stringify(args);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
export function truncateText(
|
||||
text: string | null | undefined,
|
||||
maxLength: number = 50,
|
||||
maxLength: number = 50
|
||||
): string {
|
||||
if (!text) return "N/A";
|
||||
if (text.length <= maxLength) return text;
|
||||
|
|
2
llama_stack/ui/package-lock.json
generated
2
llama_stack/ui/package-lock.json
generated
|
@ -18,7 +18,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.18.2",
|
||||
"llama-stack-client": "0.2.17",
|
||||
"llama-stack-client": "^0.2.17",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.3",
|
||||
"next-auth": "^4.24.11",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue