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