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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-19 21:40:53 +09:00
parent 545b0203b3
commit 87dac508c9
6 changed files with 126 additions and 28 deletions

View file

@ -12,4 +12,5 @@ sealed interface ChatEvent {
data object OnErrorDismissed : ChatEvent data object OnErrorDismissed : ChatEvent
data object OnVoiceModeStart : ChatEvent data object OnVoiceModeStart : ChatEvent
data object OnVoiceModeStop : ChatEvent data object OnVoiceModeStop : ChatEvent
data object OnVoiceModeInterrupt : ChatEvent
} }

View file

@ -105,6 +105,9 @@ class ChatViewModel(
is ChatEvent.OnVoiceModeStop -> { is ChatEvent.OnVoiceModeStop -> {
voiceModeManager.stop() voiceModeManager.stop()
} }
is ChatEvent.OnVoiceModeInterrupt -> {
voiceModeManager.interrupt()
}
} }
} }

View file

@ -209,7 +209,8 @@ fun ChatScreen(
VoiceModeOverlay( VoiceModeOverlay(
state = uiState.voiceModeState, state = uiState.voiceModeState,
onClose = { viewModel.onEvent(ChatEvent.OnVoiceModeStop) } onClose = { viewModel.onEvent(ChatEvent.OnVoiceModeStop) },
onInterrupt = { viewModel.onEvent(ChatEvent.OnVoiceModeInterrupt) }
) )
} }
} }

View file

@ -35,9 +35,11 @@ class VoiceModeManager(
val state: StateFlow<VoiceModeState> = _state.asStateFlow() val state: StateFlow<VoiceModeState> = _state.asStateFlow()
private var loopJob: Job? = null private var loopJob: Job? = null
private var parentScope: CoroutineScope? = null
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
if (loopJob?.isActive == true) return if (loopJob?.isActive == true) return
parentScope = scope
_state.update { _state.update {
VoiceModeState(isActive = true, phase = VoicePhase.LISTENING) VoiceModeState(isActive = true, phase = VoicePhase.LISTENING)
} }
@ -49,10 +51,25 @@ class VoiceModeManager(
fun stop() { fun stop() {
loopJob?.cancel() loopJob?.cancel()
loopJob = null loopJob = null
parentScope = null
ttsWrapper.stop() ttsWrapper.stop()
_state.value = VoiceModeState() _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() { fun destroy() {
stop() stop()
} }

View file

@ -20,7 +20,10 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -31,6 +34,7 @@ import io.picoclaw.android.core.domain.model.VoicePhase
fun VoiceModeOverlay( fun VoiceModeOverlay(
state: VoiceModeState, state: VoiceModeState,
onClose: () -> Unit, onClose: () -> Unit,
onInterrupt: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
AnimatedVisibility( AnimatedVisibility(
@ -65,9 +69,20 @@ fun VoiceModeOverlay(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
val interruptable = state.phase != VoicePhase.LISTENING &&
state.phase != VoicePhase.IDLE
VoiceOrb( VoiceOrb(
phase = state.phase, 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)) Spacer(modifier = Modifier.height(32.dp))

View file

@ -47,6 +47,13 @@ type AgentLoop struct {
channelManager *channels.Manager channelManager *channels.Manager
rateLimiter *rateLimiter rateLimiter *rateLimiter
mcpManager *mcp.Manager 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 // processOptions configures how a message is processed
@ -182,6 +189,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
summarizing: sync.Map{}, summarizing: sync.Map{},
rateLimiter: newRateLimiter(cfg.RateLimits.MaxToolCallsPerMinute, cfg.RateLimits.MaxRequestsPerMinute), rateLimiter: newRateLimiter(cfg.RateLimits.MaxToolCallsPerMinute, cfg.RateLimits.MaxRequestsPerMinute),
mcpManager: mcpManager, mcpManager: mcpManager,
activeProcs: make(map[string]*activeProcess),
} }
} }
@ -198,36 +206,67 @@ func (al *AgentLoop) Run(ctx context.Context) error {
continue continue
} }
response, err := al.processMessage(ctx, msg) sessionKey := msg.SessionKey
if err != nil { if sessionKey == "" {
response = fmt.Sprintf("Error processing message: %v", err) sessionKey = fmt.Sprintf("%s:%s", msg.Channel, msg.ChatID)
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: response,
Type: "error",
})
continue
} }
if response != "" { // Cancel active process for the same session
// Check if the message tool already sent a response during this round. al.procsMu.Lock()
// If so, skip publishing to avoid duplicate messages to the user. if active, exists := al.activeProcs[sessionKey]; exists {
alreadySent := false active.cancel()
if tool, ok := al.tools.Get("message"); ok { al.procsMu.Unlock()
if mt, ok := tool.(*tools.MessageTool); ok { select {
alreadySent = mt.HasSentInRound() 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,
})
} }
} }
}(msg, sessionKey)
if !alreadySent {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: response,
})
}
}
} }
} }
@ -469,6 +508,14 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
// 5. Run LLM iteration loop // 5. Run LLM iteration loop
finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts, &currentStatus) finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts, &currentStatus)
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 { if err != nil {
return "", err return "", err
} }
@ -520,6 +567,13 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
for iteration < al.maxIterations { for iteration < al.maxIterations {
iteration++ iteration++
// Cancellation checkpoint at start of each iteration
select {
case <-ctx.Done():
return finalContent, iteration, ctx.Err()
default:
}
logger.DebugCF("agent", "LLM iteration", logger.DebugCF("agent", "LLM iteration",
map[string]interface{}{ map[string]interface{}{
"iteration": iteration, "iteration": iteration,
@ -816,6 +870,13 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
// Save tool result message to session // Save tool result message to session
al.sessions.AddFullMessage(opts.SessionKey, toolResultMsg) al.sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
// Cancellation checkpoint after each tool execution
select {
case <-ctx.Done():
return finalContent, iteration, ctx.Err()
default:
}
} }
} }