git.iko.soy/opencode-android
on 0bc1eda help sign in
297 lines 13 kB history raw
1package soy.iko.opencode.ui.chat
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.Agent
6import soy.iko.opencode.data.model.Command
7import soy.iko.opencode.data.model.MessageWithParts
8import soy.iko.opencode.data.model.ModelOption
9import soy.iko.opencode.data.model.Permission
10import soy.iko.opencode.data.model.PermissionReplied
11import soy.iko.opencode.data.model.PermissionResponse
12import soy.iko.opencode.data.model.PermissionUpdated
13import soy.iko.opencode.data.model.SessionDeleted
14import soy.iko.opencode.data.model.SessionUpdated
15import soy.iko.opencode.data.model.TextPart
16import soy.iko.opencode.data.model.defaultOption
17import soy.iko.opencode.data.model.toOptions
18import soy.iko.opencode.data.network.EventStreamClient
19import soy.iko.opencode.data.repo.SessionRepository
20import soy.iko.opencode.di.AppContainer
21import soy.iko.opencode.R
22import android.util.Log
23import kotlinx.coroutines.ExperimentalCoroutinesApi
24import kotlinx.coroutines.flow.MutableStateFlow
25import kotlinx.coroutines.flow.SharingStarted
26import kotlinx.coroutines.flow.StateFlow
27import kotlinx.coroutines.flow.asStateFlow
28import kotlinx.coroutines.flow.collectLatest
29import kotlinx.coroutines.flow.first
30import kotlinx.coroutines.flow.flatMapLatest
31import kotlinx.coroutines.flow.flowOf
32import kotlinx.coroutines.flow.onEach
33import kotlinx.coroutines.flow.stateIn
34import kotlinx.coroutines.launch
36@OptIn(ExperimentalCoroutinesApi::class)
37class ChatViewModel(
38 private val container: AppContainer,
39 private val sessionId: String,
40) : ViewModel() {
42 private val connection get() = container.activeConnection.value
44 val connected: Boolean get() = connection != null
46 private val _loading = MutableStateFlow(true)
47 val loading: StateFlow<Boolean> = _loading.asStateFlow()
49 val messages: StateFlow<List<MessageWithParts>> =
50 container.activeConnection
51 .flatMapLatest { conn ->
52 conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
53 }
54 .onEach { _loading.value = false }
55 .stateIn(
56 scope = viewModelScope,
57 started = SharingStarted.WhileSubscribed(5_000),
58 initialValue = emptyList(),
59 )
61 private val _running = MutableStateFlow(false)
62 val running: StateFlow<Boolean> = _running.asStateFlow()
64 private val _error = MutableStateFlow<String?>(null)
65 val error: StateFlow<String?> = _error.asStateFlow()
67 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
68 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
70 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
71 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
73 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
74 container.activeConnection
75 .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
76 .stateIn(
77 scope = viewModelScope,
78 started = SharingStarted.WhileSubscribed(5_000),
79 initialValue = EventStreamClient.ConnectionState.Disconnected,
80 )
82 private val _pendingPermission = MutableStateFlow<Permission?>(null)
83 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
85 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
86 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
88 private val _selectedAgent = MutableStateFlow<String?>(null)
89 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
91 private val _commands = MutableStateFlow<List<Command>>(emptyList())
92 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
94 private val _sessionTitle = MutableStateFlow<String?>(null)
95 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
97 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
98 private val _failedDraft = MutableStateFlow<String?>(null)
99 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
101 /** Per-session draft, persisted so it survives navigation/process death. */
102 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
103 val draft: StateFlow<String> = _draft.asStateFlow()
105 fun clearError() { _error.value = null }
107 fun selectModel(option: ModelOption) { _selectedModel.value = option }
109 fun selectAgent(name: String?) { _selectedAgent.value = name }
111 fun updateDraft(text: String) {
112 _draft.value = text
113 container.draftStore.set(sessionId, text)
114 }
116 init {
117 // Watch the bus for run completion / errors / permission asks for this session.
118 viewModelScope.launch {
119 container.activeConnection.collectLatest { conn ->
120 if (conn == null) return@collectLatest
121 conn.events.events.collect { event ->
122 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
123 if (SessionRepository.isError(event, sessionId)) {
124 _running.value = false
125 _error.value = container.string(R.string.error_agent_reported)
126 }
127 when (event) {
128 is PermissionUpdated ->
129 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
130 is PermissionReplied ->
131 if (event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
132 is SessionUpdated ->
133 if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
134 is SessionDeleted ->
135 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
136 _sessionTitle.value = event.properties.info?.displayTitle
137 }
138 else -> {}
139 }
140 }
141 }
142 }
143 // Load the model catalog; preselect the server default. Failure is non-fatal —
144 // sending with no model just uses the server's default agent/model.
145 viewModelScope.launch {
146 container.activeConnection.collectLatest { conn ->
147 if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; return@collectLatest }
148 runCatching { conn.api.providers() }
149 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
150 .getOrNull()?.let { resp ->
151 val options = resp.toOptions()
152 _models.value = options
153 _selectedModel.value = resp.defaultOption(options)
154 }
155 }
156 }
157 // Load the agent catalog (non-fatal).
158 viewModelScope.launch {
159 container.activeConnection.collectLatest { conn ->
160 if (conn == null) { _agents.value = emptyList(); return@collectLatest }
161 runCatching { conn.api.agents() }
162 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
163 .getOrNull()?.let { _agents.value = it }
164 }
165 }
166 // Load the command catalog (non-fatal).
167 viewModelScope.launch {
168 container.activeConnection.collectLatest { conn ->
169 if (conn == null) { _commands.value = emptyList(); return@collectLatest }
170 runCatching { conn.api.commands() }
171 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
172 .getOrNull()?.let { _commands.value = it }
173 }
174 }
175 // Resolve the human-readable session title for the app bar (non-fatal).
176 viewModelScope.launch {
177 container.activeConnection.collectLatest { conn ->
178 if (conn == null) return@collectLatest
179 runCatching { conn.repository.listSessions() }
180 .getOrNull()
181 ?.firstOrNull { it.id == sessionId }
182 ?.let { _sessionTitle.value = it.displayTitle }
183 }
184 }
185 // If the SSE stream drops mid-run, the run indicator would spin forever;
186 // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
187 viewModelScope.launch {
188 container.activeConnection.collectLatest { conn ->
189 if (conn == null) return@collectLatest
190 conn.events.state.collect { state ->
191 if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
192 _running.value = false
193 }
194 }
195 }
196 }
197 }
199 /** Sends [text]; returns true on success so the caller can clear the draft only then. */
200 fun send(text: String): Boolean {
201 val conn = connection ?: return false
202 val trimmed = text.trim()
203 if (trimmed.isEmpty() || _running.value) return false
204 _running.value = true
205 _error.value = null
206 _failedDraft.value = null
207 // Clear the in-memory draft for the UI immediately, but don't persist the clear
208 // yet — if the send fails and the process dies before we restore, the draft
209 // would be lost forever. The persisted draft is cleared only on success.
210 _draft.value = ""
211 viewModelScope.launch {
212 val ok = runCatching {
213 conn.repository.sendPrompt(
214 sessionId,
215 trimmed,
216 model = _selectedModel.value?.ref,
217 agent = _selectedAgent.value,
218 )
219 }.isSuccess
220 _running.value = false
221 if (!ok) {
222 _failedDraft.value = trimmed
223 // Only restore the draft if the user hasn't typed anything new since.
224 if (_draft.value.isBlank()) updateDraft(trimmed)
225 _error.value = container.string(R.string.error_failed_to_send)
226 } else {
227 // Send succeeded — now it's safe to persist the empty draft.
228 container.draftStore.set(sessionId, "")
229 }
230 }
231 return true
232 }
234 /** Re-send the last draft whose send failed, if any. */
235 fun retryFailed() {
236 val draft = _failedDraft.value ?: return
237 _failedDraft.value = null
238 send(draft)
239 }
241 /** Invoke a slash-command by name via the server's /command endpoint. */
242 fun runCommand(command: Command) {
243 val conn = connection ?: return
244 if (_running.value) return
245 _running.value = true
246 _error.value = null
247 viewModelScope.launch {
248 runCatching {
249 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
250 }.onFailure { _error.value = container.friendlyError(it) }
251 _running.value = false
252 }
253 }
255 fun abort() {
256 val conn = connection ?: return
257 viewModelScope.launch {
258 runCatching { conn.repository.abort(sessionId) }
259 .onFailure { _error.value = container.friendlyError(it) }
260 _running.value = false
261 }
262 }
264 /** Reconnect to the most recently used server profile (used when the connection is gone). */
265 fun reconnect() {
266 if (container.activeConnection.value != null) return
267 viewModelScope.launch {
268 _loading.value = true
269 val recent = runCatching { container.profileStore.profiles.first() }
270 .getOrDefault(emptyList())
271 .firstOrNull() ?: run { _loading.value = false; return@launch }
272 runCatching {
273 val conn = container.connect(recent)
274 conn.api.ping()
275 }.onFailure {
276 container.disconnect()
277 _error.value = container.friendlyError(it)
278 _loading.value = false
279 }
280 }
281 }
283 fun respondPermission(permission: Permission, response: PermissionResponse) {
284 val conn = connection ?: return
285 // Clear optimistically; permission.replied will confirm. If the call fails we
286 // restore the pending permission so the user can retry instead of being stuck
287 // with a dismissed dialog and a paused tool run.
288 _pendingPermission.value = null
289 viewModelScope.launch {
290 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
291 .onFailure {
292 _error.value = container.friendlyError(it)
293 _pendingPermission.value = permission
294 }
295 }
296 }