From cf9af1fe75fd1d3e216286afb57354486cded64a Mon Sep 17 00:00:00 2001 From: JeffJiang Date: Sun, 8 Mar 2026 20:19:31 +0800 Subject: [PATCH] Enhance chat UI and compatible anthropic thinking messages (#1018) --- .../agents/middlewares/title_middleware.py | 8 +- backend/src/sandbox/local/local_sandbox.py | 38 +++-- .../tests/test_title_middleware_core_logic.py | 17 +- .../workspace/artifacts/context.tsx | 1 + .../components/workspace/chats/chat-box.tsx | 16 +- .../workspace/messages/message-list.tsx | 16 +- .../src/components/workspace/thread-title.tsx | 14 +- frontend/src/core/messages/utils.ts | 157 ++++++++++-------- frontend/src/core/threads/hooks.ts | 75 +++++++-- 9 files changed, 213 insertions(+), 129 deletions(-) diff --git a/backend/src/agents/middlewares/title_middleware.py b/backend/src/agents/middlewares/title_middleware.py index 967ced4..4650f60 100644 --- a/backend/src/agents/middlewares/title_middleware.py +++ b/backend/src/agents/middlewares/title_middleware.py @@ -43,7 +43,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): # Generate title after first complete exchange return len(user_messages) == 1 and len(assistant_messages) >= 1 - def _generate_title(self, state: TitleMiddlewareState) -> str: + async def _generate_title(self, state: TitleMiddlewareState) -> str: """Generate a concise title based on the conversation.""" config = get_title_config() messages = state.get("messages", []) @@ -66,7 +66,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): ) try: - response = model.invoke(prompt) + response = await model.ainvoke(prompt) # Ensure response content is string title_content = str(response.content) if response.content else "" title = title_content.strip().strip('"').strip("'") @@ -81,10 +81,10 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): return user_msg if user_msg else "New Conversation" @override - def after_agent(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: + async def aafter_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: """Generate and set thread title after the first agent response.""" if self._should_generate_title(state): - title = self._generate_title(state) + title = await self._generate_title(state) print(f"Generated thread title: {title}") # Store title in state (will be persisted by checkpointer if configured) diff --git a/backend/src/sandbox/local/local_sandbox.py b/backend/src/sandbox/local/local_sandbox.py index 91f400d..b3cec11 100644 --- a/backend/src/sandbox/local/local_sandbox.py +++ b/backend/src/sandbox/local/local_sandbox.py @@ -179,22 +179,34 @@ class LocalSandbox(Sandbox): def read_file(self, path: str) -> str: resolved_path = self._resolve_path(path) - with open(resolved_path) as f: - return f.read() + try: + with open(resolved_path) as f: + return f.read() + except OSError as e: + # Re-raise with the original path for clearer error messages, hiding internal resolved paths + raise type(e)(e.errno, e.strerror, path) from None def write_file(self, path: str, content: str, append: bool = False) -> None: resolved_path = self._resolve_path(path) - dir_path = os.path.dirname(resolved_path) - if dir_path: - os.makedirs(dir_path, exist_ok=True) - mode = "a" if append else "w" - with open(resolved_path, mode) as f: - f.write(content) + try: + dir_path = os.path.dirname(resolved_path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + mode = "a" if append else "w" + with open(resolved_path, mode) as f: + f.write(content) + except OSError as e: + # Re-raise with the original path for clearer error messages, hiding internal resolved paths + raise type(e)(e.errno, e.strerror, path) from None def update_file(self, path: str, content: bytes) -> None: resolved_path = self._resolve_path(path) - dir_path = os.path.dirname(resolved_path) - if dir_path: - os.makedirs(dir_path, exist_ok=True) - with open(resolved_path, "wb") as f: - f.write(content) + try: + dir_path = os.path.dirname(resolved_path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + with open(resolved_path, "wb") as f: + f.write(content) + except OSError as e: + # Re-raise with the original path for clearer error messages, hiding internal resolved paths + raise type(e)(e.errno, e.strerror, path) from None diff --git a/backend/tests/test_title_middleware_core_logic.py b/backend/tests/test_title_middleware_core_logic.py index 1a57bb1..598e3df 100644 --- a/backend/tests/test_title_middleware_core_logic.py +++ b/backend/tests/test_title_middleware_core_logic.py @@ -1,6 +1,7 @@ """Core behavior tests for TitleMiddleware.""" -from unittest.mock import MagicMock +import asyncio +from unittest.mock import AsyncMock, MagicMock from langchain_core.messages import AIMessage, HumanMessage @@ -76,7 +77,7 @@ class TestTitleMiddlewareCoreLogic: _set_test_title_config(max_chars=12) middleware = TitleMiddleware() fake_model = MagicMock() - fake_model.invoke.return_value = MagicMock(content='"A very long generated title"') + fake_model.ainvoke = AsyncMock(return_value=MagicMock(content='"A very long generated title"')) monkeypatch.setattr("src.agents.middlewares.title_middleware.create_chat_model", lambda **kwargs: fake_model) state = { @@ -85,7 +86,7 @@ class TestTitleMiddlewareCoreLogic: AIMessage(content="好的,先确认需求"), ] } - title = middleware._generate_title(state) + title = asyncio.run(middleware._generate_title(state)) assert '"' not in title assert "'" not in title @@ -95,7 +96,7 @@ class TestTitleMiddlewareCoreLogic: _set_test_title_config(max_chars=20) middleware = TitleMiddleware() fake_model = MagicMock() - fake_model.invoke.side_effect = RuntimeError("LLM unavailable") + fake_model.ainvoke = AsyncMock(side_effect=RuntimeError("LLM unavailable")) monkeypatch.setattr("src.agents.middlewares.title_middleware.create_chat_model", lambda **kwargs: fake_model) state = { @@ -104,7 +105,7 @@ class TestTitleMiddlewareCoreLogic: AIMessage(content="收到"), ] } - title = middleware._generate_title(state) + title = asyncio.run(middleware._generate_title(state)) # Assert behavior (truncated fallback + ellipsis) without overfitting exact text. assert title.endswith("...") @@ -113,11 +114,11 @@ class TestTitleMiddlewareCoreLogic: def test_after_agent_returns_title_only_when_needed(self, monkeypatch): middleware = TitleMiddleware() monkeypatch.setattr(middleware, "_should_generate_title", lambda state: True) - monkeypatch.setattr(middleware, "_generate_title", lambda state: "核心逻辑回归") + monkeypatch.setattr(middleware, "_generate_title", AsyncMock(return_value="核心逻辑回归")) - result = middleware.after_agent({"messages": []}, runtime=MagicMock()) + result = asyncio.run(middleware.aafter_model({"messages": []}, runtime=MagicMock())) assert result == {"title": "核心逻辑回归"} monkeypatch.setattr(middleware, "_should_generate_title", lambda state: False) - assert middleware.after_agent({"messages": []}, runtime=MagicMock()) is None + assert asyncio.run(middleware.aafter_model({"messages": []}, runtime=MagicMock())) is None diff --git a/frontend/src/components/workspace/artifacts/context.tsx b/frontend/src/components/workspace/artifacts/context.tsx index 3dcf71a..af9b19a 100644 --- a/frontend/src/components/workspace/artifacts/context.tsx +++ b/frontend/src/components/workspace/artifacts/context.tsx @@ -57,6 +57,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { const deselect = useCallback(() => { setSelectedArtifact(null); setAutoSelect(true); + setOpen(false); }, []); const value: ArtifactsContextType = { diff --git a/frontend/src/components/workspace/chats/chat-box.tsx b/frontend/src/components/workspace/chats/chat-box.tsx index 1fe0bc3..f77a3c1 100644 --- a/frontend/src/components/workspace/chats/chat-box.tsx +++ b/frontend/src/components/workspace/chats/chat-box.tsx @@ -27,7 +27,9 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ threadId, }) => { const { thread } = useThread(); + const threadIdRef = useRef(threadId); const layoutRef = useRef(null); + const { artifacts, open: artifactsOpen, @@ -40,13 +42,22 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); useEffect(() => { + if (threadIdRef.current !== threadId) { + threadIdRef.current = threadId; + deselect(); + } + + // Update artifacts from the current thread setArtifacts(thread.values.artifacts); + + // Deselect if the currently selected artifact no longer exists if ( - thread.values.artifacts?.length === 0 || - (selectedArtifact && !thread.values.artifacts?.includes(selectedArtifact)) + selectedArtifact && + !thread.values.artifacts?.includes(selectedArtifact) ) { deselect(); } + if ( env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && autoSelectFirstArtifact @@ -57,6 +68,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ } } }, [ + threadId, autoSelectFirstArtifact, deselect, selectArtifact, diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index e88d7cf..6059325 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -54,13 +54,15 @@ export function MessageList({ {groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { - return ( - - ); + return group.messages.map((msg) => { + return ( + + ); + }); } else if (group.type === "assistant:clarification") { const message = group.messages[0]; if (message && hasContent(message)) { diff --git a/frontend/src/components/workspace/thread-title.tsx b/frontend/src/components/workspace/thread-title.tsx index 7e89761..bfc469e 100644 --- a/frontend/src/components/workspace/thread-title.tsx +++ b/frontend/src/components/workspace/thread-title.tsx @@ -18,15 +18,17 @@ export function ThreadTitle({ const { t } = useI18n(); const { isNewThread } = useThreadChat(); useEffect(() => { - const pageTitle = isNewThread - ? t.pages.newChat - : thread.values?.title && thread.values.title !== "Untitled" - ? thread.values.title - : t.pages.untitled; + let _title = t.pages.untitled; + + if (thread.values?.title) { + _title = thread.values.title; + } else if (isNewThread) { + _title = t.pages.newChat; + } if (thread.isThreadLoading) { document.title = `Loading... - ${t.pages.appName}`; } else { - document.title = `${pageTitle} - ${t.pages.appName}`; + document.title = `${_title} - ${t.pages.appName}`; } }, [ isNewThread, diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index e9ab38b..3498ac8 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -33,96 +33,92 @@ export function groupMessages( if (messages.length === 0) { return []; } + const groups: MessageGroup[] = []; + // Returns the last group if it can still accept tool messages + // (i.e. it's an in-flight processing group, not a terminal human/assistant group). + function lastOpenGroup() { + const last = groups[groups.length - 1]; + if ( + last && + last.type !== "human" && + last.type !== "assistant" && + last.type !== "assistant:clarification" + ) { + return last; + } + return null; + } + for (const message of messages) { - const lastGroup = groups[groups.length - 1]; if (message.type === "human") { - groups.push({ - id: message.id, - type: "human", - messages: [message], - }); - } else if (message.type === "tool") { - // Check if this is a clarification tool message + groups.push({ id: message.id, type: "human", messages: [message] }); + continue; + } + + if (message.type === "tool") { if (isClarificationToolMessage(message)) { - // Add to processing group if available (to maintain tool call association) - if ( - lastGroup && - lastGroup.type !== "human" && - lastGroup.type !== "assistant" && - lastGroup.type !== "assistant:clarification" - ) { - lastGroup.messages.push(message); - } - // Also create a separate clarification group for prominent display + // Add to the preceding processing group to preserve tool-call association, + // then also open a standalone clarification group for prominent display. + lastOpenGroup()?.messages.push(message); groups.push({ id: message.id, type: "assistant:clarification", messages: [message], }); - } else if ( - lastGroup && - lastGroup.type !== "human" && - lastGroup.type !== "assistant" && - lastGroup.type !== "assistant:clarification" - ) { - lastGroup.messages.push(message); } else { - throw new Error( - "Tool message must be matched with a previous assistant message with tool calls", - ); + const open = lastOpenGroup(); + if (open) { + open.messages.push(message); + } else { + console.error( + "Unexpected tool message outside a processing group", + message, + ); + } } - } else if (message.type === "ai") { - if (hasReasoning(message) || hasToolCalls(message)) { - if (hasPresentFiles(message)) { + continue; + } + + if (message.type === "ai") { + if (hasPresentFiles(message)) { + groups.push({ + id: message.id, + type: "assistant:present-files", + messages: [message], + }); + } else if (hasSubagent(message)) { + groups.push({ + id: message.id, + type: "assistant:subagent", + messages: [message], + }); + } else if (hasReasoning(message) || hasToolCalls(message)) { + const lastGroup = groups[groups.length - 1]; + // Accumulate consecutive intermediate AI messages into one processing group. + if (lastGroup?.type !== "assistant:processing") { groups.push({ id: message.id, - type: "assistant:present-files", - messages: [message], - }); - } else if (hasSubagent(message)) { - groups.push({ - id: message.id, - type: "assistant:subagent", + type: "assistant:processing", messages: [message], }); } else { - if (lastGroup?.type !== "assistant:processing") { - groups.push({ - id: message.id, - type: "assistant:processing", - messages: [], - }); - } - const currentGroup = groups[groups.length - 1]; - if (currentGroup?.type === "assistant:processing") { - currentGroup.messages.push(message); - } else { - throw new Error( - "Assistant message with reasoning or tool calls must be preceded by a processing group", - ); - } + lastGroup.messages.push(message); } } + + // Not an else-if: a message with reasoning + content (but no tool calls) goes + // into the processing group above AND gets its own assistant bubble here. if (hasContent(message) && !hasToolCalls(message)) { - groups.push({ - id: message.id, - type: "assistant", - messages: [message], - }); + groups.push({ id: message.id, type: "assistant", messages: [message] }); } } } - const resultsOfGroups: T[] = []; - for (const group of groups) { - const resultOfGroup = mapper(group); - if (resultOfGroup !== undefined && resultOfGroup !== null) { - resultsOfGroups.push(resultOfGroup); - } - } - return resultsOfGroups; + return groups + .map(mapper) + .filter((result) => result !== undefined && result !== null) as T[]; } export function extractTextFromMessage(message: Message) { @@ -162,12 +158,21 @@ export function extractContentFromMessage(message: Message) { } export function extractReasoningContentFromMessage(message: Message) { - if (message.type !== "ai" || !message.additional_kwargs) { + if (message.type !== "ai") { return null; } - if ("reasoning_content" in message.additional_kwargs) { + if ( + message.additional_kwargs && + "reasoning_content" in message.additional_kwargs + ) { return message.additional_kwargs.reasoning_content as string | null; } + if (Array.isArray(message.content)) { + const part = message.content[0]; + if (part && "thinking" in part) { + return part.thinking as string; + } + } return null; } @@ -202,10 +207,18 @@ export function hasContent(message: Message) { } export function hasReasoning(message: Message) { - return ( - message.type === "ai" && - typeof message.additional_kwargs?.reasoning_content === "string" - ); + if (message.type !== "ai") { + return false; + } + if (typeof message.additional_kwargs?.reasoning_content === "string") { + return true; + } + if (Array.isArray(message.content)) { + const part = message.content[0]; + // Compatible with the Anthropic gateway + return (part as unknown as { type: "thinking" })?.type === "thinking"; + } + return false; } export function hasToolCalls(message: Message) { diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 73260ba..0cc8ed2 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -40,39 +40,83 @@ export function useThreadStream({ onToolEnd, }: ThreadStreamOptions) { const { t } = useI18n(); - const [_threadId, setThreadId] = useState(threadId ?? null); + const threadIdRef = useRef(threadId ?? null); const startedRef = useRef(false); + const listeners = useRef({ + onStart, + onFinish, + onToolEnd, + }); + + // Keep listeners ref updated with latest callbacks useEffect(() => { - if (_threadId && _threadId !== threadId) { - setThreadId(threadId ?? null); + listeners.current = { onStart, onFinish, onToolEnd }; + }, [onStart, onFinish, onToolEnd]); + + useEffect(() => { + if (threadIdRef.current && threadIdRef.current !== threadId) { + threadIdRef.current = threadId ?? null; startedRef.current = false; // Reset for new thread } - }, [threadId, _threadId]); + }, [threadId]); + + const _handleStart = useCallback((id: string) => { + if (!startedRef.current) { + listeners.current.onStart?.(id); + startedRef.current = true; + } + }, []); const queryClient = useQueryClient(); const updateSubtask = useUpdateSubtask(); const thread = useStream({ client: getAPIClient(isMock), assistantId: "lead_agent", - threadId: _threadId, + threadId: threadIdRef.current, reconnectOnMount: true, fetchStateHistory: { limit: 1 }, onCreated(meta) { - setThreadId(meta.thread_id); - if (!startedRef.current) { - onStart?.(meta.thread_id); - startedRef.current = true; - } + threadIdRef.current = meta.thread_id; + _handleStart(meta.thread_id); }, onLangChainEvent(event) { if (event.event === "on_tool_end") { - onToolEnd?.({ + listeners.current.onToolEnd?.({ name: event.name, data: event.data, }); } }, + onUpdateEvent(data) { + const updates: Array | 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 | 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" && @@ -89,7 +133,7 @@ export function useThreadStream({ } }, onFinish(state) { - onFinish?.(state.values); + listeners.current.onFinish?.(state.values); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); }, }); @@ -150,10 +194,7 @@ export function useThreadStream({ } setOptimisticMessages(newOptimistic); - if (!startedRef.current) { - onStart?.(threadId); - startedRef.current = true; - } + _handleStart(threadId); let uploadedFileInfo: UploadedFileInfo[] = []; @@ -289,7 +330,7 @@ export function useThreadStream({ throw error; } }, - [thread, t.uploads.uploadingFiles, onStart, context, queryClient], + [thread, _handleStart, t.uploads.uploadingFiles, context, queryClient], ); // Merge thread with optimistic messages for display