feat: add input_mode flag for voice-optimized LLM responses

Voice mode sends input_mode="voice" through the WebSocket, causing the
server to append voice-specific system prompt instructions that produce
short, conversational responses without markdown formatting for TTS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-19 22:11:02 +09:00
parent 87dac508c9
commit c7f6095904
10 changed files with 60 additions and 15 deletions

View file

@ -66,10 +66,11 @@ object MessageMapper {
) )
} }
fun toWsIncoming(text: String, base64Images: List<String>): WsIncoming { fun toWsIncoming(text: String, base64Images: List<String>, inputMode: String? = null): WsIncoming {
return WsIncoming( return WsIncoming(
content = text, content = text,
images = base64Images.ifEmpty { null } images = base64Images.ifEmpty { null },
inputMode = inputMode
) )
} }
} }

View file

@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable
data class WsIncoming( data class WsIncoming(
val content: String, val content: String,
@SerialName("sender_id") val senderId: String? = null, @SerialName("sender_id") val senderId: String? = null,
val images: List<String>? = null val images: List<String>? = null,
@SerialName("input_mode") val inputMode: String? = null
) )

View file

@ -57,11 +57,11 @@ class ChatRepositoryImpl(
} }
} }
override suspend fun sendMessage(text: String, images: List<ImageAttachment>) { override suspend fun sendMessage(text: String, images: List<ImageAttachment>, inputMode: String?) {
val results = images.map { imageFileStorage.saveFromUri(it.uri) } val results = images.map { imageFileStorage.saveFromUri(it.uri) }
val entity = MessageMapper.toEntity(text, results.map { it.imageData }, MessageStatus.SENDING) val entity = MessageMapper.toEntity(text, results.map { it.imageData }, MessageStatus.SENDING)
messageDao.insert(entity) messageDao.insert(entity)
val wsDto = MessageMapper.toWsIncoming(text, results.map { it.base64 }) val wsDto = MessageMapper.toWsIncoming(text, results.map { it.base64 }, inputMode)
val success = webSocketClient.send(wsDto) val success = webSocketClient.send(wsDto)
messageDao.update(entity.copy(status = if (success) MessageStatus.SENT.name else MessageStatus.FAILED.name)) messageDao.update(entity.copy(status = if (success) MessageStatus.SENT.name else MessageStatus.FAILED.name))
} }

View file

