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 OnVoiceModeStart : ChatEvent
data object OnVoiceModeStop : ChatEvent
data object OnVoiceModeInterrupt : ChatEvent
}

View file

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

View file

@ -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) }
)
}
}

View file

@ -35,9 +35,11 @@ class VoiceModeManager(
val state: StateFlow<VoiceModeState> = _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()
}

View file

@ -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))

View file

@ -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, &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 {
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:
}
}
}