Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
429 changes: 226 additions & 203 deletions backend/src/main/kotlin/com/silk/backend/ai/AIStepwiseAgent.kt

Large diffs are not rendered by default.

41 changes: 25 additions & 16 deletions backend/src/main/kotlin/com/silk/backend/ai/DirectModelAgent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -264,24 +264,15 @@ class DirectModelAgent(
appendLine("群聊历史已保存到 `chat_history.md`,你可以用 Grep 搜索历史消息。")
}

val response = try {
val response = runCatching {
// 优先使用 Claude CLI(内置 web_search、Grep、Read、glob 工具,原生支持 [citation:N] 引用)
try {
chatViaClaudeProcess(toolContext, callback)
} catch (e: Exception) {
logger.warn("⚠️ [DirectModelAgent] Claude CLI 调用失败,回退到 API 路径: ${e.message}")
val apiKey = AIConfig.ANTHROPIC_API_KEY
if (apiKey.isNotBlank()) {
chatViaAnthropicApi(apiKey, toolContext, callback)
} else {
throw e
}
runClaudeOrApi(toolContext, callback)
}.getOrElse { error ->
if (error is CancellationException) {
throw error
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.error("❌ [DirectModelAgent] AI 调用失败: ${e.message}")
callback("error", "❌ AI 调用失败: ${e.message}", true)
logger.error("❌ [DirectModelAgent] AI 调用失败: ${error.message}")
callback("error", "❌ AI 调用失败: ${error.message}", true)
"抱歉,处理您的问题时发生了错误。"
}

Expand All @@ -291,6 +282,24 @@ class DirectModelAgent(
return response
}

private suspend fun runClaudeOrApi(
toolContext: String,
callback: suspend (String, String, Boolean) -> Unit
): String = runCatching {
chatViaClaudeProcess(toolContext, callback)
}.getOrElse { error ->
if (error is CancellationException) {
throw error
}
logger.warn("⚠️ [DirectModelAgent] Claude CLI 调用失败,回退到 API 路径: ${error.message}")
val apiKey = AIConfig.ANTHROPIC_API_KEY
if (apiKey.isNotBlank()) {
chatViaAnthropicApi(apiKey, toolContext, callback)
} else {
throw error
}
}

private suspend fun chatViaClaudeProcess(
toolContext: String,
callback: suspend (String, String, Boolean) -> Unit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,35 +43,7 @@ class PDFReportGenerator {
private fun secondaryColor() = DeviceRgb(76, 175, 80) // 绿色
private fun headerBgColor() = DeviceRgb(245, 245, 245) // 浅灰色
private val commonChineseDiagnosisKeywords = listOf("不寐", "虚劳", "头痛")
// 中文字体路径(静态配置)- 优先使用对中英文都支持好的字体
private val chineseFontPath: String? by lazy {
// macOS 系统自带的字体,优先选择对中英文都支持好的
val fontPaths = listOf(
"/Library/Fonts/Arial Unicode.ttf", // ✅ Arial Unicode MS - 对中英文都支持好
"/System/Library/Fonts/PingFang.ttc,0", // PingFang SC Regular
"/System/Library/Fonts/STHeiti Light.ttc,0", // 华文黑体
"/System/Library/Fonts/Supplemental/Songti.ttc,0" // 宋体
)

// 尝试找到第一个可用的字体
for (fontPath in fontPaths) {
try {
val file = java.io.File(fontPath.split(",")[0])
if (file.exists()) {
logger.info("✅ 找到中文字体: {}", fontPath)
return@lazy fontPath
}
} catch (e: Exception) {
// 尝试下一个字体
continue
}
}

// 如果都失败,返回 null(使用内置字体)
logger.warn("⚠️ 未找到系统字体,将使用内置字体")
null
}


/**
* 为每个 PDF 文档创建独立的中文字体对象
* ✅ 使用正确的策略:内置CJK字体不需要embed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import com.silk.backend.database.UserRepository
import com.silk.backend.database.UserTodoItemDto
import com.silk.backend.models.ChatHistory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
Expand All @@ -18,6 +20,7 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
import java.io.IOException
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
Expand Down Expand Up @@ -201,12 +204,13 @@ actionType / actionDetail(能填就填,影响手机端是否显示「运行

val userPrompt = "用户显示名:$userName(userId=${userId.take(8)}…)\n\n$transcript"

val raw = try {
callLlm(system, userPrompt, apiKey, temperature = 0.2)
} catch (e: Exception) {
println("❌ [GroupTodoExtractionService] LLM 调用失败: ${e.message}")
null
}
val raw = callLlmOrNull(
system = system,
user = userPrompt,
apiKey = apiKey,
temperature = 0.2,
failurePrefix = "❌ [GroupTodoExtractionService] LLM 调用失败"
)

val parseResult = raw?.let { parseTodoJsonStrict(it) }
val llmDrafts = parseResult?.first ?: emptyList()
Expand Down Expand Up @@ -328,10 +332,12 @@ actionType / actionDetail(能填就填,影响手机端是否显示「运行
格式:{"todos":[{"id":"必须是输入中的id","title":"...","actionType":"alarm","actionDetail":"07:00","sourceGroupId":"可省略","sourceGroupName":"可省略","done":false}]}
输出的每条 id 必须出现在输入 JSON 的 id 集合中。"""

val raw = try {
callLlm(system, payload, apiKey)
} catch (e: Exception) {
println("❌ [GroupTodoExtractionService] 去冗 LLM 失败: ${e.message}")
val raw = callLlmOrNull(
system = system,
user = payload,
apiKey = apiKey,
failurePrefix = "❌ [GroupTodoExtractionService] 去冗 LLM 失败"
) ?: run {
UserTodoStore.dedupeByLogicalKeyInPlace(userId)
return
}
Expand Down Expand Up @@ -366,52 +372,79 @@ actionType / actionDetail(能填就填,影响手机端是否显示「运行
val out = mutableListOf<UserTodoItemDto>()
val seenOutIds = mutableSetOf<String>()
for (el in arr) {
val o = el.jsonObject
val id = o["id"]?.jsonPrimitive?.content?.trim() ?: continue
if (id !in validIds) {
println("⚠️ [GroupTodoExtractionService] 去冗跳过未知 id: ${id.take(12)}…")
continue
}
if (id in seenOutIds) continue
seenOutIds.add(id)
val orig = originalsById[id] ?: continue
val title = o["title"]?.jsonPrimitive?.content?.trim() ?: continue
if (title.isEmpty() || title.length > 500) continue
val gid = o["sourceGroupId"]?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() }
val gname = o["sourceGroupName"]?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() }
val at = o["actionType"]?.jsonPrimitive?.content?.trim()?.lowercase()
?.takeIf { it.isNotEmpty() && it != "null" }
val ad = o["actionDetail"]?.jsonPrimitive?.content?.trim()
?.takeIf { it.isNotEmpty() }
val donePrim = o["done"]?.jsonPrimitive
val done = when {
donePrim == null -> orig.done
donePrim.booleanOrNull != null -> donePrim.booleanOrNull == true
else -> donePrim.content.equals("true", ignoreCase = true) || donePrim.content == "1"
}
out.add(
UserTodoItemDto(
id = id,
title = title,
sourceGroupId = gid ?: orig.sourceGroupId,
sourceGroupName = gname ?: orig.sourceGroupName,
actionType = at?.ifBlank { null },
actionDetail = ad?.ifBlank { null },
createdAt = orig.createdAt,
updatedAt = now,
done = done,
executedAt = orig.executedAt,
reminderId = orig.reminderId
)
)
val parsed = parseCompactTodoEntry(el, validIds, seenOutIds, originalsById, now) ?: continue
out.add(parsed)
}
if (out.isEmpty()) null else out
} catch (e: Exception) {
} catch (e: SerializationException) {
println("⚠️ [GroupTodoExtractionService] 去冗 JSON 解析失败: ${e.message}")
null
} catch (e: IllegalArgumentException) {
println("⚠️ [GroupTodoExtractionService] 去冗 JSON 解析失败: ${e.message}")
null
}
}

private fun parseCompactTodoEntry(
element: kotlinx.serialization.json.JsonElement,
validIds: Set<String>,
seenOutIds: MutableSet<String>,
originalsById: Map<String, UserTodoItemDto>,
now: Long
): UserTodoItemDto? {
val todoObject = element.jsonObject
val id = todoObject["id"]?.jsonPrimitive?.content?.trim() ?: return null
if (id !in validIds) {
println("⚠️ [GroupTodoExtractionService] 去冗跳过未知 id: ${id.take(12)}…")
return null
}
if (!seenOutIds.add(id)) return null

val original = originalsById[id] ?: return null
val title = todoObject["title"]?.jsonPrimitive?.content?.trim()
?.takeIf(::isCompactTodoTitleValid)
?: return null

return UserTodoItemDto(
id = id,
title = title,
sourceGroupId = parseCompactTodoOptionalText(todoObject, "sourceGroupId") ?: original.sourceGroupId,
sourceGroupName = parseCompactTodoOptionalText(todoObject, "sourceGroupName") ?: original.sourceGroupName,
actionType = parseCompactTodoActionType(todoObject),
actionDetail = parseCompactTodoOptionalText(todoObject, "actionDetail"),
createdAt = original.createdAt,
updatedAt = now,
done = resolveCompactTodoDone(todoObject, original),
executedAt = original.executedAt,
reminderId = original.reminderId
)
}

private fun isCompactTodoTitleValid(title: String): Boolean = title.isNotEmpty() && title.length <= 500

private fun parseCompactTodoOptionalText(
todoObject: kotlinx.serialization.json.JsonObject,
key: String
): String? = todoObject[key]
?.jsonPrimitive
?.content
?.trim()
?.takeIf { it.isNotEmpty() }

private fun parseCompactTodoActionType(todoObject: kotlinx.serialization.json.JsonObject): String? =
parseCompactTodoOptionalText(todoObject, "actionType")
?.lowercase()
?.takeIf { it != "null" }

private fun resolveCompactTodoDone(
todoObject: kotlinx.serialization.json.JsonObject,
original: UserTodoItemDto
): Boolean {
val doneValue = todoObject["done"]?.jsonPrimitive ?: return original.done
doneValue.booleanOrNull?.let { return it }
return doneValue.content.equals("true", ignoreCase = true) || doneValue.content == "1"
}

private fun loadChatHistoryForGroup(groupId: String): ChatHistory? {
val sessionName = "group_$groupId"
for (base in historyBaseDirs) {
Expand Down Expand Up @@ -901,12 +934,46 @@ actionType / actionDetail(能填就填,影响手机端是否显示「运行
)
}
out to true
} catch (e: Exception) {
} catch (e: SerializationException) {
println("⚠️ [GroupTodoExtractionService] JSON 解析失败: ${e.message}")
emptyList<ExtractedTodoDraft>() to false
} catch (e: IllegalArgumentException) {
println("⚠️ [GroupTodoExtractionService] JSON 解析失败: ${e.message}")
emptyList<ExtractedTodoDraft>() to false
}
}

private fun callLlmOrNull(
system: String,
user: String,
apiKey: String,
temperature: Double = 0.35,
failurePrefix: String
): String? = try {
callLlm(system, user, apiKey, temperature)
} catch (e: CancellationException) {
throw e
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
println("$failurePrefix: ${e.message}")
null
} catch (e: IOException) {
println("$failurePrefix: ${e.message}")
null
} catch (e: SerializationException) {
println("$failurePrefix: ${e.message}")
null
} catch (e: IllegalArgumentException) {
println("$failurePrefix: ${e.message}")
null
} catch (e: IllegalStateException) {
println("$failurePrefix: ${e.message}")
null
} catch (e: SecurityException) {
println("$failurePrefix: ${e.message}")
null
}

private fun extractJsonObject(text: String): String? {
val t = text.trim()
val fence = Regex("```(?:json)?\\s*([\\s\\S]*?)```", RegexOption.IGNORE_CASE).find(t)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,8 @@ class AcpClientTest {
@Test
fun `handler throwing does not kill receive loop`() = runTest {
val transport = InMemoryAcpTransport()
val client = AcpClient(transport, scope = backgroundScope)
client.onSessionUpdate("s1") { throw RuntimeException("boom") }
val client = AcpClient(transport, scope = backgroundScope)
client.onSessionUpdate("s1") { throw IllegalStateException("boom") }

// first notification — handler throws; loop must survive
transport.pushFromServer(
Expand Down
9 changes: 0 additions & 9 deletions config/lint/detekt/backend.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CyclomaticComplexMethod:AIStepwiseAgent.kt$AIStepwiseAgent$private suspend fun callAIApiStreaming( prompt: String, onChunk: suspend (String) -&gt; Unit ): String</ID>
<ID>CyclomaticComplexMethod:AIStepwiseAgent.kt$AIStepwiseAgent$suspend fun generateQuickResponse( prompt: String, callback: suspend (content: String, isComplete: Boolean) -&gt; Unit )</ID>
<ID>CyclomaticComplexMethod:AIStepwiseAgent.kt$AIStepwiseAgent$suspend fun processDoctorDiagnosisUpdate( chatHistory: List&lt;ChatHistoryEntry&gt;, doctorMessage: String, callback: suspend (stepType: String, message: String, currentStep: Int?, totalSteps: Int?) -&gt; Unit, userName: String = "用户", groupDisplayName: String? = null )</ID>
<ID>CyclomaticComplexMethod:AgentRuntime.kt$AgentRuntime$private suspend fun handleCommand( ctx: GroupAgentContext, cmd: SilkCommand, broadcastFn: suspend (Message) -&gt; Unit, )</ID>
<ID>CyclomaticComplexMethod:GroupTodoExtractionService.kt$GroupTodoExtractionService$private fun parseCompactTodoJson( raw: String, validIds: Set&lt;String&gt;, originalsById: Map&lt;String, UserTodoItemDto&gt; ): List&lt;UserTodoItemDto&gt;?</ID>
<ID>CyclomaticComplexMethod:GroupTodoExtractionService.kt$GroupTodoExtractionService$suspend fun refreshTodosForUser(userId: String)</ID>
<ID>CyclomaticComplexMethod:Routing.kt$fun Application.configureRouting()</ID>
<ID>CyclomaticComplexMethod:UserTodoStore.kt$UserTodoStore$fun updateItem( userId: String, itemId: String, done: Boolean? = null, title: String? = null, actionType: String? = null, actionDetail: String? = null, executedAt: Long? = null, reminderId: Long? = null, clearReminderId: Boolean = false, taskKind: String? = null, repeatRule: String? = null, repeatAnchor: String? = null, activeFrom: Long? = null, activeTo: Long? = null, templateId: String? = null, lifecycleState: String? = null, closedAt: Long? = null, lastEvidenceAt: Long? = null, explicitIntent: Boolean? = null, dateBucket: String? = null, reopenCount: Int? = null ): Boolean</ID>
Expand All @@ -24,21 +21,15 @@
<ID>LoopWithTooManyJumpStatements:DirectModelAgent.kt$DirectModelAgent$for</ID>
<ID>LoopWithTooManyJumpStatements:GroupTodoExtractionService.kt$GroupTodoExtractionService$for</ID>
<ID>LoopWithTooManyJumpStatements:UserTodoStore.kt$UserTodoStore$for</ID>
<ID>NestedBlockDepth:AIStepwiseAgent.kt$AIStepwiseAgent$suspend fun generateQuickResponse( prompt: String, callback: suspend (content: String, isComplete: Boolean) -&gt; Unit )</ID>
<ID>SwallowedException:AIStepwiseAgent.kt$AIStepwiseAgent$e: Exception</ID>
<ID>SwallowedException:PDFReportGenerator.kt$PDFReportGenerator$e: Exception</ID>
<ID>SwallowedException:Routing.kt$e: Exception</ID>
<ID>ThrowsCount:AIStepwiseAgent.kt$AIStepwiseAgent$private suspend fun callAIApiStreaming( prompt: String, onChunk: suspend (String) -&gt; Unit ): String</ID>
<ID>TooGenericExceptionCaught:AIStepwiseAgent.kt$AIStepwiseAgent$e: Exception</ID>
<ID>TooGenericExceptionCaught:AgentRuntime.kt$AgentRuntime$e: Exception</ID>
<ID>TooGenericExceptionCaught:DirectModelAgent.kt$DirectModelAgent$e: Exception</ID>
<ID>TooGenericExceptionCaught:GroupTodoExtractionService.kt$GroupTodoExtractionService$e: Exception</ID>
<ID>TooGenericExceptionCaught:PDFReportGenerator.kt$PDFReportGenerator$closeEx: Exception</ID>
<ID>TooGenericExceptionCaught:PDFReportGenerator.kt$PDFReportGenerator$e: Exception</ID>
<ID>TooGenericExceptionCaught:Routing.kt$e: Exception</ID>
<ID>TooGenericExceptionCaught:WeaviateClient.kt$WeaviateClient$e: Exception</ID>
<ID>TooGenericExceptionCaught:WebSocketConfig.kt$ChatServer$e: Exception</ID>
<ID>TooGenericExceptionThrown:AcpClientTest.kt$AcpClientTest$throw RuntimeException("boom")</ID>
<ID>UnusedPrivateProperty:PDFReportGenerator.kt$PDFReportGenerator$// 中文字体路径(静态配置)- 优先使用对中英文都支持好的字体 private val chineseFontPath: String? by lazy { // macOS 系统自带的字体,优先选择对中英文都支持好的 val fontPaths = listOf( "/Library/Fonts/Arial Unicode.ttf", // ✅ Arial Unicode MS - 对中英文都支持好 "/System/Library/Fonts/PingFang.ttc,0", // PingFang SC Regular "/System/Library/Fonts/STHeiti Light.ttc,0", // 华文黑体 "/System/Library/Fonts/Supplemental/Songti.ttc,0" // 宋体 ) // 尝试找到第一个可用的字体 for (fontPath in fontPaths) { try { val file = java.io.File(fontPath.split(",")[0]) if (file.exists()) { logger.info("✅ 找到中文字体: {}", fontPath) return@lazy fontPath } } catch (e: Exception) { // 尝试下一个字体 continue } } // 如果都失败,返回 null(使用内置字体) logger.warn("⚠️ 未找到系统字体,将使用内置字体") null }</ID>
</CurrentIssues>
</SmellBaseline>
Loading
Loading