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.LoadMoreMessagesUseCase
|
||||||
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
|
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
|
||||||
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
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.core.domain.usecase.SendMessageUseCase
|
||||||
import io.picoclaw.android.feature.chat.ChatViewModel
|
import io.picoclaw.android.feature.chat.ChatViewModel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -71,10 +72,11 @@ val appModule = module {
|
||||||
factory { SendMessageUseCase(get()) }
|
factory { SendMessageUseCase(get()) }
|
||||||
factory { ObserveMessagesUseCase(get()) }
|
factory { ObserveMessagesUseCase(get()) }
|
||||||
factory { ObserveConnectionUseCase(get()) }
|
factory { ObserveConnectionUseCase(get()) }
|
||||||
|
factory { ObserveStatusUseCase(get()) }
|
||||||
factory { LoadMoreMessagesUseCase(get()) }
|
factory { LoadMoreMessagesUseCase(get()) }
|
||||||
factory { ConnectChatUseCase(get()) }
|
factory { ConnectChatUseCase(get()) }
|
||||||
factory { DisconnectChatUseCase(get()) }
|
factory { DisconnectChatUseCase(get()) }
|
||||||
|
|
||||||
// ViewModel
|
// 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
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
@ -27,6 +28,7 @@ class ChatRepositoryImpl(
|
||||||
) : ChatRepository {
|
) : ChatRepository {
|
||||||
|
|
||||||
private val _displayLimit = MutableStateFlow(INITIAL_LOAD_COUNT)
|
private val _displayLimit = MutableStateFlow(INITIAL_LOAD_COUNT)
|
||||||
|
private val _statusLabel = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
@Suppress("OPT_IN_USAGE")
|
@Suppress("OPT_IN_USAGE")
|
||||||
override val messages: StateFlow<List<ChatMessage>> =
|
override val messages: StateFlow<List<ChatMessage>> =
|
||||||
|
|
@ -38,11 +40,19 @@ class ChatRepositoryImpl(
|
||||||
|
|
||||||
override val connectionState: StateFlow<ConnectionState> = webSocketClient.connectionState
|
override val connectionState: StateFlow<ConnectionState> = webSocketClient.connectionState
|
||||||
|
|
||||||
|
override val statusLabel: StateFlow<String?> = _statusLabel.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
webSocketClient.incomingMessages.collect { dto ->
|
webSocketClient.incomingMessages.collect { dto ->
|
||||||
val entity = MessageMapper.toEntity(dto)
|
when (dto.type) {
|
||||||
messageDao.insert(entity)
|
"status" -> _statusLabel.value = dto.content
|
||||||
|
else -> {
|
||||||
|
_statusLabel.value = null
|
||||||
|
val entity = MessageMapper.toEntity(dto)
|
||||||
|
messageDao.insert(entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
interface ChatRepository {
|
interface ChatRepository {
|
||||||
val messages: StateFlow<List<ChatMessage>>
|
val messages: StateFlow<List<ChatMessage>>
|
||||||
val connectionState: StateFlow<ConnectionState>
|
val connectionState: StateFlow<ConnectionState>
|
||||||
|
val statusLabel: StateFlow<String?>
|
||||||
suspend fun sendMessage(text: String, images: List<ImageAttachment> = emptyList())
|
suspend fun sendMessage(text: String, images: List<ImageAttachment> = emptyList())
|
||||||
fun loadMore()
|
fun loadMore()
|
||||||
fun connect()
|
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 pendingImages: List<ImageAttachment> = emptyList(),
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val canLoadMore: Boolean = true,
|
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.LoadMoreMessagesUseCase
|
||||||
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
|
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
|
||||||
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
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.core.domain.usecase.SendMessageUseCase
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -20,6 +21,7 @@ class ChatViewModel(
|
||||||
private val sendMessage: SendMessageUseCase,
|
private val sendMessage: SendMessageUseCase,
|
||||||
private val observeMessages: ObserveMessagesUseCase,
|
private val observeMessages: ObserveMessagesUseCase,
|
||||||
private val observeConnection: ObserveConnectionUseCase,
|
private val observeConnection: ObserveConnectionUseCase,
|
||||||
|
private val observeStatus: ObserveStatusUseCase,
|
||||||
private val loadMoreMessages: LoadMoreMessagesUseCase,
|
private val loadMoreMessages: LoadMoreMessagesUseCase,
|
||||||
private val connectChat: ConnectChatUseCase,
|
private val connectChat: ConnectChatUseCase,
|
||||||
private val disconnectChat: DisconnectChatUseCase
|
private val disconnectChat: DisconnectChatUseCase
|
||||||
|
|
@ -45,6 +47,12 @@ class ChatViewModel(
|
||||||
_uiState.update { it.copy(connectionState = state) }
|
_uiState.update { it.copy(connectionState = state) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
observeStatus().collect { label ->
|
||||||
|
_uiState.update { it.copy(statusLabel = label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onEvent(event: ChatEvent) {
|
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.ImagePreviewRow
|
||||||
import io.picoclaw.android.feature.chat.component.MessageInput
|
import io.picoclaw.android.feature.chat.component.MessageInput
|
||||||
import io.picoclaw.android.feature.chat.component.MessageList
|
import io.picoclaw.android.feature.chat.component.MessageList
|
||||||
|
import io.picoclaw.android.feature.chat.component.StatusIndicator
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
|
@ -134,6 +135,8 @@ fun ChatScreen(
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
StatusIndicator(label = uiState.statusLabel)
|
||||||
|
|
||||||
ImagePreviewRow(
|
ImagePreviewRow(
|
||||||
images = uiState.pendingImages,
|
images = uiState.pendingImages,
|
||||||
onRemove = { viewModel.onEvent(ChatEvent.OnImageRemoved(it)) }
|
onRemove = { viewModel.onEvent(ChatEvent.OnImageRemoved(it)) }
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,13 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
response, err := al.processMessage(ctx, msg)
|
response, err := al.processMessage(ctx, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response = fmt.Sprintf("Error processing message: %v", err)
|
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 != "" {
|
if response != "" {
|
||||||
|
|
@ -406,7 +413,17 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
|
||||||
// 3. Save user message to session
|
// 3. Save user message to session
|
||||||
al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
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)
|
finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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)
|
toolResult := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
|
||||||
|
|
||||||
// Send ForUser content to user immediately if not Silent
|
// 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"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
Type string `json:"type,omitempty"` // "message" (default when empty), "status", "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageHandler func(InboundMessage) error
|
type MessageHandler func(InboundMessage) error
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,11 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status events are only supported by WebSocket clients
|
||||||
|
if msg.Type == "status" && msg.Channel != "websocket" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
channel, exists := m.channels[msg.Channel]
|
channel, exists := m.channels[msg.Channel]
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ type wsIncoming struct {
|
||||||
// wsOutgoing is the JSON message sent from picoclaw to APK.
|
// wsOutgoing is the JSON message sent from picoclaw to APK.
|
||||||
type wsOutgoing struct {
|
type wsOutgoing struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocketChannel is a server-side WebSocket channel that accepts
|
// 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)
|
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)
|
data, err := json.Marshal(out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal response: %w", err)
|
return fmt.Errorf("failed to marshal response: %w", err)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue