-
Notifications
You must be signed in to change notification settings - Fork 3
feat: Ask AI SSE streaming (performAskAIStreaming) #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
2e7b1a1
bb470c9
2701ee3
5b0bfa7
c64a8fd
9848dda
63a07ec
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| // | ||
| // AskAIStreamEvent.swift | ||
| // Cryptohopper-iOS-SDK | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| /// One normalized event from the Ask AI SSE stream. | ||
| public enum AskAIStreamEvent { | ||
| case delta(text: String) | ||
| case done(runId: String?, sessionId: String?) | ||
| case error(message: String) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| // | ||
| // AskAIStreamParser.swift | ||
| // Cryptohopper-iOS-SDK | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| /// Incremental parser for the Ask AI SSE stream. | ||
| /// Feed raw body chunks as they arrive; complete frames (separated by a | ||
| /// blank line) are parsed into events. Buffers as Data so multi-byte | ||
| /// UTF-8 characters split across chunks survive. | ||
| public final class AskAIStreamParser { | ||
|
|
||
| private var buffer = Data() | ||
|
|
||
| public init() {} | ||
|
|
||
| public func feed(_ chunk: Data) -> [AskAIStreamEvent] { | ||
| buffer.append(chunk) | ||
| var events: [AskAIStreamEvent] = [] | ||
| let lflf = Data("\n\n".utf8) | ||
| let crlfcrlf = Data("\r\n\r\n".utf8) | ||
| while true { | ||
| let lfRange = buffer.range(of: lflf) | ||
| let crRange = buffer.range(of: crlfcrlf) | ||
| let separator: Range<Data.Index> | ||
| switch (lfRange, crRange) { | ||
| case (nil, nil): | ||
| return events | ||
| case (let lf?, nil): | ||
| separator = lf | ||
| case (nil, let cr?): | ||
| separator = cr | ||
| case (let lf?, let cr?): | ||
| separator = lf.lowerBound < cr.lowerBound ? lf : cr | ||
| } | ||
| let frameData = buffer.subdata(in: buffer.startIndex..<separator.lowerBound) | ||
| buffer.removeSubrange(buffer.startIndex..<separator.upperBound) | ||
| if let event = Self.parseFrame(frameData) { | ||
| events.append(event) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Emit any trailing frame that never got its blank-line terminator | ||
| /// (e.g. the connection closed right after the last event). | ||
| public func flush() -> [AskAIStreamEvent] { | ||
| defer { buffer.removeAll() } | ||
| guard !buffer.isEmpty, let event = Self.parseFrame(buffer) else { return [] } | ||
| return [event] | ||
| } | ||
|
|
||
| static func parseFrame(_ frameData: Data) -> AskAIStreamEvent? { | ||
| guard let raw = String(data: frameData, encoding: .utf8) else { return nil } | ||
| // Normalize CRLF to LF before splitting: Swift's String treats "\r\n" | ||
| // as a single Character (grapheme cluster), so splitting on the "\n" | ||
| // Character alone would never break at a CRLF line ending. | ||
| let normalized = raw.replacingOccurrences(of: "\r\n", with: "\n") | ||
| let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false) | ||
| .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) } | ||
|
|
||
| var eventName: String? | ||
| var dataLines: [String] = [] | ||
| for line in lines { | ||
| if line.hasPrefix("event:") { | ||
| if eventName == nil { | ||
| eventName = String(line.dropFirst("event:".count)).trimmingCharacters(in: .whitespaces) | ||
| } | ||
| } else if line.hasPrefix("data:") { | ||
| var value = String(line.dropFirst("data:".count)) | ||
| if value.hasPrefix(" ") { value.removeFirst() } | ||
| dataLines.append(value) | ||
| } | ||
| // Lines starting with ":" are comments (pings); ignore. | ||
| } | ||
|
|
||
| let joined = dataLines.joined(separator: "\n") | ||
| guard let jsonData = joined.data(using: .utf8), | ||
| let payload = (try? JSONSerialization.jsonObject(with: jsonData)) as? [String: Any] else { | ||
| return nil | ||
| } | ||
|
|
||
| let kind = eventName ?? (payload["type"] as? String) | ||
| switch kind { | ||
| case "delta": | ||
| guard let content = payload["content"] as? String else { return nil } | ||
| return .delta(text: content) | ||
| case "done", "final": | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Low —
|
||
| if let errorMessage = payload["error"] as? String, !errorMessage.isEmpty { | ||
| return .error(message: errorMessage) | ||
| } | ||
| return .done(runId: payload["run_id"] as? String, | ||
| sessionId: payload["session_id"] as? String) | ||
| case "error": | ||
| return .error(message: (payload["message"] as? String) ?? "Unknown stream error") | ||
| default: | ||
| // conversation / tool_call / tool_result / metadata / unknown: ignore. | ||
| return nil | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Medium — custom
init(from:)never throws, so malformed/errordatapayloads decode as a valid answer withnilcontent.Replacing the synthesized
Codableinit with this hand-written one changes behavior for both the new streaming fallback and the existingHopperAPIPerformAskAIRequestpath (which decodes this same type viaHopperAPIRequest.responseOK). Because neithertry?branch rethrows, anydatavalue that is neither anAskAIAnswerobject nor aString(a number, an array, an error envelope, or a truncated body) leavesanswer == niland the decode succeeds. Previously such a body threw and reachedonFail. Net effect: genuinely malformed responses are now reported to callers as an empty-but-successful answer instead of an error.