feat: add real-time status indicators for agent processing
Show transient status labels (thinking, tool use, error) in the chat UI via a new `type` field on WebSocket messages. The server emits "思考中..." before LLM calls and tool-specific labels (e.g. "検索中...(query)") before each tool execution. The Android client renders these as an animated indicator above the message input without persisting to the DB. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
98309d4b36
commit
836fb6e864
14 changed files with 327 additions and 7 deletions
|
|
@ -14,6 +14,7 @@ import io.picoclaw.android.core.domain.usecase.DisconnectChatUseCase
|
|||
import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
|
||||
import io.picoclaw.android.feature.chat.ChatViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -71,10 +72,11 @@ val appModule = module {
|
|||
factory { SendMessageUseCase(get()) }
|
||||
factory { ObserveMessagesUseCase(get()) }
|
||||
factory { ObserveConnectionUseCase(get()) }
|
||||
factory { ObserveStatusUseCase(get()) }
|
||||
factory { LoadMoreMessagesUseCase(get()) }
|
||||
factory { ConnectChatUseCase(get()) }
|
||||
factory { DisconnectChatUseCase(get()) }
|
||||
|
||||
// ViewModel
|
||||
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get()) }
|
||||
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,7 @@ package io.picoclaw.android.core.data.remote.dto
|
|||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class WsOutgoing(val content: String)
|
||||
data class WsOutgoing(
|
||||
val content: String,
|
||||
val type: String? = null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
|
@ -27,6 +28,7 @@ class ChatRepositoryImpl(
|
|||
) : ChatRepository {
|
||||
|
||||
private val _displayLimit = MutableStateFlow(INITIAL_LOAD_COUNT)
|
||||
private val _statusLabel = MutableStateFlow<String?>(null)
|
||||
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
override val messages: StateFlow<List<ChatMessage>> =
|
||||
|
|
@ -38,14 +40,22 @@ class ChatRepositoryImpl(
|
|||
|
||||
override val connectionState: StateFlow<ConnectionState> = webSocketClient.connectionState
|
||||
|
||||
override val statusLabel: StateFlow<String?> = _statusLabel.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
webSocketClient.incomingMessages.collect { dto ->
|
||||
when (dto.type) {
|
||||
"status" -> _statusLabel.value = dto.content
|
||||
else -> {
|
||||
_statusLabel.value = null
|
||||
val entity = MessageMapper.toEntity(dto)
|
||||
messageDao.insert(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(text: String, images: List<ImageAttachment>) {
|
||||
val results = images.map { imageFileStorage.saveFromUri(it.uri) }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
interface ChatRepository {
|
||||
val messages: StateFlow<List<ChatMessage>>
|
||||
val connectionState: StateFlow<ConnectionState>
|
||||
val statusLabel: StateFlow<String?>
|
||||
suspend fun sendMessage(text: String, images: List<ImageAttachment> = emptyList())
|
||||
fun loadMore()
|
||||
fun connect()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package io.picoclaw.android.core.domain.usecase
|
||||
|
||||
import io.picoclaw.android.core.domain.repository.ChatRepository
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class ObserveStatusUseCase(private val repository: ChatRepository) {
|
||||
operator fun invoke(): StateFlow<String?> = repository.statusLabel
|
||||
}
|
||||
|
|
@ -11,5 +11,6 @@ data class ChatUiState(
|
|||
val pendingImages: List<ImageAttachment> = emptyList(),
|
||||
val isLoadingMore: Boolean = false,
|
||||
val canLoadMore: Boolean = true,
|
||||
val error: String? = null
|
||||
val error: String? = null,
|
||||
val statusLabel: String? = null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import io.picoclaw.android.core.domain.usecase.DisconnectChatUseCase
|
|||
import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveStatusUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -20,6 +21,7 @@ class ChatViewModel(
|
|||
private val sendMessage: SendMessageUseCase,
|
||||
private val observeMessages: ObserveMessagesUseCase,
|
||||
private val observeConnection: ObserveConnectionUseCase,
|
||||
private val observeStatus: ObserveStatusUseCase,
|
||||
private val loadMoreMessages: LoadMoreMessagesUseCase,
|
||||
private val connectChat: ConnectChatUseCase,
|
||||
private val disconnectChat: DisconnectChatUseCase
|
||||
|
|
@ -45,6 +47,12 @@ class ChatViewModel(
|
|||
_uiState.update { it.copy(connectionState = state) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
observeStatus().collect { label ->
|
||||
_uiState.update { it.copy(statusLabel = label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(event: ChatEvent) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package io.picoclaw.android.feature.chat.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StatusIndicator(
|
||||
label: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = label != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = label ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import io.picoclaw.android.feature.chat.component.ConnectionBanner
|
|||
import io.picoclaw.android.feature.chat.component.ImagePreviewRow
|
||||
import io.picoclaw.android.feature.chat.component.MessageInput
|
||||
import io.picoclaw.android.feature.chat.component.MessageList
|
||||
import io.picoclaw.android.feature.chat.component.StatusIndicator
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import java.io.File
|
||||
|
||||
|
|
@ -134,6 +135,8 @@ fun ChatScreen(
|
|||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
StatusIndicator(label = uiState.statusLabel)
|
||||
|
||||
ImagePreviewRow(
|
||||
images = uiState.pendingImages,
|
||||
onRemove = { viewModel.onEvent(ChatEvent.OnImageRemoved(it)) }
|
||||
|
|
|
|||
|
|
@ -188,6 +188,13 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||
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
|
||||
}
|
||||
|
||||
if response != "" {
|
||||
|
|
@ -406,7 +413,17 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
|
|||
// 3. Save user message to session
|
||||
al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
||||
|
||||
// 4. Run LLM iteration loop
|
||||
// 4. Emit thinking status
|
||||
if !constants.IsInternalChannel(opts.Channel) {
|
||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: opts.Channel,
|
||||
ChatID: opts.ChatID,
|
||||
Content: "思考中...",
|
||||
Type: "status",
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Run LLM iteration loop
|
||||
finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -711,6 +728,18 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
|
|||
}
|
||||
}
|
||||
|
||||
// Emit tool use status indicator
|
||||
if !constants.IsInternalChannel(opts.Channel) {
|
||||
if label := statusLabel(tc.Name, tc.Arguments); label != "" {
|
||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: opts.Channel,
|
||||
ChatID: opts.ChatID,
|
||||
Content: label,
|
||||
Type: "status",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toolResult := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
|
||||
|
||||
// Send ForUser content to user immediately if not Silent
|
||||
|
|
|
|||
199
pkg/agent/status.go
Normal file
199
pkg/agent/status.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// statusLabel generates a human-readable Japanese status label for a tool call.
|
||||
func statusLabel(toolName string, args map[string]interface{}) string {
|
||||
switch toolName {
|
||||
case "web_search":
|
||||
if q := strArg(args, "query"); q != "" {
|
||||
return fmt.Sprintf("検索中...(%s)", truncLabel(q, 20))
|
||||
}
|
||||
return "検索中..."
|
||||
case "web_fetch":
|
||||
if u := strArg(args, "url"); u != "" {
|
||||
return fmt.Sprintf("ページ取得中...(%s)", hostFromURL(u))
|
||||
}
|
||||
return "ページ取得中..."
|
||||
case "read_file":
|
||||
return fileStatusLabel("ファイル読み取り中...", args)
|
||||
case "write_file":
|
||||
return fileStatusLabel("ファイル書き込み中...", args)
|
||||
case "edit_file":
|
||||
return fileStatusLabel("ファイル編集中...", args)
|
||||
case "append_file":
|
||||
return fileStatusLabel("ファイル追記中...", args)
|
||||
case "list_dir":
|
||||
if p := strArg(args, "path"); p != "" {
|
||||
return fmt.Sprintf("フォルダ確認中...(%s)", filepath.Base(p)+"/")
|
||||
}
|
||||
return "フォルダ確認中..."
|
||||
case "exec":
|
||||
if c := strArg(args, "command"); c != "" {
|
||||
return fmt.Sprintf("コマンド実行中...(%s)", truncLabel(c, 30))
|
||||
}
|
||||
return "コマンド実行中..."
|
||||
case "memory":
|
||||
return memoryStatusLabel(args)
|
||||
case "skill":
|
||||
return skillStatusLabel(args)
|
||||
case "cron":
|
||||
return cronStatusLabel(args)
|
||||
case "message":
|
||||
return "メッセージ送信中..."
|
||||
case "spawn":
|
||||
if l := strArg(args, "label"); l != "" {
|
||||
return fmt.Sprintf("サブタスク開始中...(%s)", truncLabel(l, 20))
|
||||
}
|
||||
return "サブタスク開始中..."
|
||||
case "subagent":
|
||||
if l := strArg(args, "label"); l != "" {
|
||||
return fmt.Sprintf("サブタスク実行中...(%s)", truncLabel(l, 20))
|
||||
}
|
||||
return "サブタスク実行中..."
|
||||
case "i2c":
|
||||
return i2cStatusLabel(args)
|
||||
case "spi":
|
||||
return spiStatusLabel(args)
|
||||
default:
|
||||
return "処理中..."
|
||||
}
|
||||
}
|
||||
|
||||
func fileStatusLabel(base string, args map[string]interface{}) string {
|
||||
if p := strArg(args, "path"); p != "" {
|
||||
return fmt.Sprintf("%s(%s)", base, filepath.Base(p))
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func memoryStatusLabel(args map[string]interface{}) string {
|
||||
switch strArg(args, "action") {
|
||||
case "read_long_term":
|
||||
return "メモリ読み込み中..."
|
||||
case "read_daily":
|
||||
return "今日のメモ読み込み中..."
|
||||
case "write_long_term":
|
||||
return "メモリ書き込み中..."
|
||||
case "append_daily":
|
||||
return "今日のメモ追記中..."
|
||||
default:
|
||||
return "メモリ操作中..."
|
||||
}
|
||||
}
|
||||
|
||||
func skillStatusLabel(args map[string]interface{}) string {
|
||||
switch strArg(args, "action") {
|
||||
case "skill_list":
|
||||
return "スキル一覧取得中..."
|
||||
case "skill_read":
|
||||
if n := strArg(args, "name"); n != "" {
|
||||
return fmt.Sprintf("スキル読み込み中...(%s)", n)
|
||||
}
|
||||
return "スキル読み込み中..."
|
||||
default:
|
||||
return "スキル操作中..."
|
||||
}
|
||||
}
|
||||
|
||||
func cronStatusLabel(args map[string]interface{}) string {
|
||||
switch strArg(args, "action") {
|
||||
case "add":
|
||||
return "リマインダー設定中..."
|
||||
case "list":
|
||||
return "スケジュール一覧取得中..."
|
||||
case "remove":
|
||||
return "スケジュール削除中..."
|
||||
default:
|
||||
return "スケジュール変更中..."
|
||||
}
|
||||
}
|
||||
|
||||
func i2cStatusLabel(args map[string]interface{}) string {
|
||||
switch strArg(args, "action") {
|
||||
case "detect":
|
||||
return "I2Cバス検出中..."
|
||||
case "scan":
|
||||
if b := strArg(args, "bus"); b != "" {
|
||||
return fmt.Sprintf("I2Cデバイススキャン中...(bus %s)", b)
|
||||
}
|
||||
return "I2Cデバイススキャン中..."
|
||||
case "read":
|
||||
if addr := intArg(args, "address"); addr > 0 {
|
||||
return fmt.Sprintf("センサー読み取り中...(0x%02X)", addr)
|
||||
}
|
||||
return "センサー読み取り中..."
|
||||
case "write":
|
||||
if addr := intArg(args, "address"); addr > 0 {
|
||||
return fmt.Sprintf("デバイス書き込み中...(0x%02X)", addr)
|
||||
}
|
||||
return "デバイス書き込み中..."
|
||||
default:
|
||||
return "I2C操作中..."
|
||||
}
|
||||
}
|
||||
|
||||
func spiStatusLabel(args map[string]interface{}) string {
|
||||
switch strArg(args, "action") {
|
||||
case "list":
|
||||
return "SPIデバイス一覧取得中..."
|
||||
case "transfer":
|
||||
if d := strArg(args, "device"); d != "" {
|
||||
return fmt.Sprintf("SPI通信中...(%s)", d)
|
||||
}
|
||||
return "SPI通信中..."
|
||||
case "read":
|
||||
if d := strArg(args, "device"); d != "" {
|
||||
return fmt.Sprintf("SPI読み取り中...(%s)", d)
|
||||
}
|
||||
return "SPI読み取り中..."
|
||||
default:
|
||||
return "SPI操作中..."
|
||||
}
|
||||
}
|
||||
|
||||
// strArg extracts a string argument from a tool arguments map.
|
||||
func strArg(args map[string]interface{}, key string) string {
|
||||
if v, ok := args[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// intArg extracts an integer argument from a tool arguments map.
|
||||
func intArg(args map[string]interface{}, key string) int {
|
||||
if v, ok := args[key]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// truncLabel truncates a string to maxRunes runes, appending "..." if truncated.
|
||||
func truncLabel(s string, maxRunes int) string {
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:maxRunes]) + "..."
|
||||
}
|
||||
|
||||
// hostFromURL extracts the hostname from a URL string.
|
||||
func hostFromURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return truncLabel(rawURL, 30)
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ type OutboundMessage struct {
|
|||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type,omitempty"` // "message" (default when empty), "status", "error"
|
||||
}
|
||||
|
||||
type MessageHandler func(InboundMessage) error
|
||||
|
|
|
|||
|
|
@ -274,6 +274,11 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
|
||||
// Status events are only supported by WebSocket clients
|
||||
if msg.Type == "status" && msg.Channel != "websocket" {
|
||||
continue
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
channel, exists := m.channels[msg.Channel]
|
||||
m.mu.RUnlock()
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type wsIncoming struct {
|
|||
// wsOutgoing is the JSON message sent from picoclaw to APK.
|
||||
type wsOutgoing struct {
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// WebSocketChannel is a server-side WebSocket channel that accepts
|
||||
|
|
@ -132,7 +133,7 @@ func (c *WebSocketChannel) Send(ctx context.Context, msg bus.OutboundMessage) er
|
|||
return fmt.Errorf("no connection for chat %s", msg.ChatID)
|
||||
}
|
||||
|
||||
out := wsOutgoing{Content: msg.Content}
|
||||
out := wsOutgoing{Content: msg.Content, Type: msg.Type}
|
||||
data, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal response: %w", err)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue