git.iko.soy/opencode-android
on f9ed438 help sign in
128 lines 5.3 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.MessageWithParts
6import soy.iko.opencode.data.model.ModelOption
7import soy.iko.opencode.data.model.Permission
8import soy.iko.opencode.data.model.PermissionReplied
9import soy.iko.opencode.data.model.PermissionResponse
10import soy.iko.opencode.data.model.PermissionUpdated
11import soy.iko.opencode.data.model.defaultOption
12import soy.iko.opencode.data.model.toOptions
13import soy.iko.opencode.data.network.EventStreamClient
14import soy.iko.opencode.data.repo.SessionRepository
15import soy.iko.opencode.di.AppContainer
16import kotlinx.coroutines.flow.MutableStateFlow
17import kotlinx.coroutines.flow.SharingStarted
18import kotlinx.coroutines.flow.StateFlow
19import kotlinx.coroutines.flow.asStateFlow
20import kotlinx.coroutines.flow.flowOf
21import kotlinx.coroutines.flow.stateIn
22import kotlinx.coroutines.launch
24class ChatViewModel(
25 private val container: AppContainer,
26 private val sessionId: String,
27) : ViewModel() {
29 private val connection = container.activeConnection.value
31 val connected: Boolean get() = connection != null
33 val messages: StateFlow<List<MessageWithParts>> =
34 (connection?.repository?.observeMessages(sessionId) ?: flowOf(emptyList<MessageWithParts>()))
35 .stateIn(
36 scope = viewModelScope,
37 started = SharingStarted.WhileSubscribed(5_000),
38 initialValue = emptyList(),
39 )
41 private val _running = MutableStateFlow(false)
42 val running: StateFlow<Boolean> = _running.asStateFlow()
44 private val _error = MutableStateFlow<String?>(null)
45 val error: StateFlow<String?> = _error.asStateFlow()
47 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
48 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
50 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
51 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
53 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
54 connection?.events?.state
55 ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected).asStateFlow()
57 private val _pendingPermission = MutableStateFlow<Permission?>(null)
58 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
60 fun clearError() { _error.value = null }
62 fun selectModel(option: ModelOption) { _selectedModel.value = option }
64 init {
65 // Watch the bus for run completion / errors / permission asks for this session.
66 val conn = connection
67 if (conn != null) {
68 viewModelScope.launch {
69 conn.events.events.collect { event ->
70 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
71 if (SessionRepository.isError(event, sessionId)) {
72 _running.value = false
73 _error.value = "The agent reported an error."
74 }
75 when (event) {
76 is PermissionUpdated ->
77 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
78 is PermissionReplied ->
79 if (event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
80 else -> {}
81 }
82 }
83 }
84 // Load the model catalog; preselect the server default. Failure is non-fatal —
85 // sending with no model just uses the server's default agent/model.
86 viewModelScope.launch {
87 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
88 val options = resp.toOptions()
89 _models.value = options
90 _selectedModel.value = resp.defaultOption(options)
91 }
92 }
93 }
94 }
96 fun send(text: String) {
97 val conn = connection ?: return
98 val trimmed = text.trim()
99 if (trimmed.isEmpty() || _running.value) return
100 _running.value = true
101 _error.value = null
102 viewModelScope.launch {
103 runCatching { conn.repository.sendPrompt(sessionId, trimmed, model = _selectedModel.value?.ref) }
104 .onFailure { _error.value = it.message ?: "Failed to send" }
105 // session.idle is the authoritative finalizer; clear here too in case the
106 // POST returns first (idempotent).
107 _running.value = false
108 }
109 }
111 fun abort() {
112 val conn = connection ?: return
113 viewModelScope.launch {
114 runCatching { conn.repository.abort(sessionId) }
115 _running.value = false
116 }
117 }
119 fun respondPermission(permission: Permission, response: PermissionResponse) {
120 val conn = connection ?: return
121 // Clear optimistically; permission.replied will confirm.
122 _pendingPermission.value = null
123 viewModelScope.launch {
124 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
125 .onFailure { _error.value = it.message ?: "Failed to respond to permission" }
126 }
127 }