From 87dac508c988ff104e87474affcaab26cbd3a447 Mon Sep 17 00:00:00 2001 From: Kohei Date: Thu, 19 Feb 2026 21:40:53 +0900 Subject: [PATCH] feat: add voice interrupt via orb tap with server-side cancellation Allow users to interrupt AI responses (SPEAKING/THINKING) by tapping the voice orb, immediately returning to LISTENING state. On the server side, new messages from the same session cancel in-progress processing and preserve context with an interruption marker. Co-Authored-By: Claude Opus 4.6 --- .../android/feature/chat/ChatEvent.kt | 1 + .../android/feature/chat/ChatViewModel.kt | 3 + .../android/feature/chat/screen/ChatScreen.kt | 3 +- .../feature/chat/voice/VoiceModeManager.kt | 17 +++ .../feature/chat/voice/VoiceModeOverlay.kt | 17 ++- pkg/agent/loop.go | 113 ++++++++++++++---- 6 files changed, 126 insertions(+), 28 deletions(-) diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt index de855cf1d..809f1e8a3 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt @@ -12,4 +12,5 @@ sealed interface ChatEvent { data object OnErrorDismissed : ChatEvent data object OnVoiceModeStart : ChatEvent data object OnVoiceModeStop : ChatEvent + data object OnVoiceModeInterrupt : ChatEvent } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt index a310ea466..138432b96 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt @@ -105,6 +105,9 @@ class ChatViewModel( is ChatEvent.OnVoiceModeStop -> { voiceModeManager.stop() } + is ChatEvent.OnVoiceModeInterrupt -> { + voiceModeManager.interrupt() + } } } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt index 6999f513a..7cb48a7d6 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt @@ -209,7 +209,8 @@ fun ChatScreen( VoiceModeOverlay( state = uiState.voiceModeState, - onClose = { viewModel.onEvent(ChatEvent.OnVoiceModeStop) } + onClose = { viewModel.onEvent(ChatEvent.OnVoiceModeStop) }, + onInterrupt = { viewModel.onEvent(ChatEvent.OnVoiceModeInterrupt) } ) } } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeManager.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeManager.kt index 47bc41682..06a6e7097 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeManager.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeManager.kt @@ -35,9 +35,11 @@ class VoiceModeManager( val state: StateFlow = _state.asStateFlow() private var loopJob: Job? = null + private var parentScope: CoroutineScope? = null fun start(scope: CoroutineScope) { if (loopJob?.isActive == true) return + parentScope = scope _state.update { VoiceModeState(isActive = true, phase = VoicePhase.LISTENING) } @@ -49,10 +51,25 @@ class VoiceModeManager( fun stop() { loopJob?.cancel() loopJob = null + parentScope = null ttsWrapper.stop() _state.value = VoiceModeState() } + fun interrupt() { + val scope = parentScope ?: return + if (loopJob?.isActive != true) return + + ttsWrapper.stop() + loopJob?.cancel() + loopJob = null + + _state.update { + VoiceModeState(isActive = true, phase = VoicePhase.LISTENING) + } + loopJob = scope.launch { voiceLoop() } + } + fun destroy() { stop() } diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt index 773935878..f46d90124 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/voice/VoiceModeOverlay.kt @@ -20,7 +20,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -31,6 +34,7 @@ import io.picoclaw.android.core.domain.model.VoicePhase fun VoiceModeOverlay( state: VoiceModeState, onClose: () -> Unit, + onInterrupt: () -> Unit, modifier: Modifier = Modifier ) { AnimatedVisibility( @@ -65,9 +69,20 @@ fun VoiceModeOverlay( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { + val interruptable = state.phase != VoicePhase.LISTENING && + state.phase != VoicePhase.IDLE + VoiceOrb( phase = state.phase, - amplitudeNormalized = state.amplitudeNormalized + amplitudeNormalized = state.amplitudeNormalized, + modifier = if (interruptable) { + Modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { onInterrupt() } + } else { + Modifier + } ) Spacer(modifier = Modifier.height(32.dp)) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e434d8dae..f40e037ba 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -47,6 +47,13 @@ type AgentLoop struct { channelManager *channels.Manager rateLimiter *rateLimiter mcpManager *mcp.Manager + activeProcs map[string]*activeProcess + procsMu sync.Mutex +} + +type activeProcess struct { + cancel context.CancelFunc + done chan struct{} } // processOptions configures how a message is processed @@ -182,6 +189,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers summarizing: sync.Map{}, rateLimiter: newRateLimiter(cfg.RateLimits.MaxToolCallsPerMinute, cfg.RateLimits.MaxRequestsPerMinute), mcpManager: mcpManager, + activeProcs: make(map[string]*activeProcess), } } @@ -198,36 +206,67 @@ func (al *AgentLoop) Run(ctx context.Context) error { continue } - response, err := al.processMessage(ctx, msg) - if err != nil { - response = fmt.Sprintf("Error processing message: %v", err) - al.bus.PublishOutbound(bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: response, - Type: "error", - }) - continue + sessionKey := msg.SessionKey + if sessionKey == "" { + sessionKey = fmt.Sprintf("%s:%s", msg.Channel, msg.ChatID) } - if response != "" { - // Check if the message tool already sent a response during this round. - // If so, skip publishing to avoid duplicate messages to the user. - alreadySent := false - if tool, ok := al.tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySent = mt.HasSentInRound() + // Cancel active process for the same session + al.procsMu.Lock() + if active, exists := al.activeProcs[sessionKey]; exists { + active.cancel() + al.procsMu.Unlock() + select { + case <-active.done: + case <-time.After(5 * time.Second): + logger.WarnCF("agent", "Timed out waiting for cancelled process", + map[string]interface{}{"session_key": sessionKey}) + } + al.procsMu.Lock() + } + + procCtx, procCancel := context.WithCancel(ctx) + done := make(chan struct{}) + al.activeProcs[sessionKey] = &activeProcess{cancel: procCancel, done: done} + al.procsMu.Unlock() + + go func(m bus.InboundMessage, sk string) { + defer func() { + close(done) + al.procsMu.Lock() + if cur, ok := al.activeProcs[sk]; ok && cur.done == done { + delete(al.activeProcs, sk) + } + al.procsMu.Unlock() + procCancel() + }() + + response, err := al.processMessage(procCtx, m) + + if procCtx.Err() != nil { + return + } + if err != nil { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: m.Channel, ChatID: m.ChatID, + Content: fmt.Sprintf("Error: %v", err), Type: "error", + }) + return + } + if response != "" { + alreadySent := false + if tool, ok := al.tools.Get("message"); ok { + if mt, ok := tool.(*tools.MessageTool); ok { + alreadySent = mt.HasSentInRound() + } + } + if !alreadySent { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: m.Channel, ChatID: m.ChatID, Content: response, + }) } } - - if !alreadySent { - al.bus.PublishOutbound(bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: response, - }) - } - } + }(msg, sessionKey) } } @@ -469,6 +508,14 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str // 5. Run LLM iteration loop finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts, ¤tStatus) + + if ctx.Err() != nil { + // Processing was cancelled (e.g., new message from same session) + al.sessions.AddMessage(opts.SessionKey, "assistant", "[応答は中断されました]") + al.sessions.Save(opts.SessionKey) + return "", nil + } + if err != nil { return "", err } @@ -520,6 +567,13 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M for iteration < al.maxIterations { iteration++ + // Cancellation checkpoint at start of each iteration + select { + case <-ctx.Done(): + return finalContent, iteration, ctx.Err() + default: + } + logger.DebugCF("agent", "LLM iteration", map[string]interface{}{ "iteration": iteration, @@ -816,6 +870,13 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M // Save tool result message to session al.sessions.AddFullMessage(opts.SessionKey, toolResultMsg) + + // Cancellation checkpoint after each tool execution + select { + case <-ctx.Done(): + return finalContent, iteration, ctx.Err() + default: + } } }