merge: W6 — 文档进对话数据层(/documents + chat.documents,v0.59 形状)
This commit is contained in:
commit
a80dac7a7e
13 files changed
+1498
-1
No files matched your search
@@ -19,6 +19,7 @@ enum class SendChannel {
|
||||
* 且 `stream:true` 的回合同样在**已连接的 WS** 上流式回来 ⇒ 「REST 发、WS 收」不牺牲流式体验。
|
||||
*
|
||||
* 图片一律走 REST:真实图片的 base64 远超 4096 字节,在 WS 上**必然**断连。
|
||||
* 文档附件同理一律走 REST(上传换引用这件事整个压在同一条通道上,见 [DocumentDelivery])。
|
||||
*
|
||||
* ## 阈值
|
||||
* 判据是**文本的 UTF-8 字节数**(不是字符数——中文一个字 3 字节,按字符数算会在
|
||||
@@ -46,9 +47,17 @@ object ChatSendRouting {
|
||||
/**
|
||||
* 这一条该走哪条通道。
|
||||
* @param hasImage 本次是否带图片(带图**永远**走 REST)
|
||||
* @param hasDocuments 本次是否带**文档附件**(带附件**永远**走 REST):文档本体要先经
|
||||
* `POST /api/v1/documents` 上传,引用只随 `POST /api/v1/chat` 发出——
|
||||
* 「文档一律走 REST」见 [DocumentDelivery] 第 2 条。默认 false ⇒ 既有调用与旧行为逐字不变。
|
||||
*/
|
||||
fun channelFor(text: String, hasImage: Boolean): SendChannel = when {
|
||||
fun channelFor(
|
||||
text: String,
|
||||
hasImage: Boolean,
|
||||
hasDocuments: Boolean = false,
|
||||
): SendChannel = when {
|
||||
hasImage -> SendChannel.Rest
|
||||
hasDocuments -> SendChannel.Rest
|
||||
utf8Length(text) <= WS_TEXT_LIMIT_BYTES -> SendChannel.Ws
|
||||
else -> SendChannel.Rest
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
|
||||
/**
|
||||
* 一份待上传的本地附件。
|
||||
*
|
||||
* 只带「服务端裁决需要的两样」:[fileName](**扩展名真的参与裁决**——服务端把白名单扩展名与
|
||||
* 字节嗅探**合取**,两者都过才放行;其余部分会被丢进内容寻址的存储名里)与 [bytes]。
|
||||
*
|
||||
* 不用 `data class`:`ByteArray` 的 `equals` 是引用比较,写成 `data class` 会给出一个
|
||||
* 「看着像值语义、其实永远不等」的陷阱(测试里第一眼就会撞上)。
|
||||
*/
|
||||
class DocumentFile(
|
||||
val fileName: String,
|
||||
val bytes: ByteArray,
|
||||
) {
|
||||
val sizeBytes: Long get() = bytes.size.toLong()
|
||||
}
|
||||
|
||||
/** 正文该内联发出,还是该先落成文档。 */
|
||||
sealed interface DocumentPlan {
|
||||
|
||||
/** 照既有路由([ChatSendRouting])内联发出:没有附件,正文也没到「文件级」。 */
|
||||
data object Inline : DocumentPlan
|
||||
|
||||
/**
|
||||
* 先 `POST /api/v1/documents` 换成存储名,再用 `chat.documents` 引用发出。
|
||||
* 调用方应把这一路整个交给 REST(见类型 KDoc 第 2 条的策略说明),不要在 WS 上重开一条。
|
||||
*
|
||||
* @param files 本地附件(≤ [DocumentDelivery.MAX_ATTACHMENTS_PER_TURN] 份)。
|
||||
* @param textAsDocument 正文是否也要落成一份 `.txt` 一起当附件发。
|
||||
*/
|
||||
data class AsDocuments(
|
||||
val files: List<DocumentFile> = emptyList(),
|
||||
val textAsDocument: Boolean = false,
|
||||
) : DocumentPlan
|
||||
|
||||
/**
|
||||
* 明确拒绝:调用方**必须**给用户一个说法,**绝不静默丢**。
|
||||
* 「静默丢」正是本模块要消灭的东西——一方以为附件发上去了、另一方一无所知。
|
||||
*/
|
||||
data class Rejected(val reason: DocumentRejection) : DocumentPlan
|
||||
}
|
||||
|
||||
/** 本地就能判定的拒绝理由(服务端的同类拒绝见 [DocumentFailure])。 */
|
||||
enum class DocumentRejection {
|
||||
/** 一个回合的附件数超过上限:不做「只发前 5 个」这种裁剪,多出来的必须让用户看见。 */
|
||||
TooManyAttachments,
|
||||
}
|
||||
|
||||
/**
|
||||
* 「该不该走文件」的判定与 `chat.documents` 载荷构造(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么这条规则值得单独存在
|
||||
* 三条互不相同的约束在客户端汇成一个决定,任何一条被忘掉都会出线上事故:
|
||||
*
|
||||
* 1. **WS 帧 4096 字节硬上限,且超限是直接断连**(不是回错误)⇒ 正文超过它必须走 REST
|
||||
* —— 这一步**已经在** [ChatSendRouting] 里,本对象**不另立阈值**,只用 [textNeedsRest]
|
||||
* 把它显式暴露出来,免得接线时把「超帧限」误当成「该转文件」;
|
||||
* 2. **文档一律走 REST**(回执五 §一 S3-B 的明文规则):文档本体经
|
||||
* `POST /api/v1/documents` 上传、引用随 `POST /api/v1/chat` 发出。实测后端 WS `chat` 帧
|
||||
* **确实**能收 `documents`(`ws_hub_turn.go` 的入站结构与 REST 同形),所以这一条是
|
||||
* **客户端策略**而非后端限制——理由是把「上传 → 引用 → 起回合」压在**一条**通道上,
|
||||
* 不让两条入口在错误面(413 / 415 / 404 / too_many)上分叉。所以
|
||||
* [DocumentPlan.AsDocuments] 的调用方不该再把这条消息交给 WS;
|
||||
* 3. **长正文按文件送,比内联更划算**:内联的正文会进**每一页历史**、每一次 `history` 回放、
|
||||
* 以及这一轮的完整上下文;落成文档后历史里只留一条引用(名 / MIME / 大小 / 字符数 /
|
||||
* 状态),正文按预算注入、还能被 `document-read` 按游标**继续读**。
|
||||
*
|
||||
* ## 阈值与它们各自的分量(**别把三个数混成一个**)
|
||||
* | 界线 | 值 | 谁定的 | 越过的后果 |
|
||||
* |---|---|---|---|
|
||||
* | WS 帧上限 | 4096 字节 | 服务端 `wsReadLimit` | **直接断连**(不是报错)⇒ 走 REST |
|
||||
* | 走 REST 的正文上限 | 3584 字节 | 本端预留 512 字节 JSON 外壳 | 就是「换条通道」,无惩罚 |
|
||||
* | **转文件的正文界线** | 4000 **rune** | **本端自定**(见下) | 换形态:正文变附件 |
|
||||
*
|
||||
* ⚠️ **第三行是客户端的产品判断,不是服务端拒绝线。** 契约里唯一的 4000 rune 上限是
|
||||
* `PATCH /api/v1/messages/{id}` 的**编辑正文**上限(服务端 `maxMessageContentRunes`),
|
||||
* **`POST /api/v1/chat` 根本没有正文长度上限**(已核 `api-client.md` §1 行 3 与后端
|
||||
* `rest.go` / `ws_hub_turn.go` 的请求结构)。这里借它当「正文量级已经不像一条消息」的
|
||||
* 参照——按 Unicode 码点计,与服务端 Go 的 rune 口径一致(代理对算一个)。
|
||||
* 于是 [textNeedsFile] 只是**建议**:[plan] 会据此给出 [DocumentPlan.AsDocuments],
|
||||
* 而**接线时要不要真的自动转换,是产品取舍**(建议先问用户:「这段内容较长,
|
||||
* 作为文件发送?」),纯函数不替产品做决定、也不静默改变既有行为。
|
||||
*
|
||||
* 反过来,**正文只是超过 4096 字节(走 REST 就够)不该被当成文件**:那会平白改变一条
|
||||
* 已经验证过的路径(长文本 → REST 内联)的语义,而 REST 本来装得下。
|
||||
*/
|
||||
object DocumentDelivery {
|
||||
|
||||
/** 一个回合的附件数上限:服务端 `maxChatDocuments = 5`(回执五 §一 S3-B),含等于。 */
|
||||
const val MAX_ATTACHMENTS_PER_TURN: Int = 5
|
||||
|
||||
/**
|
||||
* 正文「已经不像一条消息」的**客户端**界线(rune,含等于 → 仍算一条消息)。
|
||||
*
|
||||
* 取值参照契约里唯一的 rune 计数上限——`PATCH /api/v1/messages/{id}` 的 4000 rune
|
||||
* (见类型 KDoc 的诚实说明:`/chat` 没有这条上限)。
|
||||
*/
|
||||
const val TEXT_AS_DOCUMENT_RUNE_LIMIT: Int = 4000
|
||||
|
||||
/** 长正文落成附件时的扩展名:服务端白名单内的 `.txt`(名字其余部分会被内容寻址丢弃)。 */
|
||||
const val TEXT_DOCUMENT_FILE_NAME: String = "message.txt"
|
||||
|
||||
/** 按 Unicode 码点计数,与服务端 Go 的 rune 口径一致(代理对算一个)。 */
|
||||
fun runeCount(text: String): Int = text.codePointCount(0, text.length)
|
||||
|
||||
/** 正文是否已经超过「一条消息的正文量级」⇒ 建议按文件发送。见类型 KDoc 的界线说明。 */
|
||||
fun textNeedsFile(text: String): Boolean = runeCount(text) > TEXT_AS_DOCUMENT_RUNE_LIMIT
|
||||
|
||||
/**
|
||||
* 正文是否连既有的 WS 通道都放不下 ⇒ 必须走 REST。
|
||||
*
|
||||
* 直接复用 [ChatSendRouting] 的判据(**不另立阈值**):这里只是把
|
||||
* 「超帧限 = 换通道」与「超 rune 界线 = 换形态」两件事在名字上分开。
|
||||
*/
|
||||
fun textNeedsRest(text: String): Boolean =
|
||||
ChatSendRouting.channelFor(text, hasImage = false) == SendChannel.Rest
|
||||
|
||||
/**
|
||||
* 这一轮该怎么发。
|
||||
*
|
||||
* 判定顺序(先拒绝、再看附件、最后看正文):
|
||||
* 1. 附件 > [MAX_ATTACHMENTS_PER_TURN] → [DocumentPlan.Rejected](**不裁剪**);
|
||||
* 2. 有附件 → [DocumentPlan.AsDocuments](附件一律先上传换存储名);
|
||||
* 3. 没附件、正文超 [TEXT_AS_DOCUMENT_RUNE_LIMIT] → [DocumentPlan.AsDocuments](正文落成 `.txt`);
|
||||
* 4. 其余 → [DocumentPlan.Inline]。
|
||||
*
|
||||
* @param text 本轮的正文(调用方已 trim;空串 = 纯附件回合,服务端**支持**纯附件回合)。
|
||||
* @param files 本轮要挂的本地附件。
|
||||
*/
|
||||
fun plan(text: String, files: List<DocumentFile>): DocumentPlan = when {
|
||||
files.size > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments)
|
||||
files.isNotEmpty() -> DocumentPlan.AsDocuments(files = files, textAsDocument = textNeedsFile(text))
|
||||
textNeedsFile(text) -> DocumentPlan.AsDocuments(textAsDocument = true)
|
||||
else -> DocumentPlan.Inline
|
||||
}
|
||||
|
||||
/**
|
||||
* 把长正文落成一份待上传的 `.txt`(服务端只看扩展名,名字其余部分会被丢弃)。
|
||||
* 字节就是 UTF-8 编码——服务端嗅探得到的基类型因此是 `text/plain`。
|
||||
*/
|
||||
fun textAsFile(text: String, fileName: String = TEXT_DOCUMENT_FILE_NAME): DocumentFile =
|
||||
DocumentFile(fileName = fileName, bytes = text.toByteArray(Charsets.UTF_8))
|
||||
|
||||
/**
|
||||
* 由**已上传**的服务端存储名构造 `chat.documents` 载荷。
|
||||
*
|
||||
* 只带 `name`(见 [DocumentRefDto]:服务端不采信其它字段并会覆盖),且**≤5 条**。
|
||||
*
|
||||
* @return `null` = 这份载荷**不合法**(超过 5 条,或有空名字),调用方**必须**给用户提示,
|
||||
* **不得**截断到 5 条、也**不得**丢掉空名字那条——服务端对>5 回 `400 too_many_documents`、
|
||||
* 对非法/不存在的名字回 `404 document_not_found`,两边都不做静默裁剪。
|
||||
*/
|
||||
fun payload(storageNames: List<String>): List<DocumentRefDto>? = when {
|
||||
storageNames.size > MAX_ATTACHMENTS_PER_TURN -> null
|
||||
storageNames.any { it.isBlank() } -> null
|
||||
else -> storageNames.map { DocumentRefDto(name = it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
|
||||
/**
|
||||
* 文档操作失败(上传 / 取值)。契约 **v0.59 待发布**,错误码依据见
|
||||
* `02-前后端协同事项.md` 回执五 §一 S1 与 §五「v0.59 会登记什么」。
|
||||
*
|
||||
* ## 为什么要把错误码收成类型
|
||||
* 四个码在**用户能做的事**上各不相同,折进一个「操作失败」会让 UI 只能靠字符串猜:
|
||||
*
|
||||
* | 码 | HTTP | 用户能做什么 |
|
||||
* |---|---|---|
|
||||
* | [UnsupportedType] `unsupported_media_type` | 415 | **换文件**:这个格式不在白名单里(deny-by-default 的文本/代码扩展名,含 `.js` / `.py` / `.sh` 一律拒),改名 / 改声明都没有用——**字节嗅探是唯一裁决** |
|
||||
* | [TooLarge] `document_too_large` | 413 | **换文件**:超过单文件上限(默认 20 MiB,可部署配置) |
|
||||
* | [TooMany] `too_many_documents` | 400 | **少选几个**:一个回合最多 5 个附件 |
|
||||
* | [NotFound] `document_not_found` | 404 | 重传:存储名形态非法或文件已不在(非法与不存在**刻意同码**,不给探测面) |
|
||||
* | [Unreadable] `document_unreadable` | 400 | **只有显式索取正文时才会出现**(如 `document-read`),不是「上传失败」 |
|
||||
*
|
||||
* 每个分支都带 [message]:四种拒绝的文案由**服务端产出**(含真实上限),客户端原样展示、
|
||||
* 不自己编数字(与 `image_too_large` 同一口径)。未知码与本地网络错误一律落 [Other],
|
||||
* **原样带上服务端消息**,绝不因为「不认识这个码」就把失败吞掉。
|
||||
*/
|
||||
sealed interface DocumentFailure {
|
||||
|
||||
/** 可直接展示给用户的文案(服务端有话说时就是它说的那句)。 */
|
||||
val message: String
|
||||
|
||||
/** `415 unsupported_media_type`:扩展名白名单外,或字节嗅探判出非文本。 */
|
||||
data class UnsupportedType(override val message: String) : DocumentFailure
|
||||
|
||||
/** `413 document_too_large`:超过单文件字节上限(`CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)。 */
|
||||
data class TooLarge(override val message: String) : DocumentFailure
|
||||
|
||||
/** `400 too_many_documents`:一个回合挂的附件数超过 5。 */
|
||||
data class TooMany(override val message: String) : DocumentFailure
|
||||
|
||||
/** `404 document_not_found`:存储名非法或文件不存在(两者同码)。 */
|
||||
data class NotFound(override val message: String) : DocumentFailure
|
||||
|
||||
/** `400 document_unreadable`:正文取不回来(**仅显式索取正文时**)。 */
|
||||
data class Unreadable(override val message: String) : DocumentFailure
|
||||
|
||||
/** 401 / 403:会话失效或角色不够(文档端点要求 `owner|admin`)。 */
|
||||
data object Forbidden : DocumentFailure {
|
||||
override val message: String get() = "登录状态已失效,请重新登录"
|
||||
}
|
||||
|
||||
/** 其它失败:网络 / 超时 / 未知码 / 服务端 5xx——消息照原样带出来。 */
|
||||
data class Other(override val message: String) : DocumentFailure
|
||||
}
|
||||
|
||||
/** 把仓库层的 `Result` 失败收敛成 [DocumentFailure]。 */
|
||||
fun Throwable.toDocumentFailure(): DocumentFailure {
|
||||
val error = (this as? CyreneException)?.error
|
||||
return when {
|
||||
error is CyreneError.Unauthorized -> DocumentFailure.Forbidden
|
||||
error is CyreneError.Server && error.httpStatus == 403 -> DocumentFailure.Forbidden
|
||||
error is CyreneError.Server -> when (error.code) {
|
||||
"unsupported_media_type" -> DocumentFailure.UnsupportedType(error.userMessage)
|
||||
"document_too_large" -> DocumentFailure.TooLarge(error.userMessage)
|
||||
"too_many_documents" -> DocumentFailure.TooMany(error.userMessage)
|
||||
"document_not_found" -> DocumentFailure.NotFound(error.userMessage)
|
||||
"document_unreadable" -> DocumentFailure.Unreadable(error.userMessage)
|
||||
else -> DocumentFailure.Other(error.userMessage)
|
||||
}
|
||||
|
||||
error != null -> DocumentFailure.Other(error.userMessage)
|
||||
else -> DocumentFailure.Other("文档操作失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/**
|
||||
* 文档抽取状态(契约 **v0.59 待发布**,形状依据见 `02-前后端协同事项.md` 回执五 §一 S3-A/S3-B)。
|
||||
*
|
||||
* **开放集合,不用 enum**:后端的 `status` 是自由字符串,客户端映射只认这三个已知值,
|
||||
* **未知值原样保留**(不丢弃、不报错、不占位符)——与 `mode` / `platform` / `mood` 同一口径。
|
||||
*
|
||||
* 三个值的语义(**别互相顶替**):
|
||||
* - [EXTRACTED]:正文已抽取并进了这一轮的上下文;
|
||||
* - [LISTED]:只给了模型一份附件清单(名 / MIME / 大小 / 结论),**正文没进上下文**;
|
||||
* 它不是错误态——大文件、超预算、或本期不支持的格式都可能停在这里;
|
||||
* - [UNREADABLE]:**读不到这个文件**。这是唯一一个「昔涟看不到内容」的可观测信号,
|
||||
* 与「内容被截断」(`truncated`)是两件事:截断者正文照样进了上下文,只是尾巴被预算切掉。
|
||||
*/
|
||||
object DocumentStatus {
|
||||
|
||||
const val EXTRACTED: String = "extracted"
|
||||
|
||||
const val LISTED: String = "listed"
|
||||
|
||||
const val UNREADABLE: String = "unreadable"
|
||||
|
||||
/**
|
||||
* 这条引用是否报告「读不到」。
|
||||
*
|
||||
* 只比 [UNREADABLE],**不猜**:服务端是本信号的唯一生产者(回合结束后回写该用户消息的
|
||||
* document 列——用户消息是回合前落的、结论是回合中才知道的),客户端不做二次推断。
|
||||
*/
|
||||
fun isUnreadable(status: String): Boolean = status == UNREADABLE
|
||||
}
|
||||
Reference in new issue
Block a user