Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c737ed137d |
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Rust / Tauri
|
||||
src-tauri/target
|
||||
src-tauri/gen
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+10497
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+4892
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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 |
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user