mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
The hub's fork, rewind and export routes had no iOS UI. The chat's menu now offers: - Fork Conversation: when the agent advertises forkCurrent, the session is live and no Codex turn is running (the web gate). The new session opens in place of this chat. - Rewind or Fork…: a sheet listing loaded user messages that have a native point in metadata.conversationHistoryPoints. Each row offers a confirmed Fork from Here or Rewind to Here, gated like web: forks need an idle turn except on Codex, and rewinds always need one. The actions live in a sheet, not on the transcript bubbles, because those keep text selection and cached row heights. After a rewind the tail resyncs with a history reset. An ambiguous native boundary (409 ambiguous_native_boundary_fork_safe) offers a fork from the same point instead. - Export Conversation…: saves the hub's lossless JSON payload through the Files save panel, using the web download's filename. A large export asks before retrying with force; a 413 explains the limit. Markdown export stays web-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
5.8 KiB
Swift
150 lines
5.8 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
|
|
/// `GET /api/sessions/:id/export` outcome.
|
|
public enum SessionExportResult: Equatable, Sendable {
|
|
/// The `HapiSessionExport` JSON, ready to save.
|
|
case file(SessionExportFile)
|
|
/// Above the hub's message limit: confirm, then export with `force`.
|
|
case warning(count: Int, limit: Int, estimatedBytes: Int)
|
|
/// 413 — beyond the hub's resource limit even with `force`.
|
|
case tooLarge(count: Int, estimatedBytes: Int, maxBytes: Int)
|
|
}
|
|
|
|
/// A JSON export named like the web download
|
|
/// (`<title-slug>-<id8>-<yyyy-mm-dd>.json`).
|
|
public struct SessionExportFile: Equatable, Sendable {
|
|
public let filename: String
|
|
public let data: Data
|
|
public let messageCount: Int
|
|
}
|
|
|
|
/// Conversation history: fork, rewind and export (`docs/api/client-contract/rest.md`).
|
|
extension APIClient {
|
|
/// `POST /api/sessions/:id/fork` — a new session from the current
|
|
/// conversation, or from before `messageLocalId`. Returns its id.
|
|
public func forkConversation(sessionId: String, messageLocalId: String? = nil) async throws -> String {
|
|
struct ForkRequest: Encodable {
|
|
let messageLocalId: String?
|
|
}
|
|
let response: ResumeSessionResponse = try await request(
|
|
.post,
|
|
"/api/sessions/\(encodePathComponent(sessionId))/fork",
|
|
body: ForkRequest(messageLocalId: messageLocalId)
|
|
)
|
|
return response.sessionId
|
|
}
|
|
|
|
/// `POST /api/sessions/:id/rewind` — drop the conversation from
|
|
/// `messageLocalId` on. A 409 `ambiguous_native_boundary_fork_safe`
|
|
/// means only a fork can preserve the point.
|
|
public func rewindConversation(sessionId: String, messageLocalId: String) async throws {
|
|
struct RewindRequest: Encodable {
|
|
let messageLocalId: String
|
|
}
|
|
try await requestVoid(
|
|
.post,
|
|
"/api/sessions/\(encodePathComponent(sessionId))/rewind",
|
|
body: RewindRequest(messageLocalId: messageLocalId)
|
|
)
|
|
}
|
|
|
|
/// `GET /api/sessions/:id/export[?force=true]`, saved as pretty JSON.
|
|
public func exportSession(sessionId: String, force: Bool = false) async throws -> SessionExportResult {
|
|
let data: Data
|
|
do {
|
|
(data, _) = try await send(
|
|
.get,
|
|
path: "/api/sessions/\(encodePathComponent(sessionId))/export",
|
|
query: force ? [URLQueryItem(name: "force", value: "true")] : []
|
|
)
|
|
} catch let error as APIError where error.status == 413 {
|
|
let body = error.body.flatMap { try? HapiJSON.decoder.decode(ExportLimits.self, from: Data($0.utf8)) }
|
|
return .tooLarge(
|
|
count: body?.count ?? 0,
|
|
estimatedBytes: body?.estimatedBytes ?? 0,
|
|
maxBytes: body?.maxBytes ?? 0
|
|
)
|
|
}
|
|
return try SessionExportFormatting.result(from: data)
|
|
}
|
|
}
|
|
|
|
private struct ExportLimits: Decodable {
|
|
let type: String?
|
|
let count: Int?
|
|
let limit: Int?
|
|
let estimatedBytes: Int?
|
|
let maxBytes: Int?
|
|
}
|
|
|
|
enum SessionExportFormatting {
|
|
private struct Payload: Decodable {
|
|
struct Session: Decodable {
|
|
struct Metadata: Decodable {
|
|
struct Summary: Decodable { let text: String? }
|
|
let name: String?
|
|
let summary: Summary?
|
|
let path: String?
|
|
}
|
|
let id: String
|
|
let metadata: Metadata?
|
|
}
|
|
let exportedAt: Int
|
|
let session: Session
|
|
let messages: [JSONValue]
|
|
}
|
|
|
|
static func result(from data: Data) throws -> SessionExportResult {
|
|
let limits = try? HapiJSON.decoder.decode(ExportLimits.self, from: data)
|
|
if limits?.type == "warning" {
|
|
return .warning(
|
|
count: limits?.count ?? 0,
|
|
limit: limits?.limit ?? 0,
|
|
estimatedBytes: limits?.estimatedBytes ?? 0
|
|
)
|
|
}
|
|
let payload = try HapiJSON.decoder.decode(Payload.self, from: data)
|
|
let object = try JSONSerialization.jsonObject(with: data)
|
|
var pretty = try JSONSerialization.data(
|
|
withJSONObject: object,
|
|
options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
|
)
|
|
pretty.append(0x0A)
|
|
return .file(SessionExportFile(
|
|
filename: filename(payload),
|
|
data: pretty,
|
|
messageCount: payload.messages.count
|
|
))
|
|
}
|
|
|
|
/// `buildSessionExportFilename`: title slug (≤ 80), id prefix, UTC date.
|
|
private static func filename(_ payload: Payload) -> String {
|
|
let metadata = payload.session.metadata
|
|
let title = [metadata?.name, metadata?.summary?.text].compactMap { $0 }.first { !$0.isEmpty }
|
|
?? metadata?.path?.split(separator: "/").last.map(String.init)
|
|
?? String(payload.session.id.prefix(8))
|
|
var slug = ""
|
|
var pendingDash = false
|
|
for scalar in title.lowercased().unicodeScalars {
|
|
let keep = ("a"..."z").contains(Character(scalar)) || ("0"..."9").contains(Character(scalar))
|
|
|| (0x4E00...0x9FFF).contains(scalar.value)
|
|
if keep {
|
|
if pendingDash, !slug.isEmpty { slug.append("-") }
|
|
pendingDash = false
|
|
slug.unicodeScalars.append(scalar)
|
|
} else {
|
|
pendingDash = true
|
|
}
|
|
}
|
|
if slug.isEmpty { slug = "session" }
|
|
let formatter = DateFormatter()
|
|
formatter.calendar = Calendar(identifier: .gregorian)
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.timeZone = TimeZone(identifier: "UTC")
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
let date = formatter.string(from: Date(timeIntervalSince1970: Double(payload.exportedAt) / 1000))
|
|
return "\(String(slug.prefix(80)))-\(payload.session.id.prefix(8))-\(date).json"
|
|
}
|
|
}
|