mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
Wires the merged M2 pieces into a usable pair -> list -> live chat flow: - pbxproj: link HapiUI into the app target (UUIDs C7/C8, mirroring the existing HapiProtocol/HapiClient product references; only project change). - HubSession: MessageWindowControllers registry + per-chat ChatSession factory with in-memory per-session SSE resume cursors and scene-phase forwarding to the active chat. - ChatSession (app): session-scope SSEClient while the chat is open; window opened/activated before subscribing; a single consume task awaits every event into the window actor, giving Android-channel-equivalent arrival-order ingestion; session-updated and friends route through the shared SyncEventRouter (detail patching), session-removed clears the window, a gap handshake triggers full resync + detail refetch + catch-up tail sync; stop hands the cursor back for seamless reopen. - ChatPipeline (HapiClient): actor running the reduction path off-main - queued-row filter, normalization memoized by row instance identity, reduce + buildVisibleChatBlocks with previousGroups-stable group ids. - ChatModel: window state + detail agentState + machine labels -> serial ~100 ms-coalesced pipeline loop (first run immediate, publishes strictly ordered), header title cascade, loading/empty/error states, loadOlder / retry, last-seen stamping on every update. - ChatView: bottom-anchored ScrollView/LazyVStack (defaultScrollAnchor + scrollPosition(id:)), auto-stick at bottom, new-messages pill, top sentinel paging with documented scroll re-anchoring, degraded banners. - Block views: user bubble w/ attachment chips, agent markdown, collapsed reasoning, tool cards (knownTools.tsx-parity presentation, status chip, read-only permission row w/ pending banner, per-tool bodies: terminal, before/after edits, write content, unified-diff detection -> DiffTextView, checklists, read-only questions, pretty-JSON fallback, children rail), tool groups, centered event rows (presentation.ts port), cli output, generated images (authed bytes + in-memory cache + full-screen viewer), codex review verdict card. - Navigation/links: session list now pushes ChatView; app-level \.hapiOpenURL handler (https/http -> SFSafariViewController, custom schemes confirm first, hapi-file:// -> M4 placeholder) + theme injection. - Tests: ChatPipelineTests drive the runner with fixture-derived windows (stable ids, memo-stable recomputes, queued filter, group-id stability across an older-page arrival). - README: M2f app-layer description.
229 lines
9.4 KiB
Swift
229 lines
9.4 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
|
|
/// Centered event-row wording: port of `web/src/chat/presentation.ts` via the
|
|
/// Android `getEventPresentation`. Render-only (the fixture projection never
|
|
/// includes presentation), so date formatting uses Foundation formatters
|
|
/// instead of the browser's `toLocaleString` while keeping the same rules.
|
|
struct EventPresentation: Equatable {
|
|
/// Leading glyph; nil for plain informational rows.
|
|
var icon: String?
|
|
var text: String
|
|
}
|
|
|
|
// MARK: - Formatting helpers
|
|
|
|
private func normalizedTimestampMs(_ value: Double) -> Double {
|
|
value < 1_000_000_000_000 ? value * 1000 : value
|
|
}
|
|
|
|
func chatFormatUnixTimestamp(_ value: Double) -> String {
|
|
let date = Date(timeIntervalSince1970: normalizedTimestampMs(value) / 1000)
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy/M/d HH:mm:ss"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
func chatFormatResetTime(_ value: Double, now: Date = Date()) -> String {
|
|
let date = Date(timeIntervalSince1970: normalizedTimestampMs(value) / 1000)
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = Calendar.current.isDate(date, inSameDayAs: now) ? "H:mm" : "MMM d, H:mm"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
/// Known limit types: five_hour → "5-hour", seven_day → "7-day"; otherwise
|
|
/// underscores become spaces.
|
|
private func formatLimitType(_ limitType: String?) -> String {
|
|
guard let limitType, !limitType.isEmpty else { return "" }
|
|
if limitType == "five_hour" { return "5-hour" }
|
|
if limitType == "seven_day" { return "7-day" }
|
|
return limitType.replacingOccurrences(of: "_", with: " ")
|
|
}
|
|
|
|
func chatFormatDuration(ms: Double) -> String {
|
|
let seconds = ms / 1000
|
|
if seconds < 60 {
|
|
return String(format: "%.1fs", seconds)
|
|
}
|
|
let minutes = Int(seconds / 60)
|
|
let remainder = Int((seconds.truncatingRemainder(dividingBy: 60)).rounded())
|
|
return "\(minutes)m \(remainder)s"
|
|
}
|
|
|
|
private func formatTokenCount(_ value: Double) -> String {
|
|
if value >= 1_000_000 { return String(format: "%.1fM", value / 1_000_000) }
|
|
if value >= 10_000 { return String(format: "%.1fk", value / 1_000) }
|
|
if value >= 1_000 { return "\(Int((value / 1_000).rounded()))k" }
|
|
return formatJSONNumber(value)
|
|
}
|
|
|
|
private func formatGoalStatus(_ status: String) -> String {
|
|
switch status {
|
|
case "active": return "active"
|
|
case "paused": return "paused"
|
|
case "budgetLimited": return "limited by budget"
|
|
case "usageLimited": return "limited by usage"
|
|
case "blocked": return "blocked"
|
|
case "complete": return "complete"
|
|
default: return status
|
|
}
|
|
}
|
|
|
|
private func threadGoalPresentation(_ event: AgentEvent) -> EventPresentation {
|
|
guard let goal = event["goal"]?.chatObject else {
|
|
return EventPresentation(icon: nil, text: "Goal updated")
|
|
}
|
|
let status = goal["status"]?.chatString ?? "updated"
|
|
var parts = ["Goal \(formatGoalStatus(status))"]
|
|
let tokensUsed = goal["tokensUsed"]?.chatNumber ?? goal["tokens_used"]?.chatNumber
|
|
let tokenBudget = goal["tokenBudget"]?.chatNumber ?? goal["token_budget"]?.chatNumber
|
|
if let tokensUsed, let tokenBudget {
|
|
parts.append("\(formatTokenCount(tokensUsed)) / \(formatTokenCount(tokenBudget))")
|
|
}
|
|
return EventPresentation(icon: nil, text: parts.joined(separator: " · "))
|
|
}
|
|
|
|
private func tokenCountPresentation(_ event: AgentEvent) -> EventPresentation {
|
|
let info = event["info"]?.chatObject
|
|
guard let total = info?["total"]?.chatObject ?? info else {
|
|
return EventPresentation(icon: "◷", text: "Context updated")
|
|
}
|
|
let inputTokens = total["inputTokens"]?.chatNumber ?? total["input_tokens"]?.chatNumber
|
|
let outputTokens = total["outputTokens"]?.chatNumber ?? total["output_tokens"]?.chatNumber
|
|
let cachedTokens = total["cachedInputTokens"]?.chatNumber
|
|
?? total["cacheReadInputTokens"]?.chatNumber
|
|
?? total["cache_read_input_tokens"]?.chatNumber
|
|
let reasoningTokens = total["reasoningOutputTokens"]?.chatNumber
|
|
?? total["reasoning_output_tokens"]?.chatNumber
|
|
let contextWindow = total["modelContextWindow"]?.chatNumber
|
|
?? total["model_context_window"]?.chatNumber
|
|
|
|
var parts: [String] = []
|
|
if let inputTokens, let contextWindow, contextWindow > 0 {
|
|
let percent = Int((inputTokens / contextWindow * 100).rounded())
|
|
parts.append("Context \(formatTokenCount(inputTokens)) / \(formatTokenCount(contextWindow)) (\(percent)%)")
|
|
} else if let inputTokens {
|
|
parts.append("Context \(formatTokenCount(inputTokens))")
|
|
} else {
|
|
parts.append("Context updated")
|
|
}
|
|
if let outputTokens { parts.append("out \(formatTokenCount(outputTokens))") }
|
|
if let cachedTokens, cachedTokens > 0 { parts.append("cached \(formatTokenCount(cachedTokens))") }
|
|
if let reasoningTokens, reasoningTokens > 0 { parts.append("reasoning \(formatTokenCount(reasoningTokens))") }
|
|
return EventPresentation(icon: "◷", text: parts.joined(separator: " · "))
|
|
}
|
|
|
|
private func apiErrorDetail(_ error: JSONValue?) -> String? {
|
|
if let text = error?.chatString {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
guard let record = error?.chatObject, record["message"] != nil else { return nil }
|
|
let trimmed = record["message"]?.chatString?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return (trimmed?.isEmpty ?? true) ? nil : trimmed
|
|
}
|
|
|
|
// MARK: - Entry point
|
|
|
|
func eventPresentation(_ event: AgentEvent) -> EventPresentation {
|
|
switch event.type {
|
|
case "api-error":
|
|
let retryAttempt = event["retryAttempt"]?.chatNumber ?? 0
|
|
let maxRetries = event["maxRetries"]?.chatNumber ?? 0
|
|
if maxRetries > 0, retryAttempt >= maxRetries {
|
|
return EventPresentation(icon: "⚠️", text: "API error: Max retries reached")
|
|
}
|
|
if maxRetries > 0 {
|
|
return EventPresentation(
|
|
icon: "⏳",
|
|
text: "API error: Retrying (\(formatJSONNumber(retryAttempt))/\(formatJSONNumber(maxRetries)))"
|
|
)
|
|
}
|
|
if retryAttempt > 0 {
|
|
if let detail = apiErrorDetail(event["error"]) {
|
|
return EventPresentation(icon: "⏳", text: "API error: Retrying... \(detail)")
|
|
}
|
|
return EventPresentation(icon: "⏳", text: "API error: Retrying...")
|
|
}
|
|
return EventPresentation(icon: "⚠️", text: "API error")
|
|
|
|
case "switch":
|
|
let mode = event["mode"]?.chatString == "local" ? "local" : "remote"
|
|
return EventPresentation(icon: "🔄", text: "Switched to \(mode)")
|
|
|
|
case "title-changed":
|
|
let title = event["title"]?.chatString ?? ""
|
|
return EventPresentation(
|
|
icon: nil,
|
|
text: title.isEmpty ? "Title changed" : "Title changed to \"\(title)\""
|
|
)
|
|
|
|
case "permission-mode-changed":
|
|
let mode = event["mode"]?.chatString ?? "default"
|
|
return EventPresentation(icon: "🔐", text: "Permission mode: \(mode)")
|
|
|
|
case "limit-warning":
|
|
let percent = Int(((event["utilization"]?.chatNumber ?? 0) * 100).rounded())
|
|
let typeLabel = formatLimitType(event["limitType"]?.chatString)
|
|
let suffix = typeLabel.isEmpty ? "" : " (\(typeLabel))"
|
|
if let endsAt = event["endsAt"]?.chatNumber {
|
|
return EventPresentation(
|
|
icon: "⚠️",
|
|
text: "Usage limit \(percent)%\(suffix) · resets \(chatFormatResetTime(endsAt))"
|
|
)
|
|
}
|
|
return EventPresentation(icon: "⚠️", text: "Usage limit \(percent)%\(suffix)")
|
|
|
|
case "limit-reached":
|
|
let typeLabel = formatLimitType(event["limitType"]?.chatString)
|
|
let suffix = typeLabel.isEmpty ? "" : " (\(typeLabel))"
|
|
if let endsAt = event["endsAt"]?.chatNumber {
|
|
return EventPresentation(
|
|
icon: "⏳",
|
|
text: "Usage limit reached\(suffix) until \(chatFormatUnixTimestamp(endsAt))"
|
|
)
|
|
}
|
|
return EventPresentation(icon: "⏳", text: "Usage limit reached\(suffix)")
|
|
|
|
case "error":
|
|
return EventPresentation(icon: "⚠️", text: event["message"]?.chatString ?? "Error")
|
|
|
|
case "message":
|
|
return EventPresentation(icon: nil, text: event["message"]?.chatString ?? "Message")
|
|
|
|
case "turn-duration":
|
|
let ms = event["durationMs"]?.chatNumber ?? 0
|
|
return EventPresentation(icon: "⏱️", text: "Turn: \(chatFormatDuration(ms: ms))")
|
|
|
|
case "microcompact":
|
|
let saved = event["tokensSaved"]?.chatNumber ?? 0
|
|
let formatted = saved >= 1000
|
|
? "\(Int((saved / 1000).rounded()))K"
|
|
: formatJSONNumber(saved)
|
|
return EventPresentation(icon: "📦", text: "Context compacted (saved \(formatted) tokens)")
|
|
|
|
case "compact":
|
|
return EventPresentation(icon: "📦", text: "Conversation compacted")
|
|
|
|
case "compact-summary":
|
|
return EventPresentation(icon: "📦", text: "Context compacted")
|
|
|
|
case "recap":
|
|
let text = event["text"]?.chatString ?? ""
|
|
return EventPresentation(icon: "💭", text: "recap: \(text)")
|
|
|
|
case "thread-goal-updated":
|
|
return threadGoalPresentation(event)
|
|
|
|
case "thread-goal-cleared":
|
|
return EventPresentation(icon: nil, text: "Goal cleared")
|
|
|
|
case "token-count":
|
|
return tokenCountPresentation(event)
|
|
|
|
default:
|
|
// Unknown event types: generic raw rendering, truncated.
|
|
return EventPresentation(icon: nil, text: chatTruncate(chatPrettyJSON(event.wireValue), 300))
|
|
}
|
|
}
|