# Conflicts: # frontend-web/src/components/workspace/settings/memory-settings-page.tsx # frontend-web/src/core/auth/sync.tsx
895 lines
29 KiB
TypeScript
895 lines
29 KiB
TypeScript
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 { useNavigate } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
|
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
|
|
|
import { getAPIClient } from "../api";
|
|
import { apiFetch } from "../api";
|
|
import { getStoredAuth } from "../auth";
|
|
import { rememberAskTime } from "../chat-timestamps";
|
|
import { getBackendBaseURL } from "../config";
|
|
import { useI18n } from "../i18n/hooks";
|
|
import type { FileInMessage } from "../messages/utils";
|
|
import type { LocalSettings } from "../settings";
|
|
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } 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;
|
|
assistantId?: string;
|
|
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.";
|
|
}
|
|
|
|
/**
|
|
* Detect "thread not found" / forbidden errors from the LangGraph SDK.
|
|
*
|
|
* Backend returns 404 both when a thread truly doesn't exist and when the
|
|
* currently authenticated user doesn't own it — the SDK surfaces both as
|
|
* ``HTTP 404: {"detail":"Thread <uuid> not found"}``. The most common cause
|
|
* we see in practice is a stale tab whose URL still points at the previous
|
|
* login session's thread. Raw-toasting the 404 just confuses the user, so
|
|
* we re-frame it around the current login identity.
|
|
*/
|
|
function isThreadNotAccessibleError(message: string): boolean {
|
|
const lowered = message.toLowerCase();
|
|
if (/thread\s+[\w-]{8,}\s+not\s+found/i.test(message)) {
|
|
return true;
|
|
}
|
|
// Fallback: a bare 404/403 from the threads endpoint when the SDK has
|
|
// wrapped the detail differently.
|
|
return (
|
|
(lowered.includes("http 404") || lowered.includes("http 403")) &&
|
|
lowered.includes("thread")
|
|
);
|
|
}
|
|
|
|
function buildCurrentUserLabel(): string {
|
|
const auth = getStoredAuth();
|
|
if (!auth) {
|
|
return "未登录";
|
|
}
|
|
const email = auth.email?.trim();
|
|
if (email) {
|
|
return email;
|
|
}
|
|
const userId = auth.user_id?.trim();
|
|
return userId ? userId : "未知用户";
|
|
}
|
|
|
|
export function useThreadStream({
|
|
threadId,
|
|
context,
|
|
isMock,
|
|
assistantId,
|
|
onStart,
|
|
onFinish,
|
|
onToolEnd,
|
|
}: ThreadStreamOptions) {
|
|
const { t } = useI18n();
|
|
const [localSettings] = useLocalSettings();
|
|
// 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 navigate = useNavigate();
|
|
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: assistantId ?? "lead_agent",
|
|
threadId: onStreamThreadId,
|
|
reconnectOnMount: runMetadataStorageRef.current
|
|
? () => runMetadataStorageRef.current!
|
|
: false,
|
|
fetchStateHistory: { limit: 1 },
|
|
onCreated(meta) {
|
|
handleStreamStart(meta.thread_id);
|
|
setOnStreamThreadId(meta.thread_id);
|
|
const metadataAgentId = context.agent_id ?? context.agent_name;
|
|
if (metadataAgentId && !isMock) {
|
|
void getAPIClient()
|
|
.threads.update(meta.thread_id, {
|
|
metadata: { agent_id: metadataAgentId },
|
|
})
|
|
.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([]);
|
|
const message = getStreamErrorMessage(error);
|
|
// A "thread not found" 404 almost always means the URL points at a
|
|
// thread from a previous login session — either this tab outlived a
|
|
// logout/re-login or the thread genuinely belongs to a different user.
|
|
// Re-frame the error around the *current* logged-in identity and bounce
|
|
// the user to a fresh chat instead of leaving them stuck on the stale
|
|
// route looking at a raw HTTP 404.
|
|
if (isThreadNotAccessibleError(message)) {
|
|
const userLabel = buildCurrentUserLabel();
|
|
toast.info(
|
|
`当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`,
|
|
);
|
|
// Prefer staying inside an agent chat context when the user is on an
|
|
// agent thread URL — otherwise fall back to the global new chat.
|
|
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
|
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
|
|
const iframeMatch = hash.match(/#\/page\/workspace\/chats_iframe\//);
|
|
const target = agentMatch
|
|
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
|
: iframeMatch
|
|
? "/page/workspace/chats_iframe/new"
|
|
: "/page/workspace/chats/new";
|
|
// First navigate to a safe URL so the post-reload page lands on a
|
|
// valid route. Then full-reload so TanStack Query cache, context
|
|
// providers and any other state captured under the previous user's
|
|
// identity get rebuilt against the current login.
|
|
if (typeof window !== "undefined") {
|
|
window.location.hash = target;
|
|
window.setTimeout(() => {
|
|
window.location.reload();
|
|
}, 900);
|
|
} else {
|
|
navigate(target, { replace: true });
|
|
}
|
|
return;
|
|
}
|
|
toast.error(message);
|
|
},
|
|
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);
|
|
// Carries the ask time of an in-flight send until the server-assigned human
|
|
// message id shows up, so we can bind the timestamp to the real id (see the
|
|
// reconciliation effect below). ``known`` is the set of human ids that
|
|
// already existed at send time, used to spot the newly-arrived one.
|
|
const pendingAskTimeRef = useRef<{ ts: string; known: Set<string> } | null>(
|
|
null,
|
|
);
|
|
// Track message count before sending so we know when server has responded
|
|
const prevMsgCountRef = useRef(thread.messages.length);
|
|
|
|
// Keep a ref to the latest `thread` so the threadId-change effect can stop
|
|
// an in-flight stream without re-running on every render that produces a
|
|
// new `thread` identity.
|
|
const threadStreamRef = useRef(thread);
|
|
threadStreamRef.current = thread;
|
|
|
|
// Reset thread-local pending UI state when the caller actually switches AWAY
|
|
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
|
|
// a fresh chat" signal — abort the in-flight SSE and clear optimistic state.
|
|
//
|
|
// We deliberately do NOT reset on every threadId change. During a normal
|
|
// "user just sent the first message on /chats/new" flow, useThreadStream
|
|
// sees threadId transition from undefined (the landing state) to the
|
|
// server-assigned thread id once onCreated fires. Stopping the stream and
|
|
// clearing optimistic messages in that case is exactly what made submitted
|
|
// questions visually disappear — the live run that just started would be
|
|
// aborted by the very effect intended to reset stale state.
|
|
useEffect(() => {
|
|
if (threadId != null) {
|
|
// Forward direction: starting or continuing with a real thread.
|
|
// No reset needed; the upstream useStream will load history for this id.
|
|
return;
|
|
}
|
|
const prevThread = threadStreamRef.current;
|
|
if (prevThread?.isLoading) {
|
|
try {
|
|
prevThread.stop();
|
|
} catch {
|
|
// best-effort; nothing useful to do if the SDK fails to abort
|
|
}
|
|
}
|
|
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]);
|
|
|
|
// Bind a just-sent question's ask time to the server-assigned human message
|
|
// id once it appears. Persists to localStorage so the time shows on every
|
|
// question and survives reloads, independent of the backend echoing it back.
|
|
useEffect(() => {
|
|
const pending = pendingAskTimeRef.current;
|
|
if (!pending) return;
|
|
for (const message of thread.messages) {
|
|
if (
|
|
message.type === "human" &&
|
|
message.id &&
|
|
!message.id.startsWith("opt-") &&
|
|
!pending.known.has(message.id)
|
|
) {
|
|
rememberAskTime(message.id, pending.ts);
|
|
pendingAskTimeRef.current = null;
|
|
break;
|
|
}
|
|
}
|
|
}, [thread.messages]);
|
|
|
|
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();
|
|
// Wall-clock time the question was asked. Stored on the human message so
|
|
// the chat can show "几点提问" and have it survive a reload (it round-trips
|
|
// through the checkpoint inside additional_kwargs, same as `files`).
|
|
const clientTs = new Date().toISOString();
|
|
// Remember which human messages already exist so the reconciliation
|
|
// effect can bind ``clientTs`` to the new one once the server returns it.
|
|
pendingAskTimeRef.current = {
|
|
ts: clientTs,
|
|
known: new Set(
|
|
thread.messages
|
|
.filter((m) => m.type === "human" && m.id)
|
|
.map((m) => m.id as string),
|
|
),
|
|
};
|
|
|
|
// 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,
|
|
client_ts: clientTs,
|
|
...(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: {
|
|
...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,
|
|
}),
|
|
);
|
|
|
|
const ragModeEnabled =
|
|
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
|
if (ragModeEnabled) {
|
|
lockThreadRagMode(threadId);
|
|
}
|
|
|
|
const mergedContext = {
|
|
...context,
|
|
...extraContext,
|
|
};
|
|
|
|
await thread.submit(
|
|
{
|
|
messages: [
|
|
{
|
|
type: "human",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text,
|
|
},
|
|
],
|
|
additional_kwargs: {
|
|
...options?.additionalKwargs,
|
|
client_ts: clientTs,
|
|
...(filesForSubmit.length > 0
|
|
? { files: filesForSubmit }
|
|
: {}),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
threadId: threadId,
|
|
streamSubgraphs: true,
|
|
streamResumable: true,
|
|
config: {
|
|
recursion_limit: 1000,
|
|
},
|
|
context: {
|
|
...mergedContext,
|
|
thinking_enabled: mergedContext.mode !== "flash",
|
|
is_plan_mode:
|
|
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
|
subagent_enabled: mergedContext.mode === "ultra",
|
|
reasoning_effort:
|
|
mergedContext.reasoning_effort ??
|
|
(mergedContext.mode === "ultra"
|
|
? "high"
|
|
: mergedContext.mode === "pro"
|
|
? "medium"
|
|
: mergedContext.mode === "thinking"
|
|
? "low"
|
|
: undefined),
|
|
memory_injection_enabled:
|
|
mergedContext.memory_injection_enabled ?? true,
|
|
rag_mode_enabled: ragModeEnabled,
|
|
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, localSettings.display.ragModeEnabled, 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.filter(
|
|
(t) => (t.metadata as Record<string, unknown> | null)?.thread_type !== "notebook",
|
|
);
|
|
},
|
|
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"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete a conversation turn (a user question together with its answer) from a
|
|
* thread. Hits the Gateway endpoint that removes the question and every
|
|
* following message up to the next question from the latest checkpoint, and
|
|
* returns the ids that were actually removed. The live `useStream` cache is not
|
|
* auto-refreshed by this call — callers hide the returned ids locally (see
|
|
* `MessageList`) so the removal shows immediately; a reload then reads the
|
|
* persisted, already-trimmed state.
|
|
*/
|
|
export function useDeleteMessage() {
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
threadId,
|
|
messageId,
|
|
}: {
|
|
threadId: string;
|
|
messageId: string;
|
|
}): Promise<string[]> => {
|
|
const response = await apiFetch(
|
|
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
|
threadId,
|
|
)}/messages/${encodeURIComponent(messageId)}`,
|
|
{
|
|
method: "DELETE",
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const error = await response
|
|
.json()
|
|
.catch(() => ({ detail: "删除消息失败。" }));
|
|
throw new Error(error.detail ?? "删除消息失败。");
|
|
}
|
|
|
|
const body = (await response.json().catch(() => ({}))) as {
|
|
deleted_ids?: string[];
|
|
};
|
|
// Fall back to the requested id so the caller can always hide something.
|
|
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
|
|
},
|
|
});
|
|
}
|
|
|
|
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;
|
|
});
|
|
},
|
|
);
|
|
},
|
|
});
|
|
}
|