feat(desktop): @hanzo/desktop — the ambient omnipresence layer (Tauri v2)
A cross-platform (macOS/Windows/Linux) menubar/tray app with a global hotkey (Cmd/Ctrl+Shift+Space) that pops a Hanzo assistant over any app. Ask, summarize the clipboard, rewrite, explain, translate, fix grammar — streamed over api.hanzo.ai via the published @hanzo/ai. Built on Tauri v2 (Rust shell + web frontend), not Electron. Reuses the raycast/office design one-to-one: pure host-agnostic action catalog (actions.ts — prompts, clipboard-context assembly/truncation, message shaping, reply parsing), a thin @hanzo/ai wrapper (hanzo.ts — run/stream/list), a local settings store (store.ts — hk- key + model, parse/merge/serialize), and the only Tauri-touching module (clipboard.ts). app.ts is glue with one run path serving the panel, the tray quick actions, and the hotkey. Rust shell (src-tauri): tray + menu, global-shortcut toggle, spotlight window (small, always-on-top, hidden-on-blur), and hide_window/paste commands (enigo synthesizes Cmd/Ctrl+V so rewrite/translate/fix land over the frontmost app). The hk- key lives only in the webview's local store and is validated before use; CSP allows connecting only to api.hanzo.ai. Frontend builds with esbuild + tsc clean; 66 vitest tests green. Native installers (.dmg/.msi/.AppImage) are a per-OS CI job (tauri build) — never built locally. Registers packages/desktop in pnpm-workspace.yaml.
@@ -0,0 +1,3 @@
|
||||
# Rust / Tauri build output (dist/ and node_modules/ are covered by the root .gitignore).
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 954 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 936 B |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,7 @@ packages:
|
||||
- 'packages/canva'
|
||||
- 'packages/cards'
|
||||
- 'packages/clio'
|
||||
- 'packages/desktop'
|
||||
- 'packages/docusign'
|
||||
- 'packages/dxt'
|
||||
- 'packages/figma'
|
||||
|
||||