@langgraph-js/sdk
Version:
The UI SDK for LangGraph - seamlessly integrate your AI agents with frontend interfaces
504 lines (422 loc) • 17.3 kB
text/typescript
import { atom } from "nanostores";
import { LangGraphClient, LangGraphClientConfig, RenderMessage, SendMessageOptions } from "../LangGraphClient.js";
import { AssistantGraph, Message, Thread } from "@langchain/langgraph-sdk";
import { debounce } from "ts-debounce";
import { ToolRenderData } from "../tool/ToolUI.js";
import { UnionTool } from "../tool/createTool.js";
import { createLangGraphServerClient } from "../client/LanggraphServer.js";
import { useArtifacts } from "../artifacts/index.js";
import { RevertChatToOptions } from "../time-travel/index.js";
import { History, SessionInfo } from "../History.js";
import { InterruptData, InterruptResponse } from "../humanInTheLoop.js";
// ============ 工具函数 ============
export const formatTime = (date: Date) => date.toLocaleTimeString();
export const formatFullTime = (date: Date) => {
const pad = (n: number) => n.toString().padStart(2, "0");
const yyyy = date.getFullYear();
const mm = pad(date.getMonth() + 1);
const dd = pad(date.getDate());
const hh = pad(date.getHours());
const mi = pad(date.getMinutes());
const ss = pad(date.getSeconds());
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`;
};
export const formatTokens = (tokens: number) => tokens.toLocaleString("en");
export const getMessageContent = (content: any) => {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((item) => {
if (typeof item === "string") return item;
if (item.type === "text") return item.text;
if (item.type === "image_url") return `[图片]`;
return JSON.stringify(item);
})
.join("");
}
return JSON.stringify(content);
};
export const getHistoryContent = (thread: Thread) => {
/** @ts-ignore */
const content: string | any[] = thread.title || thread.name || (thread?.values as any)?.messages?.[0]?.content;
if (content && Array.isArray(content)) {
return content.map((item: any) => (item.type === "text" ? item.text : undefined)).filter(Boolean);
}
return typeof content === "string" ? content : "";
};
// ============ 类型定义 ============
interface ChatStoreContext {
showHistory?: boolean;
showGraph?: boolean;
fallbackToAvailableAssistants?: boolean;
onInit?: (client: LangGraphClient) => void;
/** 初始化时是否自动激活最近的历史会话(默认 false,创建新会话) */
autoRestoreLastSession?: boolean;
}
// ============ Store 创建函数 ============
export const createChatStore = (initClientName: string, config: Partial<LangGraphClientConfig>, context: ChatStoreContext = {}) => {
// ============ 状态原子 ============
// 会话管理
const history = atom<History | null>(null);
const sessions = atom<SessionInfo[]>([]);
const client = atom<LangGraphClient | null>(null);
const historyList = atom<Thread<{ messages: Message[] }>[]>([]);
// UI 状态
const renderMessages = atom<RenderMessage[]>([]);
const userInput = atom<string>("");
const inChatError = atom<string | null>(null);
const currentAgent = atom<string>(initClientName);
const currentChatId = atom<string | null>(null);
const currentNodeName = atom<string>("__start__");
const currentStatus = atom<string>("idle");
// Interrupt 状态
const interruptData = atom<InterruptData | null>(null);
const isInterrupted = atom<boolean>(false);
// 工具和图表
const tools = atom<UnionTool<any>[]>([]);
const collapsedTools = atom<string[]>([]);
const showHistory = atom<boolean>(context.showHistory ?? false);
const showGraph = atom<boolean>(context.showGraph ?? false);
const graphVisualize = atom<AssistantGraph | null>(null);
// ============ 内部状态 ============
let cleanupCurrentClient: (() => void) | null = null;
// ============ 计算属性 ============
/** 基于 client.status 的 loading 状态 */
const loading = atom<boolean>(false);
const updateLoadingFromClientStatus = () => {
const c = client.get();
if (c) {
// interrupted 状态也应该被视为 loading,因为用户需要处理中断
loading.set(c.status === "busy" || c.status === "interrupted");
}
};
// ============ UI 更新逻辑 ============
const updateUI = debounce((newClient: LangGraphClient) => {
if (client.get() !== newClient) return;
const messages = newClient.renderMessage;
const lastMessage = messages[messages.length - 1];
currentNodeName.set(lastMessage?.node_name || lastMessage?.name || "__start__");
renderMessages.set(messages);
currentStatus.set(newClient.status);
}, 10);
// ============ 工具和图表辅助函数 ============
const refreshTools = async () => {
const c = client.get();
if (!c) return;
c.tools.clearTools();
c.tools.bindTools(tools.get());
};
const refreshGraph = async () => {
if (showGraph.get()) {
graphVisualize.set((await client.get()?.graphVisualize()) || null);
}
};
// ============ 会话管理核心逻辑 ============
async function initClient() {
const historyManager = new History({
...config,
client: config.client ?? (await createLangGraphServerClient(config as LangGraphClientConfig)),
});
await historyManager.init(currentAgent.get(), { fallbackToAvailableAssistants: context.fallbackToAvailableAssistants });
history.set(historyManager);
// 同步远程会话列表
// 根据配置决定初始化行为
if (context.autoRestoreLastSession) {
await refreshSessionList();
if (sessions.get().length > 0) {
const syncedSessions = sessions.get();
// 自动激活最近的历史会话
await activateSession(syncedSessions[0].sessionId);
}
} else {
// 创建新会话
await createNewSession();
}
return historyManager;
}
async function refreshSessionList() {
const historyManager = history.get();
if (!historyManager) return;
try {
const syncedSessions = await historyManager.syncFromRemote({ limit: 10 });
sessions.set(syncedSessions);
historyList.set(syncedSessions.filter((s) => s.thread).map((s) => s.thread!));
} catch (error) {
console.error("Failed to sync sessions:", error);
}
}
async function createNewSession() {
const historyManager = history.get();
if (!historyManager) return;
try {
const session = await historyManager.createSession();
await refreshSessionList();
await activateSession(session.sessionId);
} catch (error) {
console.error("Failed to create new session:", error);
inChatError.set((error as Error).message);
}
}
// ============ 客户端事件监听器 ============
function setupClientListeners(newClient: LangGraphClient) {
const isActiveClient = () => client.get() === newClient;
const onStart = () => {
if (isActiveClient()) updateLoadingFromClientStatus();
};
const onThread = () => {
if (!isActiveClient()) return;
const thread = newClient.getCurrentThread();
currentChatId.set(thread?.thread_id || null);
currentNodeName.set("__start__");
const historyManager = history.get();
const activeSession = historyManager?.getActiveSession();
if (activeSession && thread) {
activeSession.thread = thread;
}
refreshSessionList();
};
const onDone = () => {
if (isActiveClient()) {
updateLoadingFromClientStatus();
updateUI(newClient);
}
};
const onError = (event: any) => {
if (isActiveClient()) {
updateLoadingFromClientStatus();
inChatError.set(event.data);
}
};
const onMessage = () => {
if (isActiveClient()) {
currentChatId.set(newClient.getCurrentThread()?.thread_id || null);
updateLoadingFromClientStatus();
updateUI(newClient);
}
};
const onValue = () => {
if (isActiveClient()) {
currentChatId.set(newClient.getCurrentThread()?.thread_id || null);
updateLoadingFromClientStatus();
updateUI(newClient);
}
};
const onInterrupted = (event: any) => {
if (!isActiveClient()) return;
const interruptInfo = newClient.interruptData;
if (interruptInfo) {
interruptData.set(interruptInfo);
isInterrupted.set(true);
updateLoadingFromClientStatus();
} else {
interruptData.set(null);
isInterrupted.set(false);
}
updateUI(newClient);
client.set(client.get());
};
newClient.on("start", onStart);
newClient.on("thread", onThread);
newClient.on("done", onDone);
newClient.on("error", onError);
newClient.on("message", onMessage);
newClient.on("value", onValue);
newClient.on("interruptChange", onInterrupted);
return () => {
newClient.off("start", onStart);
newClient.off("thread", onThread);
newClient.off("done", onDone);
newClient.off("error", onError);
newClient.off("message", onMessage);
newClient.off("value", onValue);
newClient.off("interruptChange", onInterrupted);
};
}
// ============ 会话激活逻辑 ============
async function activateSession(sessionId: string, mustResetStream = false) {
const historyManager = history.get();
if (!historyManager) return;
try {
if (cleanupCurrentClient) {
cleanupCurrentClient();
cleanupCurrentClient = null;
}
inChatError.set(null);
interruptData.set(null);
isInterrupted.set(false);
const session = await historyManager.activateSession(sessionId, mustResetStream);
const activeClient = session.client;
if (activeClient) {
cleanupCurrentClient = setupClientListeners(activeClient);
context.onInit?.(activeClient);
client.set(activeClient);
currentChatId.set(sessionId);
const messages = activeClient.renderMessage;
renderMessages.set(messages);
const lastMessage = messages[messages.length - 1];
currentNodeName.set(lastMessage?.node_name || lastMessage?.name || "__start__");
updateLoadingFromClientStatus();
if (showGraph.get()) refreshGraph();
refreshTools();
const currentThread = activeClient.getCurrentThread() as any;
if (currentThread && (currentThread.status === "busy" || currentThread.status === "pending")) {
await activeClient.resetStream();
}
// 检查是否处于中断状态
if (currentThread?.status === "interrupted") {
const interruptInfo = activeClient.interruptData;
if (interruptInfo) {
interruptData.set(interruptInfo);
isInterrupted.set(true);
}
}
}
} catch (error) {
console.error("Failed to activate session:", error);
inChatError.set((error as Error).message);
}
}
// ============ 消息和交互逻辑 ============
async function sendMessage(message?: Message[], extraData?: SendMessageOptions, withoutCheck = false, isResume = false) {
const c = client.get();
if ((!withoutCheck && !userInput.get().trim() && !message?.length) || !c) return;
// 使用 client.status 判断是否正在加载
if (c.status === "busy") return;
inChatError.set(null);
try {
loading.set(true);
await c.sendMessage(message || userInput.get(), extraData);
} catch (e) {
const isThreadRunning = (e as Error).message.includes("422");
if (isThreadRunning) {
await c.resetStream();
} else {
throw e;
}
} finally {
userInput.set("");
updateLoadingFromClientStatus();
}
}
function stopGeneration() {
client.get()?.cancelRun();
}
function toggleToolCollapse(toolId: string) {
const prev = collapsedTools.get();
collapsedTools.set(prev.includes(toolId) ? prev.filter((id) => id !== toolId) : [...prev, toolId]);
}
function toggleHistoryVisible() {
showHistory.set(!showHistory.get());
if (showHistory.get()) {
refreshSessionList();
}
}
function addToHistory(thread: Thread<{ messages: Message[] }>) {
const prev = historyList.get();
historyList.set([thread, ...prev]);
}
function getToolUIRender(tool_name: string) {
const c = client.get();
if (!c) return null;
const toolRender = c.tools.getTool(tool_name)?.render;
return toolRender ? (message: RenderMessage) => toolRender(new ToolRenderData(message, c)) : null;
}
// ============ 返回 Store API ============
const artifactHook = useArtifacts(renderMessages, client);
return {
data: {
// 核心客户端
client,
history,
sessions,
// UI 状态
renderMessages,
userInput,
loading,
inChatError,
currentAgent,
currentChatId,
currentNodeName,
currentStatus,
// Interrupt 状态
interruptData,
isInterrupted,
// 工具和图表
tools,
collapsedTools,
showGraph,
graphVisualize,
// 历史记录
showHistory,
historyList,
...artifactHook.data,
},
mutations: {
// 初始化
initClient,
getClient: () => client.get(),
getHistory: () => history.get(),
// 会话管理
activateSession,
createNewSession,
refreshSessionList,
refreshHistoryList: refreshSessionList, // 向后兼容
// 消息操作
sendMessage,
stopGeneration,
setUserInput: (input: string) => userInput.set(input),
async revertChatTo(messageId: string, resend = false, sendOptions?: SendMessageOptions & RevertChatToOptions) {
await client.get()?.revertChatTo(messageId, sendOptions || {});
if (resend) {
return sendMessage([], sendOptions, true);
} else {
updateUI(client.get()!);
}
},
// 工具操作
refreshTools,
setTools(new_tools: UnionTool<any>[]) {
tools.set(new_tools);
refreshTools();
},
toggleToolCollapse,
getToolUIRender,
isFELocking: () => client.get()?.isFELocking(renderMessages.get()),
// UI 切换
toggleHistoryVisible,
toggleGraphVisible() {
showGraph.set(!showGraph.get());
if (showGraph.get()) refreshGraph();
},
refreshGraph,
// Agent 切换
setCurrentAgent(agent: string) {
currentAgent.set(agent);
return initClient();
},
resumeFromInterrupt(data: any) {
return sendMessage(
[],
{
command: {
resume: data,
},
},
true
);
},
// 历史记录(兼容旧 API)
addToHistory,
createNewChat: createNewSession,
toHistoryChat: (thread: Thread<{ messages: Message[] }>) => activateSession(thread.thread_id, true),
async deleteHistoryChat(thread: Thread<{ messages: Message[] }>) {
const historyManager = history.get();
if (historyManager) {
await historyManager.deleteSession(thread.thread_id);
await refreshSessionList();
}
},
...artifactHook.mutation,
},
};
};