mirror of
https://github.com/hanzo-templates/swiftui-chat.git
synced 2026-10-30 20:56:03 +00:00
The catalogue shipped 71 web templates and one EMPTY `mobile` repo, so "build an app" had no starting point on the platform people actually ship to. This is the iOS lane — Expo/React Native and Flutter for cross-platform, SwiftUI for native. The constraint that shaped every file here: an iOS archive needs macOS + Xcode + a signing identity, and our arc fleet has none. hanzoai has 519 registered runners and every one reports `os: linux`; luxfi has 151, same story; zooai has none. The only macOS builds in the org today (hanzoai/world build-desktop.yml) run on GitHub-HOSTED macos-14, which is exactly what the house CI rule says not to do. So `hanzo.yml` declares only what a Linux arc runner can genuinely execute — typecheck + web bundle for Expo, analyze + test + web for Flutter, `swiftc -parse` for SwiftUI — and the macOS archive lives in a separate `workflow_dispatch`-only `ios.yml`. A job pinned to a label no runner advertises would sit queued forever if it fired on push; parking it is honest, a permanently-yellow check is not. The README of every template states the gap and the one command that closes it. Same reasoning on the demos. Expo and Flutter both have a real web target, so `<slug>.hanzo.app` serves the SAME source the simulator runs. SwiftUI has no web target, so those two templates ship with no demo link rather than a screenshot pretending to be one. Upstreams are permissive only and recorded per template: expo/expo templates (MIT), flutter/samples (BSD-3-Clause), XcodeGen (MIT). Second measured gap, found while wiring this up: `hanzo-templates` is not in arcd's `orgs:` list either (hanzoai, luxfi, zooai, parsdao, zenlm), so the Linux jobs would queue with no runner too. Both gaps are one installer re-run, no code change; both are spelled out in every README rather than left to be discovered on the first red build. Third gap, found by probing rather than assuming, and FIXED here: every run in this org died as `startup_failure` with no logs and no annotation. A bare `runs-on: [self-hosted, linux]` probe queued fine while the same job importing hanzoai/ci failed identically at @v1 and at a pinned SHA — so it was never the tag. The org defaulted GITHUB_TOKEN to `read`, and the reusable declares `permissions: packages: write`; a caller cannot exceed the org maximum, so GitHub kills the run before creating a job. hanzo-templates now defaults to `write` like hanzoai/luxfi/zooai, and runs reach `queued` — the honest state, waiting on gap 2.
86 lines
3.4 KiB
Swift
86 lines
3.4 KiB
Swift
import Foundation
|
|
|
|
/// The whole Hanzo integration: one streaming call to /v1/chat/completions.
|
|
///
|
|
/// Config is read at RUNTIME (Settings) rather than baked into the binary,
|
|
/// because a compiled-in key ships inside every copy of the app and `strings`
|
|
/// finds it in seconds. Ship the app, let the user bring the key.
|
|
struct Msg: Codable, Identifiable, Equatable {
|
|
var id = UUID()
|
|
var role: String
|
|
var content: String
|
|
|
|
enum CodingKeys: String, CodingKey { case role, content }
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class Hanzo {
|
|
var base = "https://api.hanzo.ai/v1"
|
|
var model = "zen-omni"
|
|
var key = ""
|
|
|
|
enum Failure: LocalizedError {
|
|
case noKey
|
|
case http(Int, String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noKey: "No API key. Open Settings and paste a Hanzo key."
|
|
case let .http(code, body): "\(code) \(body.prefix(200))"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Streams the assistant reply token by token. Nothing here knows about views.
|
|
func complete(_ messages: [Msg]) -> AsyncThrowingStream<String, Error> {
|
|
AsyncThrowingStream { continuation in
|
|
let task = Task {
|
|
do {
|
|
guard !key.isEmpty else { throw Failure.noKey }
|
|
|
|
var request = URLRequest(url: URL(string: "\(base)/chat/completions")!)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: [
|
|
"model": model,
|
|
"stream": true,
|
|
"messages": messages.map { ["role": $0.role, "content": $0.content] },
|
|
])
|
|
|
|
let (bytes, response) = try await URLSession.shared.bytes(for: request)
|
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
guard status == 200 else { throw Failure.http(status, "") }
|
|
|
|
// `bytes.lines` already reassembles frames split across packets.
|
|
for try await line in bytes.lines {
|
|
guard line.hasPrefix("data:") else { continue }
|
|
let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
|
|
if payload == "[DONE]" { break }
|
|
if let delta = Self.delta(payload), !delta.isEmpty {
|
|
continuation.yield(delta)
|
|
}
|
|
}
|
|
continuation.finish()
|
|
} catch {
|
|
continuation.finish(throwing: error)
|
|
}
|
|
}
|
|
continuation.onTermination = { _ in task.cancel() }
|
|
}
|
|
}
|
|
|
|
/// Pulls `choices[0].delta.content` out of one SSE frame, or nil for a
|
|
/// keep-alive comment / a frame shape we do not model.
|
|
nonisolated static func delta(_ json: String) -> String? {
|
|
guard
|
|
let data = json.data(using: .utf8),
|
|
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let choices = root["choices"] as? [[String: Any]],
|
|
let delta = choices.first?["delta"] as? [String: Any]
|
|
else { return nil }
|
|
return delta["content"] as? String
|
|
}
|
|
}
|