history: add indexed search and context retrieval

This commit is contained in:
2026-08-06 21:26:37 +08:00
parent 0303bf0ac8
commit c8cdea6fab
12 changed files with 1514 additions and 222 deletions
+688 -58
View File
@@ -24,14 +24,26 @@ import java.sql.ResultSet
* 写连接由 [writeLock] 串行保护;SQLite 使用 WAL + NORMAL synchronous,允许并发读取。
*/
object ChatHistoryStore {
private const val SCHEMA_VERSION = 2
private const val SCHEMA_VERSION = 3
private const val BUSY_TIMEOUT_MS = 30_000
private const val SEARCH_FTS_TABLE = "message_record_fts"
private const val SEARCH_TEXT_TABLE = "message_record_search"
private const val SENDER_ALIAS_TABLE = "chat_history_sender_alias"
private const val SEARCH_BACKFILL_CURSOR_KEY = "search_backfill_cursor"
private const val SEARCH_INDEX_COMPLETE_KEY = "search_index_complete"
private const val DEFAULT_BACKFILL_BATCH_SIZE = 250
private const val MAX_PAGE_SIZE = 200
private const val MAX_CONTEXT_MESSAGES = 100
private val lifecycleLock = Any()
private val writeLock = Any()
@Volatile
private var initialized = false
@Volatile
private var searchIndexAvailable = false
@Volatile
private var searchIndexReady = false
private lateinit var databaseFile: File
private var writeConnection: Connection? = null
private var warningLogger: ((String, Throwable?) -> Unit)? = null
@@ -39,6 +51,12 @@ object ChatHistoryStore {
val isAvailable: Boolean
get() = initialized
val isSearchIndexAvailable: Boolean
get() = initialized && searchIndexAvailable
val isSearchIndexReady: Boolean
get() = isSearchIndexAvailable && searchIndexReady
val databaseFileOrNull: File?
get() = if (initialized) databaseFile else null
@@ -60,6 +78,8 @@ object ChatHistoryStore {
createSchema(connection)
writeConnection = connection
initialized = true
searchIndexAvailable = hasSearchIndex(connection)
searchIndexReady = searchIndexAvailable && readMeta(connection, SEARCH_INDEX_COMPLETE_KEY) == "1"
} catch (cause: Throwable) {
connection.close()
throw cause
@@ -81,6 +101,8 @@ object ChatHistoryStore {
}
writeConnection = null
initialized = false
searchIndexAvailable = false
searchIndexReady = false
warningLogger = null
}
}
@@ -91,7 +113,7 @@ object ChatHistoryStore {
val message = event.message.asSequence()
.filterNot { it is MessageSource }
.toMessageChain()
insert(ChatMessageRecord.fromSuccess(event.message.source, message))
insertRecord(ChatMessageRecord.fromSuccess(event.message.source, message), event.senderName)
}
fun record(event: MessagePostSendEvent<*>) {
@@ -100,7 +122,7 @@ object ChatHistoryStore {
val message = event.message.asSequence()
.filterNot { it is MessageSource }
.toMessageChain()
insert(ChatMessageRecord.fromSuccess(source, message))
insertRecord(ChatMessageRecord.fromSuccess(source, message), event.bot.nick)
}
fun markRecalled(event: MessageRecallEvent) {
@@ -254,31 +276,410 @@ object ChatHistoryStore {
}
}
private fun insert(record: ChatMessageRecord) {
withWriteConnection { connection ->
fun search(request: ChatHistorySearchRequest): ChatHistorySearchPage {
check(initialized) { "聊天记录数据库尚未初始化" }
require(request.limit > 0) { "limit must be positive" }
require(request.start == null || request.end == null || request.start <= request.end) {
"start must not be after end"
}
val parameters = mutableListOf<Any>()
val conditions = subjectConditions(request.subject, parameters).toMutableList()
request.start?.let {
conditions += "mr.time >= ?"
parameters += it
}
request.end?.let {
conditions += "mr.time <= ?"
parameters += it
}
request.fromId?.let {
conditions += "mr.from_id = ?"
parameters += it
}
val query = request.query?.trim().orEmpty()
val textPredicate = buildTextPredicate(query, request.matchMode, parameters)
if ((textPredicate.usesFts || textPredicate.usesSearchText) && !isSearchIndexAvailable) {
error("聊天记录全文索引尚未就绪")
}
val joins = buildString {
if (textPredicate.usesSearchText) {
append("JOIN $SEARCH_TEXT_TABLE search ON search.message_id = mr.id ")
}
if (textPredicate.usesFts) {
append("JOIN $SEARCH_FTS_TABLE ON $SEARCH_FTS_TABLE.rowid = mr.id")
}
}
textPredicate.sql?.let(conditions::add)
request.cursor?.let { cursor ->
val operator = if (request.sortOrder == ChatHistorySortOrder.NEWEST) "<" else ">"
conditions += "(mr.time $operator ? OR (mr.time = ? AND mr.id $operator ?))"
parameters += cursor.time
parameters += cursor.time
parameters += cursor.id
}
val where = conditions.joinToString(" AND ")
val order = if (request.sortOrder == ChatHistorySortOrder.NEWEST) {
"mr.time DESC, mr.id DESC"
} else {
"mr.time ASC, mr.id ASC"
}
val records = openReadConnection().use { connection ->
val sql = """
SELECT mr.id, mr.bot_id, mr.from_id, mr.target_id, mr.ids, mr.internal_ids,
mr.time, mr.kind, mr.code, mr.recalled
FROM message_record mr
$joins
WHERE $where
ORDER BY $order
LIMIT ?
""".trimIndent()
val queryParameters = parameters + (request.limit.coerceAtMost(MAX_PAGE_SIZE) + 1)
loadRecords(connection, sql, queryParameters)
}
val hasMore = records.size > request.limit.coerceAtMost(MAX_PAGE_SIZE)
val pageRecords = records.take(request.limit.coerceAtMost(MAX_PAGE_SIZE))
val last = pageRecords.lastOrNull()
val nextCursor = if (hasMore && last != null) {
ChatHistoryCursor(last.time, last.id)
} else {
null
}
val total = if (request.cursor == null) {
openReadConnection().use { connection ->
val sql = "SELECT COUNT(*) FROM message_record mr $joins WHERE $where"
connection.prepareStatement(sql).use { statement ->
bindParameters(statement, parameters)
statement.executeQuery().use { results ->
check(results.next())
results.getLong(1)
}
}
}
} else {
null
}
return ChatHistorySearchPage(pageRecords, total, nextCursor)
}
fun findAround(
subject: ChatHistorySubject,
messageId: Long,
before: Int = 8,
after: Int = 8,
): ChatHistoryContext? {
check(initialized) { "聊天记录数据库尚未初始化" }
require(before >= 0 && after >= 0) { "before and after must not be negative" }
require(before <= MAX_CONTEXT_MESSAGES && after <= MAX_CONTEXT_MESSAGES) {
"context window is too large"
}
return openReadConnection().use { connection ->
val targetParameters = mutableListOf<Any>()
val targetConditions = subjectConditions(subject, targetParameters).toMutableList()
targetConditions += "mr.id = ?"
targetParameters += messageId
val target = loadRecords(
connection,
"""
SELECT mr.id, mr.bot_id, mr.from_id, mr.target_id, mr.ids, mr.internal_ids,
mr.time, mr.kind, mr.code, mr.recalled
FROM message_record mr
WHERE ${targetConditions.joinToString(" AND ")}
""".trimIndent(),
targetParameters,
).firstOrNull() ?: return@use null
val beforeRecords = if (before == 0) {
emptyList()
} else {
val parameters = mutableListOf<Any>()
val conditions = subjectConditions(subject, parameters).toMutableList()
conditions += "(mr.time < ? OR (mr.time = ? AND mr.id < ?))"
parameters += target.time
parameters += target.time
parameters += target.id
conditions += "mr.id <> ?"
parameters += target.id
loadRecords(
connection,
"""
SELECT mr.id, mr.bot_id, mr.from_id, mr.target_id, mr.ids, mr.internal_ids,
mr.time, mr.kind, mr.code, mr.recalled
FROM message_record mr
WHERE ${conditions.joinToString(" AND ")}
ORDER BY mr.time DESC, mr.id DESC
LIMIT ?
""".trimIndent(),
parameters + before,
).asReversed()
}
val afterRecords = if (after == 0) {
emptyList()
} else {
val parameters = mutableListOf<Any>()
val conditions = subjectConditions(subject, parameters).toMutableList()
conditions += "(mr.time > ? OR (mr.time = ? AND mr.id > ?))"
parameters += target.time
parameters += target.time
parameters += target.id
conditions += "mr.id <> ?"
parameters += target.id
loadRecords(
connection,
"""
SELECT mr.id, mr.bot_id, mr.from_id, mr.target_id, mr.ids, mr.internal_ids,
mr.time, mr.kind, mr.code, mr.recalled
FROM message_record mr
WHERE ${conditions.joinToString(" AND ")}
ORDER BY mr.time ASC, mr.id ASC
LIMIT ?
""".trimIndent(),
parameters + after,
)
}
ChatHistoryContext(messageId, beforeRecords + target + afterRecords)
}
}
fun findSenderAliases(
subject: ChatHistorySubject,
query: String,
limit: Int = 10,
): List<ChatHistorySenderAliasMatch> {
check(initialized) { "聊天记录数据库尚未初始化" }
if (query.isBlank() || limit <= 0) return emptyList()
val normalized = query.trim()
return openReadConnection().use { connection ->
connection.prepareStatement(
"""
INSERT INTO message_record(
bot_id, from_id, target_id, ids, internal_ids,
time, kind, code, recalled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
SELECT user_id, name
FROM $SENDER_ALIAS_TABLE
WHERE bot_id = ? AND kind = ? AND subject_id = ?
AND lower(name) LIKE ? ESCAPE '\'
ORDER BY last_seen DESC
LIMIT ?
""".trimIndent()
).use { statement ->
statement.setLong(1, record.botId)
statement.setLong(2, record.fromId)
statement.setLong(3, record.targetId)
statement.setString(4, record.ids)
statement.setString(5, record.internalIds)
statement.setInt(6, record.time)
statement.setInt(7, record.kind.ordinal)
statement.setString(8, record.code)
statement.setInt(9, record.recalled)
statement.executeUpdate()
statement.setLong(1, subject.botId)
statement.setInt(2, subject.kind.ordinal)
statement.setLong(3, subject.subjectId)
statement.setString(4, "%${normalized.lowercase().escapeLike()}%")
statement.setInt(5, limit.coerceAtMost(50))
statement.executeQuery().use { results ->
buildList {
while (results.next()) {
val name = results.getString("name")
add(
ChatHistorySenderAliasMatch(
userId = results.getLong("user_id"),
displayName = name,
matchRank = name.aliasMatchRank(normalized),
)
)
}
}.sortedWith(compareBy({ it.matchRank }, { it.userId }))
.distinctBy(ChatHistorySenderAliasMatch::userId)
.take(limit)
}
}
}
}
fun backfillSearchIndex(
batchSize: Int = DEFAULT_BACKFILL_BATCH_SIZE,
shouldContinue: () -> Boolean = { true },
onProgress: (processed: Int) -> Unit = {},
): Int {
if (!isSearchIndexAvailable || batchSize <= 0) return 0
var processed = 0
var cursor = withWriteConnectionResult { connection ->
readMeta(connection, SEARCH_BACKFILL_CURSOR_KEY)?.toLongOrNull() ?: 0L
}
while (shouldContinue()) {
val batch = openReadConnection().use { connection ->
connection.prepareStatement(
"""
SELECT mr.id, mr.code
FROM message_record mr
WHERE mr.id > ?
AND NOT EXISTS (
SELECT 1 FROM $SEARCH_TEXT_TABLE search WHERE search.message_id = mr.id
)
ORDER BY mr.id
LIMIT ?
""".trimIndent()
).use { statement ->
statement.setLong(1, cursor)
statement.setInt(2, batchSize)
statement.executeQuery().use { results ->
buildList {
while (results.next()) {
add(results.getLong("id") to results.getString("code"))
}
}
}
}
}
if (batch.isEmpty()) {
withWriteConnection { connection ->
writeMeta(connection, SEARCH_INDEX_COMPLETE_KEY, "1")
}
searchIndexReady = true
return processed
}
withWriteConnection { connection ->
val oldAutoCommit = connection.autoCommit
connection.autoCommit = false
try {
connection.prepareStatement(
"INSERT INTO $SEARCH_TEXT_TABLE(message_id, search_text) VALUES (?, ?)"
).use { insertText ->
connection.prepareStatement(
"INSERT INTO $SEARCH_FTS_TABLE(rowid, search_text) VALUES (?, ?)"
).use { insertFts ->
batch.forEach { (id, code) ->
val text = ChatHistorySearchText.extract(code)
insertText.setLong(1, id)
insertText.setString(2, text)
insertText.addBatch()
insertFts.setLong(1, id)
insertFts.setString(2, text)
insertFts.addBatch()
}
insertText.executeBatch()
insertFts.executeBatch()
}
}
cursor = batch.last().first
writeMeta(connection, SEARCH_BACKFILL_CURSOR_KEY, cursor.toString())
writeMeta(connection, SEARCH_INDEX_COMPLETE_KEY, "0")
connection.commit()
} catch (cause: Throwable) {
connection.rollback()
throw cause
} finally {
connection.autoCommit = oldAutoCommit
}
}
processed += batch.size
onProgress(processed)
}
return processed
}
internal fun insertRecord(record: ChatMessageRecord, senderName: String = "") {
var recordId = 0L
var indexFailure: Throwable? = null
withWriteConnection { connection ->
recordId = insertPrimaryRecord(connection, record)
if (senderName.isNotBlank()) {
runCatching { upsertSenderAlias(connection, record, senderName) }
.onFailure { warn("保存聊天记录发送者别名失败: user=${record.fromId}", it) }
}
if (isSearchIndexAvailable) {
runCatching { indexRecordOnConnection(connection, recordId, record.code) }
.onFailure { indexFailure = it }
}
}
indexFailure?.let { cause ->
searchIndexReady = false
markSearchIndexIncomplete()
warn("消息已保存,但写入聊天记录全文索引失败: id=$recordId", cause)
}
}
private fun insertPrimaryRecord(connection: Connection, record: ChatMessageRecord): Long {
connection.prepareStatement(
"""
INSERT INTO message_record(
bot_id, from_id, target_id, ids, internal_ids,
time, kind, code, recalled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).use { statement ->
statement.setLong(1, record.botId)
statement.setLong(2, record.fromId)
statement.setLong(3, record.targetId)
statement.setString(4, record.ids)
statement.setString(5, record.internalIds)
statement.setInt(6, record.time)
statement.setInt(7, record.kind.ordinal)
statement.setString(8, record.code)
statement.setInt(9, record.recalled)
statement.executeUpdate()
}
connection.createStatement().use { statement ->
statement.executeQuery("SELECT last_insert_rowid()").use { results ->
check(results.next())
return results.getLong(1)
}
}
}
private fun indexRecordOnConnection(connection: Connection, recordId: Long, code: String) {
val searchText = ChatHistorySearchText.extract(code)
val oldAutoCommit = connection.autoCommit
connection.autoCommit = false
try {
connection.prepareStatement(
"INSERT INTO $SEARCH_TEXT_TABLE(message_id, search_text) VALUES (?, ?)"
).use { statement ->
statement.setLong(1, recordId)
statement.setString(2, searchText)
statement.executeUpdate()
}
connection.prepareStatement(
"INSERT INTO $SEARCH_FTS_TABLE(rowid, search_text) VALUES (?, ?)"
).use { statement ->
statement.setLong(1, recordId)
statement.setString(2, searchText)
statement.executeUpdate()
}
connection.commit()
} catch (cause: Throwable) {
connection.rollback()
throw cause
} finally {
connection.autoCommit = oldAutoCommit
}
}
private fun upsertSenderAlias(connection: Connection, record: ChatMessageRecord, senderName: String) {
val subjectId = if (record.kind == MessageSourceKind.GROUP) {
record.targetId
} else if (record.fromId == record.botId) {
record.targetId
} else {
record.fromId
}
connection.prepareStatement(
"""
INSERT INTO $SENDER_ALIAS_TABLE(
bot_id, kind, subject_id, user_id, name, last_seen
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(bot_id, kind, subject_id, user_id, name) DO UPDATE SET
last_seen = MAX(last_seen, excluded.last_seen)
""".trimIndent()
).use { statement ->
statement.setLong(1, record.botId)
statement.setInt(2, record.kind.ordinal)
statement.setLong(3, subjectId)
statement.setLong(4, record.fromId)
statement.setString(5, senderName.trim())
statement.setInt(6, record.time)
statement.executeUpdate()
}
}
private fun withWriteConnection(block: (Connection) -> Unit) {
withWriteConnectionResult { connection -> block(connection) }
}
private fun <T> withWriteConnectionResult(block: (Connection) -> T): T {
synchronized(writeLock) {
check(initialized) { "聊天记录数据库尚未初始化" }
val connection = writeConnection?.takeUnless(Connection::isClosed)
@@ -286,7 +687,7 @@ object ChatHistoryStore {
configureWriteConnection(it)
writeConnection = it
}
block(connection)
return block(connection)
}
}
@@ -313,53 +714,282 @@ object ChatHistoryStore {
}
private fun createSchema(connection: Connection) {
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS message_record(
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id INTEGER NOT NULL,
from_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
ids TEXT,
internal_ids TEXT,
time INTEGER NOT NULL,
kind INTEGER NOT NULL,
code TEXT NOT NULL,
recalled INTEGER NOT NULL DEFAULT 0
val oldAutoCommit = connection.autoCommit
connection.autoCommit = false
try {
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS message_record(
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id INTEGER NOT NULL,
from_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
ids TEXT,
internal_ids TEXT,
time INTEGER NOT NULL,
kind INTEGER NOT NULL,
code TEXT NOT NULL,
recalled INTEGER NOT NULL DEFAULT 0
)
""".trimIndent()
)
""".trimIndent()
)
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_message_subject_time " +
"ON message_record(bot_id, kind, target_id, time DESC, id DESC)"
)
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_message_sender_subject_time " +
"ON message_record(bot_id, kind, target_id, from_id, time DESC, id DESC)"
)
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_message_recall_identity " +
"ON message_record(bot_id, kind, target_id, from_id, time, ids)"
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS chat_history_meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_message_subject_time " +
"ON message_record(bot_id, kind, target_id, time DESC, id DESC)"
)
""".trimIndent()
)
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_message_sender_subject_time " +
"ON message_record(bot_id, kind, target_id, from_id, time DESC, id DESC)"
)
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_message_recall_identity " +
"ON message_record(bot_id, kind, target_id, from_id, time, ids)"
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS chat_history_meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""".trimIndent()
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS $SEARCH_TEXT_TABLE(
message_id INTEGER PRIMARY KEY,
search_text TEXT NOT NULL
)
""".trimIndent()
)
statement.executeUpdate(
"""
CREATE TABLE IF NOT EXISTS $SENDER_ALIAS_TABLE(
bot_id INTEGER NOT NULL,
kind INTEGER NOT NULL,
subject_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
last_seen INTEGER NOT NULL,
PRIMARY KEY(bot_id, kind, subject_id, user_id, name)
)
""".trimIndent()
)
statement.executeUpdate(
"CREATE INDEX IF NOT EXISTS idx_chat_history_sender_alias_scope " +
"ON $SENDER_ALIAS_TABLE(bot_id, kind, subject_id, last_seen DESC)"
)
}
connection.prepareStatement(
"INSERT INTO chat_history_meta(key, value) VALUES ('schema_version', ?) " +
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
).use { statement ->
statement.setString(1, SCHEMA_VERSION.toString())
statement.executeUpdate()
}
connection.commit()
} catch (cause: Throwable) {
connection.rollback()
throw cause
} finally {
connection.autoCommit = oldAutoCommit
}
runCatching {
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS $SEARCH_FTS_TABLE USING fts5(
search_text,
content='$SEARCH_TEXT_TABLE',
content_rowid='message_id',
tokenize='trigram'
)
""".trimIndent()
)
}
}.onFailure { warn("初始化 SQLite FTS5 聊天记录索引失败,消息记录仍会继续保存", it) }
if (hasSearchIndex(connection)) {
val hasMessages = connection.createStatement().use { statement ->
statement.executeQuery("SELECT 1 FROM message_record LIMIT 1").use(ResultSet::next)
}
connection.prepareStatement(
"INSERT INTO chat_history_meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING"
).use { statement ->
statement.setString(1, SEARCH_BACKFILL_CURSOR_KEY)
statement.setString(2, "0")
statement.executeUpdate()
statement.setString(1, SEARCH_INDEX_COMPLETE_KEY)
statement.setString(2, if (hasMessages) "0" else "1")
statement.executeUpdate()
}
}
}
private fun subjectConditions(
subject: ChatHistorySubject,
parameters: MutableList<Any>,
): List<String> {
val conditions = mutableListOf(
"mr.bot_id = ?",
"mr.kind = ?",
)
parameters += subject.botId
parameters += subject.kind.ordinal
if (subject.kind == MessageSourceKind.GROUP) {
conditions += "mr.target_id = ?"
parameters += subject.subjectId
} else {
conditions += "(mr.from_id = ? OR mr.target_id = ?)"
parameters += subject.subjectId
parameters += subject.subjectId
}
return conditions
}
private data class TextPredicate(
val sql: String?,
val usesFts: Boolean,
val usesSearchText: Boolean,
)
private fun buildTextPredicate(
query: String,
mode: ChatHistoryMatchMode,
parameters: MutableList<Any>,
): TextPredicate {
if (query.isBlank()) return TextPredicate(null, false, false)
val normalized = query.lowercase()
val terms = normalized.split(Regex("\\s+")).filter(String::isNotBlank)
val hasShortTerm = terms.any { it.codePointCount(0, it.length) < 3 }
if (mode == ChatHistoryMatchMode.ANY && hasShortTerm) {
val predicates = terms.map {
parameters += it
"instr(lower(search.search_text), ?) > 0"
}
return TextPredicate(predicates.joinToString(" OR ", prefix = "(", postfix = ")"), false, true)
}
val ftsTerms = if (mode == ChatHistoryMatchMode.PHRASE) {
listOf(normalized)
} else {
terms
}
val ftsOnly = ftsTerms.filter { it.codePointCount(0, it.length) >= 3 }
if (ftsOnly.isEmpty()) {
val predicates = terms.map {
parameters += it
"instr(lower(search.search_text), ?) > 0"
}
val joiner = if (mode == ChatHistoryMatchMode.ANY) " OR " else " AND "
return TextPredicate(predicates.joinToString(joiner, prefix = "(", postfix = ")"), false, true)
}
val ftsQuery = when (mode) {
ChatHistoryMatchMode.PHRASE -> quoteFtsTerm(normalized)
ChatHistoryMatchMode.ALL -> ftsOnly.joinToString(" AND ", transform = ::quoteFtsTerm)
ChatHistoryMatchMode.ANY -> ftsOnly.joinToString(" OR ", transform = ::quoteFtsTerm)
}
parameters += ftsQuery
val ftsPredicate = "$SEARCH_FTS_TABLE MATCH ?"
if (mode != ChatHistoryMatchMode.ALL || !hasShortTerm) {
return TextPredicate(ftsPredicate, true, false)
}
val shortPredicates = terms.filter { it.codePointCount(0, it.length) < 3 }.map {
parameters += it
"instr(lower(search.search_text), ?) > 0"
}
return TextPredicate(
(listOf(ftsPredicate) + shortPredicates).joinToString(" AND ", prefix = "(", postfix = ")"),
true,
true,
)
}
private fun quoteFtsTerm(term: String): String =
"\"${term.replace("\"", "\"\"")}\""
private fun loadRecords(
connection: Connection,
sql: String,
parameters: List<Any>,
): List<ChatMessageRecord> {
return connection.prepareStatement(sql).use { statement ->
bindParameters(statement, parameters)
statement.executeQuery().use { results ->
buildList {
while (results.next()) add(results.toRecord())
}
}
}
}
private fun bindParameters(
statement: java.sql.PreparedStatement,
parameters: List<Any>,
) {
parameters.forEachIndexed { index, value ->
when (value) {
is Int -> statement.setInt(index + 1, value)
is Long -> statement.setLong(index + 1, value)
is String -> statement.setString(index + 1, value)
else -> error("不支持的查询参数类型 ${value::class}")
}
}
}
private fun hasSearchIndex(connection: Connection): Boolean {
connection.prepareStatement(
"INSERT INTO chat_history_meta(key, value) VALUES ('schema_version', ?) " +
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1"
).use { statement ->
statement.setString(1, SCHEMA_VERSION.toString())
statement.setString(1, SEARCH_FTS_TABLE)
statement.executeQuery().use { results ->
return results.next()
}
}
}
private fun readMeta(connection: Connection, key: String): String? {
connection.prepareStatement(
"SELECT value FROM chat_history_meta WHERE key = ?"
).use { statement ->
statement.setString(1, key)
statement.executeQuery().use { results ->
return if (results.next()) results.getString(1) else null
}
}
}
private fun writeMeta(connection: Connection, key: String, value: String) {
connection.prepareStatement(
"""
INSERT INTO chat_history_meta(key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
""".trimIndent()
).use { statement ->
statement.setString(1, key)
statement.setString(2, value)
statement.executeUpdate()
}
}
private fun markSearchIndexIncomplete() {
runCatching {
withWriteConnection { connection ->
writeMeta(connection, SEARCH_INDEX_COMPLETE_KEY, "0")
}
}.onFailure { warn("标记聊天记录全文索引未完成失败", it) }
}
private fun String.escapeLike(): String =
replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
private fun String.aliasMatchRank(query: String): Int = when {
equals(query, ignoreCase = true) -> 0
startsWith(query, ignoreCase = true) -> 1
contains(query, ignoreCase = true) -> 2
else -> Int.MAX_VALUE
}
private fun ResultSet.toRecord(): ChatMessageRecord {
val kindOrdinal = getInt("kind")
val kind = MessageSourceKind.values().getOrNull(kindOrdinal)