Files
EvoScientist-WebUI/src/app/hooks/useThreads.ts
T

199 lines
6.2 KiB
TypeScript

import useSWRInfinite from "swr/infinite";
import type { Thread } from "@langchain/langgraph-sdk";
import { Client } from "@langchain/langgraph-sdk";
import { getConfig } from "@/lib/config";
export interface ThreadItem {
id: string;
updatedAt: Date;
status: Thread["status"];
title: string;
description: string;
assistantId?: string;
}
const DEFAULT_PAGE_SIZE = 20;
export function useThreads(props: {
status?: Thread["status"];
limit?: number;
}) {
const pageSize = props.limit || DEFAULT_PAGE_SIZE;
return useSWRInfinite(
(pageIndex: number, previousPageData: ThreadItem[] | null) => {
const config = getConfig();
const apiKey =
config?.langsmithApiKey ||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
"";
if (!config) {
return null;
}
// If the previous page returned no items, we've reached the end
if (previousPageData && previousPageData.length === 0) {
return null;
}
return {
kind: "threads" as const,
pageIndex,
pageSize,
deploymentUrl: config.deploymentUrl,
assistantId: config.assistantId,
apiKey,
status: props?.status,
};
},
async ({
deploymentUrl,
assistantId,
apiKey,
status,
pageIndex,
pageSize,
}: {
kind: "threads";
pageIndex: number;
pageSize: number;
deploymentUrl: string;
assistantId: string;
apiKey: string;
status?: Thread["status"];
}) => {
const client = new Client({
apiUrl: deploymentUrl,
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
});
// Always scope the thread list to the selected assistant so we never
// show threads spawned by async sub-agents (e.g. writing-agent,
// data-analysis-agent) that share the same deployment store.
//
// Every thread carries both `graph_id` (the graph name) and
// `assistant_id` (a deterministic UUID per graph) in its metadata.
// A deployed UUID is matched against `assistant_id`; a graph name
// (the local-dev case, e.g. "EvoScientist") is matched against
// `graph_id`.
const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
assistantId
);
const metadata = isUUID
? { assistant_id: assistantId }
: { graph_id: assistantId };
const threads = await client.threads.search({
limit: pageSize,
offset: pageIndex * pageSize,
sortBy: "updated_at" as const,
sortOrder: "desc" as const,
status,
metadata,
});
return threads.map((thread): ThreadItem => {
let title = "Untitled Thread";
let description = "";
try {
if (thread.values && typeof thread.values === "object") {
const values = thread.values as any;
const messages: any[] = Array.isArray(values.messages)
? values.messages
: [];
const firstHumanMessage = messages.find(
(m: any) => m.type === "human"
);
if (firstHumanMessage?.content) {
const content =
typeof firstHumanMessage.content === "string"
? firstHumanMessage.content
: firstHumanMessage.content[0]?.text || "";
title = content.slice(0, 50) + (content.length > 50 ? "..." : "");
}
// Preview = the first AI message that actually has text. Agentic
// threads often open with tool-call-only AI messages (empty
// content), so picking the literal first AI message would leave the
// row blank (looking like an "empty" thread). Also join all text
// parts rather than just content[0].
const aiText = (content: any): string => {
if (typeof content === "string") return content;
if (Array.isArray(content))
return content
.map((p: any) => (typeof p?.text === "string" ? p.text : ""))
.join("");
return "";
};
for (const m of messages) {
if (m?.type !== "ai") continue;
const t = aiText(m.content).trim();
if (t) {
description = t.slice(0, 100);
break;
}
}
}
} catch {
// Fallback to thread ID
title = `Thread ${thread.thread_id.slice(0, 8)}`;
}
// A user-set custom title (stored in metadata via rename) always wins.
const customTitle = (
thread.metadata as Record<string, unknown> | undefined
)?.title;
if (typeof customTitle === "string" && customTitle.trim()) {
title = customTitle.trim();
}
return {
id: thread.thread_id,
updatedAt: new Date(thread.updated_at),
status: thread.status,
title,
description,
assistantId,
};
});
},
{
revalidateFirstPage: true,
revalidateOnFocus: true,
}
);
}
// --- Thread mutations (used by the thread list's rename / delete actions) ---
function makeThreadsClient(): Client | null {
const config = getConfig();
if (!config) return null;
const apiKey =
config.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
return new Client({
apiUrl: config.deploymentUrl,
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
});
}
/** Permanently delete a thread. Throws if no deployment is configured. */
export async function deleteThread(id: string): Promise<void> {
const client = makeThreadsClient();
if (!client) throw new Error("No EvoScientist deployment configured.");
await client.threads.delete(id);
}
/**
* Rename a thread by storing a custom title in its metadata. `update` PATCHes
* (merges) metadata, so the `graph_id` / `assistant_id` keys the list relies on
* for filtering are preserved.
*/
export async function renameThread(id: string, title: string): Promise<void> {
const client = makeThreadsClient();
if (!client) throw new Error("No EvoScientist deployment configured.");
await client.threads.update(id, { metadata: { title } });
}