@ -9,7 +9,7 @@ interface ChatRepository {
val messages: StateFlow<List<ChatMessage>> val messages: StateFlow<List<ChatMessage>>
val connectionState: StateFlow<ConnectionState> val connectionState: StateFlow<ConnectionState>
val statusLabel: StateFlow<String?> val statusLabel: StateFlow<String?>
suspend fun sendMessage(text: String, images: List<ImageAttachment> = emptyList()) suspend fun sendMessage(text: String, images: List<ImageAttachment> = emptyList(), inputMode: String? = null)
fun loadMore() fun loadMore()
fun connect() fun connect()
fun disconnect() fun disconnect()

View file

@ -4,7 +4,7 @@ import io.picoclaw.android.core.domain.model.ImageAttachment
import io.picoclaw.android.core.domain.repository.ChatRepository import io.picoclaw.android.core.domain.repository.ChatRepository
class SendMessageUseCase(private val repository: ChatRepository) { class SendMessageUseCase(private val repository: ChatRepository) {
suspend operator fun invoke(text: String, images: List<ImageAttachment> = emptyList()) { suspend operator fun invoke(text: String, images: List<ImageAttachment> = emptyList(), inputMode: String? = null) {
repository.sendMessage(text, images) repository.sendMessage(text, images, inputMode)
} }
} }

View file

@ -119,7 +119,7 @@ class VoiceModeManager(
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
_state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text) } _state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text) }
try { try {
sendMessage(text) sendMessage(text, inputMode = "voice")
} catch (e: Exception) { } catch (e: Exception) {
_state.update { _state.update {
it.copy(phase = VoicePhase.ERROR, errorMessage = "送信に失敗しました") it.copy(phase = VoicePhase.ERROR, errorMessage = "送信に失敗しました")

View file

@ -190,14 +190,20 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
return result return result
} }
func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message { func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID, inputMode string) []providers.Message {
messages := []providers.Message{} messages := []providers.Message{}
systemPrompt := cb.BuildSystemPrompt() systemPrompt := cb.BuildSystemPrompt()
// Add Current Session info if provided // Add Current Session info if provided
if channel != "" && chatID != "" { if channel != "" && chatID != "" {
systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s\nInput Mode: %s",
channel, chatID, inputMode)
}
// Add voice mode instructions when input is from voice
if inputMode == "voice" {
systemPrompt += voiceModePrompt()
} }
// Log system prompt summary for debugging (debug mode only) // Log system prompt summary for debugging (debug mode only)

View file

@ -67,6 +67,7 @@ type processOptions struct {
EnableSummary bool // Whether to trigger summarization EnableSummary bool // Whether to trigger summarization
SendResponse bool // Whether to send response via bus SendResponse bool // Whether to send response via bus
NoHistory bool // If true, don't load session history (for heartbeat) NoHistory bool // If true, don't load session history (for heartbeat)
InputMode string // "voice" or "text"
} }
// createToolRegistry creates a tool registry with common tools. // createToolRegistry creates a tool registry with common tools.
@ -367,6 +368,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return response, nil return response, nil
} }
// Extract input_mode from metadata
inputMode := "text"
if msg.Metadata != nil {
if mode, ok := msg.Metadata["input_mode"]; ok && mode != "" {
inputMode = mode
}
}
// Process as user message // Process as user message
return al.runAgentLoop(ctx, processOptions{ return al.runAgentLoop(ctx, processOptions{
SessionKey: msg.SessionKey, SessionKey: msg.SessionKey,
@ -377,6 +386,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
DefaultResponse: "I've completed processing but have no response to give.", DefaultResponse: "I've completed processing but have no response to give.",
EnableSummary: true, EnableSummary: true,
SendResponse: false, SendResponse: false,
InputMode: inputMode,
}) })
} }
@ -463,6 +473,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
opts.Media, opts.Media,
opts.Channel, opts.Channel,
opts.ChatID, opts.ChatID,
opts.InputMode,
) )
// 3. Save user message to session // 3. Save user message to session
@ -657,6 +668,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
nil, nil,
opts.Channel, opts.Channel,
opts.ChatID, opts.ChatID,
opts.InputMode,
) )
// Important: If we are in the middle of a tool loop (iteration > 1), // Important: If we are in the middle of a tool loop (iteration > 1),
@ -720,6 +732,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
nil, nil,
opts.Channel, opts.Channel,
opts.ChatID, opts.ChatID,
opts.InputMode,
) )
continue continue

16
pkg/agent/voice_prompt.go Normal file
View file

@ -0,0 +1,16 @@
package agent
func voiceModePrompt() string {
return `
## Voice Mode Instructions
The user is currently speaking to you via voice input. Your response will be read aloud by text-to-speech.
- Keep responses short and conversational (1-3 sentences by default)
- Do NOT use markdown formatting (no headers, bold, code blocks, tables, bullet lists)
- Use natural spoken language as if having a conversation
- Spell out numbers and avoid special characters that sound awkward when spoken
- If the user explicitly asks for more detail, provide longer explanations but still in natural spoken language without markdown
- If code, file contents, or highly technical output is needed, briefly summarize and suggest switching to text mode for the full details`
}

View file

@ -19,6 +19,7 @@ type wsIncoming struct {
Content string `json:"content"` Content string `json:"content"`
SenderID string `json:"sender_id,omitempty"` SenderID string `json:"sender_id,omitempty"`
Images []string `json:"images,omitempty"` Images []string `json:"images,omitempty"`
InputMode string `json:"input_mode,omitempty"`
} }
// wsOutgoing is the JSON message sent from picoclaw to APK. // wsOutgoing is the JSON message sent from picoclaw to APK.
@ -252,7 +253,14 @@ func (c *WebSocketChannel) readPump(conn *websocket.Conn, clientID, chatID strin
"images": len(incoming.Images), "images": len(incoming.Images),
}) })
c.HandleMessage(senderID, chatID, content, media, nil) inputMode := incoming.InputMode
if inputMode == "" {
inputMode = "text"
}
metadata := map[string]string{
"input_mode": inputMode,
}
c.HandleMessage(senderID, chatID, content, media, metadata)
} }
} }