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:
parent
545b0203b3
commit
87dac508c9
6 changed files with 126 additions and 28 deletions
|
|
@ -12,4 +12,5 @@ sealed interface ChatEvent {
|
|||
data object OnErrorDismissed : ChatEvent
|
||||
data object OnVoiceModeStart : ChatEvent
|
||||
data object OnVoiceModeStop : ChatEvent
|
||||
data object OnVoiceModeInterrupt : ChatEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ class ChatViewModel(
|
|||
is ChatEvent.OnVoiceModeStop -> {
|
||||
voiceModeManager.stop()
|
||||
}
|
||||
is ChatEvent.OnVoiceModeInterrupt -> {
|
||||
voiceModeManager.interrupt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
if !alreadySent {
|
||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: msg.Channel,
|
||||
ChatID: msg.ChatID,
|
||||
Content: response,
|
||||
Channel: m.Channel, ChatID: m.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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue