feat: 初始化 Android 客户端(M0 骨架 + M1 登录 + M2 基础模块)

M0:Gradle 9.7.1 / AGP 9.2.1 / Kotlin 2.4 / Miuix 0.9.3,minSdk 33,compileSdk 37。
M1:登录页(服务器地址可配、服务端/本地错误分流、EncryptedSharedPreferences、自动登录与静默重登、401 登出)。
M2 基础模块:消息内容解析器(<p>/<action>/<narration> + 流式增量)、WebSocket 客户端、REST 数据层与分页去重。
45 个单元测试全绿,真机验证通过(登录页 + 启动图标)。
devdocs/ 收录设计文档、前后端协同事项与细化方案。
This commit is contained in:
AskaEth committed 2026-09-08 20:40:58 +08:00
commit 84de51c7b7
67 files changed
+3960

No files matched your search

@@ -0,0 +1,409 @@
package top.yeij.cyrene.domain.parse
import kotlin.math.min
/*
* ChatContentParser —— 纯 Kotlin 的聊天内容解析器(无 Android / Compose 依赖)。
*
* 权威规则:
* - `configs/personas/cyrene.yaml`「分段约定」「表达方式:动作与旁白」;
* - `devdocs/03-客户端细化开发方案.md` §5 渲染管线;
* - Web 端 `frontend/web/src/lib/parseContent.ts` + `components/chat.tsx`(行为对齐基准)。
*
* 语义摘要:
* - `<p>…</p>`:每个完整块 = 一条独立气泡;无完整 `<p>` 时回退按空行 `\n\s*\n` 切分;
* 单个 `\n` 是句内换行,绝不切分。未闭合的 `<p>` 不构成块,按普通文本处理(流式下会先缓存)。
* `<p>` 之外的非空白正文不会被丢弃:并入相邻气泡(若只是旁白/空白则依附之,否则自成一气泡)。
* - `<action>…</action>` / `<narration>…</narration>`:旁白/动作,不独立成气泡,依附气泡:
* 首部连续块 → 挂气泡上方(head);尾部连续块 → 挂下方(tail);夹在正文中间的块 → 上提到 head。
* - 漂移兼容:全角 `*…*`(无关键词时要求去空白后 ≤40 字符且不含换行 → action);
* 半角 `*…*` 仅当以 动作/旁白/内心/神态/轻声 开头才算旁白,否则保持普通文本(不吞 Markdown 强调)。
* 关键词 旁白/内心 → narration,其余 → action。
* - 未闭合/畸形的标签一律回落为普通文本,绝不抛异常。
*
* `ParsedBubble` 结构(每条气泡渲染一个):
* - `body` :气泡正文(仅 Text 部分按原顺序拼接后 trim),已剔除全部旁白/动作块;
* - `parts` :该气泡按原始顺序解析出的全部片段(Text / Action / Narration),供下钻使用;
* - `head` :要渲染在气泡上方的旁白/动作块(首部块 + 中间块「上提」);
* - `tail` :要渲染在气泡下方的旁白/动作块(尾部连续块)。
* 无正文时 `body` 为空串,仅 `head` 承载整条消息(纯动作/旁白气泡)。
* `parse("")` / 纯空白输入返回空列表;纯空白且无旁白的气泡会被丢弃。
*
* 流式边界决策(`StreamingContentParser`):
* - `onDelta` 返回「当前已确定前缀」的解析结果;`finish` 返回全量缓冲的解析结果,
* 因此 `finish() == parse(全部 delta 拼接)` 恒成立(逐字符/任意切分均一致)。
* - 未确定片段从以下最早位置开始整体扣留,不产出半截结果:
* 1) 未闭合的 `<action>` / `<narration>` / `<p>` 开标签(从该开标签起扣留);
* 2) 尾部残缺标签(如 `<act`、`</act`、`<p`),仅当它可能继续补全为合法标签时才扣留;
* 3) 最近一个完整标签之后(自由文本区)的孤立全角 `*`;
* 4) 自由文本区中可能开启关键词半角星号(如 `*动`)的孤立 `*`。
* - 标签残缺判断只影响「何时产出」,不影响最终结果;扣留期间不会抛异常。
* - 本类无副作用、非线程安全,调用方保证单线程喂入即可。
*/
/** 气泡内的一个有序片段。 */
sealed interface ContentPart {
val text: String
data class Text(override val text: String) : ContentPart
data class Action(override val text: String) : ContentPart
data class Narration(override val text: String) : ContentPart
}
/**
* 一条可渲染的气泡:正文 + 有序片段 + 上/下依附的旁白块。
* 字段含义见文件头注释;`body` 为空且 `head` 非空时,表示只有旁白没有正文。
*/
data class ParsedBubble(
val body: String,
val parts: List<ContentPart>,
val head: List<ContentPart>,
val tail: List<ContentPart>,
) {
val isEmpty: Boolean
get() = body.isEmpty() && head.isEmpty() && tail.isEmpty()
}
/** 完整 `<p>…</p>` 块(允许 `class` 等属性,但要求标签名后为空白或直接 `>`)。 */
private val P_BLOCK_RE = Regex("<p(\\s[^>]*)?>([\\s\\S]*?)</p>")
/**
* 一次匹配旁白的三种等价写法;左侧优先(最靠左的匹配获胜),
* 三种 body 均非贪婪,可跨换行;未闭合/畸形则整段回落为文本。
* 捕获组:1 = 标签名,2 = 标签 body,3 = 全角星 body,4 = 半角星 body。
*/
private val ROLE_RE = Regex(
"<\\s*(action|narration)\\s*>([\\s\\S]*?)</\\s*\\1\\s*>" +
"|*([\\s\\S]*?)*" +
"|\\*((?:动作|旁白|内心|神态|轻声)\\s*[::]?[\\s\\S]*?)\\*",
)
/** 星号块开头的关键词(可带中/英文冒号)。 */
private val ROLE_KEYWORD_RE = Regex("^(动作|旁白|内心|神态|轻声)\\s*[::]?\\s*")
private val ROLE_KEYWORDS = listOf("动作", "旁白", "内心", "神态", "轻声")
/** 空行切分:`\n` + 若干空白 + `\n`。 */
private val BLANK_LINE_RE = Regex("\\n\\s*\\n")
/** 无关键词的全角星块超过此长度即视为普通文本(对齐 Web `STAR_SHORT_RUNES`)。 */
private const val FULL_STAR_MAX_LEN = 40
private val TAG_NAMES = listOf("action", "narration", "p")
private val OPEN_ROLE_TAG_RE = Regex("^<\\s*(action|narration)\\s*>$")
private val CLOSE_ROLE_TAG_RE = Regex("^</\\s*(action|narration)\\s*>$")
private val OPEN_P_TAG_RE = Regex("^<p(\\s[^>]*)?>$")
private val CLOSE_P_TAG_RE = Regex("^</p>$")
/** 一次性解析:把一条消息的原始文本拆成若干独立气泡(含旁白依附关系)。 */
fun parse(raw: String): List<ParsedBubble> {
if (raw.isEmpty()) return emptyList()
return buildBubbles(tokenize(raw))
}
/**
* 带缓冲的流式增量解析器:`onDelta` 逐段喂入,`finish` 收口。
* 无副作用;调用方保证单线程访问。
*/
class StreamingContentParser {
private val buffer = StringBuilder()
/** 当前累计的原始文本(含尚未确定、被扣留的尾部)。 */
val raw: String
get() = buffer.toString()
/** 追加一段 delta,返回「当前已确定前缀」解析出的气泡列表。 */
fun onDelta(delta: String): List<ParsedBubble> {
if (delta.isNotEmpty()) buffer.append(delta)
val current = buffer.toString()
return parse(current.substring(0, stablePrefixEnd(current)))
}
/** 收口:对全部缓冲做一次性解析,结果与 `parse(拼接的全部 delta)` 完全一致。 */
fun finish(): List<ParsedBubble> = parse(buffer.toString())
/** 清空缓冲,可复用本实例处理下一条消息。 */
fun reset() {
buffer.setLength(0)
}
}
// ---------------------------------------------------------------------------
// 内部实现
// ---------------------------------------------------------------------------
private sealed interface Tok {
data class Txt(val text: String) : Tok
data class Act(val text: String) : Tok
data class Nar(val text: String) : Tok
object Break : Tok
}
/** 先切 `<p>` 块(决定气泡边界),再在每个文本区做旁白/星号解析。 */
private fun tokenize(raw: String): List<Tok> {
val pMatches = P_BLOCK_RE.findAll(raw).toList()
val out = ArrayList<Tok>()
if (pMatches.isEmpty()) {
appendAnnotations(raw, out, splitBlankLines = true)
return out
}
var last = 0
for (m in pMatches) {
val lead = raw.substring(last, m.range.first)
if (lead.isNotEmpty()) appendAnnotations(lead, out, splitBlankLines = false)
out.add(Tok.Break)
val inner = m.groupValues[2]
if (inner.isNotEmpty()) appendAnnotations(inner, out, splitBlankLines = false)
out.add(Tok.Break)
last = m.range.last + 1
}
val trail = raw.substring(last)
if (trail.isNotEmpty()) appendAnnotations(trail, out, splitBlankLines = false)
return out
}
/** 在文本区内按左到右顺序切出 Text / Action / Narration。 */
private fun appendAnnotations(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
var last = 0
for (m in ROLE_RE.findAll(text)) {
val before = text.substring(last, m.range.first)
if (before.isNotEmpty()) appendText(before, out, splitBlankLines)
when (val part = classify(m)) {
null -> appendText(m.value, out, splitBlankLines)
is ContentPart.Text -> appendText(part.text, out, splitBlankLines)
is ContentPart.Action -> out.add(Tok.Act(part.text))
is ContentPart.Narration -> out.add(Tok.Nar(part.text))
}
last = m.range.last + 1
}
val tail = text.substring(last)
if (tail.isNotEmpty()) appendText(tail, out, splitBlankLines)
}
/** 追加普通文本;无 `<p>` 时按空行插入气泡边界。 */
private fun appendText(text: String, out: MutableList<Tok>, splitBlankLines: Boolean) {
if (!splitBlankLines) {
out.add(Tok.Txt(text))
return
}
var last = 0
for (m in BLANK_LINE_RE.findAll(text)) {
val piece = text.substring(last, m.range.first)
if (piece.isNotEmpty()) out.add(Tok.Txt(piece))
out.add(Tok.Break)
last = m.range.last + 1
}
val tail = text.substring(last)
if (tail.isNotEmpty()) out.add(Tok.Txt(tail))
}
/** 把一次正则匹配归类;返回 null 表示「整段保持普通文本」。 */
private fun classify(m: MatchResult): ContentPart? {
if (m.groups[1] != null) {
val name = m.groupValues[1]
val body = m.groupValues[2]
return if (name == "action") ContentPart.Action(body) else ContentPart.Narration(body)
}
if (m.groups[3] != null) return classifyFullStar(m.groupValues[3])
if (m.groups[4] != null) return classifyHalfStar(m.groupValues[4])
return null
}
/** 全角 `*…*`:有关键词按关键词定类,否则短且单行才当 action。 */
private fun classifyFullStar(body: String): ContentPart? {
matchRoleKeyword(body)?.let { return it }
val trimmed = body.trim()
val short = trimmed.isNotEmpty() && trimmed.length <= FULL_STAR_MAX_LEN
if (short && !trimmed.contains('\n') && !trimmed.contains('\r')) {
return ContentPart.Action(trimmed)
}
return null
}
/** 半角 `*…*`:正则已保证以关键词开头,这里只负责剥离关键词与定类。 */
private fun classifyHalfStar(body: String): ContentPart =
matchRoleKeyword(body) ?: ContentPart.Action(body.trim())
/** 匹配并剥离开头关键词;旁白/内心 → narration,其余 → action。 */
private fun matchRoleKeyword(body: String): ContentPart? {
val m = ROLE_KEYWORD_RE.find(body) ?: return null
val rest = body.substring(m.value.length).trim()
return if (m.groupValues[1] == "旁白" || m.groupValues[1] == "内心") {
ContentPart.Narration(rest)
} else {
ContentPart.Action(rest)
}
}
/** 以 Break 为界分组,把「只有旁白没有正文」的组并入相邻气泡,再逐组构造气泡。 */
private fun buildBubbles(tokens: List<Tok>): List<ParsedBubble> {
val groups = ArrayList<MutableList<Tok>>()
var current = ArrayList<Tok>()
for (t in tokens) {
if (t is Tok.Break) {
if (current.isNotEmpty()) groups.add(current)
current = ArrayList()
} else {
current.add(t)
}
}
if (current.isNotEmpty()) groups.add(current)
val merged = ArrayList<MutableList<Tok>>()
var carry = ArrayList<Tok>()
for (group in groups) {
val combined = ArrayList<Tok>(carry.size + group.size)
combined.addAll(carry)
combined.addAll(group)
carry = ArrayList()
if (hasBodyText(combined)) merged.add(combined) else carry = combined
}
if (carry.isNotEmpty()) {
if (merged.isNotEmpty()) merged.last().addAll(carry) else merged.add(carry)
}
return merged.mapNotNull { toBubble(it) }
}
private fun hasBodyText(tokens: List<Tok>): Boolean =
tokens.any { it is Tok.Txt && it.text.isNotBlank() }
/** 构造气泡:合并相邻文本、计算 body,并按首/尾规则分配 head / tail。 */
private fun toBubble(tokens: List<Tok>): ParsedBubble? {
val parts = ArrayList<ContentPart>(tokens.size)
for (t in tokens) {
when (t) {
is Tok.Txt -> {
val prev = parts.lastOrNull()
if (prev is ContentPart.Text) {
parts[parts.size - 1] = ContentPart.Text(prev.text + t.text)
} else {
parts.add(ContentPart.Text(t.text))
}
}
is Tok.Act -> parts.add(ContentPart.Action(t.text))
is Tok.Nar -> parts.add(ContentPart.Narration(t.text))
Tok.Break -> Unit
}
}
if (parts.isEmpty()) return null
val firstText = parts.indexOfFirst { it is ContentPart.Text }
val lastText = parts.indexOfLast { it is ContentPart.Text }
val head = ArrayList<ContentPart>()
val tail = ArrayList<ContentPart>()
val body = StringBuilder()
parts.forEachIndexed { i, part ->
if (part is ContentPart.Text) {
body.append(part.text)
} else if (firstText < 0 || i < firstText || (i > firstText && i < lastText)) {
head.add(part)
} else {
tail.add(part)
}
}
val bodyText = body.toString().trim()
if (bodyText.isEmpty() && head.isEmpty() && tail.isEmpty()) return null
return ParsedBubble(body = bodyText, parts = parts.toList(), head = head.toList(), tail = tail.toList())
}
// ---------------------------------------------------------------------------
// 流式边界:计算「可安全产出」的前缀长度
// ---------------------------------------------------------------------------
/** 返回当前缓冲中可安全解析的前缀长度;其后的未完成片段一律扣留。 */
private fun stablePrefixEnd(buf: String): Int {
var cut = buf.length
val openTags = ArrayDeque<Pair<String, Int>>()
var i = 0
while (i < buf.length) {
val lt = buf.indexOf('<', i)
if (lt < 0) break
val gt = buf.indexOf('>', lt)
if (gt < 0) {
if (isPotentialTagPrefix(buf, lt)) cut = min(cut, lt)
break
}
val tag = buf.substring(lt, gt + 1)
val openName = openTagName(tag)
if (openName != null) {
openTags.addLast(openName to lt)
} else {
val closeName = closeTagName(tag)
if (closeName == null) {
i = lt + 1
continue
}
val idx = openTags.indexOfLast { it.first == closeName }
if (idx >= 0) openTags.removeAt(idx)
}
i = gt + 1
}
if (openTags.isNotEmpty()) cut = min(cut, openTags.first().second)
val freeStart = buf.lastIndexOf('>') + 1
cut = withholdDanglingFullStar(buf, freeStart, cut)
cut = withholdDanglingHalfStar(buf, freeStart, cut)
return cut.coerceIn(0, buf.length)
}
private fun openTagName(tag: String): String? {
OPEN_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (OPEN_P_TAG_RE.matches(tag)) "p" else null
}
private fun closeTagName(tag: String): String? {
CLOSE_ROLE_TAG_RE.find(tag)?.let { return it.groupValues[1] }
return if (CLOSE_P_TAG_RE.matches(tag)) "p" else null
}
/** 尾部 `<…` 是否仍可能补全为合法标签(否则它只是普通文本里的 `<`)。 */
private fun isPotentialTagPrefix(buf: String, lt: Int): Boolean {
var j = lt + 1
if (j < buf.length && buf[j] == '/') j++
while (j < buf.length && buf[j].isWhitespace()) j++
val nameStart = j
while (j < buf.length && buf[j] in 'a'..'z') j++
val name = buf.substring(nameStart, j)
if (j >= buf.length) return name.isEmpty() || TAG_NAMES.any { it.startsWith(name) }
if (name.isEmpty()) return false
if (!buf[j].isWhitespace()) return false
return TAG_NAMES.any { it.startsWith(name) }
}
/** 自由文本区内孤立的全角 `*`(计数为奇)从最后一个起扣留。 */
private fun withholdDanglingFullStar(buf: String, from: Int, cut: Int): Int {
var count = 0
var last = -1
for (i in from until buf.length) {
if (buf[i] == '*') {
count++
last = i
}
}
return if (count % 2 == 1 && last >= 0) min(cut, last) else cut
}
/** 自由文本区内孤立且可能开启关键词旁白的半角 `*` 从最后一个起扣留。 */
private fun withholdDanglingHalfStar(buf: String, from: Int, cut: Int): Int {
var count = 0
var last = -1
for (i in from until buf.length) {
if (buf[i] == '*') {
count++
last = i
}
}
if (count % 2 == 0 || last < 0) return cut
return if (couldStartRoleKeyword(buf.substring(last + 1))) min(cut, last) else cut
}
private fun couldStartRoleKeyword(after: String): Boolean {
if (after.isEmpty()) return true
return ROLE_KEYWORDS.any { it.startsWith(after) || after.startsWith(it) }
}