Initial commit: deerflow-server (frontend-web + offline-backend-20260512)
This commit is contained in:
commit
e7a455f22e
560 files changed
+101331
No files matched your search
@@ -0,0 +1,140 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
hasContent,
|
||||
hasToolCalls,
|
||||
stripUploadedFilesTag,
|
||||
} from "../messages/utils";
|
||||
|
||||
import type { AgentThread } from "./types";
|
||||
import { titleOfThread } from "./utils";
|
||||
|
||||
function formatMessageContent(message: Message): string {
|
||||
const text = extractContentFromMessage(message);
|
||||
if (!text) return "";
|
||||
return stripUploadedFilesTag(text);
|
||||
}
|
||||
|
||||
function formatToolCalls(message: Message): string {
|
||||
if (message.type !== "ai" || !hasToolCalls(message)) return "";
|
||||
const calls = message.tool_calls ?? [];
|
||||
return calls.map((call) => `- **Tool:** \`${call.name}\``).join("\n");
|
||||
}
|
||||
|
||||
export function formatThreadAsMarkdown(
|
||||
thread: AgentThread,
|
||||
messages: Message[],
|
||||
): string {
|
||||
const title = titleOfThread(thread);
|
||||
const createdAt = thread.created_at
|
||||
? new Date(thread.created_at).toLocaleString()
|
||||
: "Unknown";
|
||||
|
||||
const lines: string[] = [
|
||||
`# ${title}`,
|
||||
"",
|
||||
`*Exported on ${new Date().toLocaleString()} · Created ${createdAt}*`,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type === "human") {
|
||||
const content = formatMessageContent(message);
|
||||
if (content) {
|
||||
lines.push(`## 🧑 User`, "", content, "", "---", "");
|
||||
}
|
||||
} else if (message.type === "ai") {
|
||||
const reasoning = extractReasoningContentFromMessage(message);
|
||||
const content = formatMessageContent(message);
|
||||
const toolCalls = formatToolCalls(message);
|
||||
|
||||
if (!content && !toolCalls && !reasoning) continue;
|
||||
|
||||
lines.push(`## 🤖 Assistant`);
|
||||
|
||||
if (reasoning) {
|
||||
lines.push(
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>Thinking</summary>",
|
||||
"",
|
||||
reasoning,
|
||||
"",
|
||||
"</details>",
|
||||
);
|
||||
}
|
||||
|
||||
if (toolCalls) {
|
||||
lines.push("", toolCalls);
|
||||
}
|
||||
|
||||
if (content && hasContent(message)) {
|
||||
lines.push("", content);
|
||||
}
|
||||
|
||||
lines.push("", "---", "");
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n").trimEnd() + "\n";
|
||||
}
|
||||
|
||||
export function formatThreadAsJSON(
|
||||
thread: AgentThread,
|
||||
messages: Message[],
|
||||
): string {
|
||||
const exportData = {
|
||||
title: titleOfThread(thread),
|
||||
thread_id: thread.thread_id,
|
||||
created_at: thread.created_at,
|
||||
exported_at: new Date().toISOString(),
|
||||
messages: messages.map((msg) => ({
|
||||
type: msg.type,
|
||||
id: msg.id,
|
||||
content: typeof msg.content === "string" ? msg.content : msg.content,
|
||||
...(msg.type === "ai" && msg.tool_calls?.length
|
||||
? { tool_calls: msg.tool_calls }
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^\p{L}\p{N}_\- ]/gu, "").trim() || "conversation";
|
||||
}
|
||||
|
||||
export function downloadAsFile(
|
||||
content: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function exportThreadAsMarkdown(
|
||||
thread: AgentThread,
|
||||
messages: Message[],
|
||||
) {
|
||||
const markdown = formatThreadAsMarkdown(thread, messages);
|
||||
const filename = `${sanitizeFilename(titleOfThread(thread))}.md`;
|
||||
downloadAsFile(markdown, filename, "text/markdown;charset=utf-8");
|
||||
}
|
||||
|
||||
export function exportThreadAsJSON(thread: AgentThread, messages: Message[]) {
|
||||
const json = formatThreadAsJSON(thread, messages);
|
||||
const filename = `${sanitizeFilename(titleOfThread(thread))}.json`;
|
||||
downloadAsFile(json, filename, "application/json;charset=utf-8");
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
|
||||
import { getAPIClient } from "../api";
|
||||
import { apiFetch } from "../api";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
|
||||
import type { AgentThread, AgentThreadState } from "./types";
|
||||
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
context: LocalSettings["context"];
|
||||
isMock?: boolean;
|
||||
onStart?: (threadId: string) => void;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function normalizeStoredRunId(runId: string | null): string | null {
|
||||
if (!runId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = runId.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryIndex = trimmed.indexOf("?");
|
||||
if (queryIndex >= 0) {
|
||||
const params = new URLSearchParams(trimmed.slice(queryIndex + 1));
|
||||
const queryRunId = params.get("run_id")?.trim();
|
||||
if (queryRunId) {
|
||||
return queryRunId;
|
||||
}
|
||||
}
|
||||
|
||||
const pathWithoutQueryOrHash = trimmed.split(/[?#]/, 1)[0]?.trim() ?? "";
|
||||
if (!pathWithoutQueryOrHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runsMarker = "/runs/";
|
||||
const runsIndex = pathWithoutQueryOrHash.lastIndexOf(runsMarker);
|
||||
if (runsIndex >= 0) {
|
||||
const runIdAfterMarker = pathWithoutQueryOrHash
|
||||
.slice(runsIndex + runsMarker.length)
|
||||
.split("/", 1)[0]
|
||||
?.trim();
|
||||
if (runIdAfterMarker) {
|
||||
return runIdAfterMarker;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = pathWithoutQueryOrHash
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
return segments.at(-1) ?? null;
|
||||
}
|
||||
|
||||
function getRunMetadataStorage(): {
|
||||
getItem(key: `lg:stream:${string}`): string | null;
|
||||
setItem(key: `lg:stream:${string}`, value: string): void;
|
||||
removeItem(key: `lg:stream:${string}`): void;
|
||||
} {
|
||||
return {
|
||||
getItem(key) {
|
||||
const normalized = normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(key),
|
||||
);
|
||||
if (normalized) {
|
||||
window.sessionStorage.setItem(key, normalized);
|
||||
return normalized;
|
||||
}
|
||||
window.sessionStorage.removeItem(key);
|
||||
return null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
const normalized = normalizeStoredRunId(value);
|
||||
if (normalized) {
|
||||
window.sessionStorage.setItem(key, normalized);
|
||||
return;
|
||||
}
|
||||
window.sessionStorage.removeItem(key);
|
||||
},
|
||||
removeItem(key) {
|
||||
window.sessionStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const message = Reflect.get(error, "message");
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message;
|
||||
}
|
||||
const nestedError = Reflect.get(error, "error");
|
||||
if (nestedError instanceof Error && nestedError.message.trim()) {
|
||||
return nestedError.message;
|
||||
}
|
||||
if (typeof nestedError === "string" && nestedError.trim()) {
|
||||
return nestedError;
|
||||
}
|
||||
}
|
||||
return "Request failed.";
|
||||
}
|
||||
|
||||
export function useThreadStream({
|
||||
threadId,
|
||||
context,
|
||||
isMock,
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||
// and to allow access to the current thread id in onUpdateEvent
|
||||
const threadIdRef = useRef<string | null>(threadId ?? null);
|
||||
const startedRef = useRef(false);
|
||||
|
||||
const listeners = useRef({
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
// Keep listeners ref updated with latest callbacks
|
||||
useEffect(() => {
|
||||
listeners.current = { onStart, onFinish, onToolEnd };
|
||||
}, [onStart, onFinish, onToolEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedThreadId = threadId ?? null;
|
||||
if (!normalizedThreadId) {
|
||||
// Reset when the UI moves back to a brand new unsaved thread.
|
||||
startedRef.current = false;
|
||||
setOnStreamThreadId(normalizedThreadId);
|
||||
} else {
|
||||
setOnStreamThreadId(normalizedThreadId);
|
||||
}
|
||||
threadIdRef.current = normalizedThreadId;
|
||||
}, [threadId]);
|
||||
|
||||
const _handleOnStart = useCallback((id: string) => {
|
||||
if (!startedRef.current) {
|
||||
listeners.current.onStart?.(id);
|
||||
startedRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStreamStart = useCallback(
|
||||
(_threadId: string) => {
|
||||
threadIdRef.current = _threadId;
|
||||
_handleOnStart(_threadId);
|
||||
},
|
||||
[_handleOnStart],
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const runMetadataStorageRef = useRef<
|
||||
ReturnType<typeof getRunMetadataStorage> | undefined
|
||||
>(undefined);
|
||||
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
runMetadataStorageRef.current === undefined
|
||||
) {
|
||||
runMetadataStorageRef.current = getRunMetadataStorage();
|
||||
}
|
||||
|
||||
const thread = useStream<AgentThreadState>({
|
||||
client: getAPIClient(isMock),
|
||||
assistantId: "lead_agent",
|
||||
threadId: onStreamThreadId,
|
||||
reconnectOnMount: runMetadataStorageRef.current
|
||||
? () => runMetadataStorageRef.current!
|
||||
: false,
|
||||
fetchStateHistory: { limit: 1 },
|
||||
onCreated(meta) {
|
||||
handleStreamStart(meta.thread_id);
|
||||
setOnStreamThreadId(meta.thread_id);
|
||||
if (context.agent_name && !isMock) {
|
||||
void getAPIClient()
|
||||
.threads.update(meta.thread_id, {
|
||||
metadata: { agent_name: context.agent_name },
|
||||
})
|
||||
.catch(() => ({}));
|
||||
}
|
||||
},
|
||||
onLangChainEvent(event) {
|
||||
if (event.event === "on_tool_end") {
|
||||
listeners.current.onToolEnd?.({
|
||||
name: event.name,
|
||||
data: event.data,
|
||||
});
|
||||
}
|
||||
},
|
||||
onUpdateEvent(data) {
|
||||
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
||||
data || {},
|
||||
);
|
||||
for (const update of updates) {
|
||||
if (update && "title" in update && update.title) {
|
||||
void queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["threads", "search"],
|
||||
exact: false,
|
||||
},
|
||||
(oldData: Array<AgentThread> | undefined) => {
|
||||
return oldData?.map((t) => {
|
||||
if (t.thread_id === threadIdRef.current) {
|
||||
return {
|
||||
...t,
|
||||
values: {
|
||||
...t.values,
|
||||
title: update.title,
|
||||
},
|
||||
};
|
||||
}
|
||||
return t;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onCustomEvent(event: unknown) {
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"type" in event &&
|
||||
event.type === "task_running"
|
||||
) {
|
||||
const e = event as {
|
||||
type: "task_running";
|
||||
task_id: string;
|
||||
message: AIMessage;
|
||||
};
|
||||
updateSubtask({ id: e.task_id, latestMessage: e.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"type" in event &&
|
||||
event.type === "llm_retry" &&
|
||||
"message" in event &&
|
||||
typeof event.message === "string" &&
|
||||
event.message.trim()
|
||||
) {
|
||||
const e = event as { type: "llm_retry"; message: string };
|
||||
toast(e.message);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
setOptimisticMessages([]);
|
||||
toast.error(getStreamErrorMessage(error));
|
||||
},
|
||||
onFinish(state) {
|
||||
listeners.current.onFinish?.(state.values);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Optimistic messages shown before the server stream responds
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const sendInFlightRef = useRef(false);
|
||||
// Track message count before sending so we know when server has responded
|
||||
const prevMsgCountRef = useRef(thread.messages.length);
|
||||
|
||||
// Reset thread-local pending UI state when switching between threads so
|
||||
// optimistic messages and in-flight guards do not leak across chat views.
|
||||
useEffect(() => {
|
||||
startedRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
prevMsgCountRef.current = 0;
|
||||
setOptimisticMessages([]);
|
||||
setIsUploading(false);
|
||||
}, [threadId]);
|
||||
|
||||
// Clear optimistic when server messages arrive (count increases)
|
||||
useEffect(() => {
|
||||
if (
|
||||
optimisticMessages.length > 0 &&
|
||||
thread.messages.length > prevMsgCountRef.current
|
||||
) {
|
||||
setOptimisticMessages([]);
|
||||
}
|
||||
}, [thread.messages.length, optimisticMessages.length]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
threadId: string,
|
||||
message: PromptInputMessage,
|
||||
extraContext?: Record<string, unknown>,
|
||||
options?: SendMessageOptions,
|
||||
) => {
|
||||
if (sendInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
sendInFlightRef.current = true;
|
||||
|
||||
const text = message.text.trim();
|
||||
|
||||
// Capture current count before showing optimistic messages
|
||||
prevMsgCountRef.current = thread.messages.length;
|
||||
|
||||
// Build optimistic files list with uploading status
|
||||
const optimisticFiles: FileInMessage[] = (message.files ?? []).map(
|
||||
(f) => ({
|
||||
filename: f.filename ?? "",
|
||||
size: 0,
|
||||
status: "uploading" as const,
|
||||
}),
|
||||
);
|
||||
|
||||
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
|
||||
const optimisticAdditionalKwargs = {
|
||||
...options?.additionalKwargs,
|
||||
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
|
||||
};
|
||||
|
||||
const newOptimistic: Message[] = [];
|
||||
if (!hideFromUI) {
|
||||
newOptimistic.push({
|
||||
type: "human",
|
||||
id: `opt-human-${Date.now()}`,
|
||||
content: text ? [{ type: "text", text }] : "",
|
||||
additional_kwargs: optimisticAdditionalKwargs,
|
||||
});
|
||||
}
|
||||
|
||||
if (optimisticFiles.length > 0 && !hideFromUI) {
|
||||
// Mock AI message while files are being uploaded
|
||||
newOptimistic.push({
|
||||
type: "ai",
|
||||
id: `opt-ai-${Date.now()}`,
|
||||
content: t.uploads.uploadingFiles,
|
||||
additional_kwargs: { element: "task" },
|
||||
});
|
||||
}
|
||||
setOptimisticMessages(newOptimistic);
|
||||
|
||||
// Only fire onStart immediately for an existing persisted thread.
|
||||
// Brand-new chats should wait for onCreated(meta.thread_id) so URL sync
|
||||
// uses the real server-generated thread id.
|
||||
if (threadIdRef.current) {
|
||||
_handleOnStart(threadId);
|
||||
}
|
||||
|
||||
let uploadedFileInfo: UploadedFileInfo[] = [];
|
||||
|
||||
try {
|
||||
// Upload files first if any
|
||||
if (message.files && message.files.length > 0) {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const filePromises = message.files.map((fileUIPart) =>
|
||||
promptInputFilePartToFile(fileUIPart),
|
||||
);
|
||||
|
||||
const conversionResults = await Promise.all(filePromises);
|
||||
const files = conversionResults.filter(
|
||||
(file): file is File => file !== null,
|
||||
);
|
||||
const failedConversions = conversionResults.length - files.length;
|
||||
|
||||
if (failedConversions > 0) {
|
||||
throw new Error(
|
||||
`Failed to prepare ${failedConversions} attachment(s) for upload. Please retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!threadId) {
|
||||
throw new Error("Thread is not ready for file upload.");
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
const uploadResponse = await uploadFiles(threadId, files);
|
||||
uploadedFileInfo = uploadResponse.files;
|
||||
|
||||
// Update optimistic human message with uploaded status + paths
|
||||
const uploadedFiles: FileInMessage[] = uploadedFileInfo.map(
|
||||
(info) => ({
|
||||
filename: info.filename,
|
||||
size: info.size,
|
||||
path: info.virtual_path,
|
||||
status: "uploaded" as const,
|
||||
}),
|
||||
);
|
||||
setOptimisticMessages((messages) => {
|
||||
if (messages.length > 1 && messages[0]) {
|
||||
const humanMessage: Message = messages[0];
|
||||
return [
|
||||
{
|
||||
...humanMessage,
|
||||
additional_kwargs: { files: uploadedFiles },
|
||||
},
|
||||
...messages.slice(1),
|
||||
];
|
||||
}
|
||||
return messages;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to upload files.";
|
||||
toast.error(errorMessage);
|
||||
setOptimisticMessages([]);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Build files metadata for submission (included in additional_kwargs)
|
||||
const filesForSubmit: FileInMessage[] = uploadedFileInfo.map(
|
||||
(info) => ({
|
||||
filename: info.filename,
|
||||
size: info.size,
|
||||
path: info.virtual_path,
|
||||
status: "uploaded" as const,
|
||||
}),
|
||||
);
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
threadId: threadId,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
},
|
||||
context: {
|
||||
...extraContext,
|
||||
...context,
|
||||
thinking_enabled: context.mode !== "flash",
|
||||
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
|
||||
subagent_enabled: context.mode === "ultra",
|
||||
reasoning_effort:
|
||||
context.reasoning_effort ??
|
||||
(context.mode === "ultra"
|
||||
? "high"
|
||||
: context.mode === "pro"
|
||||
? "medium"
|
||||
: context.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
} catch (error) {
|
||||
setOptimisticMessages([]);
|
||||
setIsUploading(false);
|
||||
throw error;
|
||||
} finally {
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient],
|
||||
);
|
||||
|
||||
// Merge thread with optimistic messages for display
|
||||
const mergedThread =
|
||||
optimisticMessages.length > 0
|
||||
? ({
|
||||
...thread,
|
||||
messages: [...thread.messages, ...optimisticMessages],
|
||||
} as typeof thread)
|
||||
: thread;
|
||||
|
||||
return [mergedThread, sendMessage, isUploading] as const;
|
||||
}
|
||||
|
||||
export function useThreads(
|
||||
params: Parameters<ThreadsClient["search"]>[0] = {
|
||||
limit: 50,
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
select: ["thread_id", "updated_at", "values", "metadata"],
|
||||
},
|
||||
) {
|
||||
const apiClient = getAPIClient();
|
||||
return useQuery<AgentThread[]>({
|
||||
queryKey: ["threads", "search", params],
|
||||
queryFn: async () => {
|
||||
const maxResults = params.limit;
|
||||
const initialOffset = params.offset ?? 0;
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
// Preserve prior semantics: if a non-positive limit is explicitly provided,
|
||||
// delegate to a single search call with the original parameters.
|
||||
if (maxResults !== undefined && maxResults <= 0) {
|
||||
const response =
|
||||
await apiClient.threads.search<AgentThreadState>(params);
|
||||
return response as AgentThread[];
|
||||
}
|
||||
|
||||
const pageSize =
|
||||
typeof maxResults === "number" && maxResults > 0
|
||||
? Math.min(DEFAULT_PAGE_SIZE, maxResults)
|
||||
: DEFAULT_PAGE_SIZE;
|
||||
|
||||
const threads: AgentThread[] = [];
|
||||
let offset = initialOffset;
|
||||
|
||||
while (true) {
|
||||
if (typeof maxResults === "number" && threads.length >= maxResults) {
|
||||
break;
|
||||
}
|
||||
|
||||
const currentLimit =
|
||||
typeof maxResults === "number"
|
||||
? Math.min(pageSize, maxResults - threads.length)
|
||||
: pageSize;
|
||||
|
||||
if (typeof maxResults === "number" && currentLimit <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const response = (await apiClient.threads.search<AgentThreadState>({
|
||||
...params,
|
||||
limit: currentLimit,
|
||||
offset,
|
||||
})) as AgentThread[];
|
||||
|
||||
threads.push(...response);
|
||||
|
||||
if (response.length < currentLimit) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += response.length;
|
||||
}
|
||||
|
||||
return threads;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteThread() {
|
||||
const queryClient = useQueryClient();
|
||||
const apiClient = getAPIClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ threadId }: { threadId: string }) => {
|
||||
await apiClient.threads.delete(threadId);
|
||||
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to delete local thread data." }));
|
||||
throw new Error(error.detail ?? "Failed to delete local thread data.");
|
||||
}
|
||||
},
|
||||
onSuccess(_, { threadId }) {
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["threads", "search"],
|
||||
exact: false,
|
||||
},
|
||||
(oldData: Array<AgentThread> | undefined) => {
|
||||
if (oldData == null) {
|
||||
return oldData;
|
||||
}
|
||||
return oldData.filter((t) => t.thread_id !== threadId);
|
||||
},
|
||||
);
|
||||
},
|
||||
onSettled() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenameThread() {
|
||||
const queryClient = useQueryClient();
|
||||
const apiClient = getAPIClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
threadId,
|
||||
title,
|
||||
}: {
|
||||
threadId: string;
|
||||
title: string;
|
||||
}) => {
|
||||
await apiClient.threads.updateState(threadId, {
|
||||
values: { title },
|
||||
});
|
||||
},
|
||||
onSuccess(_, { threadId, title }) {
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["threads", "search"],
|
||||
exact: false,
|
||||
},
|
||||
(oldData: Array<AgentThread>) => {
|
||||
return oldData.map((t) => {
|
||||
if (t.thread_id === threadId) {
|
||||
return {
|
||||
...t,
|
||||
values: {
|
||||
...t.values,
|
||||
title,
|
||||
},
|
||||
};
|
||||
}
|
||||
return t;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Message, Thread } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Todo } from "../todos";
|
||||
|
||||
export interface AgentThreadState extends Record<string, unknown> {
|
||||
title: string;
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
todos?: Todo[];
|
||||
}
|
||||
|
||||
export interface AgentThreadContext extends Record<string, unknown> {
|
||||
thread_id: string;
|
||||
model_name: string | undefined;
|
||||
thinking_enabled: boolean;
|
||||
is_plan_mode: boolean;
|
||||
subagent_enabled: boolean;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
export interface AgentThread extends Thread<AgentThreadState> {
|
||||
context?: AgentThreadContext;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { AgentThread, AgentThreadContext } from "./types";
|
||||
|
||||
type ThreadRouteTarget =
|
||||
| string
|
||||
| {
|
||||
thread_id: string;
|
||||
context?: Pick<AgentThreadContext, "agent_name"> | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function pathOfThread(
|
||||
thread: ThreadRouteTarget,
|
||||
context?: Pick<AgentThreadContext, "agent_name"> | null,
|
||||
) {
|
||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||
let agentName: string | undefined;
|
||||
if (typeof thread === "string") {
|
||||
agentName = context?.agent_name;
|
||||
} else {
|
||||
agentName = thread.context?.agent_name;
|
||||
if (!agentName) {
|
||||
const metaAgent = thread.metadata?.agent_name;
|
||||
if (typeof metaAgent === "string") {
|
||||
agentName = metaAgent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return agentName
|
||||
? `/workspace/agents/${encodeURIComponent(agentName)}/chats/${threadId}`
|
||||
: `/workspace/chats/${threadId}`;
|
||||
}
|
||||
|
||||
export function textOfMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function titleOfThread(thread: AgentThread) {
|
||||
return thread.values?.title ?? "Untitled";
|
||||
}
|
||||
Reference in new issue
Block a user