git.iko.soy/opencode-android
on 8865a77 help sign in
171 lines 6.9 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.defaultOption
14import soy.iko.opencode.data.model.toOptions
15import soy.iko.opencode.data.network.EventStreamClient
16import soy.iko.opencode.data.repo.SessionRepository
17import soy.iko.opencode.di.AppContainer
18import kotlinx.coroutines.flow.MutableStateFlow
19import kotlinx.coroutines.flow.SharingStarted
20import kotlinx.coroutines.flow.StateFlow
21import kotlinx.coroutines.flow.asStateFlow
22import kotlinx.coroutines.flow.flowOf
23import kotlinx.coroutines.flow.stateIn
24import kotlinx.coroutines.launch
26class ChatViewModel(
27 private val container: AppContainer,
28 private val sessionId: String,
29) : ViewModel() {
31 private val connection = container.activeConnection.value
33 val connected: Boolean get() = connection != null
35 val messages: StateFlow<List<MessageWithParts>> =
36 (connection?.repository?.observeMessages(sessionId) ?: flowOf(emptyList<MessageWithParts>()))
37 .stateIn(
38 scope = viewModelScope,
39 started = SharingStarted.WhileSubscribed(5_000),
40 initialValue = emptyList(),
41 )
43 private val _running = MutableStateFlow(false)
44 val running: StateFlow<Boolean> = _running.asStateFlow()
46 private val _error = MutableStateFlow<String?>(null)
47 val error: StateFlow<String?> = _error.asStateFlow()
49 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
50 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
52 private val _selectedModel = MutableStateFlow<ModelOption?>(null)
53 val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
55 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
56 connection?.events?.state
57 ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected).asStateFlow()
59 private val _pendingPermission = MutableStateFlow<Permission?>(null)
60 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
62 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
63 val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
65 private val _selectedAgent = MutableStateFlow<String?>(null)
66 val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
68 private val _commands = MutableStateFlow<List<Command>>(emptyList())
69 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
71 fun clearError() { _error.value = null }
73 fun selectModel(option: ModelOption) { _selectedModel.value = option }
75 fun selectAgent(name: String?) { _selectedAgent.value = name }
77 init {
78 // Watch the bus for run completion / errors / permission asks for this session.
79 val conn = connection
80 if (conn != null) {
81 viewModelScope.launch {
82 conn.events.events.collect { event ->
83 if (SessionRepository.isIdle(event, sessionId)) _running.value = false
84 if (SessionRepository.isError(event, sessionId)) {
85 _running.value = false
86 _error.value = "The agent reported an error."
87 }
88 when (event) {
89 is PermissionUpdated ->
90 if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
91 is PermissionReplied ->
92 if (event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
93 else -> {}
94 }
95 }
96 }
97 // Load the model catalog; preselect the server default. Failure is non-fatal —
98 // sending with no model just uses the server's default agent/model.
99 viewModelScope.launch {
100 runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
101 val options = resp.toOptions()
102 _models.value = options
103 _selectedModel.value = resp.defaultOption(options)
104 }
105 }
106 // Load the agent catalog (non-fatal).
107 viewModelScope.launch {
108 runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
109 }
110 // Load the command catalog (non-fatal).
111 viewModelScope.launch {
112 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
113 }
114 }
115 }
117 fun send(text: String) {
118 val conn = connection ?: return
119 val trimmed = text.trim()
120 if (trimmed.isEmpty() || _running.value) return
121 _running.value = true
122 _error.value = null
123 viewModelScope.launch {
124 runCatching {
125 conn.repository.sendPrompt(
126 sessionId,
127 trimmed,
128 model = _selectedModel.value?.ref,
129 agent = _selectedAgent.value,
130 )
131 }.onFailure { _error.value = it.message ?: "Failed to send" }
132 _running.value = false
133 }
134 }
136 /** Invoke a slash-command: sends the command's template text with the command's agent/model if set. */
137 fun runCommand(command: Command) {
138 val conn = connection ?: return
139 if (_running.value) return
140 _running.value = true
141 _error.value = null
142 viewModelScope.launch {
143 runCatching {
144 conn.api.sendPrompt(
145 sessionId,
146 command.template,
147 agent = command.agent,
148 )
149 }.onFailure { _error.value = it.message ?: "Failed to run command" }
150 _running.value = false
151 }
152 }
154 fun abort() {
155 val conn = connection ?: return
156 viewModelScope.launch {
157 runCatching { conn.repository.abort(sessionId) }
158 _running.value = false
159 }
160 }
162 fun respondPermission(permission: Permission, response: PermissionResponse) {
163 val conn = connection ?: return
164 // Clear optimistically; permission.replied will confirm.
165 _pendingPermission.value = null
166 viewModelScope.launch {
167 runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
168 .onFailure { _error.value = it.message ?: "Failed to respond to permission" }
169 }
170 }