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.
113 lines
3.7 KiB
Swift
113 lines
3.7 KiB
Swift
import SwiftUI
|
|
|
|
struct ChatView: View {
|
|
@Environment(Hanzo.self) private var hanzo
|
|
|
|
@State private var msgs: [Msg] = [
|
|
Msg(role: "assistant",
|
|
content: "Ask me anything. Set a Hanzo API key in Settings to talk to a real model."),
|
|
]
|
|
@State private var draft = ""
|
|
@State private var busy = false
|
|
@State private var error: String?
|
|
@State private var showSettings = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(spacing: 10) {
|
|
ForEach(msgs) { Bubble(msg: $0) }
|
|
}
|
|
.padding()
|
|
}
|
|
.onChange(of: msgs.last?.content) {
|
|
withAnimation { proxy.scrollTo(msgs.last?.id, anchor: .bottom) }
|
|
}
|
|
}
|
|
|
|
if let error {
|
|
Text(error)
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
HStack(alignment: .bottom, spacing: 10) {
|
|
TextField("Message \(hanzo.model)", text: $draft, axis: .vertical)
|
|
.lineLimit(1...5)
|
|
.textFieldStyle(.roundedBorder)
|
|
.onSubmit(send)
|
|
Button(action: send) {
|
|
Image(systemName: "arrow.up")
|
|
.fontWeight(.bold)
|
|
.frame(width: 34, height: 34)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.clipShape(.circle)
|
|
.disabled(busy || draft.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
.padding()
|
|
.background(.bar)
|
|
}
|
|
.navigationTitle("Hanzo Chat")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
Button { showSettings = true } label: { Image(systemName: "gearshape") }
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
NavigationStack { SettingsView() }
|
|
}
|
|
}
|
|
|
|
private func send() {
|
|
let text = draft.trimmingCharacters(in: .whitespaces)
|
|
guard !text.isEmpty, !busy else { return }
|
|
|
|
// Push the user turn plus an empty assistant turn, then stream into it.
|
|
let history = msgs.dropFirst(msgs.count == 1 ? 1 : 0) + [Msg(role: "user", content: text)]
|
|
draft = ""
|
|
error = nil
|
|
busy = true
|
|
msgs = Array(history) + [Msg(role: "assistant", content: "")]
|
|
|
|
Task {
|
|
defer { busy = false }
|
|
do {
|
|
for try await chunk in hanzo.complete(Array(history)) {
|
|
msgs[msgs.count - 1].content += chunk
|
|
}
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
msgs = Array(history) // keep the draft the user just lost
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Bubble: View {
|
|
let msg: Msg
|
|
private var mine: Bool { msg.role == "user" }
|
|
|
|
var body: some View {
|
|
HStack {
|
|
if mine { Spacer(minLength: 40) }
|
|
Text(msg.content.isEmpty ? "…" : msg.content)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.background(mine ? Color.hanzo : Color(.secondarySystemBackground))
|
|
.foregroundStyle(mine ? .white : .primary)
|
|
.clipShape(.rect(cornerRadius: 18))
|
|
if !mine { Spacer(minLength: 40) }
|
|
}
|
|
.id(msg.id)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack { ChatView() }
|
|
.environment(Hanzo())
|
|
.preferredColorScheme(.dark)
|
|
}
|