diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardChatManagerTool.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardChatManagerTool.kt index 9b5a01a745..26ee644999 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardChatManagerTool.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardChatManagerTool.kt @@ -88,6 +88,26 @@ sealed class MessageSendStreamStartResult { data class Failed(val result: ToolResult) : MessageSendStreamStartResult() } +/** + * 定向发送时,决定悬浮窗应跟随哪个对话显示。 + * + * FLOATING runtime 的 currentChatId 由 ChatRuntimeHolder 从主界面同步而来,只用 chatIdOverride + * 发送会让消息进到目标对话、窗口却停在前台对话上。MAIN runtime 不做切换,否则后台发送会把用户 + * 正在看的对话顶掉。 + */ +internal object FloatingDisplayChatSelector { + fun select( + runtimeSlot: ChatRuntimeSlot, + targetChatId: String?, + displayedChatId: String? + ): String? { + if (runtimeSlot != ChatRuntimeSlot.FLOATING) return null + if (targetChatId.isNullOrBlank()) return null + if (targetChatId == displayedChatId) return null + return targetChatId + } +} + /** * 对话管理工具 * 负责管理对话、浮窗服务,以及按指定 runtime 发送消息 @@ -1733,7 +1753,8 @@ class StandardChatManagerTool(private val context: Context) { ) } - val core = chatRuntimeHolder.getCore(runtimeSlot ?: ChatRuntimeSlot.FLOATING) + val effectiveSlot = runtimeSlot ?: ChatRuntimeSlot.FLOATING + val core = chatRuntimeHolder.getCore(effectiveSlot) val message = tool.parameters.find { it.name == "message" }?.value if (message.isNullOrBlank()) { @@ -1892,7 +1913,18 @@ class StandardChatManagerTool(private val context: Context) { } if (hasTargetChat) { - // 后台发送到指定对话,不切换 UI + // 悬浮窗跟随目标对话:chatIdOverride 只路由消息,FLOATING runtime 的 + // currentChatId 仍停在前台对话上,窗口会显示错误的会话(#555)。 + // switchChatLocal 只改悬浮窗本地状态,不写回全局,主界面不受影响。 + FloatingDisplayChatSelector.select( + runtimeSlot = effectiveSlot, + targetChatId = targetChatId, + displayedChatId = core.currentChatId.value + )?.let { chatToFollow -> + core.switchChatLocal(chatToFollow) + } + + // 后台发送到指定对话,不切换主界面 core.sendUserMessage( promptFunctionType = PromptFunctionType.CHAT, roleCardIdOverride = roleCardId, diff --git a/app/src/test/java/com/ai/assistance/operit/core/tools/defaultTool/standard/FloatingDisplayChatSelectorTest.kt b/app/src/test/java/com/ai/assistance/operit/core/tools/defaultTool/standard/FloatingDisplayChatSelectorTest.kt new file mode 100644 index 0000000000..1c79311b8c --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/core/tools/defaultTool/standard/FloatingDisplayChatSelectorTest.kt @@ -0,0 +1,96 @@ +package com.ai.assistance.operit.core.tools.defaultTool.standard + +import com.ai.assistance.operit.api.chat.ChatRuntimeSlot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class FloatingDisplayChatSelectorTest { + + @Test + fun select_floatingRuntimeFollowsWorkflowTargetChatInsteadOfForegroundChat() { + // #555: a workflow sends to chat B while the user is looking at chat A. + val result = + FloatingDisplayChatSelector.select( + runtimeSlot = ChatRuntimeSlot.FLOATING, + targetChatId = "chat-b", + displayedChatId = "chat-a" + ) + + assertEquals("chat-b", result) + } + + @Test + fun select_floatingRuntimeFollowsTargetWhenNothingIsDisplayedYet() { + val result = + FloatingDisplayChatSelector.select( + runtimeSlot = ChatRuntimeSlot.FLOATING, + targetChatId = "chat-b", + displayedChatId = null + ) + + assertEquals("chat-b", result) + } + + @Test + fun select_mainRuntimeNeverSwitchesAwayFromForegroundChat() { + val result = + FloatingDisplayChatSelector.select( + runtimeSlot = ChatRuntimeSlot.MAIN, + targetChatId = "chat-b", + displayedChatId = "chat-a" + ) + + assertNull(result) + } + + @Test + fun select_returnsNullWhenTargetAlreadyDisplayed() { + val result = + FloatingDisplayChatSelector.select( + runtimeSlot = ChatRuntimeSlot.FLOATING, + targetChatId = "chat-a", + displayedChatId = "chat-a" + ) + + assertNull(result) + } + + @Test + fun select_returnsNullWhenNoTargetChatIsGiven() { + val floating = + FloatingDisplayChatSelector.select( + runtimeSlot = ChatRuntimeSlot.FLOATING, + targetChatId = null, + displayedChatId = "chat-a" + ) + val main = + FloatingDisplayChatSelector.select( + runtimeSlot = ChatRuntimeSlot.MAIN, + targetChatId = null, + displayedChatId = "chat-a" + ) + + assertNull(floating) + assertNull(main) + } + + @Test + fun select_returnsNullWhenTargetChatIsBlank() { + val blank = + FloatingDisplayChatSelector.select( + runtimeSlot = ChatRuntimeSlot.FLOATING, + targetChatId = " ", + displayedChatId = "chat-a" + ) + val empty = + FloatingDisplayChatSelector.select( + runtimeSlot = ChatRuntimeSlot.FLOATING, + targetChatId = "", + displayedChatId = "chat-a" + ) + + assertNull(blank) + assertNull(empty) + } +} diff --git a/docs/doc-src/package-dev/chat.md b/docs/doc-src/package-dev/chat.md index 6171a72880..2c45912505 100644 --- a/docs/doc-src/package-dev/chat.md +++ b/docs/doc-src/package-dev/chat.md @@ -103,6 +103,7 @@ findChat({ query, match?, index? }): Promise 其中: - `runtime` 用于指定本次消息发送到哪个 chat runtime,未指定时默认走 `floating` +- 传入 `chatId` 时消息只投递到该对话,走 `floating` 时悬浮窗会一并切到该对话显示,走 `main` 时不切换用户正在看的对话 - `timeout_ms` 用于控制本次发送的最长等待时间,单位毫秒 ### `call(options)`