feat: search endpoint (#198)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-12 17:16:09 -08:00
committed by GitHub
parent f61ecd6849
commit 8bb588e5af
5 changed files with 372 additions and 4 deletions

View File

@@ -37,6 +37,7 @@ import { CommandMessage } from "./components/CommandMessage";
import { EnterPlanModeDialog } from "./components/EnterPlanModeDialog";
import { ErrorMessage } from "./components/ErrorMessageRich";
import { Input } from "./components/InputRich";
import { MessageSearch } from "./components/MessageSearch";
import { ModelSelector } from "./components/ModelSelector";
import { PlanModeDialog } from "./components/PlanModeDialog";
import { QuestionDialog } from "./components/QuestionDialog";
@@ -330,6 +331,7 @@ export default function App({
// Resume selector state
const [resumeSelectorOpen, setResumeSelectorOpen] = useState(false);
const [messageSearchOpen, setMessageSearchOpen] = useState(false);
// Token streaming preference (can be toggled at runtime)
const [tokenStreamingEnabled, setTokenStreamingEnabled] =
@@ -1572,6 +1574,12 @@ export default function App({
return { submitted: true };
}
// Special handling for /search command - show message search
if (msg.trim() === "/search") {
setMessageSearchOpen(true);
return { submitted: true };
}
// Special handling for /swap command - alias for /resume
if (msg.trim().startsWith("/swap")) {
const parts = msg.trim().split(/\s+/);
@@ -3299,7 +3307,8 @@ Plan file path: ${planFilePath}`;
!toolsetSelectorOpen &&
!systemPromptSelectorOpen &&
!agentSelectorOpen &&
!resumeSelectorOpen
!resumeSelectorOpen &&
!messageSearchOpen
}
streaming={
streaming && !abortControllerRef.current?.signal.aborted
@@ -3372,6 +3381,11 @@ Plan file path: ${planFilePath}`;
/>
)}
{/* Message Search - conditionally mounted as overlay */}
{messageSearchOpen && (
<MessageSearch onClose={() => setMessageSearchOpen(false)} />
)}
{/* Plan Mode Dialog - for ExitPlanMode tool */}
{currentApproval?.toolName === "ExitPlanMode" && (
<>

View File

@@ -143,6 +143,13 @@ export const commands: Record<string, Command> = {
return "Opening session selector...";
},
},
"/search": {
desc: "Search messages across all agents",
handler: () => {
// Handled specially in App.tsx to show message search
return "Opening message search...";
},
},
};
/**

View File

@@ -0,0 +1,347 @@
import type { Letta } from "@letta-ai/letta-client";
import type { MessageSearchResponse } from "@letta-ai/letta-client/resources/messages";
import { Box, Text, useInput } from "ink";
import { useCallback, useEffect, useRef, useState } from "react";
import { getClient } from "../../agent/client";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
interface MessageSearchProps {
onClose: () => void;
}
const DISPLAY_PAGE_SIZE = 5;
const SEARCH_LIMIT = 100; // Max results from API
type SearchMode = "hybrid" | "vector" | "fts";
const SEARCH_MODES: SearchMode[] = ["hybrid", "vector", "fts"];
/**
* Format a relative time string from a date
*/
function formatRelativeTime(dateStr: string | null | undefined): string {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
const diffWeeks = Math.floor(diffDays / 7);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return `${diffWeeks}w ago`;
}
/**
* Truncate text to fit width, adding ellipsis if needed
*/
function truncateText(text: string, maxWidth: number): string {
if (text.length <= maxWidth) return text;
return `${text.slice(0, maxWidth - 3)}...`;
}
/**
* Get display text from a message
*/
function getMessageText(msg: MessageSearchResponse[number]): string {
// Assistant message content
if ("content" in msg) {
const content = msg.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const textPart = content.find(
(c) => typeof c === "object" && c && "text" in c,
);
if (textPart && typeof textPart === "object" && "text" in textPart) {
return String(textPart.text);
}
}
}
// Text field (user messages, etc)
if ("text" in msg && typeof msg.text === "string") {
return msg.text;
}
// Reasoning messages
if ("reasoning" in msg && typeof msg.reasoning === "string") {
return msg.reasoning;
}
// Tool call messages
if ("tool_call" in msg && msg.tool_call) {
const tc = msg.tool_call as { name?: string; arguments?: string };
return `Tool: ${tc.name || "unknown"}`;
}
// Tool return messages - show tool name and preview of return
if ("tool_return" in msg) {
const toolName = "name" in msg ? (msg.name as string) : "tool";
const returnValue = msg.tool_return as string;
// Truncate long return values
const preview = returnValue?.slice(0, 100) || "";
return `${toolName}: ${preview}`;
}
return `[${msg.message_type || "unknown"}]`;
}
export function MessageSearch({ onClose }: MessageSearchProps) {
const terminalWidth = useTerminalWidth();
const [searchInput, setSearchInput] = useState("");
const [activeQuery, setActiveQuery] = useState("");
const [searchMode, setSearchMode] = useState<SearchMode>("hybrid");
const [results, setResults] = useState<MessageSearchResponse>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(0);
const [selectedIndex, setSelectedIndex] = useState(0);
const clientRef = useRef<Letta | null>(null);
// Execute search
const executeSearch = useCallback(async (query: string, mode: SearchMode) => {
if (!query.trim()) return;
setLoading(true);
setError(null);
try {
const client = clientRef.current || (await getClient());
clientRef.current = client;
const searchResults = await client.messages.search({
query: query.trim(),
search_mode: mode,
limit: SEARCH_LIMIT,
});
setResults(searchResults);
setCurrentPage(0);
setSelectedIndex(0);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setResults([]);
} finally {
setLoading(false);
}
}, []);
// Submit search
const submitSearch = useCallback(() => {
if (searchInput.trim() && searchInput !== activeQuery) {
setActiveQuery(searchInput);
executeSearch(searchInput, searchMode);
}
}, [searchInput, activeQuery, searchMode, executeSearch]);
// Clear search
const clearSearch = useCallback(() => {
setSearchInput("");
setActiveQuery("");
setResults([]);
setCurrentPage(0);
setSelectedIndex(0);
}, []);
// Cycle search mode
const cycleSearchMode = useCallback(() => {
setSearchMode((current) => {
const currentIndex = SEARCH_MODES.indexOf(current);
const nextIndex = (currentIndex + 1) % SEARCH_MODES.length;
return SEARCH_MODES[nextIndex] as SearchMode;
});
}, []);
// Re-run search when mode changes (if there's an active query)
useEffect(() => {
if (activeQuery) {
executeSearch(activeQuery, searchMode);
}
}, [searchMode, activeQuery, executeSearch]);
// Calculate pagination
const totalPages = Math.ceil(results.length / DISPLAY_PAGE_SIZE);
const startIndex = currentPage * DISPLAY_PAGE_SIZE;
const pageResults = results.slice(startIndex, startIndex + DISPLAY_PAGE_SIZE);
useInput((input, key) => {
if (key.escape) {
if (searchInput || activeQuery) {
clearSearch();
} else {
onClose();
}
} else if (key.return) {
submitSearch();
} else if (key.backspace || key.delete) {
setSearchInput((prev) => prev.slice(0, -1));
} else if (key.tab) {
// Tab cycles search mode
cycleSearchMode();
} else if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(pageResults.length - 1, prev + 1));
} else if (input === "j" || input === "J") {
// Previous page
if (currentPage > 0) {
setCurrentPage((prev) => prev - 1);
setSelectedIndex(0);
}
} else if (input === "k" || input === "K") {
// Next page
if (currentPage < totalPages - 1) {
setCurrentPage((prev) => prev + 1);
setSelectedIndex(0);
}
} else if (input && !key.ctrl && !key.meta) {
setSearchInput((prev) => prev + input);
}
});
return (
<Box flexDirection="column" gap={1}>
<Box>
<Text bold color={colors.selector.title}>
Search messages across all agents
</Text>
</Box>
{/* Search input and mode */}
<Box flexDirection="column">
<Box>
<Text dimColor>Search: </Text>
{searchInput ? (
<>
<Text>{searchInput}</Text>
{searchInput !== activeQuery && (
<Text dimColor> (press Enter to search)</Text>
)}
</>
) : (
<Text dimColor italic>
(type your query)
</Text>
)}
</Box>
<Box>
<Text dimColor>Mode: </Text>
{SEARCH_MODES.map((mode, i) => (
<Text key={mode}>
{i > 0 && <Text dimColor> · </Text>}
<Text
bold={mode === searchMode}
color={
mode === searchMode
? colors.selector.itemHighlighted
: undefined
}
>
{mode}
</Text>
</Text>
))}
<Text dimColor> (Tab to change)</Text>
</Box>
</Box>
{/* Error state */}
{error && (
<Box>
<Text color="red">Error: {error}</Text>
</Box>
)}
{/* Loading state */}
{loading && (
<Box>
<Text dimColor>Searching...</Text>
</Box>
)}
{/* No results */}
{!loading && activeQuery && results.length === 0 && (
<Box>
<Text dimColor>No results found for "{activeQuery}"</Text>
</Box>
)}
{/* Results list */}
{!loading && results.length > 0 && (
<Box flexDirection="column">
{pageResults.map((msg, index) => {
const isSelected = index === selectedIndex;
const messageText = getMessageText(msg);
// All messages have a date field
const msgWithDate = msg as { date?: string };
const timestamp = msgWithDate.date
? formatRelativeTime(msgWithDate.date)
: "";
const msgType = (msg.message_type || "unknown").replace(
"_message",
"",
);
// Calculate available width for message text
const metaWidth = timestamp.length + msgType.length + 10; // padding
const availableWidth = Math.max(20, terminalWidth - metaWidth - 4);
const displayText = truncateText(
messageText.replace(/\n/g, " "),
availableWidth,
);
// Use message id + index for guaranteed uniqueness (search can return same message multiple times)
const msgId = "id" in msg ? String(msg.id) : "result";
const uniqueKey = `${msgId}-${startIndex + index}`;
return (
<Box key={uniqueKey} flexDirection="column" marginBottom={1}>
<Box flexDirection="row">
<Text
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{isSelected ? ">" : " "}
</Text>
<Text> </Text>
<Text
bold={isSelected}
color={
isSelected ? colors.selector.itemHighlighted : undefined
}
>
{displayText}
</Text>
</Box>
<Box flexDirection="row" marginLeft={2}>
<Text dimColor>
{msgType}
{timestamp && ` · ${timestamp}`}
</Text>
</Box>
</Box>
);
})}
</Box>
)}
{/* Footer */}
<Box flexDirection="column" marginTop={1}>
{results.length > 0 && (
<Box>
<Text dimColor>
Page {currentPage + 1}/{totalPages || 1} ({results.length}{" "}
results)
</Text>
</Box>
)}
<Box>
<Text dimColor>
Type + Enter to search · Tab mode · J/K page · Esc close
</Text>
</Box>
</Box>
</Box>
);
}