Compare commits

...
Author SHA1 Message Date
Hanzo AI c737ed137d feat(mobile): scaffold Hanzo AI mobile app (Tauri v2 + React 19 + @hanzo/gui)
Self-contained mobile/ app (not in any workspace):
- Tauri v2 shell (productName "Hanzo AI", id ai.hanzo.chat) with fs + http
  plugins, scoped capabilities (fs: ~/.codex ~/.claude ~/.hanzo; http:
  chatgpt.com, api.anthropic.com, api.hanzo.ai, claude.ai, hanzo.chat).
- React 19 + Vite + @hanzo/gui (createGui + defaultConfig from
  @hanzogui/config/v5). Three state-nav screens: Chat, Sessions, Usage.
- Chat -> POST /api/agents/v1/chat/completions (OpenAI-compatible);
  Sessions -> GET /api/convos; both Bearer-auth. Usage via @hanzo/usage
  (createTauriHost + useUsage), connect empty-state outside Tauri.
- Bearer token via VITE_CHAT_TOKEN or paste screen (localStorage); IAM TODO.
- Rust engine/node feature flags OFF by default; cfg-gated engine_status +
  device_register stubs pointing at hanzoai/engine + hanzoai/node.

vite build green; cargo check green (default + engine,node).
2026-07-07 15:26:29 -07:00
32 changed files with 16560 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
*.tsbuildinfo
.env
.env.local
# Rust / Tauri
src-tauri/target
src-tauri/gen
+88
View File
@@ -0,0 +1,88 @@
# Hanzo AI — mobile app
A mobile-first [Tauri v2](https://tauri.app) app (Rust shell) with a
React 19 + Vite + TypeScript webview, styled with [`@hanzo/gui`](https://www.npmjs.com/package/@hanzo/gui)
(a Tamagui fork). It talks to the Hanzo chat backend (this repo's LibreChat
fork) and shows local AI-provider usage via [`@hanzo/usage`](../../usage).
Self-contained: **not** part of the chat pnpm workspace. Install and build from
inside `mobile/`.
## Screens
| Screen | What it does | Backend |
|----------|---------------------------------------------------------------------|---------|
| Chat | Message list + composer | `POST /api/agents/v1/chat/completions` (OpenAI-compatible) |
| Sessions | Recent conversations | `GET /api/convos` |
| Usage | Provider cards (Hanzo / Codex / Claude); connect empty-state on web | `@hanzo/usage` reads `~/.codex`, `~/.claude`, `~/.hanzo` |
Navigation is a bottom tab bar backed by plain React state — no router library.
## Configuration
Copy `.env.example` to `.env`:
- `VITE_CHAT_API_URL` — backend base URL (default `https://hanzo.chat`)
- `VITE_CHAT_MODEL` — agent id / model for completions (default `hanzo`)
- `VITE_CHAT_TOKEN` — optional bearer token; if unset, the app shows a
paste-a-token screen and stores it in `localStorage`.
> **Auth is a placeholder.** The token flow is a stopgap; the real login is
> Hanzo IAM (hanzo.id) OIDC — see the `TODO(iam)` notes in `src/lib/auth.ts`.
## Develop
```sh
npm install
npm run dev # web-only, http://localhost:1420
npm run build # type-check + vite build -> dist/
# Desktop shell (needs the Rust toolchain + Tauri CLI):
npm run tauri dev
npm run tauri build
```
## Mobile targets
The mobile toolchains are **not** initialized in this repo (they generate
platform projects under `src-tauri/gen/`, which is git-ignored). Initialize them
locally when you build for a device:
```sh
# iOS (macOS + Xcode required)
cargo tauri ios init
cargo tauri ios dev
# Android (Android Studio + NDK required)
cargo tauri android init
cargo tauri android dev
```
`#[cfg_attr(mobile, tauri::mobile_entry_point)]` in `src-tauri/src/lib.rs` is the
shared entry the generated iOS/Android launchers call.
## Rust: on-device AI seams
Two cargo features stage on-device AI and stay **OFF by default** so the default
build is dependency-light and `cargo check` is fast:
- `engine` — local inference via [hanzoai/engine](https://github.com/hanzoai/engine)
- `node` — device registration / mesh via [hanzoai/node](https://github.com/hanzoai/node)
The Tauri commands `engine_status` and `device_register` exist in every build;
with the features off they return `"not built with engine"` / `"not built with
node"`. See `src-tauri/src/ai/mod.rs`.
```sh
cargo check # default, no AI deps
cargo check --features engine,node # once the crates are wired in
```
## Capabilities (scoped)
`src-tauri/capabilities/default.json` grants:
- **fs** read on `$HOME/.codex`, `$HOME/.claude`, `$HOME/.hanzo` (usage config);
write/mkdir limited to `$HOME/.hanzo`.
- **http** to `chatgpt.com`, `api.anthropic.com`, `api.hanzo.ai`, `claude.ai`,
and the chat backend origin `hanzo.chat`.
+20
View File
@@ -0,0 +1,20 @@
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from '@hanzo/gui'
// One GUI config for the whole app (DRY). Spread the canonical @hanzo/gui v5
// default config and hand it to createGui — same pattern as the Hanzo console
// (hanzoai/console/gui.config.ts). No font override here; the default system
// stack renders correctly inside the mobile webview.
export const config = createGui({
...defaultConfig,
})
export default config
export type Conf = typeof config
declare module '@hanzo/gui' {
interface TypeOverride {
conf: Conf
}
}
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="color-scheme" content="dark light" />
<title>Hanzo AI</title>
<style>
html,
body,
#root {
height: 100%;
margin: 0;
background: #000;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+10497
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@hanzo/chat-mobile",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Hanzo AI mobile app (Tauri v2 + React 19 + @hanzo/gui). Self-contained — not part of the chat pnpm workspace.",
"scripts": {
"dev": "vite",
"build": "tsc -b --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@hanzo/gui": "^7.3.0",
"@hanzo/usage": "file:../../usage/packages/core",
"@hanzogui/config": "^7.3.0",
"@hanzogui/core": "^7.3.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "^2.5.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-native-web": "^0.21.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+4892
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "hanzo-chat-mobile"
version = "0.1.0"
description = "Hanzo AI mobile app"
authors = ["Hanzo AI Inc"]
edition = "2021"
rust-version = "1.77.2"
[lib]
# Tauri mobile targets build the app as a shared library; a `_lib` suffix keeps
# the name from colliding with the desktop binary crate on Windows.
name = "hanzo_chat_mobile_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-fs = "2"
tauri-plugin-http = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[features]
# On-device AI seams — OFF by default so the default build stays dependency-light
# and `cargo check` needs no model runtime. Turning these on is where the
# hanzoai/engine (inference) and hanzoai/node (device mesh) crates get wired in.
default = []
engine = []
node = []
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,43 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Core capability: usage-config filesystem reads + chat/provider HTTP.",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "fs:allow-read-text-file",
"allow": [
{ "path": "$HOME/.codex/**" },
{ "path": "$HOME/.claude/**" },
{ "path": "$HOME/.hanzo/**" }
]
},
{
"identifier": "fs:allow-read-dir",
"allow": [
{ "path": "$HOME/.codex/**" },
{ "path": "$HOME/.claude/**" },
{ "path": "$HOME/.hanzo/**" }
]
},
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$HOME/.hanzo/**" }]
},
{
"identifier": "fs:allow-mkdir",
"allow": [{ "path": "$HOME/.hanzo/**" }]
},
{
"identifier": "http:default",
"allow": [
{ "url": "https://chatgpt.com/*" },
{ "url": "https://api.anthropic.com/*" },
{ "url": "https://api.hanzo.ai/*" },
{ "url": "https://claude.ai/*" },
{ "url": "https://hanzo.chat/*" }
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+68
View File
@@ -0,0 +1,68 @@
// On-device AI seams.
//
// This module stages two integration points that stay OFF by default (see the
// `engine` / `node` cargo features). Keeping them cfg-gated means the default
// build pulls in no model runtime and `cargo check` is fast and dependency-light.
//
// When ready, wire these to the real crates:
// - feature `engine` -> hanzoai/engine (local inference: GGUF/MLX/Metal, the
// same engine that powers Hanzo Engine on desktop).
// - feature `node` -> hanzoai/node (device registration + peer mesh so a
// phone can join a user's Hanzo compute fabric).
//
// The command surface is identical whether or not the features are enabled, so
// the JS side can always call them; disabled builds return a clear status.
use serde::Serialize;
#[derive(Serialize)]
pub struct EngineStatus {
pub enabled: bool,
pub detail: String,
}
#[derive(Serialize)]
pub struct DeviceRegistration {
pub registered: bool,
pub detail: String,
}
/// Report whether the local inference engine is compiled in and ready.
#[tauri::command]
pub fn engine_status() -> EngineStatus {
#[cfg(feature = "engine")]
{
// TODO(engine): probe hanzoai/engine — loaded model, backend, VRAM.
EngineStatus {
enabled: true,
detail: "engine feature enabled (runtime not yet wired)".into(),
}
}
#[cfg(not(feature = "engine"))]
{
EngineStatus {
enabled: false,
detail: "not built with engine".into(),
}
}
}
/// Register this device with the user's Hanzo node mesh.
#[tauri::command]
pub fn device_register(_name: Option<String>) -> DeviceRegistration {
#[cfg(feature = "node")]
{
// TODO(node): register with hanzoai/node — keypair, enroll, join mesh.
DeviceRegistration {
registered: true,
detail: "node feature enabled (mesh join not yet wired)".into(),
}
}
#[cfg(not(feature = "node"))]
{
DeviceRegistration {
registered: false,
detail: "not built with node".into(),
}
}
}
+18
View File
@@ -0,0 +1,18 @@
mod ai;
// Shared entry point for desktop and mobile (iOS/Android call this from their
// generated launchers). Registers the fs + http plugins the webview uses for
// provider-usage reads and cross-origin API calls (scopes live in
// capabilities/default.json), plus the on-device AI command stubs.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init())
.invoke_handler(tauri::generate_handler![
ai::engine_status,
ai::device_register
])
.run(tauri::generate_context!())
.expect("error while running Hanzo AI");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an extra console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hanzo_chat_mobile_lib::run()
}
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hanzo AI",
"version": "0.1.0",
"identifier": "ai.hanzo.chat",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Hanzo AI",
"width": 420,
"height": 900,
"minWidth": 320,
"minHeight": 480,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+38
View File
@@ -0,0 +1,38 @@
import { useState } from 'react'
import { Text, XStack, YStack } from '@hanzo/gui'
import { Nav, type Screen } from './components/Nav'
import { TokenGate } from './components/TokenGate'
import { Chat } from './screens/Chat'
import { Sessions } from './screens/Sessions'
import { Usage } from './screens/Usage'
// App shell: fixed header, active screen, bottom nav. State-based routing only.
export function App() {
const [screen, setScreen] = useState<Screen>('chat')
return (
<TokenGate>
<YStack flex={1} backgroundColor="$background">
<XStack
paddingHorizontal="$4"
paddingVertical="$3"
borderBottomWidth={1}
borderColor="$borderColor"
alignItems="center"
>
<Text fontSize="$6" fontWeight="700" color="$color">
Hanzo AI
</Text>
</XStack>
<YStack flex={1}>
{screen === 'chat' && <Chat />}
{screen === 'sessions' && <Sessions />}
{screen === 'usage' && <Usage />}
</YStack>
<Nav active={screen} onChange={setScreen} />
</YStack>
</TokenGate>
)
}
+68
View File
@@ -0,0 +1,68 @@
import type { ReactElement } from 'react'
import { Text, YStack } from '@hanzo/gui'
export type Screen = 'chat' | 'sessions' | 'usage'
// Inline SVG glyphs — the @hanzo/gui web output renders to the DOM, so a plain
// <svg> is the lightest icon (avoids react-native-svg, whose web build wants a
// react-native-web internal that RNW 0.21 no longer ships).
type Glyph = (p: { active: boolean }) => ReactElement
const stroke = (active: boolean) => (active ? '#fff' : '#888')
const ChatIcon: Glyph = ({ active }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={stroke(active)} strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const ListIcon: Glyph = ({ active }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={stroke(active)} strokeWidth="2">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const GaugeIcon: Glyph = ({ active }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={stroke(active)} strokeWidth="2">
<path d="M12 14l4-4M20.66 17A9 9 0 1 0 3.34 17" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const TABS: Array<{ id: Screen; label: string; Icon: Glyph }> = [
{ id: 'chat', label: 'Chat', Icon: ChatIcon },
{ id: 'sessions', label: 'Sessions', Icon: ListIcon },
{ id: 'usage', label: 'Usage', Icon: GaugeIcon },
]
// Bottom tab bar — the app's only navigation. Plain state, no router lib.
export function Nav({ active, onChange }: { active: Screen; onChange: (s: Screen) => void }) {
return (
<YStack
flexDirection="row"
borderTopWidth={1}
borderColor="$borderColor"
backgroundColor="$background"
paddingBottom="$2"
>
{TABS.map(({ id, label, Icon }) => {
const selected = id === active
return (
<YStack
key={id}
flex={1}
alignItems="center"
gap="$1"
paddingVertical="$3"
pressStyle={{ opacity: 0.6 }}
onPress={() => onChange(id)}
>
<Icon active={selected} />
<Text fontSize="$1" color={selected ? '$color' : '$color10'}>
{label}
</Text>
</YStack>
)
})}
</YStack>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, type ReactNode } from 'react'
import { Button, Input, Paragraph, Text, YStack } from '@hanzo/gui'
import { hasToken, setToken } from '../lib/auth'
// Simple paste-a-token gate. Renders children once a bearer token is present
// (from VITE_CHAT_TOKEN or a previous paste), otherwise a single input.
// TODO(iam): swap for a Hanzo IAM OIDC login (see lib/auth.ts).
export function TokenGate({ children }: { children: ReactNode }) {
const [authed, setAuthed] = useState(hasToken())
const [value, setValue] = useState('')
if (authed) return <>{children}</>
const submit = () => {
const token = value.trim()
if (!token) return
setToken(token)
setAuthed(true)
}
return (
<YStack flex={1} justifyContent="center" gap="$4" padding="$6" backgroundColor="$background">
<Text fontSize="$8" fontWeight="700" color="$color">
Hanzo AI
</Text>
<Paragraph color="$color10">
Paste a bearer token to connect to the chat backend. You can set{' '}
<Text fontFamily="$mono">VITE_CHAT_TOKEN</Text> to skip this.
</Paragraph>
<Input
value={value}
onChangeText={setValue}
placeholder="Bearer token"
secureTextEntry
autoCapitalize="none"
size="$4"
onSubmitEditing={submit}
/>
<Button theme="active" size="$4" disabled={!value.trim()} onPress={submit}>
Connect
</Button>
</YStack>
)
}
+126
View File
@@ -0,0 +1,126 @@
// Thin typed client for the Hanzo chat backend (LibreChat fork).
//
// Two real endpoints, both Bearer-authenticated (see api/server/routes in the
// chat repo):
//
// POST {base}/api/agents/v1/chat/completions
// OpenAI-compatible chat completions (api/server/routes/agents/openai.js →
// OpenAIChatCompletionController). Body: { model, messages, stream }.
// `model` is an agent id. We call it non-streaming for a simple mobile UI.
//
// GET {base}/api/convos?limit=&cursor=
// Conversation list (api/server/routes/convos.js). Returns
// { conversations: Conversation[], nextCursor: string | null }.
//
// Base URL comes from VITE_CHAT_API_URL (default https://hanzo.chat). The model
// / agent id comes from VITE_CHAT_MODEL (default "hanzo"). Auth header is
// `Authorization: Bearer <token>` — a JWT for /convos, an agent API key for the
// OpenAI-compatible route; in a gateway deployment the same IAM bearer covers
// both, so we send one token.
import { getToken } from './auth'
const BASE_URL = (
(import.meta.env.VITE_CHAT_API_URL as string | undefined) ?? 'https://hanzo.chat'
).replace(/\/$/, '')
const DEFAULT_MODEL = (import.meta.env.VITE_CHAT_MODEL as string | undefined) ?? 'hanzo'
export interface ChatMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export interface Conversation {
conversationId: string
title?: string
endpoint?: string
model?: string
updatedAt?: string
}
export interface ConversationPage {
conversations: Conversation[]
nextCursor: string | null
}
interface ChatCompletion {
choices?: Array<{ message?: { role?: string; content?: string } }>
}
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
function authHeaders(): Record<string, string> {
const token = getToken()
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
return headers
}
async function readError(res: Response): Promise<string> {
try {
const body = (await res.json()) as { error?: string; message?: string }
return body.error ?? body.message ?? res.statusText
} catch {
return res.statusText || `HTTP ${res.status}`
}
}
export function getBaseUrl(): string {
return BASE_URL
}
/**
* Send a full message history and return the assistant's reply text.
* Non-streaming for simplicity; the endpoint also supports SSE (stream:true).
*/
export async function chat(
messages: ChatMessage[],
opts: { model?: string; signal?: AbortSignal } = {},
): Promise<string> {
const res = await fetch(`${BASE_URL}/api/agents/v1/chat/completions`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
model: opts.model ?? DEFAULT_MODEL,
messages,
stream: false,
}),
signal: opts.signal,
})
if (!res.ok) throw new ApiError(res.status, await readError(res))
const data = (await res.json()) as ChatCompletion
const content = data.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new ApiError(res.status, 'Malformed completion: no assistant content')
}
return content
}
/** List recent conversations for the authenticated user. */
export async function listConversations(
opts: { limit?: number; cursor?: string; signal?: AbortSignal } = {},
): Promise<ConversationPage> {
const params = new URLSearchParams()
params.set('limit', String(opts.limit ?? 25))
if (opts.cursor) params.set('cursor', opts.cursor)
const res = await fetch(`${BASE_URL}/api/convos?${params.toString()}`, {
method: 'GET',
headers: authHeaders(),
signal: opts.signal,
})
if (!res.ok) throw new ApiError(res.status, await readError(res))
const data = (await res.json()) as Partial<ConversationPage>
return {
conversations: data.conversations ?? [],
nextCursor: data.nextCursor ?? null,
}
}
+45
View File
@@ -0,0 +1,45 @@
// Bearer-token auth for the chat backend.
//
// Precedence: a build-time VITE_CHAT_TOKEN (handy for local dev / CI) wins;
// otherwise we use whatever the user pasted into the token screen, persisted in
// localStorage. The pasted token survives reloads and (in the Tauri webview)
// app restarts.
//
// TODO(iam): replace the paste flow with a real Hanzo IAM (hanzo.id) OIDC login
// — open the IAM authorize URL in the system browser / an in-app auth webview,
// capture the callback, and store the returned access token here. The rest of
// the app already reads the token through getToken(), so only this module
// changes.
const STORAGE_KEY = 'hanzo.chat.token'
const envToken = (import.meta.env.VITE_CHAT_TOKEN as string | undefined)?.trim()
export function getToken(): string | undefined {
if (envToken) return envToken
try {
return localStorage.getItem(STORAGE_KEY) ?? undefined
} catch {
return undefined
}
}
export function setToken(token: string): void {
try {
localStorage.setItem(STORAGE_KEY, token.trim())
} catch {
// no-op: private-mode webview without storage; token lives for this session
}
}
export function clearToken(): void {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// no-op
}
}
export function hasToken(): boolean {
return Boolean(getToken())
}
+47
View File
@@ -0,0 +1,47 @@
// Usage store wiring for the mobile app.
//
// @hanzo/usage is headless: a UsageStore driven by a host that can read files
// (provider config in ~/.codex, ~/.claude, ~/.hanzo) and make HTTP calls. In
// Tauri we build that host from the fs + http plugins (createTauriHost). In a
// plain browser there is no filesystem, so there is no host — the Usage screen
// shows a connect empty-state instead.
import { UsageStore, allProviders } from '@hanzo/usage'
import { createTauriHost } from '@hanzo/usage/tauri'
/** True when running inside the Tauri webview (vs. a plain browser tab). */
export function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
}
/**
* Build a UsageStore backed by the Tauri fs/http plugins, or return null when
* not running under Tauri. Plugin modules are imported dynamically so a browser
* build never pulls the Tauri APIs into the initial bundle.
*/
export async function createUsageStore(): Promise<UsageStore | null> {
if (!isTauri()) return null
const [fs, http, path] = await Promise.all([
import('@tauri-apps/plugin-fs'),
import('@tauri-apps/plugin-http'),
import('@tauri-apps/api/path'),
])
const host = await createTauriHost({
fs: {
readTextFile: (p) => fs.readTextFile(p),
readDir: (p) => fs.readDir(p).then((es) => es.map((e) => ({ name: e.name }))),
writeTextFile: (p, contents) => fs.writeTextFile(p, contents),
mkdir: (p, opts) => fs.mkdir(p, opts),
},
fetch: http.fetch,
homeDir: path.homeDir,
})
return new UsageStore({
host,
providers: allProviders,
sourceMode: 'auto',
})
}
+19
View File
@@ -0,0 +1,19 @@
import '@hanzogui/core/reset.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { GuiProvider } from '@hanzo/gui'
import config from '../gui.config'
import { App } from './App'
const root = document.getElementById('root')
if (!root) throw new Error('root element missing')
// Dark by default — a chat surface reads best on a dark ground, and it matches
// the index.html background so there is no first-paint flash.
createRoot(root).render(
<StrictMode>
<GuiProvider config={config} defaultTheme="dark">
<App />
</GuiProvider>
</StrictMode>,
)
+103
View File
@@ -0,0 +1,103 @@
import { useRef, useState } from 'react'
import { Button, Input, Paragraph, ScrollView, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { chat, ApiError, type ChatMessage } from '../lib/api'
// Chat screen: message list + composer. Sends the full history to the backend's
// OpenAI-compatible completions endpoint and appends the reply.
export function Chat() {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// RN-style ScrollView exposes scrollToEnd; the gui ref type is broad, so we
// narrow to the one method we call and attach it with a cast.
const scrollRef = useRef<{ scrollToEnd?: (o?: { animated?: boolean }) => void }>(null)
const send = async () => {
const content = draft.trim()
if (!content || busy) return
const next: ChatMessage[] = [...messages, { role: 'user', content }]
setMessages(next)
setDraft('')
setError(null)
setBusy(true)
try {
const reply = await chat(next)
setMessages([...next, { role: 'assistant', content: reply }])
} catch (e) {
const msg = e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)
setError(msg)
setMessages(next)
} finally {
setBusy(false)
requestAnimationFrame(() => scrollRef.current?.scrollToEnd?.({ animated: true }))
}
}
return (
<YStack flex={1} backgroundColor="$background">
<ScrollView ref={scrollRef as never} flex={1} contentContainerStyle={{ padding: 12, gap: 10 }}>
{messages.length === 0 && (
<YStack flex={1} alignItems="center" justifyContent="center" paddingVertical="$10">
<Paragraph color="$color10">Ask anything to get started.</Paragraph>
</YStack>
)}
{messages.map((m, i) => (
<Bubble key={i} message={m} />
))}
{busy && (
<XStack gap="$2" alignItems="center" paddingVertical="$2">
<Spinner size="small" color="$color10" />
<Text color="$color10">Thinking</Text>
</XStack>
)}
{error && (
<YStack backgroundColor="$red3" borderRadius="$4" padding="$3">
<Text color="$red11">{error}</Text>
</YStack>
)}
</ScrollView>
<XStack
gap="$2"
padding="$3"
borderTopWidth={1}
borderColor="$borderColor"
alignItems="flex-end"
>
<Input
flex={1}
value={draft}
onChangeText={setDraft}
placeholder="Message"
multiline
maxHeight={120}
size="$4"
onSubmitEditing={send}
/>
<Button theme="active" size="$4" disabled={busy || !draft.trim()} onPress={send}>
Send
</Button>
</XStack>
</YStack>
)
}
function Bubble({ message }: { message: ChatMessage }) {
const mine = message.role === 'user'
return (
<XStack justifyContent={mine ? 'flex-end' : 'flex-start'}>
<YStack
maxWidth="85%"
backgroundColor={mine ? '$color5' : '$color2'}
borderRadius="$5"
paddingHorizontal="$3"
paddingVertical="$2"
>
<Paragraph color="$color" whiteSpace="pre-wrap">
{message.content}
</Paragraph>
</YStack>
</XStack>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import { Button, Paragraph, ScrollView, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { listConversations, ApiError, type Conversation } from '../lib/api'
// Sessions screen: the user's recent conversations from GET /api/convos.
export function Sessions() {
const [items, setItems] = useState<Conversation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = () => {
const controller = new AbortController()
setLoading(true)
setError(null)
listConversations({ signal: controller.signal })
.then((page) => setItems(page.conversations))
.catch((e) => {
if (controller.signal.aborted) return
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e))
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false)
})
return () => controller.abort()
}
useEffect(load, [])
if (loading) {
return (
<YStack flex={1} alignItems="center" justifyContent="center" backgroundColor="$background">
<Spinner size="large" color="$color10" />
</YStack>
)
}
return (
<ScrollView flex={1} backgroundColor="$background" contentContainerStyle={{ padding: 12, gap: 8 }}>
{error && (
<YStack gap="$3" backgroundColor="$red3" borderRadius="$4" padding="$3">
<Text color="$red11">{error}</Text>
<Button size="$3" onPress={load}>
Retry
</Button>
</YStack>
)}
{!error && items.length === 0 && (
<YStack flex={1} alignItems="center" justifyContent="center" paddingVertical="$10">
<Paragraph color="$color10">No conversations yet.</Paragraph>
</YStack>
)}
{items.map((c) => (
<YStack
key={c.conversationId}
backgroundColor="$color2"
borderRadius="$4"
padding="$3"
gap="$1"
pressStyle={{ opacity: 0.7 }}
>
<Text color="$color" fontWeight="600" numberOfLines={1}>
{c.title?.trim() || 'Untitled'}
</Text>
<XStack gap="$2">
{c.model && (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{c.model}
</Text>
)}
{c.updatedAt && (
<Text fontSize="$1" color="$color10">
{new Date(c.updatedAt).toLocaleDateString()}
</Text>
)}
</XStack>
</YStack>
))}
</ScrollView>
)
}
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useState } from 'react'
import { Button, Paragraph, ScrollView, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { useUsage } from '@hanzo/usage/react'
import type { UsageStore, ProviderState, RateWindow } from '@hanzo/usage'
import { createUsageStore, isTauri } from '../lib/usage'
const PROVIDERS: Array<{ id: string; label: string }> = [
{ id: 'hanzo', label: 'Hanzo' },
{ id: 'codex', label: 'Codex' },
{ id: 'claude', label: 'Claude' },
]
// Usage screen: provider cards from @hanzo/usage. Needs a filesystem host
// (Tauri) to read ~/.codex, ~/.claude, ~/.hanzo — outside Tauri we show a
// connect empty-state.
export function Usage() {
const [store, setStore] = useState<UsageStore | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
let live = true
let created: UsageStore | null = null
createUsageStore()
.then((s) => {
if (!live) {
s?.stop()
return
}
created = s
setStore(s)
s?.start()
})
.finally(() => live && setReady(true))
return () => {
live = false
created?.stop()
}
}, [])
if (!ready) {
return (
<YStack flex={1} alignItems="center" justifyContent="center" backgroundColor="$background">
<Spinner size="large" color="$color10" />
</YStack>
)
}
if (!store) return <ConnectEmptyState />
return <UsageCards store={store} />
}
function ConnectEmptyState() {
return (
<YStack
flex={1}
alignItems="center"
justifyContent="center"
gap="$3"
padding="$6"
backgroundColor="$background"
>
<Text fontSize="$6" fontWeight="700" color="$color">
Usage is a desktop feature
</Text>
<Paragraph color="$color10" textAlign="center">
{isTauri()
? 'Usage data is unavailable in this build.'
: 'Open the Hanzo AI app to read your local Codex, Claude, and Hanzo provider usage.'}
</Paragraph>
</YStack>
)
}
function UsageCards({ store }: { store: UsageStore }) {
const state = useUsage(store)
return (
<ScrollView flex={1} backgroundColor="$background" contentContainerStyle={{ padding: 12, gap: 10 }}>
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$6" fontWeight="700" color="$color">
Usage
</Text>
<Button size="$2" disabled={state.refreshing} onPress={() => void store.refresh()}>
{state.refreshing ? 'Refreshing…' : 'Refresh'}
</Button>
</XStack>
{PROVIDERS.map(({ id, label }) => (
<ProviderCard key={id} label={label} state={state.providers[id]} />
))}
</ScrollView>
)
}
function ProviderCard({ label, state }: { label: string; state?: ProviderState }) {
const snap = state?.snapshot
return (
<YStack backgroundColor="$color2" borderRadius="$5" padding="$4" gap="$3">
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$5" fontWeight="600" color="$color">
{label}
</Text>
{state?.refreshing && <Spinner size="small" color="$color10" />}
</XStack>
{state?.error && <Text color="$red11">{state.error}</Text>}
{!state?.error && !snap && (
<Paragraph color="$color10">
{state?.refreshing ? 'Loading…' : 'Not connected. Sign in to this provider on this device.'}
</Paragraph>
)}
{snap && (
<YStack gap="$3">
<Meter title="Session" window={snap.primary} />
<Meter title="Weekly" window={snap.secondary} />
{snap.identity?.plan && (
<Text fontSize="$1" color="$color10">
Plan: {snap.identity.plan}
</Text>
)}
{state?.sourceLabel && (
<Text fontSize="$1" color="$color10">
Source: {state.sourceLabel}
</Text>
)}
</YStack>
)}
</YStack>
)
}
function Meter({ title, window: w }: { title: string; window?: RateWindow }) {
if (!w) return null
const pct = Math.round(Math.max(0, Math.min(100, w.usedPercent)))
return (
<YStack gap="$1.5">
<XStack justifyContent="space-between">
<Text fontSize="$2" color="$color11">
{title}
</Text>
<Text fontSize="$2" color="$color11">
{pct}% used
</Text>
</XStack>
<YStack height={8} borderRadius="$10" backgroundColor="$color4" overflow="hidden">
<YStack height={8} width={`${pct}%`} backgroundColor="$color10" />
</YStack>
{w.resetDescription && (
<Text fontSize="$1" color="$color10">
Resets {w.resetDescription}
</Text>
)}
</YStack>
)
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"]
},
"include": ["src", "gui.config.ts", "vite.config.ts"]
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// @hanzo/gui is a Tamagui fork: its web build reaches for `react-native`
// primitives, so we alias them to `react-native-web` (the same wiring the
// hanzoai/status Vite app uses). No Tamagui compiler plugin — runtime config
// via GuiProvider is enough for a mobile webview and keeps the build simple.
//
// Tauri: the dev server must stay on a fixed host/port so the Rust shell can
// point its WebView at it (see src-tauri/tauri.conf.json `devUrl`).
export default defineConfig({
plugins: [react()],
clearScreen: false,
resolve: {
alias: {
'react-native': 'react-native-web',
},
},
define: {
// Tamagui reads this to pick the web output at bundle time.
'process.env.TAMAGUI_TARGET': JSON.stringify('web'),
},
build: {
outDir: 'dist',
emptyOutDir: true,
// Match tsconfig (ES2022); esbuild refuses to downlevel some dependency
// syntax to vite's legacy baseline. Mobile webviews are modern.
target: 'es2022',
},
server: {
host: '0.0.0.0',
port: 1420,
strictPort: true,
},
})