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:
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 }
|
||||
|
||||
Reference in new issue
Block a user