一个不依赖任何第三方库的原生安卓应用:程序化 UI、SSE 流式生成、分层记忆压缩、 结构化人物档案、可核对的费用账本,以及一层把 15 家模型接进来的适配层。 这一页把关键取舍和实现讲清楚。
做减法:能不引依赖就不引,能不联网的服务端就不搭
只用 Android SDK 自带 API:HttpURLConnection 发请求、org.json 解析、
自绘 Canvas 画图表。没有 Gradle 之外的构建插件,APK 才 638KB 左右。
界面在 Java 里搭(LinearLayout + GradientDrawable),样式统一走一套主题调色板,
深浅色切换只需重新装载色板 + recreate()。好处是换肤、动效、圆角都能在一处统一改。
没有数据库:filesDir/novels/novel_<时间戳>.json,写入用
临时文件 + renameTo 原子替换,进程被杀也不会写出半个坏文件。
请求带 stream:true,逐行解析 data: 帧,正文增量往界面上刷;
UI 侧做节流,避免每个 token 都触发布局。
PromptBuilder 负责"读什么、按什么顺序读",NovelStore 负责存档,
DeepSeek 只管收发与方言,互不越界。
官网是纯静态页面(Netlify),App 直接带着你自己的 Key 访问模型厂商。 没有中转服务器,也就没有"你的稿子存在别人机器上"这件事。
四层:界面只管显示,业务层管"该给 AI 看什么"和"钱花在哪",网络层管协议,存储层管落盘
书架、创作设置、生成页、重写、续写、记忆档案、模型管理、花费页、对话页;图表与开关都是自绘。
决定每章"读哪些前文、带哪些档案",维护章节与版本,记录每一笔花费,解析当前该用哪家模型。
SSE 解析、停止令牌、错误分类、思考参数"方言"、用量与思考 token 解析、余额与模型列表查询。
书稿、人物档案、章节版本、费用账本都在书自己的 JSON 里;设置与对话历史在 prefs。
全部落在应用私有目录,卸载即消失;导出即是一份完整快照
# 应用私有目录(无需任何存储权限) files/ ├─ novels/ │ ├─ novel_1730000000000.json # 一本书的全部内容(章节 / 档案 / 版本 / token / 费用账本) │ ├─ novel_1730000000000.记忆.txt # 同内容的可读版:人物档案 + 每章摘要 │ └─ ... └─ chat-costs.json # 对话页的花费(不属于任何一本书) # SharedPreferences "settings" providers # 服务方列表(含密钥、模型目录、思考写法、可选计价) write_provider / write_model / char_provider / char_model think_write / think_char / think_effort / cache_opt theme / font_size / tts_* / export_dir_uri ...
| 字段 | 用途 |
|---|---|
title / characters / background / globalPrompt | 创作设定(AI 每次都会读到) |
memoryMode / memoryChapters | 记忆模式(传统 / modern)与"最近几章给原文" |
chapters[] | 每章:正文、摘要、人物记录、token 与花费、生成时间、versions[] 历史版本 |
charactersJson[] | 结构化人物档案:姓名 / 年龄 / 性别 / 身份 / 性格 / 当前状态 / 其他 |
memoryText | 我的笔记 + 章节摘要区(用户可编辑,AI 只读) |
costs[] | 费用账本:时间 / 用途 / 金额 / 输入命中·未命中·输出·思考 token / 三段费用 / 模型 |
xxx.json.tmp,再 renameTo 覆盖正式文件。
写一半被杀进程只会留下一个临时文件,正式稿永远完整。
从"点生成"到"章节落盘",中间发生的事
PromptBuilder 按这本书的记忆模式拼出 system + user:传统模式=人物档案 → 全部摘要 +
最近 N 章原文;modern 记忆引擎=本地检索出的事实卡片 + 未闭合线索 + 上一章全文 →
本章要求 → 最后贴"写作纪律"(放在最末尾,靠近因效应压住跑题)。
本地多路检索:本章要求 / 每章通用要求 / 未闭合线索 / 上一章结尾各算一路,取每张卡的最高分, 再按图谱 2 跳扩展、按重要度与时间衰减加权。未闭合线索与用户点名的物品强制带上, 全程不额外调用模型。传统模式没有这一步。
开启后把请求改成"接着上一轮继续"的多轮形式:[system] + (user 要求, assistant 正文)×N + [本章要求],
并把每章实际发出的 user 消息原样存档,下次请求前缀与上一轮完全一致 → 整段命中缓存(命中价约为未命中的 1/50)。
逐行读 data: 帧:choices[0].delta.content 是正文,
reasoning_content 是思考内容(单独进"思考"面板);末尾的 usage 块给出真实用量。
写完把本章正文交给一次非流式请求:同一个 JSON 里同时返回人物档案、物品档案与 本章新事实(记忆卡片),本地按"槽位"合并落库 —— 同一件事发生变化就把旧卡作废、新卡记下取代关系。 这一步**搭在原有请求里,不多花一次调用**。
模型会在正文后追加 【本章摘要】(传统模式还会追加 【人物记录】)。
显示层一旦遇到标记就停止输出,标记后的内容只进存档——用户看到的正文始终是干净的。
按 usage 的三段 token 计价(没有就按字数估算),写入这本书的 costs[];同时刷新查看页顶部的余额条。
作者写长篇最大的敌人是"注意力涣散":越写越长,模型越容易忘设定、重复情节
| 传统模式 | modern 模式(记忆引擎) | |
|---|---|---|
| 给什么 | 全部已批准章节的摘要 + 最近 N 章(1~50,可自定义)的完整原文 | 检索出来的事实卡片(约 1500 字)+ 未闭合线索 + 上一章全文(保持文风与衔接) |
| 额外机制 | — | 多路检索 + 图谱扩展;写完把本章新事实抽成卡片存回库;每 10 章把过旧的低重要度卡片折叠成「卷摘要」 |
| 上下文随篇幅 | 线性增长(摘要越堆越多) | 基本恒定:写 20 章和写 200 章,每次要看的差不多 |
| 适合 | 短篇;希望模型贴着前文文风细节续写 | 长篇;希望越写越不容易前后矛盾、且花费不随篇幅膨胀 |
每章开写前,本地从记忆库里挑出与本章相关的事实卡片(多路查询取最高分,图谱扩展两跳), 不额外调用模型、不额外花钱。
模型只依据卡片写作:卡片里没有的细节可以发挥,但不得与卡片冲突;矛盾项会被标成「⚠待确认」, 按保守方式处理。
写完把本章新事实抽成卡片存回库:谁做了什么、状态/关系变化、承诺与伏笔。旧卡作废但留痕, 重写旧章节时只读"章号早于本章"的卡片。
写一百章也不会"谁受了伤、谁藏了秘密、东西在谁手上"记乱
档案是 JSON 数组而不是一段文本。每章写完单独发一次请求,只让模型输出新出场或信息有变化的角色, 再按字段合并进现有档案:同名覆盖非空字段、新名追加、永不删除角色。输出量小,角色再多也不会漏。
每章会存下"写这一章之前"的档案快照(charsBefore)。以后重写第 N 章时,模型读到的是
"没有写过这一章"的档案,避免它认为那些剧情已经发生过——重写不再自相矛盾。
物品档案比人物更简单,只有"状态"一个字段,而且只记你在「要记的物品」里写下的类别: 填「灵魂碎片」,它就记下正文里出现过的「黑色碎片」「蓝色碎片」及其状态,没点名的类别一律不记。
人物与物品在同一次档案更新请求里返回并合并,不多花一次调用;物品同样会进记忆档案和每次生成的上下文, 所以"东西在谁手上、还剩几枚"这类信息不会漂。
"花了多少"不该是个黑盒 —— 按用途、按计费项两个口径分开看
| 计费项 | 含义 | DeepSeek 空闲价(元/百万 tokens) |
|---|---|---|
| 输入 · 缓存命中 | 命中缓存的前文,约为未命中的 1/50 | Flash 0.02 / Pro 0.15 |
| 输入 · 未命中 | 这次新读进去的前文与要求 | Flash 1 / Pro 4.5 |
| 输出(含思考) | 正文与思考过程,按输出价计费 | Flash 4 / Pro 13.5 |
completion_tokens 里已经含了 reasoning token,
所以"思考"是从输出费用里按 token 占比切出来的——两个数加起来正好等于输出总费用,不会重复计。
服务端没返回 reasoning_tokens 时,按流式收到的思考正文估算(取不到就如实标注)。
每一笔都写进所属书籍 JSON 的 costs[]:时间、用途(正文 / 重写 / 人物档案 / 摘要 / 定位)、
金额、四类 token、三段费用、模型名。所以导出原文件、整包备份都把花费带走,换设备后账目仍在。
升级前的老书写的是"每章一共花了多少"。第一次记账时会把这些按章记录合成为"正文生成 / 未分项", 一两百章也不会算两遍;记录超过 900 条会按用途压缩,文件不会无限膨胀。
写小说用什么模型由你定;各家接口的"方言差异"由这一层吃掉
| 方言 | 思考参数写法 | 典型厂商 |
|---|---|---|
deepseek | thinking:{type} + reasoning_effort | DeepSeek 官方 |
glm | 只发 thinking:{type} | 智谱 GLM、火山方舟(豆包) |
qwen | 顶层 enable_thinking | 阿里云百炼、Kimi、硅基流动 |
minimax | thinking:{type:"adaptive"} | MiniMax |
standard | 只发 reasoning_effort | OpenAI、Gemini、xAI、混元 |
openrouter | reasoning:{effort} | OpenRouter |
none | 什么都不发 | 千帆、星火、本地 Ollama |
基元律动 TokenRhythm、OpenRouter、智谱 GLM、阿里云百炼、Kimi、MiniMax、硅基流动、火山方舟、 腾讯混元、百度千帆、讯飞星火、OpenAI、Gemini、xAI Grok、本地 Ollama。
每家都带几个代表性模型,点一下就把地址、模型、思考写法填好;编辑页里还能 "从接口获取模型列表"实时拉取你账号下的全部模型。
第三方没有统一价目表,所以在提供方(或单个模型)上留了"计费价格"三项: 输入命中 / 未命中 / 输出。填了就能算钱,没填就如实显示"只记 token 与次数"。
一个静态 version.json 撑起整条更新链
内容只有版本号、更新说明、APK 地址。App 启动时拉一次,用 versionCode 与本机比对
(同一新版本只提醒一次)。
自己实现了一个 FileProvider 暴露私有目录里的 APK,下载完直接拉起系统安装器;
下载过程有环形进度动画。
更新检查地址写在设置里,换域名不用重新打包——发版只改 version.json 一个文件。
都是真机上暴露出来的,记在这里免得再犯
人物档案走的是非流式接口,代码里从来没人解析过它的 usage —— 于是"人物档案"这一项的花费一直是 0。
修完才看得到钱花在哪。
一开始想做成"输入 / 思考 / 输出"三段独立计费,等于把思考算了两遍。正确做法是: 思考是输出的一部分,只是从输出费用里按占比切出来展示。
高亮选中的扇形时把其余片压到了 36% 不透明度,深色模式下看着像被删了。 现在改成完全不压暗,靠"弹出 + 变粗 + 柔光"表达选中。