git.iko.soy/opencode-android
on e37d1bb help sign in
196 lines 8.0 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.TextPart
14import soy.iko.opencode.data.model.defaultOption
15import soy.iko.opencode.data.model.toOptions
16import soy.iko.opencode.data.network.EventStreamClient
17import soy.iko.opencode.data.repo.SessionRepository
18import soy.iko.opencode.di.AppContainer
19import kotlinx.coroutines.flow.MutableStateFlow
20import kotlinx.coroutines.flow.SharingStarted
21import kotlinx.coroutines.flow.StateFlow
22import kotlinx.coroutines.flow.asStateFlow
23import kotlinx.coroutines.flow.flowOf
24import kotlinx.coroutines.flow.stateIn
25import kotlinx.coroutines.launch
27class ChatViewModel(
28 private val container: AppContainer,
29 private val sessionId: String,
30) : ViewModel() {
32 private val connection = container.activeConnection.value
34 val connected: Boolean get() = connection != null
36 val messages: StateFlow<List<MessageWithParts>> =
37 (connection?.repository?.observeMessages(sessionId) ?: flowOf(emptyList<MessageWithParts>()))
38 .stateIn(
39 scope = viewModelScope,
40 started = SharingStarted.WhileSubscribed(5_000),
41 initialValue = emptyList(),
42 )
44 private val _running = MutableStateFlow(false)
45 val running: StateFlow<Boolean> = _running.asStateFlow()
47 private val _error = MutableStateFlow<String?>(null)
48 val error: StateFlow<String?> = _error.asStateFlow()
50 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
51 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
53 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
54 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
56 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
57 connection?.events?.state
58 ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected).asStateFlow()
60 private val _pendingPermission = MutableStateFlow<Permission?>(null)
61 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
63 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
64 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
66 private val _selectedAgent = MutableStateFlow<String?>(null)
67 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
69 private val _commands = MutableStateFlow<List<Command>>(emptyList())
70 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
72 private val _sessionTitle = MutableStateFlow<String?>(null)
73 val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
75 /** The text of the last send that failed, surfaced so the UI can offer a retry. */
76 private val _failedDraft = MutableStateFlow<String?>(null)
77 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
79 fun clearError() { _error.value = null }
81 fun selectModel(option: ModelOption) { _selectedModel.value = option }
83 fun selectAgent(name: String?) { _selectedAgent.value = name }
85 init {
86 // Watch the bus for run completion / errors / permission asks for this session.
87 val conn = connection
88 if (conn != null) {
89 viewModelScope.launch {
90 conn.events.events.collect { event ->
91 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
92 if (SessionRepository.isError(event, sessionId)) {
93 _running.value = false
94 _error.value = "The agent reported an error."
95 }
96 when (event) {
97 is PermissionUpdated ->
98 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
99 is PermissionReplied ->
100 if (event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
101 else -> {}
102 }
103 }
104 }
105 // Load the model catalog; preselect the server default. Failure is non-fatal —
106 // sending with no model just uses the server's default agent/model.
107 viewModelScope.launch {
108 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
109 val options = resp.toOptions()
110 _models.value = options
111 _selectedModel.value = resp.defaultOption(options)
112 }
113 }
114 // Load the agent catalog (non-fatal).
115 viewModelScope.launch {
116 runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
117 }
118 // Load the command catalog (non-fatal).
119 viewModelScope.launch {
120 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
121 }
122 // Resolve the human-readable session title for the app bar (non-fatal).
123 viewModelScope.launch {
124 runCatching { conn.repository.listSessions() }
125 .getOrNull()
126 ?.firstOrNull { it.id == sessionId }
127 ?.let { _sessionTitle.value = it.displayTitle }
128 }
129 }
130 }
132 /** Sends [text]; returns true on success so the caller can clear the draft only then. */
133 fun send(text: String): Boolean {
134 val conn = connection ?: return false
135 val trimmed = text.trim()
136 if (trimmed.isEmpty() || _running.value) return false
137 _running.value = true
138 _error.value = null
139 _failedDraft.value = null
140 viewModelScope.launch {
141 val ok = runCatching {
142 conn.repository.sendPrompt(
143 sessionId,
144 trimmed,
145 model = _selectedModel.value?.ref,
146 agent = _selectedAgent.value,
147 )
148 }.isSuccess
149 _running.value = false
150 if (!ok) {
151 _failedDraft.value = trimmed
152 _error.value = "Failed to send"
153 }
154 }
155 return true
156 }
158 /** Re-send the last draft whose send failed, if any. */
159 fun retryFailed() {
160 val draft = _failedDraft.value ?: return
161 _failedDraft.value = null
162 send(draft)
163 }
165 /** Invoke a slash-command by name via the server's /command endpoint. */
166 fun runCommand(command: Command) {
167 val conn = connection ?: return
168 if (_running.value) return
169 _running.value = true
170 _error.value = null
171 viewModelScope.launch {
172 runCatching {
173 conn.repository.runCommand(sessionId, command.name, agent = command.agent)
174 }.onFailure { _error.value = it.message ?: "Failed to run command" }
175 _running.value = false
176 }
177 }
179 fun abort() {
180 val conn = connection ?: return
181 viewModelScope.launch {
182 runCatching { conn.repository.abort(sessionId) }
183 _running.value = false
184 }
185 }
187 fun respondPermission(permission: Permission, response: PermissionResponse) {
188 val conn = connection ?: return
189 // Clear optimistically; permission.replied will confirm.
190 _pendingPermission.value = null
191 viewModelScope.launch {
192 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
193 .onFailure { _error.value = it.message ?: "Failed to respond to permission" }
194 }
195 }