根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:

```
docs(readme): 更新文档说明

- 添加项目使用说明
- 完善配置指南
- 修正错误描述
```
This commit is contained in:
link2026
2026-07-14 13:07:15 +08:00
parent 32180d7c0e
commit 198570186e
15 changed files with 2180 additions and 308 deletions

View File

@@ -28,6 +28,7 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
private let lock = NSLock()
private var handle: FileHandle?
private var written: Int = 0
private var expectedTotal: Int = 0
private var onProgress: ((Int) -> Void)?
private var responseError: Error?
private var continuation: CheckedContinuation<Void, Error>?
@@ -77,24 +78,33 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
lock.withLock {
self.handle = fileHandle
self.written = offset
self.expectedTotal = expectedBytes
self.onProgress = onProgress
self.responseError = nil
}
// offset 0 `bytes=0-`: 206 + Content-Range ,
// didReceive (2026-07-14 ),
// sizeMismatch, 3.5GB
var request = URLRequest(url: url)
if offset > 0 {
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
}
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
// didCompleteWithError ( delegate , didReceive)
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
lock.lock()
self.continuation = cont
lock.unlock()
session.dataTask(with: request).resume()
do {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
lock.lock()
self.continuation = cont
lock.unlock()
session.dataTask(with: request).resume()
}
} catch let error as DownloadError {
// : .part,
// badStatus( 503) .part,便
if case .sizeMismatch = error { try? fm.removeItem(at: part) }
throw error
}
let finalSize = Self.fileSize(at: part)
@@ -119,9 +129,27 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
completionHandler(.cancel)
} else {
completionHandler(.allow)
return
}
// :206 Content-Range(`bytes a-b/total`),
// ,(,)
// 206 200 Content-Length (gzip),,
if let http = response as? HTTPURLResponse, http.statusCode == 206,
let contentRange = http.value(forHTTPHeaderField: "Content-Range"),
let totalPart = contentRange.split(separator: "/").last,
let serverTotal = Int(totalPart) {
lock.lock()
let expected = expectedTotal
lock.unlock()
if serverTotal != expected {
lock.lock()
responseError = DownloadError.sizeMismatch(expected: expected, got: serverTotal)
lock.unlock()
completionHandler(.cancel)
return
}
}
completionHandler(.allow)
}
nonisolated func urlSession(

View File

@@ -198,15 +198,17 @@ actor GeminiBackend {
}
/// Gemini `GenerateContentResponse`
private struct GeminiResponse: Decodable {
struct Candidate: Decodable { let content: Content? }
struct Content: Decodable { let parts: [Part]? }
struct Part: Decodable { let text: String? }
struct Usage: Decodable {
/// nonisolated: MainActor , Decodable MainActor,
/// GeminiBackend (Swift 6 )
private nonisolated struct GeminiResponse: Decodable {
nonisolated struct Candidate: Decodable { let content: Content? }
nonisolated struct Content: Decodable { let parts: [Part]? }
nonisolated struct Part: Decodable { let text: String? }
nonisolated struct Usage: Decodable {
let promptTokenCount: Int?
let candidatesTokenCount: Int?
}
struct APIError: Decodable { let message: String? }
nonisolated struct APIError: Decodable { let message: String? }
let candidates: [Candidate]?
let usageMetadata: Usage?
let error: APIError?

View File

@@ -42,19 +42,23 @@ nonisolated enum ModelManifest {
// LLMModelFactory "gemma4" (mlx-swift-lm 3.31.4 ;Gemma4Model
// language_model ,/)e2b ( gemma4_unified 12B)
// ModelScope mlx-community/gemma-4-e2b-it-4bit blob
//(repo/files API,2026-07 ) README.md / .gitattributes / configuration.json
//( ModelScope ,MLX )Gemma 4 3n : tokenizer.model /
// special_tokens_map.json,tokenizer_config.json (chat template chat_template.jinja),
// processor_config.json mlx-swift-lm ,
//(Range Content-Range ,2026-07-14 / hf-mirror )
// 2026-07 checkpoint( 3.581G3.551G,config/tokenizer_config/
// processor_config/index ;model_type gemma44bit,)
// FileDownloader sizeMismatch,,
// README.md / .gitattributes / configuration.json( ModelScope ,MLX
// )Gemma 4 3n : tokenizer.model / special_tokens_map.json,
// tokenizer_config.json (chat template chat_template.jinja),
// processor_config.json mlx-swift-lm ,
return [
ModelFile(path: "config.json", bytes: 5_996),
ModelFile(path: "config.json", bytes: 6_395),
ModelFile(path: "generation_config.json", bytes: 208),
ModelFile(path: "model.safetensors", bytes: 3_581_101_896),
ModelFile(path: "model.safetensors.index.json", bytes: 230_329),
ModelFile(path: "model.safetensors", bytes: 3_550_670_554),
ModelFile(path: "model.safetensors.index.json", bytes: 218_323),
ModelFile(path: "tokenizer.json", bytes: 32_169_626),
ModelFile(path: "tokenizer_config.json", bytes: 2_095),
ModelFile(path: "tokenizer_config.json", bytes: 2_740),
ModelFile(path: "chat_template.jinja", bytes: 17_336),
ModelFile(path: "processor_config.json", bytes: 902),
ModelFile(path: "processor_config.json", bytes: 1_316),
]
case .vl:
// Qwen3-VL-4B-Instruct-4bit: mlx-community blob

View File

@@ -0,0 +1,143 @@
import Foundation
/// LLM prompt:,
/// ,
///
/// (穿 prompt):
///
///
/// 线沿 `DiaryAssistPrompts.organize`:///;
/// § aiOutputLanguageDirective(App/Localization.swift)
///
/// nonisolated:,;`DiaryChatService` nonisolated
/// (roundsUsed/isWrappedUp) `maxRounds`, MainActor
nonisolated enum DiaryChatPrompts {
/// ()
static let maxRounds = 4
/// 稿(),2B/E2B context
static let distillTranscriptLimit = 2000
/// AI (View , LLM)
static var staticOpening: String { String(appLoc: "今天身体感觉怎么样?想到什么就说什么,我帮你记着。") }
// MARK: -
/// ,: + 1
static func opening(dataJSON: String) -> String {
"""
你是「康康」,用户健康 App 里陪伴记录的小伙伴,你不是医生。
用户刚打开「聊着记」,你要说第一句话:先自然地打个招呼,再基于下面【健康记录】里最近一条记录,像老朋友一样关心地问 1 个问题。
铁律:
- 只能提到【健康记录】里真实出现过的内容,记录里没有的绝不编造。
- 数值、日期、药名必须与记录完全一致,一个字都不能改。
- 不诊断、不给用药或剂量建议、不写「建议就医」、不评价病情轻重、不用「患者」称呼。
- 最多 2 句话,只问 1 个问题;不用列表、不用 Markdown、不用表情符号。
- 记录里 diaries / indicators 全为空时,不提任何具体记录,只简单问今天感觉怎么样。
示例 1(记录里有「2026-07-11 日记:头疼了一下午」):
前天你记过头疼了一下午,现在好些了吗?
示例 2(记录里有血压 145/92 偏高):
昨天量的血压 145/92 有点偏高,今天有没有再量一次?
示例 3(记录为空):
嗨,今天身体感觉怎么样?想到什么就跟我说说,我帮你记下来。
【健康记录】:
\(dataJSON)
直接输出这句话,不要引号、不要解释、不要 <think> 标签。
\(aiOutputLanguageDirective())
/no_think
"""
}
// MARK: -
/// ,:,
/// - roundsLeft: ;> 0 ,== 0
static func reply(transcript: String, latest: String, dataJSON: String, roundsLeft: Int) -> String {
// Swift ,:,
let roundRule: String = roundsLeft > 0
? "本轮还可以追问,最多问 1 个新问题。追问只围绕「把这条记录补完整」:时间、部位、程度、吃没吃药、睡眠饮食等;可以结合【健康记录】做有记忆的追问(如「上次你记过 XX,这次也一样吗」)。"
: "【不要再提任何问题】。用 1-2 句话温和收尾:先接住用户刚说的话,再告诉 TA 今天先聊到这儿,点下面的「聊完了」,我帮你整理成一条记录。"
return """
你是「康康」,用户健康 App 里陪伴记录的小伙伴,你不是医生。
你正在「聊着记」里陪用户聊天。任务:先用一小句接住用户刚说的话(共情或确认),再按【本轮规则】决定要不要追问。
铁律:
- 只能提到【健康记录】里真实出现过的内容,记录里没有的绝不编造;数值、日期、药名必须与记录完全一致。
- 不诊断、不给用药或剂量建议、不写「建议就医」、不评价病情轻重、不用「患者」称呼。
- 总共最多 2 句话;不用列表、不用 Markdown、不用表情符号。
【本轮规则】:
\(roundRule)
示例(还能追问,用户说「下午头就不疼了,不过晚上只睡了五个小时」):
不疼了就好。昨晚只睡五个小时,是入睡难还是半夜醒了?
示例(收尾):
好,记下啦。今天先聊到这儿,点下面的「聊完了」,我帮你把这些整理成一条记录。
【健康记录】:
\(dataJSON)
【对话】:
\(transcript.isEmpty ? "" : transcript)
【用户刚说的】:
\(latest)
直接输出你要说的话,不要引号、不要解释、不要 <think> 标签。\(aiOutputLanguageDirective())
/no_think
"""
}
// MARK: -
/// 稿线 `DiaryAssistPrompts.organize`:
/// ,**** aiOutputLanguageDirective:
static func distill(transcript: String) -> String {
let trimmed = String(transcript.prefix(distillTranscriptLimit))
return """
你是「康康」,用户健康 App 里陪伴记录的小伙伴。下面是你和用户在「聊着记」里的对话稿。
请把用户说的话蒸馏成一条健康日记草稿。
硬性规则:
- 【只取「我:」说的内容作为事实】——「康康:」的话只是提问和陪伴,绝不能把康康提到的记录、猜测或问题当成用户今天发生的事写进日记。
- 【绝对不许】增加、删除或改动任何数值、单位、药名、时间——原话说 140/90 就必须写 140/90。
- 输出语言必须与「我:」的原话完全一致:原话是中文就用中文,是英文就用英文,是日文/韩文就用对应语言,不要翻译。
- 用第一人称;去掉口头语和重复;不加入对话里没有的事实。
- 内容只涉及一两个方面 → 整理成一段通顺的话(2-4 句)。
- 内容涉及多个方面(症状/用药/饮食/睡眠/运动等) → 按「方面:内容」分行。
- 不诊断、不给用药建议、不写「建议就医」。
- 只输出日记正文,不要开头语、不要解释、不要 markdown 围栏、不要 <think> 标签。
示例 1(单方面 → 成段):
我: 今天头有点晕早上量了血压140 90
康康: 记下啦。上次你记过没吃早饭,今天吃了吗?
我: 吃了吃了 喝了粥
输出:
今天头有点晕,早上量了血压 140/90。早饭吃了,喝了粥。
示例 2(多方面 → 分行;注意康康的话不进日记):
我: 今天头晕了一上午下午好点了血压早上量的140 90缬沙坦吃了
康康: 记下啦。上次你血压也偏高,是不是又没睡好?昨晚睡了几个小时?
我: 就睡了五个小时中午吃的清淡
输出:
症状:头晕了一上午,下午好转。
血压:早上 140/90。
用药:缬沙坦已服。
睡眠:只睡了五个小时。
饮食:午餐清淡。
【对话记录】:
\(trimmed)
Output: /no_think
"""
}
}