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 OnErrorDismissed : ChatEvent
|
||||||
data object OnVoiceModeStart : ChatEvent
|
data object OnVoiceModeStart : ChatEvent
|
||||||
data object OnVoiceModeStop : ChatEvent
|
data object OnVoiceModeStop : ChatEvent
|
||||||
|
data object OnVoiceModeInterrupt : ChatEvent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,9 @@ class ChatViewModel(
|
||||||
is ChatEvent.OnVoiceModeStop -> {
|
is ChatEvent.OnVoiceModeStop -> {
|
||||||
voiceModeManager.stop()
|
voiceModeManager.stop()
|
||||||
}
|
}
|
||||||
|
is ChatEvent.OnVoiceModeInterrupt -> {
|
||||||
|
voiceModeManager.interrupt()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 != "" {
|
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
|
alreadySent := false
|
||||||
if tool, ok := al.tools.Get("message"); ok {
|
if tool, ok := al.tools.Get("message"); ok {
|
||||||
if mt, ok := tool.(*tools.MessageTool); ok {
|
if mt, ok := tool.(*tools.MessageTool); ok {
|
||||||
alreadySent = mt.HasSentInRound()
|
alreadySent = mt.HasSentInRound()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !alreadySent {
|
if !alreadySent {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||||
Channel: msg.Channel,
|
Channel: m.Channel, ChatID: m.ChatID, Content: response,
|
||||||
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
|
// 5. Run LLM iteration loop
|
||||||
finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts, ¤tStatus)
|
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 {
|
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:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue