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:
parent
87dac508c9
commit
c7f6095904
10 changed files with 60 additions and 15 deletions
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = "送信に失敗しました")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
16
pkg/agent/voice_prompt.go
Normal 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`
|
||||||
|
}
|
||||||
|
|
@ -16,9 +16,10 @@ import (
|
||||||
|
|
||||||
// wsIncoming is the JSON message sent from APK to picoclaw.
|
// wsIncoming is the JSON message sent from APK to picoclaw.
|
||||||
type wsIncoming struct {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue