mirror of
https://github.com/hanzo-templates/expo-chat.git
synced 2026-10-30 20:54:58 +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.
80 lines
2.6 KiB
TypeScript
80 lines
2.6 KiB
TypeScript
/** The whole Hanzo integration: one streaming call to /v1/chat/completions.
|
|
*
|
|
* Config is read at RUNTIME (Settings tab) rather than baked in at build time,
|
|
* because an EXPO_PUBLIC_* key is compiled into the JS bundle and therefore
|
|
* published to every user who installs the app. Ship the app, let the user
|
|
* bring the key.
|
|
*/
|
|
import { useSyncExternalStore } from 'react';
|
|
|
|
export type Msg = { role: 'user' | 'assistant' | 'system'; content: string };
|
|
|
|
const DEFAULT_BASE = 'https://api.hanzo.ai/v1';
|
|
const DEFAULT_MODEL = 'zen-omni';
|
|
|
|
// Minimal observable store — no state library for four fields.
|
|
let cfg = { base: DEFAULT_BASE, key: '', model: DEFAULT_MODEL };
|
|
const subs = new Set<() => void>();
|
|
const emit = () => subs.forEach((f) => f());
|
|
|
|
export const useConfig = () =>
|
|
useSyncExternalStore(
|
|
(f) => (subs.add(f), () => subs.delete(f)),
|
|
() => cfg,
|
|
() => cfg,
|
|
);
|
|
|
|
export const setConfig = (patch: Partial<typeof cfg>) => {
|
|
cfg = { ...cfg, ...patch };
|
|
emit();
|
|
};
|
|
|
|
/** Streams the assistant reply, calling `onDelta` with each token chunk.
|
|
* Falls back to a single non-streamed read when the platform has no
|
|
* ReadableStream body (older RN fetch polyfills). */
|
|
export async function complete(
|
|
messages: Msg[],
|
|
onDelta: (chunk: string) => void,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
if (!cfg.key) throw new Error('No API key. Open Settings and paste a Hanzo key.');
|
|
|
|
const res = await fetch(`${cfg.base}/chat/completions`, {
|
|
method: 'POST',
|
|
signal,
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.key}` },
|
|
body: JSON.stringify({ model: cfg.model, messages, stream: true }),
|
|
});
|
|
|
|
if (!res.ok) throw new Error(`${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
|
|
const reader = (res.body as ReadableStream<Uint8Array> | null)?.getReader?.();
|
|
if (!reader) {
|
|
const json = await res.json();
|
|
onDelta(json.choices?.[0]?.message?.content ?? '');
|
|
return;
|
|
}
|
|
|
|
const dec = new TextDecoder();
|
|
let buf = '';
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += dec.decode(value, { stream: true });
|
|
// SSE frames are separated by a blank line; a chunk may split one in half.
|
|
const frames = buf.split('\n\n');
|
|
buf = frames.pop() ?? '';
|
|
for (const f of frames) {
|
|
const line = f.split('\n').find((l) => l.startsWith('data:'));
|
|
if (!line) continue;
|
|
const payload = line.slice(5).trim();
|
|
if (payload === '[DONE]') return;
|
|
try {
|
|
onDelta(JSON.parse(payload).choices?.[0]?.delta?.content ?? '');
|
|
} catch {
|
|
/* keep-alive comment or partial frame — skip it */
|
|
}
|
|
}
|
|
}
|
|
}
|