From 53509eaeb1a728e330d07da5f62baa58982fcbda Mon Sep 17 00:00:00 2001 From: ruitanglin Date: Mon, 9 Feb 2026 15:15:20 +0800 Subject: [PATCH] fix(frontend): no half-finished citations, correct state when SSE ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Citations: - In shouldShowCitationLoading, treat any unreplaced [cite-N] in cleanContent as show-loading (no body). Fixes Ultra and other modes when refs arrive before the block in the stream. - Single rule: hasUnreplacedCitationRefs(cleanContent) => true forces loading; then isLoading && hasCitationsBlock(rawContent) for streaming indicator. SSE end state: - When stream finishes, SDK may set isLoading=false before client state has the final message content, so UI stayed wrong until refresh. - Store onFinish(state) as finalState in chat page; clear when stream starts. - Pass messagesOverride={finalState.messages} to MessageList when not loading so the list uses server-complete messages right after SSE ends (no refresh). Chore: - Stop tracking .githooks/pre-commit; add .githooks/ to .gitignore (local only). Co-authored-by: Cursor --- fix(前端): 杜绝半成品引用,SSE 结束时展示正确状态 引用: - shouldShowCitationLoading 中只要 cleanContent 仍含未替换的 [cite-N] 就 只显示加载、不渲染正文,解决流式时引用块未到就出现 [cite-1] 的问题。 - 规则:hasUnreplacedCitationRefs(cleanContent) 为真则一律显示加载; 此外 isLoading && hasCitationsBlock 用于流式时显示「正在整理引用」。 SSE 结束状态: - 流结束时 SDK 可能先置 isLoading=false,客户端 messages 尚未包含 最终内容,导致需刷新才显示正确。 - 在对话页保存 onFinish(state) 为 finalState,流开始时清空。 - 非加载时向 MessageList 传入 messagesOverride={finalState.messages}, 列表在 SSE 结束后立即用服务端完整消息渲染,无需刷新。 杂项: - 取消跟踪 .githooks/pre-commit,.gitignore 增加 .githooks/(仅本地)。 --- .githooks/pre-commit | 29 ------------------- .gitignore | 3 ++ .../app/workspace/chats/[thread_id]/page.tsx | 13 ++++++++- .../workspace/messages/message-list.tsx | 7 ++++- frontend/src/core/citations/utils.ts | 10 +++---- 5 files changed, 25 insertions(+), 37 deletions(-) delete mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index f18ee5b..0000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/sh -# Reject commit if author or committer email is *@bytedance.com - -config_email="$(git config user.email)" -author_email="${GIT_AUTHOR_EMAIL:-$config_email}" -committer_email="${GIT_COMMITTER_EMAIL:-$config_email}" - -check() { - case "$1" in - *@bytedance.com) return 0;; # matched = bad - *) return 1;; - esac -} - -err=0 -if check "$author_email"; then - echo "pre-commit: 拒绝提交:作者邮箱不能为 *@bytedance.com (当前: $author_email)" - err=1 -fi -if check "$committer_email"; then - echo "pre-commit: 拒绝提交:提交者邮箱不能为 *@bytedance.com (当前: $committer_email)" - err=1 -fi - -if [ $err -eq 1 ]; then - echo "请使用: git config user.email \"lofisuchat@gmail.com\"" - exit 1 -fi -exit 0 diff --git a/.gitignore b/.gitignore index 1bfd7f6..559f548 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,8 @@ coverage/ skills/custom/* logs/ +# Local git hooks (keep only on this machine, do not push) +.githooks/ + # pnpm .pnpm-store diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index d521b7d..5d39207 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -28,8 +28,9 @@ import { Welcome } from "@/components/workspace/welcome"; import { useI18n } from "@/core/i18n/hooks"; import { useNotification } from "@/core/notification/hooks"; import { useLocalSettings } from "@/core/settings"; -import { type AgentThread } from "@/core/threads"; +import { type AgentThread, type AgentThreadState } from "@/core/threads"; import { useSubmitThread, useThreadStream } from "@/core/threads/hooks"; +import type { Message } from "@langchain/langgraph-sdk"; import { pathOfThread, textOfMessage, @@ -88,10 +89,12 @@ export default function ChatPage() { }, [threadIdFromPath]); const { showNotification } = useNotification(); + const [finalState, setFinalState] = useState(null); const thread = useThreadStream({ isNewThread, threadId, onFinish: (state) => { + setFinalState(state); if (document.hidden || !document.hasFocus()) { let body = "Conversation finished"; const lastMessage = state.messages[state.messages.length - 1]; @@ -111,6 +114,9 @@ export default function ChatPage() { } }, }); + useEffect(() => { + if (thread.isLoading) setFinalState(null); + }, [thread.isLoading]); const title = useMemo(() => { let result = isNewThread @@ -239,6 +245,11 @@ export default function ChatPage() { className={cn("size-full", !isNewThread && "pt-10")} threadId={threadId} thread={thread} + messagesOverride={ + !thread.isLoading && finalState?.messages + ? (finalState.messages as Message[]) + : undefined + } paddingBottom={todoListCollapsed ? 160 : 280} /> diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 24232aa..430d94e 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -18,6 +18,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; +import type { Message } from "@langchain/langgraph-sdk"; import { cn } from "@/lib/utils"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; @@ -33,16 +34,20 @@ export function MessageList({ className, threadId, thread, + messagesOverride, paddingBottom = 160, }: { className?: string; threadId: string; thread: UseStream; + /** When set (e.g. from onFinish), use instead of thread.messages so SSE end shows complete state. */ + messagesOverride?: Message[]; paddingBottom?: number; }) { const { t } = useI18n(); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const updateSubtask = useUpdateSubtask(); + const messages = messagesOverride ?? thread.messages; if (thread.isThreadLoading) { return ; } @@ -51,7 +56,7 @@ export function MessageList({ className={cn("flex size-full flex-col justify-center", className)} > - {groupMessages(thread.messages, (group) => { + {groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { return ( block in stream); also show loading while streaming with citation block. */ export function shouldShowCitationLoading( rawContent: string, cleanContent: string, isLoading: boolean, ): boolean { - return ( - (isLoading && hasCitationsBlock(rawContent)) || - (hasCitationsBlock(rawContent) && hasUnreplacedCitationRefs(cleanContent)) - ); + if (hasUnreplacedCitationRefs(cleanContent)) return true; + return isLoading && hasCitationsBlock(rawContent); } /**