This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# 构建 debug APK
./gradlew assembleDebug
# 清理构建
./gradlew clean
# 构建时输出详细堆栈(排查 native 编译错误时使用)
./gradlew assembleDebug --stacktrace
# 运行单元测试
./gradlew test
# 运行单个测试类
./gradlew test --tests "com.llmapp.data.rag.TextSplitterTest"这是一个 Android 项目,建议使用 Android Studio 打开以获得完整 IDE 支持。
LLMApp 是一个基于阿里 MNN 推理框架的端侧 LLM 聊天应用(包名 com.llmapp),在 arm64-v8a 设备上本地运行量化 LLM 模型,并支持 RAG(检索增强生成)和语音输入。
UI 层 (Compose, Material 3)
├── MainActivity.kt — ViewPager2 四页容器(聊天/模板/知识库/设置)
├── ChatScreen.kt — 流式 Markdown 渲染 + RAG FilterChip
├── KnowledgeScreen.kt — 知识库管理(文档导入/删除、embedding 模型状态)
├── TemplateScreen.kt — 提示词模板选择
└── SettingsScreen.kt — 模型目录管理
ViewModel 层
├── ChatViewModel.kt — 会话/消息状态管理、流式推理编排、RAG 检索注入
├── KnowledgeViewModel.kt — 文档列表、embedding 模型加载
├── PromptTemplateViewModel.kt — 模板管理
└── SettingsViewModel.kt — 模型发现、目录管理
数据层
├── ChatRepository.kt — 封装 ChatDao(sessions + messages)
├── RagRepository.kt — 文档导入流水线(解析→切片→向量化→入库)、向量搜索
├── ModelRepository.kt — DataStore 偏好(模型路径、embedding 路径)
├── TemplateRepository.kt — JSON 模板文件读写
└── AppDatabase.kt — Room DB v3(sessions/messages/documents/chunks)
JNI 桥接层
├── NativeLib.kt — Kotlin external fun 声明(LLM 10 个 + Embedding 5 个函数)
└── llm_infer_jni.cpp — C++17 JNI 实现(LlmSession + EmbeddingSession)
Native 引擎 (MNN)
└── 外部 MNN 库,CMakeLists.txt 引用 ../../../../../MNN(仓库根目录兄弟目录)
提供 MNN::Transformer::Llm(LLM 推理)
提供 MNN::Interpreter(BERT embedding 推理)
LLMApplication(继承 Application)持有全局唯一的两个会话指针和状态流:
sessionPtr: Long— LLM 推理会话embeddingSessionPtr: Long— Embedding 模型会话isModelLoaded: StateFlow<Boolean>/isEmbeddingModelLoaded: StateFlow<Boolean>/isModelLoading: StateFlow<Boolean>- 模板选中事件流
templateSelected、模型切换事件流modelReloadEvents - 启动时自动加载上次选中的模型和 embedding 模型路径
- ViewModel 通过
(application as LLMApplication)引用
NativeLib.createSession(path)→ 分配LlmSessionNativeLib.loadModel(ptr)→MNN::Transformer::Llm::createLLM(config.json)→llm->load()NativeLib.inferenceStream(ptr, prompt, imagePath, callback)→ detachedstd::thread调用llm->response(),Utf8StreamProcessor做 UTF-8 解码,约 30ms 批量通过 JNI 回调onToken/onComplete/onErrorNativeLib.runTest(ptr, input)→ 同步llm->response()NativeLib.destroySession(ptr)→Llm::destroy()+ 释放- 对话历史管理:
clearHistory、getHistoryTurnCount、trimHistory
JNI 层在 JNI_OnLoad 中缓存全局 JavaVM* 用于线程挂载。
使用 MNN Interpreter API(非 Llm API),BERT 模型结构不同:
NativeLib.createEmbeddingSession(path)→ 分配EmbeddingSessionNativeLib.loadEmbeddingModel(ptr)→Interpreter::createFromFile(mnn)→createSession({input_ids, attention_mask} → {sentence_embedding}),零填充 [CLS]=101 [SEP]=102 跑 dummy forward 确定 dim=512NativeLib.computeEmbedding(ptr, text)→Tokenizer::encode()→ 填充到 512 →runSession()→ 读 CLS token 位置 512 维向量NativeLib.destroyEmbeddingSession(ptr)→releaseSession()+delete tokenizer
约束:模型 shape 固定 [1, 512, 1, 1](NCHW),不可 resize,短序列零填充。tokenizer.cpp 从 MNN 源码直接编译(不在 libMNN.so 中)。
用户发消息(RAG FilterChip 选中)
→ ChatViewModel.sendMessage()
→ RagRepository.searchSimilar(query, topK=3)
→ NativeLib.computeEmbedding(query) → FloatArray(512)
→ getAllChunks() → 逐 chunk 字节→浮点→余弦相似度→排序取 top-3
→ buildRagPrompt(query, context) — 英文指令 + Context + Question
→ NativeLib.inferenceStream(finalPrompt) → LLM 流式生成
文档导入:SAF URI → 内部存储复制 → DocumentParserFactory → parse() → TextSplitter.split() → 逐 chunk computeEmbedding → FloatArray→ByteArray(小端序) → Room 入库
Scoped Storage:C++ std::ifstream 无法读取 /storage/emulated/0/,必须用 SAF ContentResolver.openInputStream() 复制到 /data/data/com.llmapp/files/ 后传路径给 JNI。
VoskSpeechRecognizer(Vosk 0.3.47)— ChatFragment 中按住麦克风按钮触发,onPartialResult 实时显示,松开后 stopListening() 获取最终识别文本并填入输入框。Vosk 模型(vosk-model-small-cn-0.22, ~66MB)放在 app/src/main/assets/,首次使用自动解压到应用私有目录。
- sessions: id, title, createTime
- messages: id, sessionId (FK), role, content, timestamp, imagePath
- documents: id, fileName, fileType, fileSize, importTime, chunkCount, status
- chunks: id, documentId (FK CASCADE), chunkIndex, content, embedding (BLOB, 小端序 FloatArray), tokenCount
- DataStore:
model_directories(Set<String>),selected_model_path(String),embedding_model_path(String)
LLM 模型目录:config.json、llm.mnn + llm.mnn.weight、tokenizer.mtok 或 tokenizer.txt、可选的 visual.mnn
Embedding 模型目录:config.json(含 embedding_model 键指向 .mnn 文件名)、bge_small_zh.mnn、tokenizer.txt(BERT WordPiece vocab,一行一个 token)
- 仅构建 arm64-v8a ABI
- NDK 27.2.12479018、CMake 3.22.1 — 不要随意升级,可能破坏 native 编译
- KSP 版本必须严格对齐 Kotlin 版本:当前 Kotlin 2.0.21 → KSP 2.0.21-1.0.28
- MNN 源码树必须在仓库根目录的兄弟目录:
CMakeLists.txt中MNN_ROOT路径为../../../../../MNN(从app/src/main/cpp向上 5 级到项目根,再../MNN) libMNN.so必须放在app/src/main/jniLibs/arm64-v8a/- LLM 和 Embedding 会话都是全局单实例:不要创建多个
CMakeLists.txt从 MNN 源码直接编译tokenizer.cpp(${MNN_ROOT}/transformers/llm/engine/src/tokenizer.cpp),不在libMNN.so中- SettingsFragment 已改为 ComposeView,不再使用 XML binding
| 文件 | 说明 |
|---|---|
app/src/main/java/com/llmapp/LLMApplication.kt |
Application 单例,持有会话指针和仓库引用 |
app/src/main/java/com/llmapp/jni/NativeLib.kt |
Kotlin JNI external 声明(15 个函数) |
app/src/main/cpp/llm_infer_jni.cpp |
C++17 JNI 实现(LlmSession + EmbeddingSession) |
app/src/main/cpp/CMakeLists.txt |
CMake 构建配置 |
app/src/main/java/com/llmapp/data/AppDatabase.kt |
Room 数据库(4 张表) |
app/src/main/java/com/llmapp/data/RagRepository.kt |
RAG 流水线(导入/分块/向量化/搜索) |
app/src/main/java/com/llmapp/ui/chat/ChatViewModel.kt |
聊天核心 ViewModel |
app/src/main/java/com/llmapp/ui/main/MainActivity.kt |
入口 Activity(ViewPager2) |
app/src/main/java/com/llmapp/asr/VoskSpeechRecognizer.kt |
Vosk 离线语音识别 |
app/src/main/cpp/llm/llm.hpp |
MNN Llm API 头文件 |
app/src/main/cpp/llm/llmconfig.hpp |
MNN LlmConfig 头文件 |
单元测试位于 app/src/test/java/com/llmapp/,使用 JUnit 4 + Mockito + kotlinx-coroutines-test:
ChatViewModelTest.kt— ViewModel 逻辑测试RagRepositoryTest.kt— RAG 仓库测试TextSplitterTest.kt— 文本切片测试MarkdownBufferTest.kt— Markdown 缓冲解析测试
./gradlew test # 全部单元测试
./gradlew test --tests "ClassName" # 单个测试类check_onnx.py— ONNX 模型格式校验export_bge_mnn.py— 将 BGE embedding 模型导出为 MNN 格式test_embedding_consistency.py— 验证 embedding 推理一致性