
























@@ -17,6 +17,7 @@ import kotlinx.serialization.json.JsonPrimitive
1717import kotlinx.serialization.json.buildJsonObject
1818import java.util.UUID
1919import java.util.concurrent.ConcurrentHashMap
20+import java.util.concurrent.atomic.AtomicLong
20212122class ChatController(
2223private val scope: CoroutineScope,
@@ -33,6 +34,9 @@ class ChatController(
3334private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
3435val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
353637+private val _historyLoading = MutableStateFlow(false)
38+val historyLoading: StateFlow<Boolean> = _historyLoading.asStateFlow()
39+3640private val _errorText = MutableStateFlow<String?>(null)
3741val errorText: StateFlow<String?> = _errorText.asStateFlow()
3842@@ -59,25 +63,27 @@ class ChatController(
5963private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
6064private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
6165private val pendingRunTimeoutMs = 120_000L
66+private val historyLoadGeneration = AtomicLong(0)
62676368private var lastHealthPollAtMs: Long? = null
64696570fun onDisconnected(message: String) {
6671_healthOk.value = false
67-// Not an error; keep connection status in the UI pill.
6872_errorText.value = null
6973 clearPendingRuns()
7074 pendingToolCallsById.clear()
7175 publishPendingToolCalls()
7276_streamingAssistantText.value = null
77+_historyLoading.value = false
7378_sessionId.value = null
7479 }
75807681fun load(sessionKey: String) {
7782val key = normalizeRequestedSessionKey(sessionKey)
78-_sessionKey.value = key
79- optimisticMessagesByRunId.clear()
80- scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
83+val generation = beginHistoryLoad(key, clearMessages = key != _sessionKey.value)
84+ scope.launch {
85+ bootstrap(sessionKey = key, generation = generation, forceHealth = true, refreshSessions = true)
86+ }
8187 }
82888389fun applyMainSessionKey(mainSessionKey: String) {
@@ -91,12 +97,23 @@ class ChatController(
9197 )
9298 appliedMainSessionKey = nextState.appliedMainSessionKey
9399if (_sessionKey.value == nextState.currentSessionKey) return
94-_sessionKey.value = nextState.currentSessionKey
95- scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
100+val generation = beginHistoryLoad(nextState.currentSessionKey, clearMessages = true)
101+ scope.launch {
102+ bootstrap(
103+ sessionKey = nextState.currentSessionKey,
104+ generation = generation,
105+ forceHealth = true,
106+ refreshSessions = true,
107+ )
108+ }
96109 }
9711098111fun refresh() {
99- scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
112+val key = normalizeRequestedSessionKey(_sessionKey.value)
113+val generation = beginHistoryLoad(key, clearMessages = false)
114+ scope.launch {
115+ bootstrap(sessionKey = key, generation = generation, forceHealth = true, refreshSessions = true)
116+ }
100117 }
101118102119fun refreshSessions(limit: Int? = null) {
@@ -113,11 +130,30 @@ class ChatController(
113130val key = normalizeRequestedSessionKey(sessionKey)
114131if (key.isEmpty()) return
115132if (key == _sessionKey.value) return
133+val generation = beginHistoryLoad(key, clearMessages = true)
134+ scope.launch {
135+ bootstrap(sessionKey = key, generation = generation, forceHealth = true, refreshSessions = false)
136+ }
137+ }
138+139+private fun beginHistoryLoad(
140+key: String,
141+clearMessages: Boolean,
142+ ): Long {
143+val generation = historyLoadGeneration.incrementAndGet()
116144_sessionKey.value = key
117- optimisticMessagesByRunId.clear()
118-// Keep the thread switch path lean: history + health are needed immediately,
119-// but the session list is usually unchanged and can refresh on explicit pull-to-refresh.
120- scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
145+_errorText.value = null
146+_healthOk.value = false
147+ clearPendingRuns()
148+ pendingToolCallsById.clear()
149+ publishPendingToolCalls()
150+_streamingAssistantText.value = null
151+_sessionId.value = null
152+_historyLoading.value = true
153+if (clearMessages) {
154+_messages.value = emptyList()
155+ }
156+return generation
121157 }
122158123159private fun normalizeRequestedSessionKey(sessionKey: String): String {
@@ -288,23 +324,22 @@ class ChatController(
288324 }
289325290326private suspend fun bootstrap(
327+sessionKey: String,
328+generation: Long,
291329forceHealth: Boolean,
292330refreshSessions: Boolean,
293331 ) {
294-_errorText.value = null
295-_healthOk.value = false
296- clearPendingRuns()
297- pendingToolCallsById.clear()
298- publishPendingToolCalls()
299-_streamingAssistantText.value = null
300-_sessionId.value = null
301-302-val key = _sessionKey.value
303332try {
304-val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
305-val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
333+val historyJson =
334+ session.request(
335+"chat.history",
336+ buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(),
337+ )
338+if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
339+val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
306340_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
307341_sessionId.value = history.sessionId
342+_historyLoading.value = false
308343 history.thinkingLevel
309344 ?.trim()
310345 ?.takeIf { it.isNotEmpty() }
@@ -315,7 +350,9 @@ class ChatController(
315350 fetchSessions(limit = 50)
316351 }
317352 } catch (err: Throwable) {
353+if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
318354_errorText.value = err.message
355+_historyLoading.value = false
319356 }
320357 }
321358@@ -382,9 +419,29 @@ class ChatController(
382419_streamingAssistantText.value = null
383420 scope.launch {
384421try {
422+val currentSessionKey = _sessionKey.value
423+val currentGeneration = historyLoadGeneration.get()
385424val historyJson =
386- session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
387-val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
425+ session.request(
426+"chat.history",
427+ buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
428+ )
429+if (
430+!isCurrentHistoryLoad(
431+ currentSessionKey,
432+_sessionKey.value,
433+ currentGeneration,
434+ historyLoadGeneration.get(),
435+ )
436+ ) {
437+return@launch
438+ }
439+val history =
440+ parseHistory(
441+ historyJson,
442+ sessionKey = currentSessionKey,
443+ previousMessages = _messages.value,
444+ )
388445_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
389446_sessionId.value = history.sessionId
390447 history.thinkingLevel
@@ -573,6 +630,13 @@ class ChatController(
573630 }
574631}
575632633+internal fun isCurrentHistoryLoad(
634+requestedSessionKey: String,
635+currentSessionKey: String,
636+requestGeneration: Long,
637+activeGeneration: Long,
638+): Boolean = requestedSessionKey == currentSessionKey && requestGeneration == activeGeneration
639+576640internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? {
577641val obj = el.asObjectOrNull() ?: return null
578642return when (obj["type"].asStringOrNull() ?: "text") {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。