refactor: move biz logic to core

This commit is contained in:
Henry Li
2026-01-16 19:51:39 +08:00
parent 7066a3b691
commit 03f0e3f0c7
5 changed files with 89 additions and 81 deletions

View File

@@ -1,11 +1,8 @@
"use client"; "use client";
import { type HumanMessage } from "@langchain/core/messages";
import { useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { BreadcrumbItem } from "@/components/ui/breadcrumb"; import { BreadcrumbItem } from "@/components/ui/breadcrumb";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/message-list/message-list"; import { MessageList } from "@/components/workspace/message-list/message-list";
@@ -17,13 +14,12 @@ import {
} from "@/components/workspace/workspace-container"; } from "@/components/workspace/workspace-container";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { type AgentThread } from "@/core/threads"; import { type AgentThread } from "@/core/threads";
import { useThreadStream } from "@/core/threads/hooks"; import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
import { titleOfThread } from "@/core/threads/utils"; import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
export default function ChatPage() { export default function ChatPage() {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const isNewThread = useMemo( const isNewThread = useMemo(
() => threadIdFromPath === "new", () => threadIdFromPath === "new",
@@ -43,40 +39,15 @@ export default function ChatPage() {
isNewThread, isNewThread,
threadId, threadId,
}); });
const handleSubmit = useCallback( const handleSubmit = useSubmitThread({
async (message: PromptInputMessage) => { isNewThread,
const text = message.text.trim(); threadId,
if (isNewThread) { thread,
router.replace(`/workspace/chats/${threadId}`); threadContext,
} afterSubmit() {
await thread.submit( router.push(pathOfThread(threadId!));
{
messages: [
{
type: "human",
content: [
{
type: "text",
text,
},
],
},
] as HumanMessage[],
},
{
threadId: isNewThread ? threadId! : undefined,
streamSubgraphs: true,
streamResumable: true,
context: {
...threadContext,
thread_id: threadId!,
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
}, },
[isNewThread, queryClient, router, thread, threadContext, threadId], });
);
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);

View File

@@ -5,6 +5,7 @@ import {
Message as AIElementMessage, Message as AIElementMessage,
MessageContent as AIElementMessageContent, MessageContent as AIElementMessageContent,
MessageResponse as AIElementMessageResponse, MessageResponse as AIElementMessageResponse,
MessageToolbar,
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import { import {
extractContentFromMessage, extractContentFromMessage,
@@ -15,6 +16,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MessageGroup } from "./message-group"; import { MessageGroup } from "./message-group";
import { CopyButton } from "../copy-button";
export function MessageListItem({ export function MessageListItem({
className, className,
@@ -38,6 +40,17 @@ export function MessageListItem({
messagesInGroup={messagesInGroup} messagesInGroup={messagesInGroup}
isLoading={isLoading} isLoading={isLoading}
/> />
<MessageToolbar
className={cn(
message.type === "human" && "justify-end",
message.type === "human" ? "-bottom-9" : "-bottom-8",
"absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100",
)}
>
<div className="flex gap-1">
<CopyButton clipboardData={extractContentFromMessage(message)} />
</div>
</MessageToolbar>
</AIElementMessage> </AIElementMessage>
); );
} }

View File

@@ -31,7 +31,7 @@ export function MessageList({
<ConversationContent className="mx-auto w-full max-w-(--container-width-md)"> <ConversationContent className="mx-auto w-full max-w-(--container-width-md)">
{groupMessages( {groupMessages(
thread.messages, thread.messages,
(groupedMessages, groupIndex, isLastGroup) => { (groupedMessages) => {
if (groupedMessages[0] && hasContent(groupedMessages[0])) { if (groupedMessages[0] && hasContent(groupedMessages[0])) {
const message = groupedMessages[0]; const message = groupedMessages[0];
return ( return (

View File

@@ -57,7 +57,7 @@ export function RecentChatList() {
<SidebarMenu> <SidebarMenu>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
{threads.map((thread) => { {threads.map((thread) => {
const isActive = pathOfThread(thread, false) === pathname; const isActive = pathOfThread(thread.thread_id) === pathname;
return ( return (
<SidebarMenuItem <SidebarMenuItem
key={thread.thread_id} key={thread.thread_id}
@@ -67,7 +67,7 @@ export function RecentChatList() {
<div> <div>
<Link <Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden" className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)} href={pathOfThread(thread.thread_id)}
> >
{titleOfThread(thread)} {titleOfThread(thread)}
</Link> </Link>

View File

@@ -22,16 +22,37 @@ export function useThreadStream({
threadId: string | null | undefined; threadId: string | null | undefined;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useStream<AgentThreadState>({ const thread = useStream<AgentThreadState>({
client: getAPIClient(), client: getAPIClient(),
assistantId: "lead_agent", assistantId: "lead_agent",
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
reconnectOnMount: true, reconnectOnMount: true,
fetchStateHistory: true, fetchStateHistory: true,
onFinish() { onFinish(state) {
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); // void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
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: state.values.title,
},
};
}
return t;
});
},
);
}, },
}); });
return thread;
} }
export function useSubmitThread({ export function useSubmitThread({
@@ -39,43 +60,47 @@ export function useSubmitThread({
thread, thread,
threadContext, threadContext,
isNewThread, isNewThread,
message, afterSubmit,
}: { }: {
isNewThread: boolean; isNewThread: boolean;
threadId: string; threadId: string | null | undefined;
thread: UseStream<AgentThreadState>; thread: UseStream<AgentThreadState>;
threadContext: AgentThreadContext; threadContext: Omit<AgentThreadContext, "thread_id">;
message: PromptInputMessage; afterSubmit?: () => void;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const text = message.text.trim(); const callback = useCallback(
const callback = useCallback(async () => { async (message: PromptInputMessage) => {
await thread.submit( const text = message.text.trim();
{ await thread.submit(
messages: [ {
{ messages: [
type: "human", {
content: [ type: "human",
{ content: [
type: "text", {
text, type: "text",
}, text,
], },
}, ],
] as HumanMessage[], },
}, ] as HumanMessage[],
{
threadId: isNewThread ? threadId : undefined,
streamSubgraphs: true,
streamResumable: true,
context: {
...threadContext,
thread_id: threadId,
}, },
}, {
); threadId: isNewThread ? threadId! : undefined,
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); streamSubgraphs: true,
}, [queryClient, thread, threadContext, threadId, isNewThread, text]); streamResumable: true,
context: {
...threadContext,
thread_id: threadId,
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
afterSubmit?.();
},
[thread, isNewThread, threadId, threadContext, queryClient, afterSubmit],
);
return callback; return callback;
} }
@@ -86,12 +111,11 @@ export function useThreads(
sortOrder: "desc", sortOrder: "desc",
}, },
) { ) {
const langGraphClient = getAPIClient(); const apiClient = getAPIClient();
return useQuery<AgentThread[]>({ return useQuery<AgentThread[]>({
queryKey: ["threads", "search", params], queryKey: ["threads", "search", params],
queryFn: async () => { queryFn: async () => {
const response = const response = await apiClient.threads.search<AgentThreadState>(params);
await langGraphClient.threads.search<AgentThreadState>(params);
return response as AgentThread[]; return response as AgentThread[];
}, },
}); });
@@ -99,10 +123,10 @@ export function useThreads(
export function useDeleteThread() { export function useDeleteThread() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const langGraphClient = getAPIClient(); const apiClient = getAPIClient();
return useMutation({ return useMutation({
mutationFn: async ({ threadId }: { threadId: string }) => { mutationFn: async ({ threadId }: { threadId: string }) => {
await langGraphClient.threads.delete(threadId); await apiClient.threads.delete(threadId);
}, },
onSuccess(_, { threadId }) { onSuccess(_, { threadId }) {
queryClient.setQueriesData( queryClient.setQueriesData(