
看大部头、剧情复杂度高的小说最让人头疼的往往不是“没时间读”而是“看到后面忘了前面”。几十条人物线、上百个事件节点、穿插在前文里的伏笔如果没有一份结构化笔记看到第 500 章时经常要回翻第 50 章才想起来某个角色到底是谁。这篇文章要聊的是一个名为“溯阅”的鸿蒙本地阅读 AI 助手导入本地小说文件后由设备端 AI 自动生成前情提要、人物关系、时间线和伏笔追踪提示。整个方案强调数据本地优先用户的阅读记录、解析结果、AI 导读信息都不上传云端隐私层面会安心很多。本文适合这几类读者有鸿蒙基础、想做一个完整 ArkTS 项目练手的开发者对端侧 AI 能力、本地数据存储感兴趣的产品/技术同学想解决“长篇小说越看越乱”这一实际痛点的阅读类应用开发者。文章中会给出工程结构、数据层设计、轻量 AI 分析服务的核心代码、UI 展示方案以及落地时容易踩的坑。读完以后你可以照着搭出一个 MVP也可以在这个框架上扩展端侧大模型能力。1. 项目背景与技术定位1.1 从阅读痛点说起阅读长篇小说时大脑需要维护一份“动态索引”这个角色是谁和主角什么关系这件事发生在这个时间点前因后果是什么某件物品或某句话之前出现过现在是伏笔回收还是新伏笔小说越长这份索引的维护成本越高。传统做法是手动做读书笔记或者依赖书评网站的“人物关系图”但它们有两个问题手动笔记效率低并且容易打断沉浸式阅读外部书评往往是“别人视角”不一定符合当前读者的阅读进度还可能剧透。“溯阅”的解法是把解析能力放在本地让 AI 按当前阅读进度生成导读内容。你读到第 10 章它帮你回忆前 9 章你读到第 300 章它帮你整理跨越 300 章的人物关系和伏笔链路。1.2 产品定位不是“改文”而是“辅助理解”这里需要区分一点“溯阅”不会修改小说原文也不会生成所谓的“缩写版”让读者跳过原文。它做的事情更像“带注释的阅读导航”前情提要总结当前章节之前的关键剧情降低读后忘前的成本人物关系抽取主要人物以及共现关系形成关系网络时间线按时间词和章节顺序整理事件发展伏笔提示标记高重复、又尚未明确解释的关键词提醒读者“这里可能有事”。这种产品定位决定了它更适合做数据本地优先。因为导读内容生成出来后本质上是一个“个人阅读知识库”每个读者读到的内容、进度、笔记都不一样放云端反而增加隐私风险和同步复杂度。1.3 为什么数据本地优先值得关注“数据本地优先”可以拆成三层文件不上传本地小说文件不会因为 AI 分析而离开设备解析结果本地落库人物关系、时间线、摘要都存到本地数据库端侧计算尽量在设备端 CPU / NPU / GPU 上完成推理。对用户来说这带来三个直接好处隐私安全阅读喜好、导入书库、个人摘要都不经过第三方服务器离线可用没有网络时也能分析和查看导读响应更快省去了网络上传和等待时间尤其适合章节级即时导读。代价是端侧算力有限不能像云端大模型那样处理超大上下文。所以在架构上我会先用“规则 轻量算法”做 MVP再预留端侧模型接口按需升级。2. 开发环境与工程准备2.1 开发环境与版本说明本文示例基于鸿蒙应用开发工程编写开发工具为 DevEco Studio。具体环境如下实际版本请按你的 SDK 情况调整项目说明操作系统Windows 10/11 或 macOS开发工具DevEco Studio应用语言ArkTS ArkUI目标 API以 HarmonyOS NEXT / API 12 的写法为例构建方式工程默认的 hvigor 构建需要说明的一点是鸿蒙 SDK 版本更新较快部分 API 的导入方式在 API 9、API 10、API 12 之间存在差异。比如// 旧版本写法 import relationalStore from ohos.data.relationalStore; import util from ohos.util; // 较新版本API 12 起推荐使用 kit 方式 import { relationalStore } from kit.ArkData; import { util } from kit.ArkTS;如果你在编译时遇到导入报错优先检查当前工程的compileSdkVersion再按 SDK 对应文档调整导入语句。代码逻辑本身是通用的。2.2 鸿蒙应用工程目录规划建议按职责分层entry/src/main/ets/ ├── entryability/ │ └── EntryAbility.ets ├── model/ │ └── NovelModels.ets ├── repository/ │ └── LocalNovelRepository.ets ├── services/ │ ├── ChapterParser.ets │ ├── LocalAiService.ets │ └── PromptTemplateManager.ets └── pages/ ├── Index.ets └── ReaderPage.etsmodel数据模型比如小说、章节、人物、关系、时间线、伏笔repository本地文件与数据库访问层负责导入小说、保存解析结果services纯业务逻辑包括章节解析、AI 分析、Prompt 模板管理pagesArkUI 页面。这样分层的好处是后续把“规则 AI”替换成“端侧大模型”时只需要替换LocalAiService的内部实现UI 和数据层不用大改。3. 数据层本地导入与存储3.1 使用 DocumentViewPicker 导入本地小说在鸿蒙中大多数需要用户主动选择文件的能力都建议使用文件选择器Picker而不是直接读取任意路径。这样既符合系统安全规范也让应用不需要申请过大的存储权限。示例打开文档选择器选择一本书。import { picker } from kit.CoreFileKit; async function pickNovelFile(): Promisestring { const documentPicker new picker.DocumentViewPicker(); const pickResult await documentPicker.select({ maxSelectNumber: 1 }); if (pickResult pickResult.length 0) { return pickResult[0].uri; } throw new Error(未选择文件); }关键点说明DocumentViewPicker返回的是文件 URI不是真实沙箱路径。maxSelectNumber限制一次只能选一个避免误选多文件。对于阅读类应用获得 URI 后应该尽快把文件复制到应用沙箱避免再次授权失效。3.2 大文本文件的安全读取拿到 URI 后下一步是读取文件内容。这里最简单的方式是直接读取整个文件后转成字符串但长篇小说动辄几 MB直接readSync进内存再fromCharCode拼接可能会卡 UI 线程。建议分两步先用fs.statSync查看文件大小过大时提示用户或改为异步解析读取后用TextDecoder解码为 UTF-8 字符串避免中文乱码。示例import { fileIo as fs } from kit.CoreFileKit; import { util } from kit.ArkTS; function readUriToText(uri: string): string { const file fs.openSync(uri, fs.OpenMode.READ_ONLY); const stat fs.statSync(file.fd); const buffer new ArrayBuffer(stat.size); fs.readSync(file.fd, buffer); fs.closeSync(file); const textDecoder util.TextDecoder.create(utf-8); return textDecoder.decodeToString(new Uint8Array(buffer)); }生产环境建议做异步版本或者把读取动作放到子线程。ArkTS 中可以使用TaskPool或Worker避免大文件解析阻塞 UI。本文先把逻辑写清楚线程优化会在第 7 节展开。3.3 章节切分逻辑小说导入后需要先按章节切分后续的“前情提要”“时间线”才能按章组织。切分规则不必太复杂大多数 txt 小说都有“第x章”这种章节标题。可以用正则表达式做基础切分export interface Chapter { title: string; content: string; chapterNo: number; } export function splitChapters(content: string): Chapter[] { const chapterRegex /^\s*(第[0-9一二三四五六七八九十百千万][章节回卷部].*)$/gm; const chapters: Chapter[] []; let match: RegExpExecArray | null; let lastIndex 0; let currentTitle 前言; while ((match chapterRegex.exec(content)) ! null) { if (chapters.length 0 match.index 0) { // 把开头无章节标题的部分作为“前言” chapters.push({ title: currentTitle, content: content.slice(0, match.index), chapterNo: 0 }); } if (chapters.length 0) { const prev chapters[chapters.length - 1]; prev.content content.slice(lastIndex, match.index); } currentTitle match[1]; lastIndex match.index; chapters.push({ title: currentTitle, content: , chapterNo: chapters.length }); } if (chapters.length 0) { const lastChapter chapters[chapters.length - 1]; lastChapter.content content.slice(lastIndex); } return chapters; }这个切分方案有几个边界情况如果正文开头没有“第x章”会作为前言存储如果章节标题格式不统一比如只有“第一章”没有“节”正则需要扩充如果文件里有“第x章”出现在引用内容里可能会误切分。这里可以在真实项目中增加“章节标题必须在行首且较短”的过滤。3.4 使用 RDB 持久化小说与导读结果章节切分完之后需要落库。本地优先方案里关系型数据库RDB适合保存结构化信息比如章节列表、人物、关系、时间线。创建数据库表和保存小说的示例import { relationalStore } from kit.ArkData; import { common } from kit.AbilityKit; const STORE_CONFIG: relationalStore.StoreConfig { name: suyue.db, securityLevel: relationalStore.SecurityLevel.S1 }; export async function initDatabase(context: common.UIAbilityContext) { const store await relationalStore.getRdbStore(context, STORE_CONFIG); await store.executeSql( CREATE TABLE IF NOT EXISTS novel ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT, chapter_count INTEGER, imported_at TEXT DEFAULT CURRENT_TIMESTAMP ) ); await store.executeSql( CREATE TABLE IF NOT EXISTS chapter ( id INTEGER PRIMARY KEY AUTOINCREMENT, novel_id INTEGER NOT NULL, chapter_no INTEGER NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL ) ); await store.executeSql( CREATE TABLE IF NOT EXISTS ai_analysis ( id INTEGER PRIMARY KEY AUTOINCREMENT, novel_id INTEGER NOT NULL, chapter_no INTEGER, analysis_type TEXT NOT NULL, analysis_data TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ); return store; }存储解析结果时可以把 JSON 字符串写入analysis_data字段。对于前情提要、人物关系、时间线这类结构不固定的数据用 JSON 存储比强行拆成多个表更灵活。4. AI 助手实现从前情摘要到伏笔追踪4.1 本地 AI 服务的接口抽象为了让后期能替换算法或模型先定义统一的 AI 服务接口export interface NovelAnalysisResult { summary: string; characters: string[]; relations: RelationItem[]; timeline: TimelineItem[]; foreshadowing: ForeshadowingItem[]; } export interface LocalAiService { analyzeNovel(novelId: number, chapters: Chapter[]): PromiseNovelAnalysisResult; analyzeChapter(novelId: number, chapter: Chapter, contextChapters: Chapter[]): PromiseNovelAnalysisResult; }接口设计成两个方法一个用于整本分析一个用于“按当前章节生成导读”。阅读器里更常用的是后者因为读者读到第 50 章时不需要全书分析只需要基于前 50 章生成近期前情。4.2 MVP 方案规则驱动的轻量文本分析在端侧接入大模型之前可以先做一个规则驱动的 MVP。它的优点是不依赖模型文件包体小离线可用逻辑可解释方便调优。缺点是理解能力有限只能处理“明确出现”的信息无法做复杂推理。但对人物共现、时间词抽取、高频关键词这类任务规则方案已经够用。看一个简单的人物抽取示例function extractCharacters(chapters: Chapter[], minFreq: number): string[] { const charFreq: Mapstring, number new Map(); for (const chapter of chapters) { // 这里用连续的 2~4 字片段做候选实际项目会接入分词或实体识别模型 const candidates extractNameCandidates(chapter.content); for (const name of candidates) { charFreq.set(name, (charFreq.get(name) ?? 0) 1); } } return Array.from(charFreq.entries()) .filter(([name, freq]) freq minFreq) .sort((a, b) b[1] - a[1]) .map(([name]) name) .slice(0, 50); }关于extractNameCandidates的完整实现最简单的方式是维护一个“已知人物表”或者使用基于词性的实体识别。为了演示这里用“高频词 排除常用词”的思路const STOP_WORDS new Set([说道, 什么, 一个, 没有, 自己, 就是, 知道, 不是, 可以, 我们, 他们]); function extractNameCandidates(text: string): string[] { // 简化处理按标点切分后取连续 2-4 字片段 const segments text.split(/[。、\s“”\n]/); const results: string[] []; for (const seg of segments) { const trimmed seg.trim(); if (trimmed.length 2 trimmed.length 4 !STOP_WORDS.has(trimmed)) { results.push(trimmed); } } return results; }这个方案会很粗糙会抽出一堆非人名。因此在实际工程中我更建议先用分词服务把句子分成词序列再结合词性标注找出“名词 - 人名”最后再按频率过滤。鸿蒙端侧可以使用 HUAWEI ML Kit 提供的文本实体识别能力也可以集成开源分词库。这里的核心是“数据不出设备”的原则而不是纠结某一套分词方案。4.3 前情提要的生成思路前情提要不能只是“高频词的拼接”它需要按章节前后关系组织关键信息。MVP 阶段可以这样做对每个章节提取关键词保留频率高的名词和动词记录每章开头和结尾的 1~2 句关键句组合成“在第 x 章中主角面临……结尾留下……的悬念。”例如function generateChapterSummary(chapter: Chapter): string { const sentences chapter.content.split(/(?[。])/); const opening sentences[0] ?? ; const closing sentences[sentences.length - 1] ?? ; return 本章「${chapter.title}」${opening} 尾部关键信息${closing}; }这种摘要的优点是稳定、不产生幻觉缺点是句式机械。如果你打算接入本地大模型可以把抽取出的关键词和章首尾句作为“上下文约束”让模型改写得更自然而不是让它从零生成这样可以减少事实性错误。4.4 人物关系与伏笔时间线人物关系可以采用“章节共现”策略两个人物在同一章节中同时出现说明他们可能存在关系。出现次数越多关系权重越高。export interface RelationItem { source: string; target: string; weight: number; } export function buildRelations(chapters: Chapter[], characters: string[]): RelationItem[] { const relationMap: Mapstring, Mapstring, number new Map(); for (const character of characters) { relationMap.set(character, new Map()); } for (const chapter of chapters) { const presentChars characters.filter((name) chapter.content.includes(name)); for (let i 0; i presentChars.length; i) { for (let j i 1; j presentChars.length; j) { const a presentChars[i]; const b presentChars[j]; relationMap.get(a)!.set(b, (relationMap.get(a)!.get(b) ?? 0) 1); relationMap.get(b)!.set(a, (relationMap.get(b)!.get(a) ?? 0) 1); } } } const relations: RelationItem[] []; relationMap.forEach((targets, source) { targets.forEach((weight, target) { if (source target weight 2) { relations.push({ source, target, weight }); } }); }); return relations.sort((a, b) b.weight - a.weight); }时间线则可以通过“时间词 章节号”来组织export interface TimelineItem { chapterNo: number; timeDesc: string; content: string; } const TIME_PATTERN /(\d{4}年|\d月\d日|三日后|几日后|翌日|次日|当天晚上|数日后|突然|与此同时)/g; export function extractTimeline(chapter: Chapter): TimelineItem[] { const items: TimelineItem[] []; const text chapter.content; let match: RegExpExecArray | null; while ((match TIME_PATTERN.exec(text)) ! null) { const start match.index; const end Math.min(start 50, text.length); items.push({ chapterNo: chapter.chapterNo, timeDesc: match[0], content: text.slice(start, end).replace(/\s/g, ) }); } return items; }伏笔追踪更像一个“词频异常检测”问题。可以记录某关键词在前文反复出现但后文始终没有解释就标记为“疑似伏笔”。具体实现可以在章节解析完成后统计关键词在各章节出现的频率曲线把“高重复 突然消失或陡增”的词标记出来。4.5 进阶接入端侧模型并设计结构化 Prompt规则 MVP 只能提供基础能力如果要做更自然的前情提要和角色关系说明可以接入端侧模型。鸿蒙生态中可以在应用内集成 MindSpore Lite 或 ONNX Runtime加载小体量模型。大模型的完整接入涉及模型转换、资源打包、推理框架初始化等步骤不是一篇文章能覆盖完整细节这里给出架构思路和一个 Prompt 模板示例。建议把 Prompt 设计成“角色 输入 输出格式”三段式你是一位阅读辅助助手。请根据下面提供的小说章节内容生成结构化导读。 输出限定为 JSON 格式包含 - summary: 本章前情提要 - characters: 出现的主要人物数组 - timeline: 关键事件时间线数组 - foreshadowing: 疑似伏笔描述 要求 1. 不要修改原文内容 2. 不要输出原文之外的猜测 3. 使用简洁中文。 小说章节标题${chapterTitle} 小说章节内容${chapterContent}关键点在于模型生成结果需要做 JSON 解析和字段校验如果模型返回了非法 JSON可以做一次“提取 JSON 片段”的兜底解析而不是直接崩溃。5. UI 展示把导读揉进阅读体验5.1 阅读页基础布局阅读页用 ArkUI 实现核心结构如下Entry Component struct ReaderPage { State currentChapterNo: number 0; State chapterTitle: string ; State contentText: string ; State showAiPanel: boolean false; build() { Column() { Scroll() { Column() { Text(this.chapterTitle) .fontSize(22) .fontWeight(FontWeight.Bold) .margin({ bottom: 12 }); Text(this.contentText) .fontSize(17) .lineHeight(26) .textAlign(TextAlign.Start); } .alignItems(HorizontalAlign.Start) .padding(16) } .layoutWeight(1) Row({ space: 16 }) { Button(上一章) .onClick(() this.changeChapter(-1)) Button(AI 导读) .onClick(() { this.showAiPanel !this.showAiPanel; }) Button(下一章) .onClick(() this.changeChapter(1)) } .justifyContent(FlexAlign.Center) .padding(12) if (this.showAiPanel) { AiSummaryPanel() .height(40%) .backgroundColor(#F8F9FA) } } } changeChapter(delta: number) { // 根据章节索引加载新的章节数据 } }这里用if控制底部 AI 面板的显示与隐藏。优点是实现简单缺点是面板出现时会挤压正文空间。更自然的做法是用bindSheet半模态或Navigation路由进入独立导读页你可以按产品体验需求选择。5.2 导读面板前情、人物、时间线、伏笔导读面板可以用Tabs分成多个子页签Component struct AiSummaryPanel { Prop analysisResult: NovelAnalysisResult; build() { Tabs() { TabContent() { Text(this.analysisResult.summary) .padding(16) .fontSize(15) .lineHeight(22) }.tabBar(前情) TabContent() { List({ space: 8 }) { ForEach(this.analysisResult.characters, (name: string) { ListItem() { Text(name) .fontSize(15) .padding(8) } }) } .padding(16) }.tabBar(人物) TabContent() { List({ space: 10 }) { ForEach(this.analysisResult.timeline, (item: TimelineItem) { ListItem() { Column() { Text(第 ${item.chapterNo} 章 · ${item.timeDesc}) .fontSize(14) .fontWeight(FontWeight.Medium) Text(item.content) .fontSize(13) .fontColor(#666666) .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) .width(100%) } }) } .padding(16) }.tabBar(时间线) } .height(100%) } }人物关系如果只是文本列表可读性一般。比较实用的做法是先展示“和主角关系最密切的 Top 10 角色”再按共现权重排序展示关系链。如果需要绘制真正的网状图可以后续用 Canvas 绘制节点和连线但 MVP 阶段先做列表能更快验证核心流程。5.3 伏笔提示的展示策略伏笔提示不应该打扰阅读更适合放在当前章节末尾以轻量卡片的形式出现Component struct ForeshadowingCard { Prop item: ForeshadowingItem; build() { Row() { Text(伏笔) .fontSize(12) .fontColor(Color.White) .backgroundColor(#F59E0B) .borderRadius(4) .padding({ left: 6, right: 6, top: 2, bottom: 2 }) Text(this.item.description) .fontSize(14) .fontColor(#333333) .margin({ left: 8 }) } .padding(10) .backgroundColor(#FEF3C7) .borderRadius(8) .margin({ top: 8, left: 16, right: 16 }) } }这种卡片式提示用户看到可以自行判断要不要回翻前文不会强制打断阅读流程。6. 常见问题与排查思路在实际开发“溯阅”这类本地阅读 AI 应用时下面几个问题最容易出现。问题现象可能原因解决思路选择文件后读取为空URI 授权失效或文件非 UTF-8 编码先复制到沙箱再读取尝试 GBK 编码解码章节切分错乱正则太严格或太宽松先抽样本书测试增加“章节标题行较短”过滤中文乱码解码方式与文件编码不匹配优先使用 UTF-8兼容 GBK/GB18030大文件解析卡顿在主线程解析大文本使用 TaskPool 或 Worker 异步解析数据库插入很慢逐条 insert 大量章节使用事务批量插入AI 摘要出现错误信息规则或模型产生幻觉明确限制模型只能基于给定文本输出模型加载失败模型文件路径错误或格式不兼容确认模型是否转换为端侧格式并检查包体路径补充说明章节误切分是最隐蔽的 Bug。例如有些小说会在章末插入“第x章 明天继续”这种内容如果正则匹配到它章节列表就会多出空章节。建议在切分后做一次基础校验如果某个章节内容长度小于 50 字先标记为“疑似过渡页”再由用户决定是否合并。另外解决中文乱码时可以按顺序尝试。第一次先用 UTF-8 解码检测结果中是否包含大量\uFFFD替换字符如果有再尝试 GBK。鸿蒙的TextDecoder本身支持多编码但需要针对不同编码创建对应 decoder。7. 工程化与最佳实践7.1 数据本地优先的工程原则既然强调数据本地优先代码层面就要贯彻“不出设备”的默认值。所有文件解析、数据库写入、AI 推理都使用本地沙箱路径不申请不必要的存储权限文件选择通过 Picker 完成日志中不要打印小说正文内容防止本地日志被其他调试工具读取如果未来要做云同步建议只同步用户自己的导读笔记不同步小说原文。7.2 性能优化别让大文本卡死 UI长篇小说单本可能在 1MB 到 10MB 之间解析 JSON、正则匹配、关系抽取都是 CPU 密集型任务。建议在架构上做三件事使用TaskPool把解析和 AI 分析放到子线程章节内容只在需要展示时加载单章不把整本小说一次加载进内存每次分析结果按novel_id chapter_no analysis_type缓存到 RDB下次直接读缓存。一个简单的任务池示例思路import { taskpool } from kit.ArkTS; Concurrent function analyzeChapterTask(chapterContent: string): string { // 这里执行耗时分析 return ; } const task new taskpool.Task(analyzeChapterTask, content); const result await taskpool.execute(task) as string;注意Concurrent装饰的函数不能访问普通对象上下文只能通过参数传递数据。因此对于大文本你要把章节内容作为字符串参数传进去。7.3 权限、隐私与合规阅读应用涉及用户私人文件合规方面需要特别留意最小权限原则只申请实际用到的权限文件读取尽量走 Picker不申请全盘存储权限用户授权AI 分析前说明“本应用通过本地 AI 生成导读不上传任何数据”删除机制用户删除小说或导读结果时应同步清除数据库中的对应记录测试数据开发阶段不要使用未经授权的版权内容做测试建议用自己撰写或公开授权的文本验证功能。7.4 模型与 AI 能力接入的演进路径规则 MVP 是起点但不是终点。演进路径可以这样规划阶段一规则 词频 共现分析完成 MVP验证用户体验 阶段二接入端侧实体识别和关键词提取提升人物抽取准确率 阶段三接入小参数端侧语言模型用结构化 Prompt 生成更自然的前情提要和伏笔判断 阶段四引入用户反馈机制例如“这个导读有帮助/没帮助”用反馈数据在本地微调提示词模板。每一步都保持LocalAiService接口不变尽量不影响 UI 层。8. 最小 MVP 路线图与下一步动手计划如果你想快速跑通“导入小说 - 生成导读 - 展示”的完整流程可以按这个顺序动手先实现文件选择和读取第 3.1、3.2 节代码实现章节切分和 RDB 存储第 3.3、3.4 节代码实现一个最简单的LocalAiService只输出“章节首尾句拼成的摘要”和“人物共现关系”搭一个阅读页底部用Tabs展示导读数据第 5.1、5.2 节代码跑通后再替换规则算法为真实模型。完成这五步你就拥有一个数据本地优先、离线可用、隐私可控的鸿蒙小说阅读 AI 助手雏形。后续的时间线抽取、伏笔追踪、关系图谱可视化都是在这个骨架上的扩展。如果这篇文章对你有帮助可以先收藏备用。等到实际跑通第一版你会发现“数据本地优先”并不是一个限制反而让整个应用结构更干净没有服务器、没有网络请求、没有账号体系也能完成一套完整的 AI 阅读辅助体验。