mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
258 lines
9.3 KiB
Kotlin
258 lines
9.3 KiB
Kotlin
package top.jie65535.mirai.profile
|
|
|
|
import com.aallam.openai.api.chat.ChatCompletionRequest
|
|
import com.aallam.openai.api.chat.ChatMessage
|
|
import com.aallam.openai.api.chat.ChatResponseFormat
|
|
import com.aallam.openai.api.chat.StreamOptions
|
|
import com.aallam.openai.api.core.Usage
|
|
import com.aallam.openai.api.model.ModelId
|
|
import kotlinx.serialization.SerializationException
|
|
import kotlinx.serialization.json.Json
|
|
import net.mamoe.mirai.message.data.MessageSourceKind
|
|
import top.jie65535.mirai.data.ModelUsageAttribution
|
|
import top.jie65535.mirai.data.ModelUsageRecorder
|
|
import top.jie65535.mirai.llm.LargeLanguageModels
|
|
import top.jie65535.mirai.llm.ModelService
|
|
|
|
internal val profileResponseJson = Json {
|
|
ignoreUnknownKeys = true
|
|
explicitNulls = false
|
|
}
|
|
|
|
interface ProfileModel {
|
|
val modelName: String
|
|
|
|
suspend fun analyze(
|
|
profile: UserProfileSnapshot,
|
|
batch: ProfileHistoryBatch,
|
|
): ProfileModelResult
|
|
|
|
suspend fun analyze(
|
|
profile: UserProfileSnapshot,
|
|
batch: ProfileHistoryBatch,
|
|
supportStats: Map<String, ProfileItemSupportStats>,
|
|
): ProfileModelResult = analyze(profile, batch)
|
|
}
|
|
|
|
interface ConversationProfileModel {
|
|
val modelName: String
|
|
|
|
suspend fun analyzeConversation(
|
|
profiles: Map<Long, UserProfileSnapshot>,
|
|
batch: ConversationProfileBatch,
|
|
eligibleUserIds: Set<Long>,
|
|
): ConversationProfileModelResult
|
|
|
|
suspend fun analyzeConversation(
|
|
profiles: Map<Long, UserProfileSnapshot>,
|
|
batch: ConversationProfileBatch,
|
|
eligibleUserIds: Set<Long>,
|
|
supportStatsByUserId: Map<Long, Map<String, ProfileItemSupportStats>>,
|
|
): ConversationProfileModelResult = analyzeConversation(profiles, batch, eligibleUserIds)
|
|
}
|
|
|
|
interface ProfileCompactionModel {
|
|
val modelName: String
|
|
|
|
suspend fun compact(
|
|
profile: UserProfileSnapshot,
|
|
supportStats: Map<String, ProfileItemSupportStats>,
|
|
): ProfileCompactionModelResult
|
|
}
|
|
|
|
class ProfileModelClient(
|
|
private val endpoint: LargeLanguageModels.ProfileEndpoint,
|
|
) : ProfileModel, ConversationProfileModel, ProfileCompactionModel {
|
|
private val json = profileResponseJson
|
|
|
|
override val modelName: String
|
|
get() = endpoint.model
|
|
|
|
override suspend fun analyze(
|
|
profile: UserProfileSnapshot,
|
|
batch: ProfileHistoryBatch,
|
|
): ProfileModelResult = analyze(profile, batch, emptyMap())
|
|
|
|
override suspend fun analyze(
|
|
profile: UserProfileSnapshot,
|
|
batch: ProfileHistoryBatch,
|
|
supportStats: Map<String, ProfileItemSupportStats>,
|
|
): ProfileModelResult {
|
|
val completion = complete(
|
|
ChatCompletionRequest(
|
|
model = ModelId(endpoint.model),
|
|
responseFormat = ChatResponseFormat.JsonObject,
|
|
streamOptions = StreamOptions(includeUsage = true),
|
|
messages = listOf(
|
|
ChatMessage.System(ProfilePromptStore.systemPrompt),
|
|
ChatMessage.User(ProfilePromptStore.buildUserPrompt(profile, batch, supportStats)),
|
|
),
|
|
)
|
|
)
|
|
recordUsage(batch.usageAttribution(), completion)
|
|
require(completion.content.isNotBlank()) { "模型流式响应没有文本内容" }
|
|
val raw = completion.content.replace(THINK_REGEX, "").trim()
|
|
val response = parseResponse(raw)
|
|
return ProfileModelResult(
|
|
response = response,
|
|
rawResponse = raw,
|
|
usage = completion.usage,
|
|
)
|
|
}
|
|
|
|
override suspend fun analyzeConversation(
|
|
profiles: Map<Long, UserProfileSnapshot>,
|
|
batch: ConversationProfileBatch,
|
|
eligibleUserIds: Set<Long>,
|
|
): ConversationProfileModelResult = analyzeConversation(profiles, batch, eligibleUserIds, emptyMap())
|
|
|
|
override suspend fun analyzeConversation(
|
|
profiles: Map<Long, UserProfileSnapshot>,
|
|
batch: ConversationProfileBatch,
|
|
eligibleUserIds: Set<Long>,
|
|
supportStatsByUserId: Map<Long, Map<String, ProfileItemSupportStats>>,
|
|
): ConversationProfileModelResult {
|
|
val completion = complete(
|
|
ChatCompletionRequest(
|
|
model = ModelId(endpoint.model),
|
|
responseFormat = ChatResponseFormat.JsonObject,
|
|
streamOptions = StreamOptions(includeUsage = true),
|
|
messages = listOf(
|
|
ChatMessage.System(ProfilePromptStore.conversationSystemPrompt),
|
|
ChatMessage.User(
|
|
ProfilePromptStore.buildConversationUserPrompt(
|
|
profiles,
|
|
batch,
|
|
eligibleUserIds,
|
|
supportStatsByUserId,
|
|
)
|
|
),
|
|
),
|
|
)
|
|
)
|
|
recordUsage(
|
|
ModelUsageAttribution(
|
|
botId = batch.botId,
|
|
userId = 0,
|
|
groupId = batch.groupId,
|
|
),
|
|
completion,
|
|
)
|
|
require(completion.content.isNotBlank()) { "模型流式响应没有文本内容" }
|
|
val raw = completion.content.replace(THINK_REGEX, "").trim()
|
|
return ConversationProfileModelResult(
|
|
response = parseObject(raw),
|
|
rawResponse = raw,
|
|
usage = completion.usage,
|
|
)
|
|
}
|
|
|
|
override suspend fun compact(
|
|
profile: UserProfileSnapshot,
|
|
supportStats: Map<String, ProfileItemSupportStats>,
|
|
): ProfileCompactionModelResult {
|
|
val completion = complete(
|
|
ChatCompletionRequest(
|
|
model = ModelId(endpoint.model),
|
|
responseFormat = ChatResponseFormat.JsonObject,
|
|
streamOptions = StreamOptions(includeUsage = true),
|
|
messages = listOf(
|
|
ChatMessage.System(ProfilePromptStore.compactionSystemPrompt),
|
|
ChatMessage.User(ProfilePromptStore.buildCompactionUserPrompt(profile, supportStats)),
|
|
),
|
|
)
|
|
)
|
|
recordUsage(
|
|
ModelUsageAttribution(
|
|
userId = profile.userId,
|
|
),
|
|
completion,
|
|
)
|
|
require(completion.content.isNotBlank()) { "模型流式响应没有文本内容" }
|
|
val raw = completion.content.replace(THINK_REGEX, "").trim()
|
|
return ProfileCompactionModelResult(
|
|
response = json.decodeFromString(extractObject(raw)),
|
|
rawResponse = raw,
|
|
usage = completion.usage,
|
|
)
|
|
}
|
|
|
|
private suspend fun complete(request: ChatCompletionRequest): CompletedProfileResponse {
|
|
val content = StringBuilder()
|
|
var lastUsage: Usage? = null
|
|
var cacheUsage: ModelService.CacheUsage? = null
|
|
endpoint.service.chatCompletions(request) { cacheUsage = it }.collect { chunk ->
|
|
chunk.choices.firstOrNull()?.delta?.content?.let(content::append)
|
|
chunk.usage?.let { lastUsage = it }
|
|
}
|
|
return CompletedProfileResponse(
|
|
content = content.toString(),
|
|
usage = lastUsage.toProfileUsage(cacheUsage),
|
|
usageAvailable = lastUsage != null,
|
|
)
|
|
}
|
|
|
|
private fun Usage?.toProfileUsage(cacheUsage: ModelService.CacheUsage?) = ProfileTokenUsage(
|
|
promptTokens = this?.promptTokens ?: 0,
|
|
completionTokens = this?.completionTokens ?: 0,
|
|
cachedTokens = cacheUsage?.hitTokens ?: 0,
|
|
)
|
|
|
|
private fun recordUsage(attribution: ModelUsageAttribution, completion: CompletedProfileResponse) {
|
|
if (!completion.usageAvailable) return
|
|
val usage = completion.usage
|
|
ModelUsageRecorder.recordTokenValues(
|
|
attribution = attribution,
|
|
endpointLabel = "profile",
|
|
modelAlias = endpoint.alias,
|
|
provider = endpoint.provider,
|
|
model = endpoint.model,
|
|
usageKind = "profile",
|
|
promptTokens = usage.promptTokens.toLong(),
|
|
completionTokens = usage.completionTokens.toLong(),
|
|
cachedTokens = usage.cachedTokens.toLong(),
|
|
)
|
|
}
|
|
|
|
private fun ProfileHistoryBatch.usageAttribution(): ModelUsageAttribution {
|
|
val record = messages.firstOrNull()?.record
|
|
return ModelUsageAttribution(
|
|
botId = record?.botId ?: 0,
|
|
userId = userId,
|
|
groupId = record?.targetId?.takeIf { record.kind == MessageSourceKind.GROUP },
|
|
)
|
|
}
|
|
|
|
private fun parseResponse(raw: String): ProfileModelResponse {
|
|
return parseObject(raw)
|
|
}
|
|
|
|
private inline fun <reified T> parseObject(raw: String): T {
|
|
return json.decodeFromString(extractObject(raw))
|
|
}
|
|
|
|
private fun extractObject(raw: String): String {
|
|
val unfenced = raw
|
|
.removePrefix("```json").removePrefix("```")
|
|
.removeSuffix("```").trim()
|
|
return if (unfenced.startsWith('{') && unfenced.endsWith('}')) {
|
|
unfenced
|
|
} else {
|
|
val start = unfenced.indexOf('{')
|
|
val end = unfenced.lastIndexOf('}')
|
|
if (start < 0 || end <= start) throw SerializationException("模型响应中没有完整 JSON object")
|
|
unfenced.substring(start, end + 1)
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private val THINK_REGEX = Regex("<think>[\\s\\S]*?</think>")
|
|
}
|
|
|
|
private data class CompletedProfileResponse(
|
|
val content: String,
|
|
val usage: ProfileTokenUsage,
|
|
val usageAvailable: Boolean,
|
|
)
|
|
}
|