Merge remote-tracking branch 'origin/feat/desktop'

This commit is contained in:
Hanzo Dev
2026-07-03 21:47:00 -07:00
42 changed files with 2459 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Rust / Tauri build output (dist/ and node_modules/ are covered by the root .gitignore).
src-tauri/target/
src-tauri/gen/
+164
View File
@@ -0,0 +1,164 @@
# @hanzo/desktop
The Hanzo AI desktop app — the **ambient omnipresence layer**. A cross-platform
(macOS / Windows / Linux) menubar/tray app with a global hotkey that pops a Hanzo
assistant over whatever app you are in. Ask, summarize the clipboard, rewrite,
explain, translate, and fix grammar — wired to the `api.hanzo.ai` gateway via the
published [`@hanzo/ai`](https://www.npmjs.com/package/@hanzo/ai). Built on
[Tauri v2](https://tauri.app) (a Rust shell + a web frontend) — lighter and more
native than Electron.
It is the same assistant as the Raycast / Office / PDF surfaces, reusing the same
action catalog and prompt/parse contracts, packaged as a standalone always-on-top
window you summon from anywhere.
## What it does
- **Global hotkey** — `Cmd/Ctrl+Shift+Space` shows/hides a small, always-on-top,
spotlight-style window over the frontmost app. Press again to dismiss; it also
hides when it loses focus.
- **Tray / menubar icon** — Open, the quick actions, Settings, and Quit. Left-click
the icon to toggle the window; the menu is the secondary surface.
- **Quick actions over the clipboard** — Copy text in any app, summon Hanzo, and:
- **Ask** — ask anything (answered from the clipboard if present, else general
knowledge).
- **Summarize clipboard** / **Explain** — the answer is streamed and copied to
the clipboard.
- **Rewrite** / **Translate** / **Fix grammar** — the result is streamed, then
**pasted back** over the frontmost app (the corrected text lands where the
original was).
- **Streaming** — every reply streams token-by-token into the panel.
- **Your key, your machine** — paste your `hk-` key from
[hanzo.id](https://hanzo.id) into Settings. It is stored **only** in the app's
local store (the webview's `localStorage`) and sent **only** to `api.hanzo.ai`
as the bearer. No secret is bundled or committed.
## Architecture
A thin Rust shell + a pure web frontend. The AI logic is entirely in the frontend;
the Rust is the native container.
```
packages/desktop/
├── src/ frontend (TypeScript, esbuild → dist/)
│ ├── config.ts gateway endpoints, default model, hk- key guard, store key
│ ├── actions.ts PURE: action catalog, prompt builders, clipboard-context
│ │ assembly/truncation, message shaping, reply parsing
│ ├── hanzo.ts THIN wrapper over @hanzo/ai (createAiClient): run / stream / list
│ ├── store.ts local settings (hk- key + model): parse / merge / serialize
│ ├── clipboard.ts Tauri I/O boundary: read clipboard, copy, paste-back
│ ├── app.ts DOM controller (glue only — one run path for every action)
│ ├── index.html the panel markup
│ └── styles.css the panel styling
├── src-tauri/ Rust shell
│ ├── src/main.rs one-line binary entrypoint
│ ├── src/lib.rs tray + global hotkey + window toggle + hide_window/paste commands
│ ├── Cargo.toml tauri 2.11, clipboard-manager + global-shortcut 2.3, enigo 0.6
│ ├── tauri.conf.json window (small, always-on-top, hidden-on-blur), tray, CSP, bundle
│ ├── capabilities/default.json plugin + core permissions for the main window
│ ├── entitlements.plist macOS hardened-runtime entitlements (network client)
│ └── icons/ generated icon set (png / ico / icns)
├── build.js esbuild the frontend → dist/ (Tauri's frontendDist)
├── test/ vitest — the pure frontend logic
├── package.json tsconfig.json vitest.config.ts
```
**Separation of concerns:** `actions.ts` and `store.ts` are pure (no Tauri, no SDK,
no DOM) and fully unit-tested. `hanzo.ts` is the only module that calls `@hanzo/ai`.
`clipboard.ts` is the only frontend module that imports Tauri. `app.ts` is glue.
The tray quick actions and the hotkey both emit frontend events, so `app.ts`'s
single run path serves every surface — DRY across native and web.
## Develop
Requires Node ≥ 18 + [pnpm](https://pnpm.io), and — for the native shell only — the
[Rust toolchain](https://tauri.app/start/prerequisites/) and your platform's WebView
dependencies (WebView2 on Windows, WebKitGTK on Linux; built in on macOS).
```bash
pnpm install
# Frontend only — bundle src/ → dist/ (no Rust toolchain needed):
pnpm --filter @hanzo/desktop build # one-shot
pnpm --filter @hanzo/desktop watch # rebuild on change
# Type-check the frontend + run the unit tests:
pnpm --filter @hanzo/desktop typecheck
pnpm --filter @hanzo/desktop test
# Full app in dev (needs the Rust toolchain) — builds the frontend, launches the
# native shell with the tray + global hotkey, hot-reloads the frontend:
pnpm --filter @hanzo/desktop tauri:dev
```
`tauri:dev` runs `node build.js --watch` as its `beforeDevCommand` and serves
`dist/`; edit `src/*.ts` and the panel reloads.
## Build installers (CI only)
Building the native installers needs the Rust toolchain and, for a distributable
build, code-signing — so it is a **CI job, never run on a developer machine**.
```bash
# CI, per-OS runner:
pnpm --filter @hanzo/desktop tauri:build
```
`tauri build` runs `node build.js` (frontend) then compiles the Rust shell and
bundles the installers configured in `tauri.conf.json → bundle.targets`:
| OS | Runner | Artifact(s) |
| ------- | ------------- | -------------------------------------------------------------- |
| macOS | `macos-*` | `src-tauri/target/release/bundle/dmg/Hanzo_0.1.0_*.dmg` (+ `.app`) |
| Windows | `windows-*` | `src-tauri/target/release/bundle/{msi,nsis}/Hanzo_0.1.0_*.{msi,exe}` |
| Linux | `ubuntu-*` | `src-tauri/target/release/bundle/{appimage,deb}/hanzo_0.1.0_*.{AppImage,deb}` |
Each OS bundle is produced on its own runner (Tauri does not cross-compile the
native shell). Only the frontend (`node build.js`) is verified locally.
### Code signing & notarization (future)
Distributable builds must be signed so the OS does not warn/block them:
- **macOS** — sign with a Developer ID Application certificate and **notarize**
with Apple (`APPLE_CERTIFICATE`, `APPLE_ID`, `APPLE_PASSWORD`,
`APPLE_TEAM_ID` in CI). `src-tauri/entitlements.plist` declares the
hardened-runtime entitlements (network client). The synthetic **paste** keystroke
requires the user to grant **Accessibility** to the app at first use (System
Settings → Privacy & Security → Accessibility) — not an entitlement.
- **Windows** — Authenticode-sign the `.msi`/`.exe` with an EV/OV certificate.
- **Linux** — the `.AppImage` / `.deb` are unsigned; publish checksums.
All signing secrets live in **KMS** and are injected by CI — never in this repo.
## The flow
```
copy text in any app
▼ Cmd/Ctrl+Shift+Space (global hotkey) ─── or ─── click the tray icon / menu
Rust shell: toggle_window() → show + focus, emit "focus-prompt"
│ tray quick action → emit "run-action" <id>
frontend app.ts (one run path):
gate on hk- key → captureSubject() reads the clipboard (Tauri plugin)
→ buildActionMessages(id, inputs, ctx) [pure]
→ streamChat(...) over @hanzo/ai → api.hanzo.ai /v1/chat/completions
→ parseResult(reply) [pure]
→ copy (ask/summarize/explain) OR paste-back (rewrite/translate/fix):
writeText(result) → invoke("paste") → Rust hides the window and
synthesizes Cmd/Ctrl+V into the now-frontmost app (enigo)
```
## Notes
- All model traffic goes to `api.hanzo.ai` over `/v1/...` (never an `/api/` prefix)
through `@hanzo/ai` — the transport is never reimplemented here.
- The window CSP allows connecting only to `https://api.hanzo.ai`.
- The `hk-` key is validated (`isHanzoKey`) before it is ever used as a bearer, so
a wrong paste (an IAM JWT, an OpenAI `sk-` key) is rejected in the UI.
## License
MIT
+64
View File
@@ -0,0 +1,64 @@
// Build the desktop app's FRONTEND into dist/: bundle app.ts (ESM) and copy the
// static shell (index.html, styles.css). Tauri loads dist/ as its `frontendDist`
// (see src-tauri/tauri.conf.json). No framework — esbuild + Node stdlib only,
// mirroring @hanzo/pdf's and @hanzo/canva's build. Every model call goes to
// api.hanzo.ai via @hanzo/ai; the app holds no secret — the user pastes their own
// hk- key into the settings pane, stored only in the webview's localStorage.
//
// node build.js → production build (dist/index.html, app.js, styles.css)
// node build.js --watch → rebuild on change (Tauri dev serves dist/)
//
// This is the ONLY step that runs on a developer machine. Building the native
// installers (.dmg / .msi / .AppImage) is `tauri build`, which needs the Rust
// toolchain and code-signing — a CI job, never run locally.
import esbuild from "esbuild";
import { rmSync, mkdirSync, copyFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const watch = process.argv.includes("--watch");
const src = join(__dirname, "src");
const dist = join(__dirname, "dist");
async function build() {
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
// Bundle app.ts → dist/app.js as ESM for the Tauri webview (a modern WebView2 /
// WKWebView / WebKitGTK). @hanzo/ai and the @tauri-apps/* JS APIs are bundled
// in — the page is served standalone from dist/, no import map.
const ctx = await esbuild.context({
entryPoints: [join(src, "app.ts")],
outfile: join(dist, "app.js"),
bundle: true,
format: "esm",
platform: "browser",
target: ["chrome100", "edge100", "firefox100", "safari15"],
define: { "process.env.NODE_ENV": JSON.stringify(watch ? "development" : "production") },
sourcemap: true,
minify: !watch,
logLevel: "info",
});
await ctx.rebuild();
// Copy the static shell.
copyFileSync(join(src, "index.html"), join(dist, "index.html"));
copyFileSync(join(src, "styles.css"), join(dist, "styles.css"));
console.log("Hanzo desktop frontend built -> dist/");
console.log(" Tauri loads dist/index.html as its frontendDist.");
if (watch) {
await ctx.watch();
console.log("watching...");
} else {
await ctx.dispose();
}
}
build().catch((e) => {
console.error(e);
process.exit(1);
});
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@hanzo/desktop",
"version": "0.1.0",
"description": "Hanzo AI desktop app — the ambient omnipresence layer. A cross-platform (macOS/Windows/Linux) menubar/tray app with a global hotkey that pops a Hanzo assistant over any app: ask, summarize the clipboard, rewrite, explain, translate, and fix grammar, wired to the api.hanzo.ai gateway via the published @hanzo/ai. Built on Tauri v2.",
"private": true,
"type": "module",
"scripts": {
"build": "node build.js",
"watch": "node build.js --watch",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hanzo/ai": "^0.2.0",
"@hanzo/iam": "^0.13.2",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-global-shortcut": "^2.3.2"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/node": "^22.0.0",
"esbuild": "0.25.8",
"typescript": "5.8.3",
"vitest": "3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"hanzo",
"desktop",
"tauri",
"ai",
"assistant",
"menubar",
"tray",
"global-hotkey",
"zen"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/hanzoai/extension.git",
"directory": "packages/desktop"
}
}
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "hanzo-desktop"
version = "0.1.0"
description = "Hanzo AI desktop assistant — the ambient omnipresence layer (tray + global hotkey)"
authors = ["Hanzo AI"]
license = "MIT"
repository = "https://github.com/hanzoai/extension"
edition = "2021"
rust-version = "1.77.2"
# The binary is a one-liner (main.rs); all shell logic lives in the library
# (lib.rs) per the Tauri v2 convention, so the crate is reusable and the mobile
# entrypoint (if ever added) shares one run().
[lib]
name = "hanzo_desktop_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[[bin]]
name = "hanzo-desktop"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
tauri = { version = "2.11.5", features = ["tray-icon"] }
tauri-plugin-clipboard-manager = "2.3.2"
# Synthetic paste keystroke into the frontmost app (rewrite/fix/translate outcome).
enigo = "0.6.1"
# The global-shortcut plugin is desktop-only (no mobile target for this app).
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-global-shortcut = "2.3.2"
# Release profile tuned for a small, fast native shell — the installers CI ships.
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
panic = "abort"
strip = true
+7
View File
@@ -0,0 +1,7 @@
// Tauri's build script: generates the context (config, capabilities, icons) the
// macro `tauri::generate_context!()` reads at compile time. Standard boilerplate;
// CI runs it as part of `tauri build`.
fn main() {
tauri_build::build();
}
@@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissions for the Hanzo assistant window: read/write the clipboard, listen for shell events (run-action / focus-prompt), and invoke the app's own commands (hide_window, paste).",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text"
]
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
macOS entitlements for the signed/notarized build (a CI step). The assistant
needs outbound network to api.hanzo.ai; the hardened runtime is required for
notarization. Apple Events / Accessibility (for the synthetic paste keystroke)
are granted by the USER at first use via System Settings > Privacy, not by an
entitlement, so nothing extra is declared here.
-->
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+215
View File
@@ -0,0 +1,215 @@
// The Rust shell for the Hanzo desktop assistant — the ambient omnipresence layer.
// It is deliberately minimal: it owns the tray/menubar icon, a global hotkey
// (Cmd/Ctrl+Shift+Space) that shows/hides a small always-on-top window, and two
// commands the frontend invokes (hide_window, paste). ALL AI logic lives in the
// frontend (src/*.ts over @hanzo/ai); this crate is the native container only.
//
// Design: one function toggles the window (show_and_focus / toggle_window), one
// place registers the hotkey, one place builds the tray + menu. The tray quick
// actions and the hotkey both emit frontend events so the single run path in
// app.ts serves every surface (DRY across native and web).
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Emitter, Manager, WebviewWindow,
};
// The one window the app has. Referenced by label everywhere so the tray, hotkey,
// and commands all target the same webview.
const MAIN: &str = "main";
// show_and_focus reveals the assistant window, brings it to the front, focuses it,
// and tells the frontend to focus the prompt box for immediate typing. The single
// "bring the assistant up" path — the hotkey and the tray's Open both call it.
fn show_and_focus(window: &WebviewWindow) {
let _ = window.show();
let _ = window.set_focus();
// Ask the frontend to focus the prompt input (best-effort; ignored if the page
// is not ready yet — the autofocus attribute covers the cold-start case).
let _ = window.emit("focus-prompt", ());
}
// toggle_window is the hotkey behavior: hide the window if it is visible and
// focused, else show and focus it. One obvious toggle so the global shortcut has a
// single, predictable effect.
fn toggle_window(window: &WebviewWindow) {
let visible = window.is_visible().unwrap_or(false);
let focused = window.is_focused().unwrap_or(false);
if visible && focused {
let _ = window.hide();
} else {
show_and_focus(window);
}
}
// hide_window is invoked by the frontend after a copy-only action (or Esc) to get
// the panel out of the way. A command (not just a JS window.hide) so the same hide
// path is used from Rust and JS.
#[tauri::command]
fn hide_window(window: WebviewWindow) {
let _ = window.hide();
}
// paste hides the assistant and synthesizes the platform paste keystroke into the
// now-frontmost app, so a rewrite/fix/translate result (already on the clipboard
// via the frontend) lands where the original text was. Returns Ok even when the OS
// denies synthetic input (e.g. macOS Accessibility not granted) — the text is
// still on the clipboard for a manual paste; the error is logged, not fatal.
#[tauri::command]
fn paste(window: WebviewWindow) -> Result<(), String> {
// Hide first so the paste lands in the previously-frontmost app, not our panel.
let _ = window.hide();
#[cfg(desktop)]
{
use enigo::{Direction, Enigo, Key, Keyboard, Settings};
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?;
// Cmd+V on macOS, Ctrl+V elsewhere.
#[cfg(target_os = "macos")]
let modifier = Key::Meta;
#[cfg(not(target_os = "macos"))]
let modifier = Key::Control;
enigo.key(modifier, Direction::Press).map_err(|e| e.to_string())?;
enigo.key(Key::Unicode('v'), Direction::Click).map_err(|e| e.to_string())?;
enigo.key(modifier, Direction::Release).map_err(|e| e.to_string())?;
}
Ok(())
}
// build_tray creates the menubar/tray icon and its menu: Open, the quick actions
// (each emits `run-action` with its id so the frontend's single run path handles
// it), Settings, and Quit. Left-click on the icon opens the window; the menu is
// the right-click / secondary surface. The quick-action ids MUST match the
// frontend catalog (actions.ts ACTIONS) — the shell only forwards the id.
fn build_tray(app: &tauri::App) -> tauri::Result<()> {
let handle = app.handle();
let open = MenuItem::with_id(app, "open", "Open Hanzo", true, Some("CmdOrCtrl+Shift+Space"))?;
let summarize = MenuItem::with_id(app, "summarize", "Summarize clipboard", true, None::<&str>)?;
let rewrite = MenuItem::with_id(app, "rewrite", "Rewrite", true, None::<&str>)?;
let explain = MenuItem::with_id(app, "explain", "Explain", true, None::<&str>)?;
let translate = MenuItem::with_id(app, "translate", "Translate", true, None::<&str>)?;
let fix = MenuItem::with_id(app, "fixGrammar", "Fix grammar", true, None::<&str>)?;
let settings = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit Hanzo", true, Some("CmdOrCtrl+Q"))?;
let sep = PredefinedMenuItem::separator(app)?;
let actions = Submenu::with_id_and_items(
app,
"actions",
"Quick actions",
true,
&[&summarize, &rewrite, &explain, &translate, &fix],
)?;
let menu = Menu::with_items(app, &[&open, &actions, &sep, &settings, &sep, &quit])?;
TrayIconBuilder::with_id("hanzo-tray")
.icon(app.default_window_icon().unwrap().clone())
.tooltip("Hanzo")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| {
let window = app.get_webview_window(MAIN);
match event.id.as_ref() {
"open" => {
if let Some(w) = &window {
show_and_focus(w);
}
}
"settings" => {
if let Some(w) = &window {
show_and_focus(w);
// The frontend opens the settings pane on this signal.
let _ = w.emit("run-action", "settings");
}
}
"quit" => app.exit(0),
// Any other id is a quick action; show the window and forward the id
// so the frontend runs it against the clipboard.
other => {
if let Some(w) = &window {
show_and_focus(w);
let _ = w.emit("run-action", other.to_string());
}
}
}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
if let Some(w) = tray.app_handle().get_webview_window(MAIN) {
toggle_window(&w);
}
}
})
.build(app)?;
// The handle is kept alive by the app; nothing else to hold.
let _ = handle;
Ok(())
}
// register_hotkey wires the global Cmd/Ctrl+Shift+Space to toggle_window. The
// plugin's handler fires on every registered shortcut; we match ours and act only
// on Pressed (not Released) so one keypress is one toggle.
#[cfg(desktop)]
fn register_hotkey(app: &tauri::App) -> tauri::Result<()> {
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
// Cmd+Shift+Space on macOS, Ctrl+Shift+Space elsewhere — the modifier the
// handler compares against.
#[cfg(target_os = "macos")]
let mods = Modifiers::SUPER | Modifiers::SHIFT;
#[cfg(not(target_os = "macos"))]
let mods = Modifiers::CONTROL | Modifiers::SHIFT;
let toggle = Shortcut::new(Some(mods), Code::Space);
app.handle().plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app, shortcut, event| {
if shortcut == &toggle && event.state() == ShortcutState::Pressed {
if let Some(w) = app.get_webview_window(MAIN) {
toggle_window(&w);
}
}
})
.build(),
)?;
app.global_shortcut().register(toggle)?;
Ok(())
}
// run is the single Tauri bootstrap: install the clipboard-manager plugin (the
// frontend reads/writes the clipboard through it), set up the tray and hotkey, and
// register the two commands. The window itself (small, always-on-top, hidden on
// blur, no taskbar entry) is declared in tauri.conf.json so the shell code stays
// about behavior, not geometry.
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
.invoke_handler(tauri::generate_handler![hide_window, paste])
.setup(|app| {
build_tray(app)?;
#[cfg(desktop)]
register_hotkey(app)?;
// Hide the window when it loses focus (spotlight behavior) so the panel
// never lingers over the app the user switched back to.
if let Some(window) = app.get_webview_window(MAIN) {
let w = window.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
let _ = w.hide();
}
});
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running the Hanzo desktop application");
}
+10
View File
@@ -0,0 +1,10 @@
// Thin entrypoint. All the shell logic (tray, global shortcut, window toggle, the
// clipboard `paste` command) lives in lib.rs::run so the crate is a library with a
// one-line binary — the Tauri v2 convention. Prevents an extra console window on
// Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hanzo_desktop_lib::run();
}
@@ -0,0 +1,66 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hanzo",
"version": "0.1.0",
"identifier": "ai.hanzo.desktop",
"build": {
"frontendDist": "../dist",
"beforeDevCommand": "node build.js --watch",
"beforeBuildCommand": "node build.js"
},
"app": {
"withGlobalTauri": false,
"windows": [
{
"label": "main",
"title": "Hanzo",
"url": "index.html",
"width": 560,
"height": 440,
"minWidth": 420,
"minHeight": 120,
"center": true,
"resizable": true,
"decorations": false,
"transparent": true,
"alwaysOnTop": true,
"skipTaskbar": true,
"visible": false
}
],
"trayIcon": {
"id": "hanzo-tray",
"iconPath": "icons/icon.png",
"iconAsTemplate": true,
"tooltip": "Hanzo"
},
"macOSPrivateApi": true,
"security": {
"csp": {
"default-src": "'self'",
"connect-src": "'self' https://api.hanzo.ai",
"style-src": "'self' 'unsafe-inline'",
"script-src": "'self'",
"img-src": "'self' data:"
}
}
},
"bundle": {
"active": true,
"targets": ["dmg", "app", "msi", "nsis", "appimage", "deb"],
"category": "Productivity",
"shortDescription": "Hanzo AI, over any app.",
"longDescription": "The Hanzo assistant, one hotkey away over any app. Ask, summarize the clipboard, rewrite, explain, translate, and fix grammar — wired to api.hanzo.ai via @hanzo/ai.",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"entitlements": "entitlements.plist",
"minimumSystemVersion": "10.15"
}
}
}
+326
View File
@@ -0,0 +1,326 @@
// The desktop action catalog and everything pure around it: the prompt builders
// for the quick actions (summarize / explain / rewrite / translate / fix-grammar
// / ask), the clipboard-context assembly and truncation, the message shaping
// @hanzo/ai takes, and the response parsing that turns a model reply into
// paste-ready text. NO Tauri imports, NO @hanzo/ai imports, NO DOM — this module
// is host-agnostic and fully unit-testable. The app reads the clipboard via
// clipboard.ts, hands the text here to build a request, sends it through hanzo.ts,
// and parses the reply back here before copying/pasting via clipboard.ts.
//
// It mirrors @hanzo/raycast's actions core so the productivity suite stays
// consistent: the same context/prompt/parse contracts, one code path from an
// (action id + inputs) to a message list, and one path from a raw reply to
// paste-ready text.
// A message in the OpenAI-compatible schema — the shape @hanzo/ai's
// chat.completions.create takes. A local alias so the pure builders have a
// precise, testable return type independent of the SDK's broader union.
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
// SYSTEM_PROMPT grounds every answer in the text the user handed us. It forbids
// inventing facts (the failure mode that makes a rewrite assistant dangerous) and
// keeps output ready to paste straight back — no preamble, no fences. Brand-
// neutral, surface-neutral: this runs over whatever the frontmost app copied.
export const SYSTEM_PROMPT =
"You are Hanzo AI, an assistant that pops up over whatever app the user is in. " +
"You operate on the text on their clipboard, copied from the app in front of " +
"them. Work only from the text provided below; never invent facts, figures, " +
"names, or claims that are not present in it or in the user's instruction. " +
"Return ONLY the resulting text, ready to paste straight back — no preamble, no " +
'explanation, no surrounding quotes, no markdown code fences, no "Here is". ' +
"Preserve the input's format (lists stay lists, code stays code) unless the " +
"instruction says otherwise.";
// ---- Context assembly -----------------------------------------------------
//
// The app captures what it can see — the clipboard contents — and an optional
// freeform instruction, and hands the clipboard here as an ActionContext. We
// render the text into a compact, fenced block the model reads as data (never as
// instructions), capped at the budget. Honest truncation.
// What the app observed and passes to a prompt builder. `text` is the subject
// (the clipboard). Optional: an `ask` with an empty clipboard has only the
// instruction.
export interface ActionContext {
/** The captured subject text — the clipboard contents. */
text?: string;
}
// The result of rendering an ActionContext down to what we attach: the text and
// whether it was truncated (so the prompt and UI can say so).
export interface RenderedContext {
text: string;
truncated: boolean;
}
// chooseSubject decides which captured text becomes the model's subject: the
// clipboard when it is non-empty, else none. Pure and total so the capture policy
// is unit-tested without Tauri. It lives here with the context assembly (not
// braided into the clipboard I/O in clipboard.ts) so the whole "what does the
// model see" decision is one testable module.
export function chooseSubject(clipboard: string | null): ActionContext {
const clip = (clipboard ?? "").trim();
if (clip) return { text: clip };
return {};
}
// buildContext renders the observed subject into what we attach, capped at
// `budget` characters. Pure and total. `truncated` is true whenever the text was
// cut to fit; the caller shows that honestly rather than answering as though it
// saw the whole input.
export function buildContext(
ctx: ActionContext,
budget: number = CONTEXT_CHAR_BUDGET,
): RenderedContext {
const raw = (ctx.text ?? "").trim();
if (!raw) return { text: "", truncated: false };
if (raw.length <= budget) return { text: raw, truncated: false };
return { text: raw.slice(0, budget), truncated: true };
}
// CONTEXT_CHAR_BUDGET default mirrors config.ts. Re-declared here as the builder's
// own default so actions.ts stays importable with zero config dependency (the app
// passes the config value explicitly when it wants to; tests do not).
const CONTEXT_CHAR_BUDGET = 24_000;
// ---- Prompt assembly ------------------------------------------------------
// contextNote is the one honest sentence prepended when the render was cut, so the
// model does not answer as though it saw the whole input.
function contextNote(truncated: boolean): string {
return truncated
? "Note: the text below was truncated to fit — answer only from what is shown."
: "";
}
// buildMessages fences the captured clipboard text as DATA (never instructions)
// and rides the honest note inside the user turn so it is never lost. The task
// (the resolved action prompt, plus any user instruction) comes last. Pure — the
// whole prompt is asserted in a test. When there is no context (an `ask` with an
// empty clipboard), the fence is omitted so the user turn is just the task.
export function buildMessages(
task: string,
rendered: RenderedContext,
): ChatMessage[] {
const system: ChatMessage = { role: "system", content: SYSTEM_PROMPT };
const note = contextNote(rendered.truncated);
const notePrefix = note ? `${note}\n\n` : "";
const fence = rendered.text
? `---- clipboard ----\n${rendered.text}\n---- end clipboard ----\n\n`
: "";
const user: ChatMessage = {
role: "user",
content: `${notePrefix}${fence}---- task ----\n${task}`,
};
return [system, user];
}
// ---- The action catalog ---------------------------------------------------
//
// An action is (id → label + a prompt builder). The builder takes the action's
// inputs (an optional user instruction, a target language) and returns the task
// string layered onto the fenced context by buildMessages. Most actions require
// the captured text (summarize/explain/rewrite/translate/fix); `ask` works with
// or without it. Keeping each action's task in one builder means every command and
// every test resolve prompts one way.
// The inputs a quick action may take. All optional; each builder reads only the
// fields it needs. `instruction` is the user's freeform text (the tweak for
// rewrite, the question for ask); `language` is the target for translate.
export interface ActionInputs {
instruction?: string;
language?: string;
}
// The six actions. Each `build` returns the task string. `needsText` marks the
// actions that operate on the clipboard (the app tells the user to copy something
// first when it is empty). `needsInstruction` marks actions that require a user
// instruction (ask) so the app validates before sending. Frozen so the catalog is
// immutable at runtime.
export const ACTIONS = {
ask: {
label: "Ask",
needsText: false,
needsInstruction: true,
build: (i: ActionInputs): string => {
const q = (i.instruction ?? "").trim();
return (
`Answer this: "${q}". If clipboard text is provided above, answer from it; ` +
"otherwise answer from general knowledge. If the provided text does not " +
"support an answer, say so plainly."
);
},
},
summarize: {
label: "Summarize clipboard",
needsText: true,
needsInstruction: false,
build: (i: ActionInputs): string => {
const focus = (i.instruction ?? "").trim();
const how = focus
? `Focus the summary on: ${focus}.`
: "Cover the key points and any conclusion.";
return (
"Summarize the clipboard text above concisely in clear prose. " +
`${how} Do NOT add any fact not present in the text. Return only the summary.`
);
},
},
rewrite: {
label: "Rewrite",
needsText: true,
needsInstruction: false,
build: (i: ActionInputs): string => {
const tweak = (i.instruction ?? "").trim();
const how = tweak
? `Rewrite it as follows: ${tweak}.`
: "Rewrite it to be clearer, tighter, and more natural, keeping the same meaning and roughly the same length.";
return (
`Rewrite the clipboard text above. ${how} Do NOT add any fact not present ` +
"in the text or the instruction. Preserve its format. Return only the " +
"rewritten text."
);
},
},
explain: {
label: "Explain",
needsText: true,
needsInstruction: false,
build: (i: ActionInputs): string => {
const focus = (i.instruction ?? "").trim();
const how = focus
? `Tailor the explanation to: ${focus}.`
: "Assume a smart reader new to this topic.";
return (
"Explain the clipboard text above in plain language — what it means and " +
`why it matters. ${how} If it is code, explain what it does step by step. ` +
"Ground the explanation only in the text. Return only the explanation."
);
},
},
translate: {
label: "Translate",
needsText: true,
needsInstruction: false,
build: (i: ActionInputs): string => {
const lang = (i.language ?? "").trim() || "English";
return (
`Translate the clipboard text above into ${lang}. Preserve the tone, ` +
"intent, formatting, and any proper names verbatim; localize idioms " +
"naturally rather than translating word for word. Return only the " +
"translated text."
);
},
},
fixGrammar: {
label: "Fix grammar",
needsText: true,
needsInstruction: false,
build: (): string => {
return (
"Correct the grammar, spelling, and punctuation of the clipboard text " +
"above. Do NOT change its meaning, tone, voice, or wording beyond what the " +
"corrections require; preserve its format. Return only the corrected text."
);
},
},
} as const;
// An action id from the catalog.
export type ActionId = keyof typeof ACTIONS;
// isActionId narrows an arbitrary string to a known id. Boundary guard — the app
// validates an inbound id here before running it.
export function isActionId(id: string): id is ActionId {
return Object.prototype.hasOwnProperty.call(ACTIONS, id);
}
// actionList is the ordered catalog for building UI (the assistant window's action
// row + the tray menu), derived from the action map so the UI and the catalog can
// never drift.
export function actionList(): Array<{
id: ActionId;
label: string;
needsText: boolean;
needsInstruction: boolean;
}> {
return (Object.keys(ACTIONS) as ActionId[]).map((id) => {
const a = ACTIONS[id];
return {
id,
label: a.label,
needsText: a.needsText,
needsInstruction: a.needsInstruction,
};
});
}
// actionTask resolves an id + inputs to its task string. Throws on an unknown id
// (a boundary error surfaced to the caller) rather than silently running a
// default. This is the single id→prompt resolution the app calls.
export function actionTask(id: string, inputs: ActionInputs = {}): string {
if (!isActionId(id)) throw new Error(`Unknown action: ${id}`);
return ACTIONS[id].build(inputs);
}
// buildActionMessages is the single path from an (action id, inputs, observed
// context) to the message list @hanzo/ai takes. Resolve the task, render the
// context, assemble the messages. Pure — the whole request is asserted in a test.
// The app calls exactly this, then hands the result to hanzo.ts.
export function buildActionMessages(
id: string,
inputs: ActionInputs,
ctx: ActionContext,
budget: number = CONTEXT_CHAR_BUDGET,
): ChatMessage[] {
const task = actionTask(id, inputs);
return buildMessages(task, buildContext(ctx, budget));
}
// ---- Response parsing -----------------------------------------------------
//
// Models sometimes wrap paste-ready text despite the system prompt: a leading
// "Here's the summary:", a Markdown code fence, or matched surrounding quotes.
// parseResult strips those so the text pastes cleanly. Total and idempotent —
// parsing already-clean text returns it unchanged.
// stripFence removes a single wrapping Markdown code fence (```lang ... ```) if the
// WHOLE reply is fenced. It does NOT touch fences in the middle of a reply (those
// are intentional content). Pure.
function stripFence(s: string): string {
const m = s.match(/^```[^\n]*\n([\s\S]*?)\n?```$/);
return m ? m[1] : s;
}
// stripWrappingQuotes removes one matched pair of surrounding quotes (straight or
// curly) when the ENTIRE string is quoted — the model quoting a single line. It
// leaves quotes that are part of the content (an opening quote with no close, an
// internal quote). Pure.
function stripWrappingQuotes(s: string): string {
const pairs: Array<[string, string]> = [
['"', '"'],
["'", "'"],
["“", "”"],
["", ""],
];
for (const [open, close] of pairs) {
if (s.length >= 2 && s.startsWith(open) && s.endsWith(close)) {
const inner = s.slice(1, -1);
if (!inner.includes(close)) return inner;
}
}
return s;
}
// parseResult turns a raw model reply into paste-ready text: trim, drop a single
// wrapping code fence, drop matched surrounding quotes, trim again. Idempotent.
// This is the single reply→text path the app uses before copying/pasting.
export function parseResult(raw: string): string {
let s = raw.trim();
s = stripFence(s).trim();
s = stripWrappingQuotes(s).trim();
return s;
}
+308
View File
@@ -0,0 +1,308 @@
// The frontend controller: wires the pure modules (actions.ts prompts/parse,
// store.ts settings) and the I/O boundaries (hanzo.ts @hanzo/ai, clipboard.ts
// Tauri) to the assistant panel DOM. It owns only glue: render the model picker
// and the action row from the catalog, run the ask/quick-action flows, stream the
// reply into the output, and toggle the settings pane. No prompt string, no
// request shape, no parsing lives here — those are in the tested pure modules.
import { listen } from "@tauri-apps/api/event";
import {
actionList,
buildActionMessages,
parseResult,
isActionId,
ACTIONS,
type ActionId,
} from "./actions.js";
import { streamChat, listModels } from "./hanzo.js";
import { captureSubject, copyResult, pasteBack } from "./clipboard.js";
import { loadSettings, saveSettings, mergeSettings, type Settings } from "./store.js";
import { isHanzoKey } from "./config.js";
// $ is the single typed DOM lookup — throws on a missing id (a build/markup bug,
// surfaced loudly) so the rest of the controller can trust the element exists.
function $<T extends HTMLElement>(id: string): T {
const el = document.getElementById(id);
if (!el) throw new Error(`Missing element #${id}`);
return el as T;
}
const el = {
model: $<HTMLSelectElement>("model"),
promptForm: $<HTMLFormElement>("prompt-form"),
prompt: $<HTMLTextAreaElement>("prompt"),
send: $<HTMLButtonElement>("send"),
actions: $<HTMLElement>("actions"),
outputWrap: $<HTMLElement>("output-wrap"),
output: $<HTMLElement>("output"),
status: $<HTMLElement>("status"),
copy: $<HTMLButtonElement>("copy"),
settings: $<HTMLElement>("settings"),
settingsToggle: $<HTMLButtonElement>("settings-toggle"),
apikey: $<HTMLInputElement>("apikey"),
saveSettings: $<HTMLButtonElement>("save-settings"),
settingsStatus: $<HTMLElement>("settings-status"),
};
// Session state: the persisted settings and the in-flight request's aborter (so a
// new send / action cancels the previous stream). Kept minimal — the app is a
// stateless funnel from an action to a streamed reply.
let settings: Settings = loadSettings();
let inflight: AbortController | null = null;
// ---- Rendering ------------------------------------------------------------
// renderModelOption ensures the settings model is selectable even before the
// gateway list loads, so the picker is never empty.
function renderModelOptions(ids: string[]): void {
const seen = new Set(ids);
if (settings.model && !seen.has(settings.model)) ids = [settings.model, ...ids];
el.model.innerHTML = "";
for (const id of ids) {
const opt = document.createElement("option");
opt.value = id;
opt.textContent = id;
if (id === settings.model) opt.selected = true;
el.model.appendChild(opt);
}
}
// renderActions builds the quick-action chips from the catalog (minus `ask`, which
// is the prompt box's send button). Clicking a chip runs that action over the
// clipboard.
function renderActions(): void {
el.actions.innerHTML = "";
for (const a of actionList()) {
if (a.id === "ask") continue;
const chip = document.createElement("button");
chip.className = "chip";
chip.type = "button";
chip.textContent = a.label;
chip.addEventListener("click", () => runAction(a.id));
el.actions.appendChild(chip);
}
}
// ---- Status helpers -------------------------------------------------------
function setStatus(text: string, kind: "" | "err" | "ok" = ""): void {
el.status.textContent = text;
el.status.className = kind ? `status ${kind}` : "status";
}
function showOutput(): void {
el.outputWrap.hidden = false;
el.output.textContent = "";
el.copy.hidden = true;
}
// busy toggles the controls while a request is in flight so the user can't fire
// two overlapping streams.
function busy(on: boolean): void {
el.send.disabled = on;
for (const chip of el.actions.querySelectorAll("button")) {
(chip as HTMLButtonElement).disabled = on;
}
}
// ---- The one run path -----------------------------------------------------
//
// Every action — the ask box and every quick-action chip — funnels through run():
// gate on a key, capture the clipboard, build the request from the catalog, stream
// the reply into the output, parse it, and offer copy/paste. One code path so the
// key gate, error handling, and streaming are identical everywhere.
async function run(
id: ActionId,
inputs: { instruction?: string; language?: string },
outcome: "copy" | "paste",
): Promise<void> {
if (!isHanzoKey(settings.apiKey)) {
openSettings("Paste your hk- key from hanzo.id to start.");
return;
}
// Cancel any in-flight stream before starting a new one.
inflight?.abort();
const controller = new AbortController();
inflight = controller;
const spec = ACTIONS[id];
const ctx = spec.needsText ? await captureSubject() : {};
if (spec.needsText && !ctx.text) {
showOutput();
setStatus("Nothing on the clipboard — copy some text first.", "err");
return;
}
const messages = buildActionMessages(id, inputs, ctx);
const model = el.model.value || settings.model;
showOutput();
setStatus("Thinking…");
busy(true);
try {
const full = await streamChat(
messages,
(delta) => {
el.output.textContent += delta;
el.output.scrollTop = el.output.scrollHeight;
},
{ token: settings.apiKey, model, signal: controller.signal },
);
const result = parseResult(full);
el.output.textContent = result;
if (outcome === "paste") {
await pasteBack(result);
setStatus("Pasted.", "ok");
} else {
await copyResult(result);
setStatus("Copied to clipboard.", "ok");
offerCopy(result);
}
} catch (e) {
if (controller.signal.aborted) return; // superseded by a newer request
setStatus(errorMessage(e), "err");
} finally {
if (inflight === controller) inflight = null;
busy(false);
}
}
// runAction runs a quick-action chip: summarize/explain/ask copy the answer;
// rewrite/translate/fix-grammar paste it back over the frontmost app. The outcome
// is derived from the action, not configured, so there is one obvious behavior.
function runAction(id: ActionId): void {
const paste: Record<ActionId, boolean> = {
ask: false,
summarize: false,
explain: false,
rewrite: true,
translate: true,
fixGrammar: true,
};
const lang =
id === "translate"
? (window.prompt("Translate to which language?", "English") ?? "").trim()
: undefined;
if (id === "translate" && !lang) return;
void run(id, { language: lang }, paste[id] ? "paste" : "copy");
}
// offerCopy shows the copy button for a copy-outcome result so the user can
// re-copy after reading (the first copy already landed on the clipboard).
function offerCopy(result: string): void {
el.copy.hidden = false;
el.copy.onclick = () => {
void copyResult(result);
setStatus("Copied to clipboard.", "ok");
};
}
function errorMessage(e: unknown): string {
const msg = e instanceof Error ? e.message : String(e);
return msg || "Something went wrong.";
}
// ---- Settings pane --------------------------------------------------------
function openSettings(hint = ""): void {
el.settings.hidden = false;
el.apikey.value = settings.apiKey;
el.settingsStatus.textContent = hint;
el.settingsStatus.className = hint ? "status err" : "status";
el.apikey.focus();
}
function toggleSettings(): void {
if (el.settings.hidden) openSettings();
else el.settings.hidden = true;
}
async function saveSettingsFromForm(): Promise<void> {
const { settings: next, error } = mergeSettings(settings, {
apiKey: el.apikey.value,
model: el.model.value,
});
if (error) {
el.settingsStatus.textContent = error;
el.settingsStatus.className = "status err";
return;
}
settings = next;
saveSettings(settings);
el.settingsStatus.textContent = "Saved.";
el.settingsStatus.className = "status ok";
el.settings.hidden = true;
void refreshModels();
}
// ---- Bootstrap ------------------------------------------------------------
// refreshModels loads the gateway model list (org-scoped by the key) into the
// picker. Best-effort — a network/key failure leaves the picker on the stored
// model so the app still works.
async function refreshModels(): Promise<void> {
renderModelOptions(settings.model ? [settings.model] : []);
if (!isHanzoKey(settings.apiKey)) return;
try {
const ids = await listModels({ token: settings.apiKey });
if (ids.length) renderModelOptions(ids);
} catch {
// Keep the stored model; the picker is already seeded with it.
}
}
function wireEvents(): void {
// Ask box: Enter sends, Shift+Enter inserts a newline.
el.prompt.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
el.promptForm.requestSubmit();
}
});
el.promptForm.addEventListener("submit", (e) => {
e.preventDefault();
const q = el.prompt.value.trim();
if (!q) return;
void run("ask", { instruction: q }, "copy");
});
el.model.addEventListener("change", () => {
settings = { ...settings, model: el.model.value };
saveSettings(settings);
});
el.settingsToggle.addEventListener("click", toggleSettings);
el.saveSettings.addEventListener("click", () => void saveSettingsFromForm());
// Esc hides the settings pane if open, else asks the shell to hide the window.
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (!el.settings.hidden) el.settings.hidden = true;
});
}
// wireTauri listens for the tray/hotkey-driven action: the Rust shell emits
// `run-action` with an action id when the user picks a quick action from the tray
// menu, so the same run path serves the tray and the panel. Guarded so a bad
// payload can't crash the panel.
async function wireTauri(): Promise<void> {
await listen<string>("run-action", (event) => {
const id = event.payload;
if (isActionId(id)) runAction(id);
});
// When the window is shown (hotkey/tray), focus the prompt for typing.
await listen("focus-prompt", () => el.prompt.focus());
}
function boot(): void {
renderActions();
wireEvents();
void refreshModels();
void wireTauri();
if (!isHanzoKey(settings.apiKey)) openSettings("Paste your hk- key from hanzo.id to start.");
}
boot();
+50
View File
@@ -0,0 +1,50 @@
// The Tauri I/O layer: read the system clipboard (the text the frontmost app
// copied), write a result back to it, and paste it over the frontmost app via the
// Rust `paste` command. This is the ONLY frontend module that imports Tauri APIs,
// so actions.ts (prompts/parsing), hanzo.ts (@hanzo/ai), and store.ts (settings)
// stay pure and unit-testable. The `chooseSubject` decision — which capture
// becomes the model's subject — is factored into actions.ts so it CAN be tested.
import { readText, writeText } from "@tauri-apps/plugin-clipboard-manager";
import { invoke } from "@tauri-apps/api/core";
import { chooseSubject, type ActionContext } from "./actions.js";
// captureSubject reads the system clipboard and returns the ActionContext to run
// an action against. readText resolves to "" when the clipboard is empty or holds
// non-text; either way chooseSubject collapses it to an empty context. This is the
// impure I/O the quick-action commands call first.
export async function captureSubject(): Promise<ActionContext> {
let clipboard: string | null = null;
try {
clipboard = await readText();
} catch {
// Clipboard empty or non-text — treat as no subject.
clipboard = null;
}
return chooseSubject(clipboard);
}
// copyResult copies the result to the clipboard — the outcome for a
// summarize/explain/ask where the user wants the answer on the clipboard, not
// injected over their document. The app calls this after parsing the reply.
export async function copyResult(text: string): Promise<void> {
await writeText(text);
}
// pasteBack copies the result to the clipboard and asks the Rust shell to hide the
// assistant window and synthesize a paste keystroke into the frontmost app — the
// ergonomic outcome for a rewrite/fix/translate: the corrected text lands where
// the original was. The `paste` command is implemented in src-tauri; when the OS
// denies synthetic input (e.g. macOS Accessibility not yet granted) the text is
// still on the clipboard for a manual paste, and the command returns that state.
export async function pasteBack(text: string): Promise<void> {
await writeText(text);
await invoke("paste");
}
// hideWindow asks the Rust shell to hide the assistant window (the same as losing
// focus or pressing the hotkey again). The app calls this after a copy-only action
// so the panel gets out of the way.
export async function hideWindow(): Promise<void> {
await invoke("hide_window");
}
+57
View File
@@ -0,0 +1,57 @@
// Desktop app config — the api.hanzo.ai model gateway (default model + endpoints)
// and the context character budget. Endpoints and the `hk-` key guard mirror
// @hanzo/raycast / @hanzo/office so the productivity suite stays DRY: the action
// catalog and prompt/parse logic live in actions.ts, the thin @hanzo/ai adapter
// in hanzo.ts, the local key store in store.ts, and the Tauri clipboard I/O in
// clipboard.ts. This module is pure config — no I/O, no SDK.
// ---- Hanzo model gateway --------------------------------------------------
// Where the Hanzo model gateway lives. `@hanzo/ai` (createAiClient) defaults here
// too. /v1 only, never an /api/ prefix (api.hanzo.ai IS the api host).
export const HANZO_API_BASE_URL = "https://api.hanzo.ai";
// Default model. A Zen model (qwen3+). Overridable via the settings model-picker;
// the gateway routes it.
export const DEFAULT_MODEL = "zen5";
// Public IAM origin that mints Hanzo user tokens, and the OAuth client id an
// inbound Hanzo token is audienced to, under the shared <org>-<app> naming scheme.
// The app itself uses a pasted `hk-` key held in the local store (store.ts); these
// exist so a Hanzo-hosted backend can validate one via @hanzo/iam.
export const DEFAULT_IAM_SERVER_URL = "https://hanzo.id";
export const DEFAULT_IAM_CLIENT_ID = "hanzo-desktop";
// chatCompletionsURL / modelsURL — the model gateway endpoints. The client
// (createAiClient) builds these itself; these exist for tests + honest docs.
export function chatCompletionsURL(): string {
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${HANZO_API_BASE_URL}/v1/models`;
}
// ---- Context budget -------------------------------------------------------
//
// The clipboard text a quick action operates on can run long — a whole article to
// summarize, a page of code to explain. This caps the characters of context we
// attach to any one request so it fits comfortably in a model window alongside the
// reply. Honest truncation, never silent drop (actions.ts marks `truncated`).
export const CONTEXT_CHAR_BUDGET = 24_000;
// ---- Local store key ------------------------------------------------------
//
// The single localStorage key under which the app persists its settings (the
// pasted `hk-` key + default model). One constant so store.ts and any test agree.
export const STORE_KEY = "hanzo.desktop.settings";
// ---- Key guard ------------------------------------------------------------
//
// A Hanzo API key is minted by hanzo.id and is always prefixed `hk-`. isHanzoKey
// is the boundary guard: the settings pane validates the pasted key against this
// before it is ever handed to the client as a bearer, so an obviously-wrong paste
// (an IAM JWT, an OpenAI `sk-` key) is rejected in the UI instead of failing
// opaquely at the gateway. Trim first — users paste with whitespace.
export function isHanzoKey(key: string | undefined | null): key is string {
return typeof key === "string" && /^hk-[A-Za-z0-9._-]{16,}$/.test(key.trim());
}
+99
View File
@@ -0,0 +1,99 @@
// The single call path to the model — a THIN wrapper over the PUBLISHED headless
// client `@hanzo/ai` (createAiClient). We do NOT reimplement the transport. This
// module owns only client construction (token + baseURL wiring) and three
// primitives the app needs: run a built message list to text (quick actions),
// stream a built message list chunk-by-chunk (the assistant view), and list models
// (the picker). The action-aware layer (context, prompts, parsing) is entirely in
// actions.ts; the Tauri-aware layer (read clipboard, paste) is in clipboard.ts.
// Both are pure of the SDK; this is the only place the SDK is called.
import { createAiClient, type AiClient } from "@hanzo/ai";
import { DEFAULT_MODEL, HANZO_API_BASE_URL } from "./config.js";
import type { ChatMessage } from "./actions.js";
export interface AskOptions {
model?: string;
temperature?: number;
/** Hanzo `hk-` key (from the local store); sent as the bearer. */
token?: string;
baseURL?: string;
/** Injected client (tests). Defaults to a real createAiClient. */
client?: AiClient;
signal?: AbortSignal;
}
// client resolves an AiClient: the injected one (tests) or a real published
// @hanzo/ai client pointed at the gateway with the caller's bearer. One place
// constructs it so the token/baseURL wiring is identical for every call.
function client(opts: AskOptions): AiClient {
return (
opts.client ??
createAiClient({
token: opts.token,
baseUrl: opts.baseURL ?? HANZO_API_BASE_URL,
})
);
}
// runChat is the single non-streaming path from a built message list to the model.
// The quick-action commands (summarize, explain, rewrite, translate, fix-grammar)
// funnel here when the app wants the whole result before it copies/pastes. token
// may be empty (the gateway serves anonymous/limited models), but the app gates on
// a valid `hk-` key first.
export async function runChat(
messages: ChatMessage[],
opts: AskOptions = {},
): Promise<string> {
const res = await client(opts).chat.completions.create(
{
model: opts.model ?? DEFAULT_MODEL,
messages,
temperature: opts.temperature,
stream: false,
},
{ signal: opts.signal },
);
const content = res.choices?.[0]?.message?.content;
if (typeof content !== "string" || content === "") {
throw new Error("Hanzo API returned no content");
}
return content;
}
// streamChat is the single streaming path — the assistant view renders the reply
// as it arrives. It calls onDelta with each text chunk and resolves with the full
// concatenated text (so the caller can parse/copy the final result). Same client
// wiring as runChat; stream:true selects the SDK's AsyncGenerator overload.
export async function streamChat(
messages: ChatMessage[],
onDelta: (text: string) => void,
opts: AskOptions = {},
): Promise<string> {
const stream = await client(opts).chat.completions.create(
{
model: opts.model ?? DEFAULT_MODEL,
messages,
temperature: opts.temperature,
stream: true,
},
{ signal: opts.signal },
);
let full = "";
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta?.content;
if (typeof delta === "string" && delta) {
full += delta;
onDelta(delta);
}
}
if (full === "") throw new Error("Hanzo API returned no content");
return full;
}
// listModels returns the model ids the caller may route to, from /v1/models via
// the headless client. Org-scoped by the bearer; an empty token lists public
// models. Powers the settings model-picker.
export async function listModels(opts: AskOptions = {}): Promise<string[]> {
const models = await client(opts).models.list({ signal: opts.signal });
return models.map((m) => m.id);
}
+67
View File
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main id="app" class="panel">
<!-- Header: brand + model picker + settings toggle -->
<header class="bar">
<span class="brand">Hanzo</span>
<select id="model" class="model" title="Model" aria-label="Model"></select>
<button id="settings-toggle" class="icon" title="Settings" aria-label="Settings"></button>
</header>
<!-- Prompt row: the ask box + send. Enter sends, Shift+Enter newlines. -->
<form id="prompt-form" class="prompt">
<textarea
id="prompt"
class="input"
rows="1"
placeholder="Ask Hanzo, or copy text and pick an action…"
autofocus
></textarea>
<button id="send" class="send" type="submit" title="Send (Enter)">Ask</button>
</form>
<!-- Quick actions: act on the clipboard. Rendered from the action catalog. -->
<nav id="actions" class="actions" aria-label="Quick actions"></nav>
<!-- Output: streamed reply + a copy button. Hidden until there is output. -->
<section id="output-wrap" class="output-wrap" hidden>
<div id="output" class="output" aria-live="polite"></div>
<div class="output-foot">
<span id="status" class="status"></span>
<button id="copy" class="ghost" type="button" hidden>Copy</button>
</div>
</section>
<!-- Settings pane: paste the hk- key. Hidden by default. -->
<section id="settings" class="settings" hidden>
<label class="field">
<span>Hanzo API key</span>
<input
id="apikey"
class="input"
type="password"
placeholder="hk-…"
autocomplete="off"
spellcheck="false"
/>
</label>
<p class="hint">
Paste your <code>hk-</code> key from hanzo.id. It is stored only on this
machine and sent only to api.hanzo.ai.
</p>
<div class="settings-foot">
<span id="settings-status" class="status"></span>
<button id="save-settings" class="send" type="button">Save</button>
</div>
</section>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
// The app's local settings store: the pasted `hk-` key and the default model,
// persisted in the webview's localStorage. The pure serialization (parse / merge /
// serialize) is factored out so it is unit-testable with no DOM; the DOM adapter
// (loadSettings / saveSettings) is the thin boundary that touches localStorage.
//
// The `hk-` key never leaves this machine — it is written only to the webview's
// origin-scoped localStorage and sent only to api.hanzo.ai as the bearer via
// @hanzo/ai. No secret is bundled, no secret is committed.
import { DEFAULT_MODEL, STORE_KEY, isHanzoKey } from "./config.js";
// Settings the app persists. `apiKey` is the Hanzo `hk-` key (empty until the user
// pastes one); `model` is the default model id sent to the gateway.
export interface Settings {
apiKey: string;
model: string;
}
// DEFAULT_SETTINGS is the state of a fresh install: no key yet, the default Zen
// model. loadSettings falls back to this when nothing (or garbage) is stored.
export const DEFAULT_SETTINGS: Settings = { apiKey: "", model: DEFAULT_MODEL };
// parseSettings turns a raw stored string into Settings, tolerating anything: null
// (nothing stored), invalid JSON, a non-object, or missing/wrong-typed fields all
// collapse to DEFAULT_SETTINGS-filled values. Pure and total — the boundary guard
// for what may be sitting in localStorage. A stored key that no longer matches the
// `hk-` shape is dropped (treated as absent) so a corrupted key can't be sent.
export function parseSettings(raw: string | null): Settings {
if (!raw) return { ...DEFAULT_SETTINGS };
let obj: unknown;
try {
obj = JSON.parse(raw);
} catch {
return { ...DEFAULT_SETTINGS };
}
if (typeof obj !== "object" || obj === null) return { ...DEFAULT_SETTINGS };
const rec = obj as Record<string, unknown>;
const rawKey = typeof rec.apiKey === "string" ? rec.apiKey.trim() : "";
const apiKey = isHanzoKey(rawKey) ? rawKey : "";
const model =
typeof rec.model === "string" && rec.model.trim()
? rec.model.trim()
: DEFAULT_MODEL;
return { apiKey, model };
}
// mergeSettings applies a partial update over the current settings, trimming the
// key and normalizing an empty/blank model back to the default. Pure — the single
// place a settings edit is validated before it is serialized. An invalid key
// (wrong shape) is rejected here and returned in `error` so the settings pane can
// show it without persisting garbage.
export function mergeSettings(
current: Settings,
patch: Partial<Settings>,
): { settings: Settings; error?: string } {
const next: Settings = { ...current };
if (patch.apiKey !== undefined) {
const key = patch.apiKey.trim();
if (key === "") {
next.apiKey = "";
} else if (isHanzoKey(key)) {
next.apiKey = key;
} else {
return {
settings: current,
error: "Not a Hanzo key — expected an hk- key from hanzo.id.",
};
}
}
if (patch.model !== undefined) {
next.model = patch.model.trim() || DEFAULT_MODEL;
}
return { settings: next };
}
// serializeSettings is the single settings→string path written to localStorage.
// Pure; the inverse of parseSettings.
export function serializeSettings(s: Settings): string {
return JSON.stringify(s);
}
// ---- DOM adapter ----------------------------------------------------------
//
// The two impure functions the app calls. They are the ONLY code here that touches
// localStorage; everything above is pure and tested. localStorage is unavailable
// only in a non-DOM context (it always exists in the Tauri webview), so these
// guard defensively and fall back to defaults rather than throw.
// loadSettings reads and parses the stored settings, or DEFAULT_SETTINGS on a
// fresh install / unavailable storage.
export function loadSettings(): Settings {
try {
return parseSettings(globalThis.localStorage?.getItem(STORE_KEY) ?? null);
} catch {
return { ...DEFAULT_SETTINGS };
}
}
// saveSettings serializes and writes the settings. Best-effort; a storage failure
// is swallowed (the in-memory settings still drive the session).
export function saveSettings(s: Settings): void {
try {
globalThis.localStorage?.setItem(STORE_KEY, serializeSettings(s));
} catch {
// Storage unavailable / quota — the session's in-memory settings still apply.
}
}
+230
View File
@@ -0,0 +1,230 @@
/* Hanzo desktop assistant panel — a compact, always-on-top spotlight-style
* window that pops over the frontmost app. Dark, translucent, keyboard-first.
* No framework; a single self-contained sheet the Tauri webview loads. */
:root {
--bg: rgba(20, 20, 22, 0.96);
--panel: rgba(30, 30, 34, 0.9);
--fg: #ececf1;
--muted: #9a9aa6;
--line: rgba(255, 255, 255, 0.08);
--accent: #7c5cff;
--accent-fg: #fff;
--danger: #ff6b6b;
--ok: #57d38c;
--radius: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
background: transparent;
color: var(--fg);
overflow: hidden;
}
.panel {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg);
backdrop-filter: blur(24px);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
/* Header bar: draggable region (Tauri) + model picker + settings. */
.bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
-webkit-app-region: drag;
}
.bar select,
.bar button {
-webkit-app-region: no-drag;
}
.brand {
font-weight: 600;
font-size: 13px;
letter-spacing: 0.2px;
color: var(--fg);
margin-right: auto;
}
.model {
background: var(--panel);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 8px;
padding: 4px 8px;
font-size: 12px;
max-width: 160px;
}
.icon {
background: transparent;
border: none;
color: var(--muted);
font-size: 15px;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
}
.icon:hover {
color: var(--fg);
background: var(--panel);
}
/* Prompt row. */
.prompt {
display: flex;
gap: 8px;
padding: 10px;
align-items: flex-end;
}
.input {
flex: 1;
background: var(--panel);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 10px;
padding: 9px 11px;
font-size: 14px;
font-family: inherit;
resize: none;
max-height: 140px;
line-height: 1.4;
outline: none;
}
.input:focus {
border-color: var(--accent);
}
.send {
background: var(--accent);
color: var(--accent-fg);
border: none;
border-radius: 10px;
padding: 9px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.send:disabled {
opacity: 0.5;
cursor: default;
}
/* Quick action row. */
.actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 10px 10px;
}
.chip {
background: var(--panel);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 11px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.chip:hover {
border-color: var(--accent);
}
.chip:disabled {
opacity: 0.4;
cursor: default;
}
/* Output. */
.output-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border-top: 1px solid var(--line);
}
.output {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.output-foot,
.settings-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-top: 1px solid var(--line);
}
.status {
font-size: 12px;
color: var(--muted);
margin-right: auto;
}
.status.err {
color: var(--danger);
}
.status.ok {
color: var(--ok);
}
.ghost {
background: transparent;
color: var(--fg);
border: 1px solid var(--line);
border-radius: 8px;
padding: 5px 12px;
font-size: 12px;
cursor: pointer;
}
.ghost:hover {
border-color: var(--accent);
}
/* Settings. */
.settings {
padding: 12px;
border-top: 1px solid var(--line);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.hint {
font-size: 11px;
color: var(--muted);
line-height: 1.5;
margin: 10px 0 0;
}
.hint code {
color: var(--fg);
background: var(--panel);
padding: 1px 4px;
border-radius: 4px;
}
+257
View File
@@ -0,0 +1,257 @@
import { describe, it, expect } from "vitest";
import {
ACTIONS,
SYSTEM_PROMPT,
actionList,
actionTask,
isActionId,
chooseSubject,
buildContext,
buildMessages,
buildActionMessages,
parseResult,
type ActionContext,
} from "../src/actions";
// ---- chooseSubject (capture policy) ---------------------------------------
describe("chooseSubject", () => {
it("returns the clipboard trimmed when non-empty", () => {
expect(chooseSubject(" hello ")).toEqual({ text: "hello" });
});
it("returns an empty context for null / empty / whitespace clipboard", () => {
expect(chooseSubject(null)).toEqual({});
expect(chooseSubject("")).toEqual({});
expect(chooseSubject(" \n\t ")).toEqual({});
});
});
// ---- buildContext (truncation) --------------------------------------------
describe("buildContext", () => {
it("passes short text through untruncated", () => {
const r = buildContext({ text: "abc" });
expect(r).toEqual({ text: "abc", truncated: false });
});
it("trims the raw text before measuring", () => {
const r = buildContext({ text: " padded " });
expect(r.text).toBe("padded");
expect(r.truncated).toBe(false);
});
it("truncates to the budget and marks it", () => {
const long = "x".repeat(100);
const r = buildContext({ text: long }, 10);
expect(r.text).toBe("x".repeat(10));
expect(r.text.length).toBe(10);
expect(r.truncated).toBe(true);
});
it("does not truncate at exactly the budget", () => {
const r = buildContext({ text: "x".repeat(10) }, 10);
expect(r.truncated).toBe(false);
});
it("returns an empty render for no text", () => {
expect(buildContext({})).toEqual({ text: "", truncated: false });
});
});
// ---- buildMessages (prompt shaping) ---------------------------------------
describe("buildMessages", () => {
it("emits a system + user pair with the system prompt first", () => {
const msgs = buildMessages("do the task", { text: "SUBJECT", truncated: false });
expect(msgs).toHaveLength(2);
expect(msgs[0]).toEqual({ role: "system", content: SYSTEM_PROMPT });
expect(msgs[1].role).toBe("user");
});
it("fences the clipboard text as data and puts the task last", () => {
const msgs = buildMessages("TASK", { text: "SUBJECT", truncated: false });
const user = msgs[1].content;
expect(user).toContain("---- clipboard ----\nSUBJECT\n---- end clipboard ----");
expect(user).toContain("---- task ----\nTASK");
// fence comes before the task.
expect(user.indexOf("clipboard")).toBeLessThan(user.indexOf("task"));
});
it("prepends the honest truncation note only when truncated", () => {
const cut = buildMessages("T", { text: "S", truncated: true })[1].content;
expect(cut).toContain("truncated to fit");
const whole = buildMessages("T", { text: "S", truncated: false })[1].content;
expect(whole).not.toContain("truncated to fit");
});
it("omits the fence entirely when there is no context", () => {
const user = buildMessages("just ask", { text: "", truncated: false })[1].content;
expect(user).not.toContain("clipboard");
expect(user).toContain("---- task ----\njust ask");
});
});
// ---- The catalog -----------------------------------------------------------
describe("action catalog", () => {
it("has the six expected actions with ask first", () => {
const ids = actionList().map((a) => a.id);
expect(ids).toEqual(["ask", "summarize", "rewrite", "explain", "translate", "fixGrammar"]);
});
it("marks only ask as not needing text, and only ask as needing an instruction", () => {
const byId = Object.fromEntries(actionList().map((a) => [a.id, a]));
expect(byId.ask.needsText).toBe(false);
expect(byId.ask.needsInstruction).toBe(true);
for (const id of ["summarize", "rewrite", "explain", "translate", "fixGrammar"] as const) {
expect(byId[id].needsText).toBe(true);
expect(byId[id].needsInstruction).toBe(false);
}
});
it("isActionId narrows known ids and rejects unknown ones", () => {
expect(isActionId("summarize")).toBe(true);
expect(isActionId("fixGrammar")).toBe(true);
expect(isActionId("settings")).toBe(false);
expect(isActionId("")).toBe(false);
expect(isActionId("__proto__")).toBe(false);
});
it("actionList is derived from ACTIONS so labels never drift", () => {
for (const a of actionList()) {
expect(a.label).toBe(ACTIONS[a.id].label);
}
});
});
// ---- actionTask (prompt builders) -----------------------------------------
describe("actionTask", () => {
it("throws on an unknown id", () => {
expect(() => actionTask("nope")).toThrow(/Unknown action/);
});
it("summarize uses the default framing without a focus, and honors a focus", () => {
expect(actionTask("summarize")).toContain("key points");
expect(actionTask("summarize", { instruction: "the risks" })).toContain(
"Focus the summary on: the risks",
);
});
it("rewrite defaults to clearer/tighter and honors an explicit tweak", () => {
expect(actionTask("rewrite")).toContain("clearer, tighter");
expect(actionTask("rewrite", { instruction: "make it formal" })).toContain(
"Rewrite it as follows: make it formal",
);
});
it("explain defaults to a smart-newcomer audience and honors a focus", () => {
expect(actionTask("explain")).toContain("smart reader new to this topic");
expect(actionTask("explain", { instruction: "a lawyer" })).toContain(
"Tailor the explanation to: a lawyer",
);
});
it("translate defaults to English and honors a target language", () => {
expect(actionTask("translate")).toContain("into English");
expect(actionTask("translate", { language: "Japanese" })).toContain("into Japanese");
});
it("translate treats a blank language as English", () => {
expect(actionTask("translate", { language: " " })).toContain("into English");
});
it("fixGrammar corrects without changing meaning", () => {
const t = actionTask("fixGrammar");
expect(t).toContain("Correct the grammar");
expect(t).toContain("Do NOT change its meaning");
});
it("ask embeds the question and the fallback instruction", () => {
const t = actionTask("ask", { instruction: "what is quasar?" });
expect(t).toContain('Answer this: "what is quasar?"');
expect(t).toContain("answer from general knowledge");
});
it("every builder forbids inventing facts (except ask which allows general knowledge)", () => {
for (const id of ["summarize", "rewrite"] as const) {
expect(actionTask(id)).toContain("Do NOT add any fact not present");
}
});
});
// ---- buildActionMessages (end-to-end request) -----------------------------
describe("buildActionMessages", () => {
const ctx: ActionContext = { text: "The quarter closed up 12%." };
it("produces a system+user pair with the fenced subject and the resolved task", () => {
const msgs = buildActionMessages("summarize", {}, ctx);
expect(msgs[0].content).toBe(SYSTEM_PROMPT);
expect(msgs[1].content).toContain("The quarter closed up 12%.");
expect(msgs[1].content).toContain("Summarize the clipboard text above");
});
it("threads the truncation budget so a long subject is cut and noted", () => {
const msgs = buildActionMessages("summarize", {}, { text: "y".repeat(50) }, 10);
expect(msgs[1].content).toContain("truncated to fit");
expect(msgs[1].content).toContain("y".repeat(10));
expect(msgs[1].content).not.toContain("y".repeat(11));
});
it("threads action inputs into the task", () => {
const msgs = buildActionMessages("translate", { language: "German" }, ctx);
expect(msgs[1].content).toContain("into German");
});
it("an ask with no clipboard has no fence, only the question", () => {
const msgs = buildActionMessages("ask", { instruction: "define entropy" }, {});
// The fence markers must be absent (the task prose may mention "clipboard").
expect(msgs[1].content).not.toContain("---- clipboard ----");
expect(msgs[1].content).not.toContain("---- end clipboard ----");
expect(msgs[1].content).toContain('Answer this: "define entropy"');
});
it("throws on an unknown action id at the boundary", () => {
expect(() => buildActionMessages("bogus", {}, ctx)).toThrow(/Unknown action/);
});
});
// ---- parseResult (reply cleanup) ------------------------------------------
describe("parseResult", () => {
it("trims surrounding whitespace", () => {
expect(parseResult(" hello ")).toBe("hello");
});
it("strips a whole-reply code fence", () => {
expect(parseResult("```\ncode here\n```")).toBe("code here");
expect(parseResult("```ts\nconst x = 1;\n```")).toBe("const x = 1;");
});
it("leaves an internal fence untouched", () => {
const md = "Here is code:\n```js\nfoo()\n```\ndone";
expect(parseResult(md)).toBe(md);
});
it("strips one matched pair of surrounding straight or curly quotes", () => {
expect(parseResult('"quoted line"')).toBe("quoted line");
expect(parseResult("“smart quoted”")).toBe("smart quoted");
expect(parseResult("'single'")).toBe("single");
});
it("does NOT strip quotes that are part of the content", () => {
expect(parseResult('He said "hi" to me')).toBe('He said "hi" to me');
expect(parseResult('"unterminated')).toBe('"unterminated');
});
it("strips a fence then wrapping quotes together", () => {
expect(parseResult('```\n"wrapped"\n```')).toBe("wrapped");
});
it("is idempotent on already-clean text", () => {
const clean = "Just a clean sentence.";
expect(parseResult(parseResult(clean))).toBe(clean);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import {
HANZO_API_BASE_URL,
DEFAULT_MODEL,
STORE_KEY,
chatCompletionsURL,
modelsURL,
isHanzoKey,
} from "../src/config";
describe("gateway constants", () => {
it("points at api.hanzo.ai with no /api/ prefix", () => {
expect(HANZO_API_BASE_URL).toBe("https://api.hanzo.ai");
expect(chatCompletionsURL()).toBe("https://api.hanzo.ai/v1/chat/completions");
expect(modelsURL()).toBe("https://api.hanzo.ai/v1/models");
expect(chatCompletionsURL()).not.toContain("/api/");
});
it("defaults to a Zen model", () => {
expect(DEFAULT_MODEL).toBe("zen5");
});
it("names the local store key", () => {
expect(STORE_KEY).toBe("hanzo.desktop.settings");
});
});
describe("isHanzoKey", () => {
it("accepts a well-formed hk- key", () => {
expect(isHanzoKey("hk-" + "a".repeat(16))).toBe(true);
expect(isHanzoKey("hk-Abc123._-Abc123._-")).toBe(true);
});
it("trims surrounding whitespace before matching", () => {
expect(isHanzoKey(" hk-" + "b".repeat(20) + " ")).toBe(true);
});
it("rejects the wrong prefix, too-short, or non-string", () => {
expect(isHanzoKey("sk-" + "a".repeat(20))).toBe(false); // OpenAI key
expect(isHanzoKey("hk-short")).toBe(false); // < 16 body chars
expect(isHanzoKey("eyJhbGciOiJI.jwt.token")).toBe(false); // an IAM JWT
expect(isHanzoKey("")).toBe(false);
expect(isHanzoKey(undefined)).toBe(false);
expect(isHanzoKey(null)).toBe(false);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect } from "vitest";
import type { AiClient } from "@hanzo/ai";
import { runChat, streamChat, listModels } from "../src/hanzo";
import type { ChatMessage } from "../src/actions";
// A fake AiClient capturing the params passed to chat.completions.create and
// models.list. This is how we assert request shaping over @hanzo/ai without a
// network. The `create` fake honors the stream flag: stream:false returns a
// completion object; stream:true returns an async generator of chunks.
function fakeClient(opts: {
reply?: string;
chunks?: string[];
models?: string[];
}): { client: AiClient; calls: { params: any; options: any }[] } {
const { reply = "", chunks = [], models = ["zen5", "zen5-mini"] } = opts;
const calls: { params: any; options: any }[] = [];
const client = {
chat: {
completions: {
create: async (params: any, options: any) => {
calls.push({ params, options });
if (params.stream) {
return (async function* () {
for (const c of chunks) {
yield {
id: "x",
object: "chat.completion.chunk",
created: 0,
model: params.model,
choices: [{ index: 0, delta: { content: c }, finish_reason: null }],
};
}
})();
}
return {
id: "x",
object: "chat.completion",
created: 0,
model: params.model,
choices: [
{ index: 0, message: { role: "assistant", content: reply }, finish_reason: "stop" },
],
};
},
},
},
models: {
list: async () => models.map((id) => ({ id, object: "model" })),
},
} as unknown as AiClient;
return { client, calls };
}
const MESSAGES: ChatMessage[] = [
{ role: "system", content: "sys" },
{ role: "user", content: "hi" },
];
describe("runChat (non-streaming request shaping)", () => {
it("sends model + messages, stream:false, temperature, and returns the content", async () => {
const f = fakeClient({ reply: "Summary." });
const out = await runChat(MESSAGES, { client: f.client, model: "zen5-mini", temperature: 0.3 });
expect(out).toBe("Summary.");
expect(f.calls).toHaveLength(1);
expect(f.calls[0].params.model).toBe("zen5-mini");
expect(f.calls[0].params.stream).toBe(false);
expect(f.calls[0].params.temperature).toBe(0.3);
expect(f.calls[0].params.messages).toEqual(MESSAGES);
});
it("defaults the model to zen5 when none is given", async () => {
const f = fakeClient({ reply: "x" });
await runChat(MESSAGES, { client: f.client });
expect(f.calls[0].params.model).toBe("zen5");
});
it("throws when the model returns empty content", async () => {
const f = fakeClient({ reply: "" });
await expect(runChat(MESSAGES, { client: f.client })).rejects.toThrow(/no content/);
});
it("forwards the abort signal in options", async () => {
const f = fakeClient({ reply: "x" });
const controller = new AbortController();
await runChat(MESSAGES, { client: f.client, signal: controller.signal });
expect(f.calls[0].options.signal).toBe(controller.signal);
});
});
describe("streamChat (streaming request shaping)", () => {
it("sends stream:true, calls onDelta per chunk, and returns the full text", async () => {
const f = fakeClient({ chunks: ["Hel", "lo ", "world"] });
const seen: string[] = [];
const out = await streamChat(MESSAGES, (d) => seen.push(d), { client: f.client, model: "zen5" });
expect(f.calls[0].params.stream).toBe(true);
expect(seen).toEqual(["Hel", "lo ", "world"]);
expect(out).toBe("Hello world");
});
it("skips empty deltas but keeps the concatenation correct", async () => {
const f = fakeClient({ chunks: ["A", "", "B"] });
const seen: string[] = [];
const out = await streamChat(MESSAGES, (d) => seen.push(d), { client: f.client });
expect(seen).toEqual(["A", "B"]);
expect(out).toBe("AB");
});
it("throws when the stream yields no content", async () => {
const f = fakeClient({ chunks: [] });
await expect(streamChat(MESSAGES, () => {}, { client: f.client })).rejects.toThrow(/no content/);
});
it("forwards the abort signal in options", async () => {
const f = fakeClient({ chunks: ["x"] });
const controller = new AbortController();
await streamChat(MESSAGES, () => {}, { client: f.client, signal: controller.signal });
expect(f.calls[0].options.signal).toBe(controller.signal);
});
});
describe("listModels", () => {
it("returns the model ids from the client", async () => {
const f = fakeClient({ models: ["zen5", "zen5-mini", "zen5-coder"] });
expect(await listModels({ client: f.client })).toEqual(["zen5", "zen5-mini", "zen5-coder"]);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_SETTINGS,
parseSettings,
mergeSettings,
serializeSettings,
type Settings,
} from "../src/store";
const KEY = "hk-" + "a".repeat(20);
const KEY2 = "hk-" + "b".repeat(20);
describe("parseSettings", () => {
it("returns defaults for null / empty", () => {
expect(parseSettings(null)).toEqual(DEFAULT_SETTINGS);
expect(parseSettings("")).toEqual(DEFAULT_SETTINGS);
});
it("returns defaults for invalid JSON", () => {
expect(parseSettings("{not json")).toEqual(DEFAULT_SETTINGS);
});
it("returns defaults for a non-object payload", () => {
expect(parseSettings("42")).toEqual(DEFAULT_SETTINGS);
expect(parseSettings("null")).toEqual(DEFAULT_SETTINGS);
expect(parseSettings('"a string"')).toEqual(DEFAULT_SETTINGS);
});
it("reads a valid stored key + model", () => {
const raw = JSON.stringify({ apiKey: KEY, model: "zen5-coder" });
expect(parseSettings(raw)).toEqual({ apiKey: KEY, model: "zen5-coder" });
});
it("drops a stored key that no longer matches the hk- shape", () => {
const raw = JSON.stringify({ apiKey: "sk-bad", model: "zen5" });
expect(parseSettings(raw)).toEqual({ apiKey: "", model: "zen5" });
});
it("trims the stored key and falls back to the default model when missing/blank", () => {
const raw = JSON.stringify({ apiKey: ` ${KEY} `, model: " " });
expect(parseSettings(raw)).toEqual({ apiKey: KEY, model: DEFAULT_SETTINGS.model });
});
it("fills defaults for wrong-typed fields", () => {
const raw = JSON.stringify({ apiKey: 123, model: [] });
expect(parseSettings(raw)).toEqual(DEFAULT_SETTINGS);
});
});
describe("mergeSettings", () => {
const base: Settings = { apiKey: "", model: "zen5" };
it("accepts a valid key patch", () => {
const { settings, error } = mergeSettings(base, { apiKey: KEY });
expect(error).toBeUndefined();
expect(settings.apiKey).toBe(KEY);
});
it("trims the key before validating and storing", () => {
const { settings } = mergeSettings(base, { apiKey: ` ${KEY2} ` });
expect(settings.apiKey).toBe(KEY2);
});
it("clears the key on an empty patch (explicit removal)", () => {
const withKey: Settings = { apiKey: KEY, model: "zen5" };
const { settings, error } = mergeSettings(withKey, { apiKey: " " });
expect(error).toBeUndefined();
expect(settings.apiKey).toBe("");
});
it("rejects a malformed key WITHOUT mutating current settings", () => {
const { settings, error } = mergeSettings(base, { apiKey: "sk-openai-style" });
expect(error).toMatch(/Not a Hanzo key/);
expect(settings).toBe(base); // returns the untouched original
});
it("normalizes a blank model back to the default", () => {
const { settings } = mergeSettings(base, { model: " " });
expect(settings.model).toBe(DEFAULT_SETTINGS.model);
});
it("applies a model patch and leaves the key untouched", () => {
const withKey: Settings = { apiKey: KEY, model: "zen5" };
const { settings } = mergeSettings(withKey, { model: "zen5-mini" });
expect(settings).toEqual({ apiKey: KEY, model: "zen5-mini" });
});
it("applies key + model together", () => {
const { settings, error } = mergeSettings(base, { apiKey: KEY, model: "zen5-coder" });
expect(error).toBeUndefined();
expect(settings).toEqual({ apiKey: KEY, model: "zen5-coder" });
});
});
describe("serializeSettings <-> parseSettings round-trip", () => {
it("round-trips a valid settings object", () => {
const s: Settings = { apiKey: KEY, model: "zen5-coder" };
expect(parseSettings(serializeSettings(s))).toEqual(s);
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"isolatedModules": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "build.js"],
"exclude": ["dist", "src-tauri"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
},
});
+1
View File
@@ -6,6 +6,7 @@ packages:
- 'packages/canva'
- 'packages/cards'
- 'packages/clio'
- 'packages/desktop'
- 'packages/docusign'
- 'packages/dxt'
- 'packages/figma'