mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-19 04:14:46 +08:00
Enhance chat UI and compatible anthropic thinking messages (#1018)
This commit is contained in:
@@ -43,7 +43,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
# Generate title after first complete exchange
|
# Generate title after first complete exchange
|
||||||
return len(user_messages) == 1 and len(assistant_messages) >= 1
|
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."""
|
"""Generate a concise title based on the conversation."""
|
||||||
config = get_title_config()
|
config = get_title_config()
|
||||||
messages = state.get("messages", [])
|
messages = state.get("messages", [])
|
||||||
@@ -66,7 +66,7 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = model.invoke(prompt)
|
response = await model.ainvoke(prompt)
|
||||||
# Ensure response content is string
|
# Ensure response content is string
|
||||||
title_content = str(response.content) if response.content else ""
|
title_content = str(response.content) if response.content else ""
|
||||||
title = title_content.strip().strip('"').strip("'")
|
title = title_content.strip().strip('"').strip("'")
|
||||||
@@ -81,10 +81,10 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
return user_msg if user_msg else "New Conversation"
|
return user_msg if user_msg else "New Conversation"
|
||||||
|
|
||||||
@override
|
@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."""
|
"""Generate and set thread title after the first agent response."""
|
||||||
if self._should_generate_title(state):
|
if self._should_generate_title(state):
|
||||||
title = self._generate_title(state)
|
title = await self._generate_title(state)
|
||||||
print(f"Generated thread title: {title}")
|
print(f"Generated thread title: {title}")
|
||||||
|
|
||||||
# Store title in state (will be persisted by checkpointer if configured)
|
# Store title in state (will be persisted by checkpointer if configured)
|
||||||
|
|||||||
@@ -179,22 +179,34 @@ class LocalSandbox(Sandbox):
|
|||||||
|
|
||||||
def read_file(self, path: str) -> str:
|
def read_file(self, path: str) -> str:
|
||||||
resolved_path = self._resolve_path(path)
|
resolved_path = self._resolve_path(path)
|
||||||
with open(resolved_path) as f:
|
try:
|
||||||
return f.read()
|
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:
|
def write_file(self, path: str, content: str, append: bool = False) -> None:
|
||||||
resolved_path = self._resolve_path(path)
|
resolved_path = self._resolve_path(path)
|
||||||
dir_path = os.path.dirname(resolved_path)
|
try:
|
||||||
if dir_path:
|
dir_path = os.path.dirname(resolved_path)
|
||||||
os.makedirs(dir_path, exist_ok=True)
|
if dir_path:
|
||||||
mode = "a" if append else "w"
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
with open(resolved_path, mode) as f:
|
mode = "a" if append else "w"
|
||||||
f.write(content)
|
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:
|
def update_file(self, path: str, content: bytes) -> None:
|
||||||
resolved_path = self._resolve_path(path)
|
resolved_path = self._resolve_path(path)
|
||||||
dir_path = os.path.dirname(resolved_path)
|
try:
|
||||||
if dir_path:
|
dir_path = os.path.dirname(resolved_path)
|
||||||
os.makedirs(dir_path, exist_ok=True)
|
if dir_path:
|
||||||
with open(resolved_path, "wb") as f:
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
f.write(content)
|
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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Core behavior tests for TitleMiddleware."""
|
"""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
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ class TestTitleMiddlewareCoreLogic:
|
|||||||
_set_test_title_config(max_chars=12)
|
_set_test_title_config(max_chars=12)
|
||||||
middleware = TitleMiddleware()
|
middleware = TitleMiddleware()
|
||||||
fake_model = MagicMock()
|
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)
|
monkeypatch.setattr("src.agents.middlewares.title_middleware.create_chat_model", lambda **kwargs: fake_model)
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@@ -85,7 +86,7 @@ class TestTitleMiddlewareCoreLogic:
|
|||||||
AIMessage(content="好的,先确认需求"),
|
AIMessage(content="好的,先确认需求"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
title = middleware._generate_title(state)
|
title = asyncio.run(middleware._generate_title(state))
|
||||||
|
|
||||||
assert '"' not in title
|
assert '"' not in title
|
||||||
assert "'" not in title
|
assert "'" not in title
|
||||||
@@ -95,7 +96,7 @@ class TestTitleMiddlewareCoreLogic:
|
|||||||
_set_test_title_config(max_chars=20)
|
_set_test_title_config(max_chars=20)
|
||||||
middleware = TitleMiddleware()
|
middleware = TitleMiddleware()
|
||||||
fake_model = MagicMock()
|
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)
|
monkeypatch.setattr("src.agents.middlewares.title_middleware.create_chat_model", lambda **kwargs: fake_model)
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@@ -104,7 +105,7 @@ class TestTitleMiddlewareCoreLogic:
|
|||||||
AIMessage(content="收到"),
|
AIMessage(content="收到"),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
title = middleware._generate_title(state)
|
title = asyncio.run(middleware._generate_title(state))
|
||||||
|
|
||||||
# Assert behavior (truncated fallback + ellipsis) without overfitting exact text.
|
# Assert behavior (truncated fallback + ellipsis) without overfitting exact text.
|
||||||
assert title.endswith("...")
|
assert title.endswith("...")
|
||||||
@@ -113,11 +114,11 @@ class TestTitleMiddlewareCoreLogic:
|
|||||||
def test_after_agent_returns_title_only_when_needed(self, monkeypatch):
|
def test_after_agent_returns_title_only_when_needed(self, monkeypatch):
|
||||||
middleware = TitleMiddleware()
|
middleware = TitleMiddleware()
|
||||||
monkeypatch.setattr(middleware, "_should_generate_title", lambda state: True)
|
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": "核心逻辑回归"}
|
assert result == {"title": "核心逻辑回归"}
|
||||||
|
|
||||||
monkeypatch.setattr(middleware, "_should_generate_title", lambda state: False)
|
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
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
|||||||
const deselect = useCallback(() => {
|
const deselect = useCallback(() => {
|
||||||
setSelectedArtifact(null);
|
setSelectedArtifact(null);
|
||||||
setAutoSelect(true);
|
setAutoSelect(true);
|
||||||
|
setOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value: ArtifactsContextType = {
|
const value: ArtifactsContextType = {
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
threadId,
|
threadId,
|
||||||
}) => {
|
}) => {
|
||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
|
const threadIdRef = useRef(threadId);
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
artifacts,
|
artifacts,
|
||||||
open: artifactsOpen,
|
open: artifactsOpen,
|
||||||
@@ -40,13 +42,22 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (threadIdRef.current !== threadId) {
|
||||||
|
threadIdRef.current = threadId;
|
||||||
|
deselect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update artifacts from the current thread
|
||||||
setArtifacts(thread.values.artifacts);
|
setArtifacts(thread.values.artifacts);
|
||||||
|
|
||||||
|
// Deselect if the currently selected artifact no longer exists
|
||||||
if (
|
if (
|
||||||
thread.values.artifacts?.length === 0 ||
|
selectedArtifact &&
|
||||||
(selectedArtifact && !thread.values.artifacts?.includes(selectedArtifact))
|
!thread.values.artifacts?.includes(selectedArtifact)
|
||||||
) {
|
) {
|
||||||
deselect();
|
deselect();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||||
autoSelectFirstArtifact
|
autoSelectFirstArtifact
|
||||||
@@ -57,6 +68,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
threadId,
|
||||||
autoSelectFirstArtifact,
|
autoSelectFirstArtifact,
|
||||||
deselect,
|
deselect,
|
||||||
selectArtifact,
|
selectArtifact,
|
||||||
|
|||||||
@@ -54,13 +54,15 @@ export function MessageList({
|
|||||||
<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(messages, (group) => {
|
{groupMessages(messages, (group) => {
|
||||||
if (group.type === "human" || group.type === "assistant") {
|
if (group.type === "human" || group.type === "assistant") {
|
||||||
return (
|
return group.messages.map((msg) => {
|
||||||
<MessageListItem
|
return (
|
||||||
key={group.id}
|
<MessageListItem
|
||||||
message={group.messages[0]!}
|
key={`${group.id}/${msg.id}`}
|
||||||
isLoading={thread.isLoading}
|
message={msg}
|
||||||
/>
|
isLoading={thread.isLoading}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
} else if (group.type === "assistant:clarification") {
|
} else if (group.type === "assistant:clarification") {
|
||||||
const message = group.messages[0];
|
const message = group.messages[0];
|
||||||
if (message && hasContent(message)) {
|
if (message && hasContent(message)) {
|
||||||
|
|||||||
@@ -18,15 +18,17 @@ export function ThreadTitle({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { isNewThread } = useThreadChat();
|
const { isNewThread } = useThreadChat();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pageTitle = isNewThread
|
let _title = t.pages.untitled;
|
||||||
? t.pages.newChat
|
|
||||||
: thread.values?.title && thread.values.title !== "Untitled"
|
if (thread.values?.title) {
|
||||||
? thread.values.title
|
_title = thread.values.title;
|
||||||
: t.pages.untitled;
|
} else if (isNewThread) {
|
||||||
|
_title = t.pages.newChat;
|
||||||
|
}
|
||||||
if (thread.isThreadLoading) {
|
if (thread.isThreadLoading) {
|
||||||
document.title = `Loading... - ${t.pages.appName}`;
|
document.title = `Loading... - ${t.pages.appName}`;
|
||||||
} else {
|
} else {
|
||||||
document.title = `${pageTitle} - ${t.pages.appName}`;
|
document.title = `${_title} - ${t.pages.appName}`;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isNewThread,
|
isNewThread,
|
||||||
|
|||||||
@@ -33,96 +33,92 @@ export function groupMessages<T>(
|
|||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups: MessageGroup[] = [];
|
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) {
|
for (const message of messages) {
|
||||||
const lastGroup = groups[groups.length - 1];
|
|
||||||
if (message.type === "human") {
|
if (message.type === "human") {
|
||||||
groups.push({
|
groups.push({ id: message.id, type: "human", messages: [message] });
|
||||||
id: message.id,
|
continue;
|
||||||
type: "human",
|
}
|
||||||
messages: [message],
|
|
||||||
});
|
if (message.type === "tool") {
|
||||||
} else if (message.type === "tool") {
|
|
||||||
// Check if this is a clarification tool message
|
|
||||||
if (isClarificationToolMessage(message)) {
|
if (isClarificationToolMessage(message)) {
|
||||||
// Add to processing group if available (to maintain tool call association)
|
// Add to the preceding processing group to preserve tool-call association,
|
||||||
if (
|
// then also open a standalone clarification group for prominent display.
|
||||||
lastGroup &&
|
lastOpenGroup()?.messages.push(message);
|
||||||
lastGroup.type !== "human" &&
|
|
||||||
lastGroup.type !== "assistant" &&
|
|
||||||
lastGroup.type !== "assistant:clarification"
|
|
||||||
) {
|
|
||||||
lastGroup.messages.push(message);
|
|
||||||
}
|
|
||||||
// Also create a separate clarification group for prominent display
|
|
||||||
groups.push({
|
groups.push({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
type: "assistant:clarification",
|
type: "assistant:clarification",
|
||||||
messages: [message],
|
messages: [message],
|
||||||
});
|
});
|
||||||
} else if (
|
|
||||||
lastGroup &&
|
|
||||||
lastGroup.type !== "human" &&
|
|
||||||
lastGroup.type !== "assistant" &&
|
|
||||||
lastGroup.type !== "assistant:clarification"
|
|
||||||
) {
|
|
||||||
lastGroup.messages.push(message);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
const open = lastOpenGroup();
|
||||||
"Tool message must be matched with a previous assistant message with tool calls",
|
if (open) {
|
||||||
);
|
open.messages.push(message);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Unexpected tool message outside a processing group",
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (message.type === "ai") {
|
continue;
|
||||||
if (hasReasoning(message) || hasToolCalls(message)) {
|
}
|
||||||
if (hasPresentFiles(message)) {
|
|
||||||
|
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({
|
groups.push({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
type: "assistant:present-files",
|
type: "assistant:processing",
|
||||||
messages: [message],
|
|
||||||
});
|
|
||||||
} else if (hasSubagent(message)) {
|
|
||||||
groups.push({
|
|
||||||
id: message.id,
|
|
||||||
type: "assistant:subagent",
|
|
||||||
messages: [message],
|
messages: [message],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (lastGroup?.type !== "assistant:processing") {
|
lastGroup.messages.push(message);
|
||||||
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)) {
|
if (hasContent(message) && !hasToolCalls(message)) {
|
||||||
groups.push({
|
groups.push({ id: message.id, type: "assistant", messages: [message] });
|
||||||
id: message.id,
|
|
||||||
type: "assistant",
|
|
||||||
messages: [message],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultsOfGroups: T[] = [];
|
return groups
|
||||||
for (const group of groups) {
|
.map(mapper)
|
||||||
const resultOfGroup = mapper(group);
|
.filter((result) => result !== undefined && result !== null) as T[];
|
||||||
if (resultOfGroup !== undefined && resultOfGroup !== null) {
|
|
||||||
resultsOfGroups.push(resultOfGroup);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resultsOfGroups;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractTextFromMessage(message: Message) {
|
export function extractTextFromMessage(message: Message) {
|
||||||
@@ -162,12 +158,21 @@ export function extractContentFromMessage(message: Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractReasoningContentFromMessage(message: Message) {
|
export function extractReasoningContentFromMessage(message: Message) {
|
||||||
if (message.type !== "ai" || !message.additional_kwargs) {
|
if (message.type !== "ai") {
|
||||||
return null;
|
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;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,10 +207,18 @@ export function hasContent(message: Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasReasoning(message: Message) {
|
export function hasReasoning(message: Message) {
|
||||||
return (
|
if (message.type !== "ai") {
|
||||||
message.type === "ai" &&
|
return false;
|
||||||
typeof message.additional_kwargs?.reasoning_content === "string"
|
}
|
||||||
);
|
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) {
|
export function hasToolCalls(message: Message) {
|
||||||
|
|||||||
@@ -40,39 +40,83 @@ export function useThreadStream({
|
|||||||
onToolEnd,
|
onToolEnd,
|
||||||
}: ThreadStreamOptions) {
|
}: ThreadStreamOptions) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [_threadId, setThreadId] = useState<string | null>(threadId ?? null);
|
const threadIdRef = useRef<string | null>(threadId ?? null);
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
|
|
||||||
|
const listeners = useRef({
|
||||||
|
onStart,
|
||||||
|
onFinish,
|
||||||
|
onToolEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep listeners ref updated with latest callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (_threadId && _threadId !== threadId) {
|
listeners.current = { onStart, onFinish, onToolEnd };
|
||||||
setThreadId(threadId ?? null);
|
}, [onStart, onFinish, onToolEnd]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (threadIdRef.current && threadIdRef.current !== threadId) {
|
||||||
|
threadIdRef.current = threadId ?? null;
|
||||||
startedRef.current = false; // Reset for new thread
|
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 queryClient = useQueryClient();
|
||||||
const updateSubtask = useUpdateSubtask();
|
const updateSubtask = useUpdateSubtask();
|
||||||
const thread = useStream<AgentThreadState>({
|
const thread = useStream<AgentThreadState>({
|
||||||
client: getAPIClient(isMock),
|
client: getAPIClient(isMock),
|
||||||
assistantId: "lead_agent",
|
assistantId: "lead_agent",
|
||||||
threadId: _threadId,
|
threadId: threadIdRef.current,
|
||||||
reconnectOnMount: true,
|
reconnectOnMount: true,
|
||||||
fetchStateHistory: { limit: 1 },
|
fetchStateHistory: { limit: 1 },
|
||||||
onCreated(meta) {
|
onCreated(meta) {
|
||||||
setThreadId(meta.thread_id);
|
threadIdRef.current = meta.thread_id;
|
||||||
if (!startedRef.current) {
|
_handleStart(meta.thread_id);
|
||||||
onStart?.(meta.thread_id);
|
|
||||||
startedRef.current = true;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onLangChainEvent(event) {
|
onLangChainEvent(event) {
|
||||||
if (event.event === "on_tool_end") {
|
if (event.event === "on_tool_end") {
|
||||||
onToolEnd?.({
|
listeners.current.onToolEnd?.({
|
||||||
name: event.name,
|
name: event.name,
|
||||||
data: event.data,
|
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) {
|
onCustomEvent(event: unknown) {
|
||||||
if (
|
if (
|
||||||
typeof event === "object" &&
|
typeof event === "object" &&
|
||||||
@@ -89,7 +133,7 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFinish(state) {
|
onFinish(state) {
|
||||||
onFinish?.(state.values);
|
listeners.current.onFinish?.(state.values);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -150,10 +194,7 @@ export function useThreadStream({
|
|||||||
}
|
}
|
||||||
setOptimisticMessages(newOptimistic);
|
setOptimisticMessages(newOptimistic);
|
||||||
|
|
||||||
if (!startedRef.current) {
|
_handleStart(threadId);
|
||||||
onStart?.(threadId);
|
|
||||||
startedRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let uploadedFileInfo: UploadedFileInfo[] = [];
|
let uploadedFileInfo: UploadedFileInfo[] = [];
|
||||||
|
|
||||||
@@ -289,7 +330,7 @@ export function useThreadStream({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[thread, t.uploads.uploadingFiles, onStart, context, queryClient],
|
[thread, _handleStart, t.uploads.uploadingFiles, context, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Merge thread with optimistic messages for display
|
// Merge thread with optimistic messages for display
|
||||||
|
|||||||
Reference in New Issue
Block a user