feat(chat): add paged history with stable transcript and scroll identity (#1211)

Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
AlexDev_andm57 authored and GitHub committed 2026-09-22 14:42:03 +03:00
1 parent 83b2a8fa9b
commit a78b20bc99
26 files changed
+3942 -884

No files matched your search

@@ -1,23 +1,83 @@
package com.m57.hermescontrol.data.local
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
@Dao
interface ChatMessageDao {
@Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE session_id = :sessionId)")
suspend fun sessionExists(sessionId: String): Boolean
@Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC")
@Query(
"SELECT * FROM chat_messages WHERE session_id = :sessionId " +
"ORDER BY sort_group, sort_order, id",
)
suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(message: ChatMessageEntity)
@Query(
"SELECT * FROM chat_messages WHERE session_id = :sessionId " +
"ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT :limit",
)
suspend fun getLatestMessagePage(
sessionId: String,
limit: Int,
): List<ChatMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(messages: List<ChatMessageEntity>)
@Query(
"SELECT * FROM chat_messages WHERE session_id = :sessionId " +
"AND (sort_group, sort_order, id) < (:beforeGroup, :beforeOrder, :beforeId) " +
"ORDER BY sort_group DESC, sort_order DESC, id DESC LIMIT :limit",
)
suspend fun getMessagePage(
sessionId: String,
beforeGroup: Int,
beforeOrder: Long,
beforeId: String,
limit: Int,
): List<ChatMessageEntity>
@Query("SELECT * FROM chat_messages WHERE id = :id")
suspend fun getMessage(id: String): ChatMessageEntity?
// Physical insertion sequence includes confirmed aliases, so confirmation cannot reuse a local cursor key.
@Query("SELECT COALESCE(MAX(rowid), 0) + 1 FROM chat_messages")
suspend fun nextLocalOrder(): Long
@Upsert
suspend fun writeMessage(message: ChatMessageEntity)
// Allocate once, in the same transaction as the write. Content/clock updates never move a local row.
@Transaction
suspend fun upsert(message: ChatMessageEntity) {
val existing = getMessage(message.id)
val restId = message.restId ?: existing?.restId
val canonicalOrder = canonicalMessageOrder(restId ?: message.id, message.sessionId)
writeMessage(
message.copy(
restId = restId,
completionId = message.completionId ?: existing?.completionId,
sortGroup = if (canonicalOrder != null) 0 else 1,
sortOrder = canonicalOrder ?: existing?.sortOrder ?: nextLocalOrder(),
),
)
}
@Transaction
suspend fun upsertAll(messages: List<ChatMessageEntity>) {
messages.forEach { upsert(it) }
}
// History confirms identity, but must not overwrite a newer WS-owned payload.
@Transaction
suspend fun confirmIdentity(message: ChatMessageEntity) {
val existing = getMessage(message.id)
upsert(
existing?.copy(restId = message.restId, completionId = existing.completionId ?: message.completionId)
?: message,
)
}
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
suspend fun deleteMessagesForSession(sessionId: String)
@@ -10,13 +10,13 @@ import androidx.room3.PrimaryKey
* UI model but is stored independently so it survives process death.
*
* Messages are scoped by [sessionId] so switching sessions loads the right
* thread. The [timestamp] field preserves original ordering even if Room
* reorders internally.
* thread. [sortOrder] preserves numeric server order or local insertion order;
* [timestamp] is display metadata only.
*/
@Entity(
tableName = "chat_messages",
indices = [
Index(value = ["session_id", "timestamp"]),
Index(value = ["session_id", "sort_group", "sort_order", "id"]),
],
)
data class ChatMessageEntity(
@@ -48,4 +48,21 @@ data class ChatMessageEntity(
val tps: Double? = null,
@ColumnInfo(name = "completion_id")
val completionId: String? = null,
@ColumnInfo(name = "rest_id")
val restId: String? = null,
@ColumnInfo(name = "sort_group", defaultValue = "1")
val sortGroup: Int = 1,
@ColumnInfo(name = "sort_order", defaultValue = "0")
val sortOrder: Long = 0,
)
/** Only the exact session prefix and a nonnegative decimal suffix identify a canonical row. */
internal fun canonicalMessageOrder(
id: String,
sessionId: String,
): Long? {
val prefix = "rest-$sessionId-"
if (!id.startsWith(prefix)) return null
val suffix = id.removePrefix(prefix)
return suffix.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } }?.toLongOrNull()
}
@@ -38,6 +38,8 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
tokenCount = tokenCount,
tps = tps,
completionId = completionId,
restId = restId,
localOrder = sortOrder.takeIf { sortGroup == 1 && it > 0 },
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -56,4 +58,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
tokenCount = tokenCount,
tps = tps,
completionId = completionId,
restId = restId,
)
@@ -14,7 +14,7 @@ import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 8,
version = 9,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -90,6 +90,31 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
val MIGRATION_8_9: Migration =
object : Migration(8, 9) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE chat_messages ADD COLUMN rest_id TEXT")
connection.execSQL("ALTER TABLE chat_messages ADD COLUMN sort_group INTEGER NOT NULL DEFAULT 1")
connection.execSQL("ALTER TABLE chat_messages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
// v8 has no durable local sequence. Preserve its surviving physical insertion order.
connection.execSQL("UPDATE chat_messages SET sort_order = rowid")
val suffix = "substr(id, length(session_id) + 7)"
val digits = "ltrim($suffix, '0')"
connection.execSQL(
"UPDATE chat_messages SET sort_group = 0, sort_order = CAST($suffix AS INTEGER) " +
"WHERE substr(id, 1, length(session_id) + 6) = 'rest-' || session_id || '-' " +
"AND $suffix != '' AND $suffix NOT GLOB '*[^0-9]*' " +
"AND (length($digits) < 19 OR " +
"(length($digits) = 19 AND $digits <= '9223372036854775807'))",
)
connection.execSQL("DROP INDEX index_chat_messages_session_id_timestamp")
connection.execSQL(
"CREATE INDEX index_chat_messages_session_id_sort_group_sort_order_id " +
"ON chat_messages (session_id, sort_group, sort_order, id)",
)
}
}
suspend fun get(context: Context): HermesDatabase =
withContext(Dispatchers.IO) {
instance?.let { return@withContext it }
@@ -126,6 +151,7 @@ abstract class HermesDatabase : RoomDatabase() {
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
).fallbackToDestructiveMigration(false)
.build()
.also { instance = it }