fix(frontend): no half-finished citations, correct state when SSE ends

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 <citations> 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 <cursoragent@cursor.com>

---
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/(仅本地)。
This commit is contained in:
LofiSu
2026-02-09 15:15:20 +08:00
parent 4f9d1d524e
commit d9a86c10e8
5 changed files with 25 additions and 37 deletions

View File

@@ -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

3
.gitignore vendored
View File

@@ -35,5 +35,8 @@ coverage/
skills/custom/* skills/custom/*
logs/ logs/
# Local git hooks (keep only on this machine, do not push)
.githooks/
# pnpm # pnpm
.pnpm-store .pnpm-store

View File

@@ -28,8 +28,9 @@ import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings"; 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 { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
import type { Message } from "@langchain/langgraph-sdk";
import { import {
pathOfThread, pathOfThread,
textOfMessage, textOfMessage,
@@ -88,10 +89,12 @@ export default function ChatPage() {
}, [threadIdFromPath]); }, [threadIdFromPath]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [finalState, setFinalState] = useState<AgentThreadState | null>(null);
const thread = useThreadStream({ const thread = useThreadStream({
isNewThread, isNewThread,
threadId, threadId,
onFinish: (state) => { onFinish: (state) => {
setFinalState(state);
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished"; let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1]; 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(() => { const title = useMemo(() => {
let result = isNewThread let result = isNewThread
@@ -239,6 +245,11 @@ export default function ChatPage() {
className={cn("size-full", !isNewThread && "pt-10")} className={cn("size-full", !isNewThread && "pt-10")}
threadId={threadId} threadId={threadId}
thread={thread} thread={thread}
messagesOverride={
!thread.isLoading && finalState?.messages
? (finalState.messages as Message[])
: undefined
}
paddingBottom={todoListCollapsed ? 160 : 280} paddingBottom={todoListCollapsed ? 160 : 280}
/> />
</div> </div>

View File

@@ -18,6 +18,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import type { Subtask } from "@/core/tasks"; import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import type { Message } from "@langchain/langgraph-sdk";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { ArtifactFileList } from "../artifacts/artifact-file-list";
@@ -33,16 +34,20 @@ export function MessageList({
className, className,
threadId, threadId,
thread, thread,
messagesOverride,
paddingBottom = 160, paddingBottom = 160,
}: { }: {
className?: string; className?: string;
threadId: string; threadId: string;
thread: UseStream<AgentThreadState>; thread: UseStream<AgentThreadState>;
/** When set (e.g. from onFinish), use instead of thread.messages so SSE end shows complete state. */
messagesOverride?: Message[];
paddingBottom?: number; paddingBottom?: number;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const messages = messagesOverride ?? thread.messages;
if (thread.isThreadLoading) { if (thread.isThreadLoading) {
return <MessageListSkeleton />; return <MessageListSkeleton />;
} }
@@ -51,7 +56,7 @@ export function MessageList({
className={cn("flex size-full flex-col justify-center", className)} className={cn("flex size-full flex-col justify-center", className)}
> >
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12"> <ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12">
{groupMessages(thread.messages, (group) => { {groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") { if (group.type === "human" || group.type === "assistant") {
return ( return (
<MessageListItem <MessageListItem

View File

@@ -205,18 +205,16 @@ export function hasUnreplacedCitationRefs(cleanContent: string): boolean {
/** /**
* Single source of truth: true when body must not be rendered (show loading instead). * Single source of truth: true when body must not be rendered (show loading instead).
* Use after parseCitations: pass raw content, parsed cleanContent, and isLoading. * Use after parseCitations: pass raw content, parsed cleanContent, and isLoading.
* When streaming and any citation block is present, show loading so the indicator * Never show body when cleanContent still has [cite-N] (e.g. refs arrived before
* is visible in all modes (Pro/Ultra often receive complete blocks in one chunk). * <citations> block in stream); also show loading while streaming with citation block.
*/ */
export function shouldShowCitationLoading( export function shouldShowCitationLoading(
rawContent: string, rawContent: string,
cleanContent: string, cleanContent: string,
isLoading: boolean, isLoading: boolean,
): boolean { ): boolean {
return ( if (hasUnreplacedCitationRefs(cleanContent)) return true;
(isLoading && hasCitationsBlock(rawContent)) || return isLoading && hasCitationsBlock(rawContent);
(hasCitationsBlock(rawContent) && hasUnreplacedCitationRefs(cleanContent))
);
} }
/** /**