# SSE 归一化(StreamNormalizer)设计 ## 为什么需要 - WorkBuddy 上游 ≈ OpenAI SSE,透传即可 - Trae SOLO 上游是私有事件协议,字段/信封和 OpenAI 不一样 - 客户端只认 data: {"choices":[{"delta":{"content":...}}]} 和 data: [DONE] ## 当前实现模式 先做 raw passthrough(实时转发上游字节),再做归一: 上游 InputStream -> respondOutputStream -> 客户端 好处:先解决“能不能连上/能不能出字”,再解决“客户端能不能解析”。 ## 归一化目标映射 把 Trae 私有事件翻译成 OpenAI Chat Completions chunk: | Trae 事件 | OpenAI chunk | |---|---| | 正文增量事件 | data: {"choices":[{"delta":{"content":"..."}}]} | | 推理/思考块 | delta.reasoning_content(部分客户端支持) | | 结束事件 | delta {} finish_reason stop + data: [DONE] | | 用量事件 | 合并到 finish chunk 的 usage | | 业务错误 | data: {"error":{...}} + data: [DONE] | | 心跳注释 | : relay-keepalive(客户端自动忽略) | ## 设计原则 1. 见到样本再实现:每个上游私有格式千差万别,不要盲抄 2. 未知事件透传:宁可让客户端看到未知 data,也不要吞掉内容 3. 工具调用增量合并:Trae 可能发累计快照或增量,两种都要能收敛成 tool_calls[].function.arguments 4. 心跳:上游长时间停顿时发 SSE 注释行防超时 5. 断流兜底:上游异常断开时补一个 finish_reason,避免客户端永远 loading ## 最小实现骨架 fun normalizeLine(raw: String): String? { if (!raw.startsWith("data:")) return null // 忽略注释/空行 val payload = raw.removePrefix("data:").trim() if (payload == "[DONE]") return raw val json = Json.parseToJsonElement(payload) as? JsonObject ?: return null // 根据 event/type 字段抽取 content/reasoning/tool_calls val content = extractTraeText(json) ?: return null return """data: {"choices":[{"delta":{"content":"$content"}}]}""" } > 具体 Trae 事件字段需要以真实抓包/探针输出为准(仓库里自带“测试 Trae 流”探针)。