Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a263c67aa2 | ||
|
|
bb5528f0df | ||
|
|
bf61ccd98a | ||
|
|
5603d5695d | ||
|
|
a202069b2f | ||
|
|
f52eff1b68 | ||
|
|
1a967f8bb2 | ||
|
|
66f12b08f1 | ||
|
|
c62346e94c | ||
|
|
4faa92e902 | ||
|
|
e37b8e7a07 | ||
|
|
6baf19e04c | ||
|
|
90a9383a6e | ||
|
|
04699e3f9a | ||
|
|
16620df721 | ||
|
|
de93905ce3 | ||
|
|
bceb7176d9 | ||
|
|
85eebae869 | ||
|
|
abe3ad8b65 | ||
|
|
5d54f72aa9 | ||
|
|
2911113fda | ||
|
|
1374690f9f | ||
|
|
51d6b45667 | ||
|
|
aa8444aff6 | ||
|
|
6772773b40 | ||
|
|
b5a44c8b66 | ||
|
|
0f0def8287 | ||
|
|
71f76cd97e | ||
|
|
c262c6e506 | ||
|
|
7c5557a901 | ||
|
|
b24f0eb76e | ||
|
|
5c76eab8bc | ||
|
|
68df12bae2 | ||
|
|
24bec0883f | ||
|
|
e79d3a853e | ||
|
|
21686d4eda | ||
|
|
1f13590df8 | ||
|
|
4b84ccfcd6 | ||
|
|
dc1c9b4faa | ||
|
|
bbd424bb36 | ||
|
|
f85de497ee | ||
|
|
1533315944 | ||
|
|
0379404940 | ||
|
|
3e90fd6e3a | ||
|
|
e2bd0cacc5 | ||
|
|
1c63e5592e | ||
|
|
0591d31d95 | ||
|
|
0ec82c318b | ||
|
|
b31bcbef56 | ||
|
|
bded43dbce | ||
|
|
733c87313d | ||
|
|
bb1a88abd0 | ||
|
|
a1a355b1d1 | ||
|
|
91a595b822 | ||
|
|
110b91f54c | ||
|
|
85dc2db900 | ||
|
|
c213289abf | ||
|
|
72464665c5 | ||
|
|
7f57e4d2b8 | ||
|
|
2655a5f227 |
@@ -21,14 +21,17 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Build shared workspace libraries first — consumers (slack/teams/…) import
|
||||
# @hanzo/cards via its built dist/, so it must exist before their tests run.
|
||||
- name: Build shared libraries
|
||||
run: pnpm --filter @hanzo/cards build
|
||||
|
||||
- name: Test browser extension
|
||||
run: pnpm --filter @hanzo/browser-extension test
|
||||
|
||||
- name: Test ACI package
|
||||
run: pnpm --filter @hanzo/aci test
|
||||
|
||||
- name: Test AI package
|
||||
run: pnpm --filter @hanzo/ai test
|
||||
|
||||
- name: Test auth core
|
||||
run: pnpm --filter @hanzo/auth test
|
||||
@@ -42,6 +45,72 @@ jobs:
|
||||
- name: Test Google Workspace add-on
|
||||
run: pnpm --filter @hanzo/gworkspace-addon test
|
||||
|
||||
- name: Test PDF workspace
|
||||
run: pnpm --filter @hanzo/pdf test
|
||||
|
||||
- name: Test Clio integration
|
||||
run: pnpm --filter @hanzo/clio test
|
||||
|
||||
- name: Test Notion integration
|
||||
run: pnpm --filter @hanzo/notion test
|
||||
|
||||
- name: Test DocuSign integration
|
||||
run: pnpm --filter @hanzo/docusign test
|
||||
|
||||
- name: Test Salesforce (pure helpers)
|
||||
run: pnpm --filter @hanzo/salesforce test
|
||||
|
||||
- name: Test Zendesk app
|
||||
run: pnpm --filter @hanzo/zendesk test
|
||||
|
||||
- name: Test HubSpot app
|
||||
run: pnpm --filter @hanzo/hubspot test
|
||||
|
||||
- name: Test Shopify app
|
||||
run: pnpm --filter @hanzo/shopify test
|
||||
|
||||
- name: Test Figma plugin
|
||||
run: pnpm --filter @hanzo/figma test
|
||||
|
||||
- name: Test Sketch plugin
|
||||
run: pnpm --filter @hanzo/sketch test
|
||||
|
||||
- name: Test Canva app
|
||||
run: pnpm --filter @hanzo/canva test
|
||||
|
||||
- name: Test GitHub App
|
||||
run: pnpm --filter @hanzo/github test
|
||||
|
||||
- name: Test Raycast extension
|
||||
run: pnpm --filter hanzo-ai test
|
||||
|
||||
- name: Test JupyterLab extension
|
||||
run: pnpm --filter @hanzo/jupyter test
|
||||
|
||||
- name: Test GitLab integration
|
||||
run: pnpm --filter @hanzo/gitlab test
|
||||
|
||||
- name: Test Desktop app (frontend)
|
||||
run: pnpm --filter @hanzo/desktop test
|
||||
|
||||
- name: Test Procore (construction)
|
||||
run: pnpm --filter @hanzo/procore test
|
||||
|
||||
- name: Test QuickBooks (finance)
|
||||
run: pnpm --filter @hanzo/quickbooks test
|
||||
|
||||
- name: Test cards (shared card-builder)
|
||||
run: pnpm --filter @hanzo/cards test
|
||||
|
||||
- name: Test Slack app
|
||||
run: pnpm --filter @hanzo/slack test
|
||||
|
||||
- name: Test Teams app
|
||||
run: pnpm --filter @hanzo/teams test
|
||||
|
||||
- name: Test Zoom + Meet
|
||||
run: pnpm --filter @hanzo/meetings test
|
||||
|
||||
- name: Test tools package
|
||||
run: pnpm --filter @hanzo/cli-tools test
|
||||
|
||||
|
||||
@@ -161,13 +161,15 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
cd dist
|
||||
# Prefer the built .app products; fall back to the Xcode source project.
|
||||
APPS=$(find safari-build-macos safari-build-ios -name '*.app' -type d 2>/dev/null || true)
|
||||
if [ -n "$APPS" ]; then
|
||||
zip -r -y "/tmp/hanzo-ai-safari-v${VERSION}.zip" $APPS
|
||||
else
|
||||
zip -r "/tmp/hanzo-ai-safari-v${VERSION}.zip" "safari/Hanzo AI"
|
||||
# Zip the whole Safari output tree: the Xcode project (build.js always
|
||||
# emits dist/safari — a developer opens it to build+sign+sideload) plus any
|
||||
# built .app products. Guard so a `zip` exit code (e.g. 12 "nothing to do")
|
||||
# never blanks the release asset; only skip if there is genuinely nothing.
|
||||
TARGETS=$(ls -d safari safari-build-macos safari-build-ios 2>/dev/null || true)
|
||||
if [ -n "$TARGETS" ]; then
|
||||
zip -r -y "/tmp/hanzo-ai-safari-v${VERSION}.zip" $TARGETS || true
|
||||
fi
|
||||
ls -la /tmp/hanzo-ai-safari-v*.zip 2>/dev/null || echo "no Safari output produced"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Safari bundle
|
||||
@@ -352,11 +354,272 @@ jobs:
|
||||
name: hanzo-ai-outlook
|
||||
path: /tmp/hanzo-ai-outlook-v*.zip
|
||||
|
||||
# ─── Figma plugin ───
|
||||
figma:
|
||||
name: Figma Plugin
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/cards build
|
||||
pnpm --filter @hanzo/figma build
|
||||
# dist/ = manifest.json + code.js + ui.html. Import via
|
||||
# Figma → Plugins → Development → Import plugin from manifest.
|
||||
cd packages/figma/dist && npx --yes bestzip "/tmp/hanzo-ai-figma-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Figma plugin
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-figma
|
||||
path: /tmp/hanzo-ai-figma-v*.zip
|
||||
|
||||
# ─── Sketch plugin ───
|
||||
sketch:
|
||||
name: Sketch Plugin
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/cards build
|
||||
pnpm --filter @hanzo/sketch build
|
||||
# dist/ contains the "Hanzo AI.sketchplugin" bundle; zip from dist so the
|
||||
# bundle sits at the archive root (double-click to install into Sketch).
|
||||
cd packages/sketch/dist && npx --yes bestzip "/tmp/hanzo-ai-sketch-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Sketch plugin
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-sketch
|
||||
path: /tmp/hanzo-ai-sketch-v*.zip
|
||||
|
||||
# ─── Microsoft Teams app ───
|
||||
teams:
|
||||
name: Teams App
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
env:
|
||||
# The manifest builder substitutes the bot app id into the sideloadable
|
||||
# package and errors on unset placeholders. Use the registered bot id when
|
||||
# available; otherwise a nil-GUID placeholder so the release asset always
|
||||
# builds (sideloaders replace it with their own Azure Bot registration).
|
||||
MICROSOFT_APP_ID: ${{ secrets.MICROSOFT_APP_ID != '' && secrets.MICROSOFT_APP_ID || '00000000-0000-0000-0000-000000000000' }}
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/cards build
|
||||
pnpm --filter @hanzo/teams build
|
||||
# The teams `package` script emits dist/hanzo-teams.zip (manifest + icons).
|
||||
pnpm --filter @hanzo/teams package
|
||||
cp packages/teams/dist/hanzo-teams.zip "/tmp/hanzo-ai-teams-v${VERSION}.zip"
|
||||
|
||||
- name: Upload Teams app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-teams
|
||||
path: /tmp/hanzo-ai-teams-v*.zip
|
||||
|
||||
# ─── Zendesk Support app ───
|
||||
zendesk:
|
||||
name: Zendesk App
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build + package
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
pnpm --filter @hanzo/cards build
|
||||
pnpm --filter @hanzo/zendesk build
|
||||
# dist/ is the ZAF v2 app (manifest.json + assets/ + translations/).
|
||||
cd packages/zendesk/dist && npx --yes bestzip "/tmp/hanzo-ai-zendesk-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Zendesk app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-zendesk
|
||||
path: /tmp/hanzo-ai-zendesk-v*.zip
|
||||
|
||||
# ─── Desktop app — per-OS Tauri installers ───
|
||||
desktop:
|
||||
name: Desktop (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: secrets
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install Tauri Linux deps
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libgtk-3-dev \
|
||||
libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- run: pnpm install
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm --filter @hanzo/desktop build
|
||||
|
||||
- name: Build Tauri installers
|
||||
run: pnpm --filter @hanzo/desktop tauri:build
|
||||
continue-on-error: true
|
||||
|
||||
# Collect the native installer(s) and rename to a stable, per-OS release name.
|
||||
# Signing/notarization is future work — a per-OS failure must not block others.
|
||||
- name: Package for release (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
mkdir -p desktop-artifacts
|
||||
DMG=$(ls packages/desktop/src-tauri/target/release/bundle/dmg/*.dmg 2>/dev/null | head -1)
|
||||
if [ -n "$DMG" ]; then cp "$DMG" "desktop-artifacts/hanzo-ai-desktop-macos-v${VERSION}.dmg"; fi
|
||||
ls -la desktop-artifacts/ || echo "no macOS installer produced"
|
||||
|
||||
- name: Package for release (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
mkdir -p desktop-artifacts
|
||||
MSI=$(ls packages/desktop/src-tauri/target/release/bundle/msi/*.msi 2>/dev/null | head -1)
|
||||
EXE=$(ls packages/desktop/src-tauri/target/release/bundle/nsis/*.exe 2>/dev/null | head -1)
|
||||
if [ -n "$MSI" ]; then cp "$MSI" "desktop-artifacts/hanzo-ai-desktop-windows-v${VERSION}.msi"; fi
|
||||
if [ -n "$EXE" ]; then cp "$EXE" "desktop-artifacts/hanzo-ai-desktop-windows-v${VERSION}.exe"; fi
|
||||
ls -la desktop-artifacts/ || echo "no Windows installer produced"
|
||||
|
||||
- name: Package for release (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
mkdir -p desktop-artifacts
|
||||
APPIMAGE=$(ls packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage 2>/dev/null | head -1)
|
||||
DEB=$(ls packages/desktop/src-tauri/target/release/bundle/deb/*.deb 2>/dev/null | head -1)
|
||||
if [ -n "$APPIMAGE" ]; then cp "$APPIMAGE" "desktop-artifacts/hanzo-ai-desktop-linux-v${VERSION}.AppImage"; fi
|
||||
if [ -n "$DEB" ]; then cp "$DEB" "desktop-artifacts/hanzo-ai-desktop-linux-v${VERSION}.deb"; fi
|
||||
ls -la desktop-artifacts/ || echo "no Linux installer produced"
|
||||
|
||||
- name: Upload Desktop installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-desktop-${{ runner.os }}
|
||||
path: desktop-artifacts/*
|
||||
if-no-files-found: ignore
|
||||
continue-on-error: true
|
||||
|
||||
# ─── JupyterLab extension (Python wheel) ───
|
||||
jupyter:
|
||||
name: JupyterLab Extension
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: secrets
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# JupyterLab provides `jlpm` (the yarn wrapper the labextension build calls) and
|
||||
# the `build` frontend. Install it BEFORE the labextension build, else
|
||||
# `jlpm build:lib` fails with `jlpm: not found` and skips the whole release.
|
||||
- name: Install JupyterLab toolchain
|
||||
continue-on-error: true
|
||||
run: python -m pip install --upgrade jupyterlab build
|
||||
|
||||
# Build the PROD labextension via pnpm (NOT jlpm). jlpm is yarn, which refuses to
|
||||
# `install` inside this pnpm monorepo ("not part of the project"). Running the
|
||||
# scripts through pnpm uses the already-installed node_modules and produces
|
||||
# hanzo_jupyter/labextension/static/style.js — the hook's skip-if-exists target,
|
||||
# so the wheel build below skips jlpm entirely.
|
||||
- name: Build labextension (prod, via pnpm)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
pnpm --filter @hanzo/jupyter run build:lib:prod
|
||||
pnpm --filter @hanzo/jupyter run build:labextension
|
||||
|
||||
- name: Build wheel
|
||||
continue-on-error: true
|
||||
working-directory: packages/jupyter
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
# skip-if-exists (hanzo_jupyter/labextension/static/style.js) is satisfied by
|
||||
# the prod labextension above, so hatch-jupyter-builder does NOT invoke jlpm.
|
||||
python -m build --wheel
|
||||
mkdir -p /tmp/jupyter
|
||||
WHL=$(ls dist/hanzo_jupyter-*.whl dist/hanzo_ai_jupyter-*.whl 2>/dev/null | head -1)
|
||||
if [ -n "$WHL" ]; then
|
||||
# Rename to the tag version so the advertised release link resolves even
|
||||
# if pyproject's version has not been synced to the tag.
|
||||
cp "$WHL" "/tmp/jupyter/hanzo_jupyter-${VERSION}-py3-none-any.whl"
|
||||
fi
|
||||
ls -la /tmp/jupyter/ || echo "no wheel produced"
|
||||
|
||||
- name: Upload JupyterLab wheel
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hanzo-ai-jupyter
|
||||
path: /tmp/jupyter/*.whl
|
||||
if-no-files-found: ignore
|
||||
continue-on-error: true
|
||||
|
||||
# ─── GitHub Release with downloadable artifacts ───
|
||||
release:
|
||||
name: GitHub Release
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
needs: [browser, safari, vscode, jetbrains, office, outlook]
|
||||
needs: [browser, safari, vscode, jetbrains, office, outlook, figma, sketch, teams, zendesk, desktop, jupyter]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -406,6 +669,44 @@ jobs:
|
||||
> Chrome zip also works for Brave, Opera, Vivaldi, Arc.
|
||||
> Cursor, Windsurf, and Antigravity use the VS Code VSIX format.
|
||||
|
||||
## Design Tools
|
||||
|
||||
| App | Platform | Download |
|
||||
|-----|----------|----------|
|
||||
| **Figma** | Windows / macOS / Web | [`hanzo-ai-figma-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-figma-v${{ steps.version.outputs.version }}.zip) |
|
||||
| **Sketch** | macOS | [`hanzo-ai-sketch-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-sketch-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
> Figma: unzip and import `manifest.json` via Figma → Plugins → Development → Import plugin from manifest.
|
||||
> Sketch: unzip and double-click `Hanzo AI.sketchplugin`.
|
||||
|
||||
## Team Apps
|
||||
|
||||
| App | Platform | Download |
|
||||
|-----|----------|----------|
|
||||
| **Microsoft Teams** | Windows / macOS / Web | [`hanzo-ai-teams-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-teams-v${{ steps.version.outputs.version }}.zip) |
|
||||
| **Zendesk** | Web | [`hanzo-ai-zendesk-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-zendesk-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
> Teams: sideload the zip via Teams → Apps → Manage your apps → Upload an app. Set your own Azure Bot app id before publishing.
|
||||
> Zendesk: upload the zip via the Zendesk Admin Center → Apps and integrations → Upload private app.
|
||||
|
||||
## Desktop App
|
||||
|
||||
| OS | Format | Download |
|
||||
|----|--------|----------|
|
||||
| **macOS** | `.dmg` | [`hanzo-ai-desktop-macos-v${{ steps.version.outputs.version }}.dmg`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-desktop-macos-v${{ steps.version.outputs.version }}.dmg) |
|
||||
| **Windows** | `.msi` / `.exe` | [`hanzo-ai-desktop-windows-v${{ steps.version.outputs.version }}.msi`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-desktop-windows-v${{ steps.version.outputs.version }}.msi) · [`.exe`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-desktop-windows-v${{ steps.version.outputs.version }}.exe) |
|
||||
| **Linux** | `.AppImage` / `.deb` | [`hanzo-ai-desktop-linux-v${{ steps.version.outputs.version }}.AppImage`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-desktop-linux-v${{ steps.version.outputs.version }}.AppImage) · [`.deb`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-desktop-linux-v${{ steps.version.outputs.version }}.deb) |
|
||||
|
||||
> Desktop installers are unsigned for now — macOS: right-click → Open; Windows: "More info" → Run anyway.
|
||||
|
||||
## Data Science
|
||||
|
||||
| App | Platform | Download |
|
||||
|-----|----------|----------|
|
||||
| **JupyterLab** | Windows / macOS / Linux | [`hanzo_jupyter-${{ steps.version.outputs.version }}-py3-none-any.whl`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo_jupyter-${{ steps.version.outputs.version }}-py3-none-any.whl) |
|
||||
|
||||
> Jupyter: `pip install hanzo_jupyter-*.whl` then restart JupyterLab.
|
||||
|
||||
**Install**: `code --install-extension hanzo-ai-vscode-v${{ steps.version.outputs.version }}.vsix`
|
||||
**Antigravity**: `antigravity --install-extension hanzo-ai-antigravity-v${{ steps.version.outputs.version }}.vsix`
|
||||
files: |
|
||||
@@ -415,3 +716,9 @@ jobs:
|
||||
artifacts/hanzo-ai-office/*
|
||||
artifacts/hanzo-ai-outlook/*
|
||||
artifacts/hanzo-ai-safari/*
|
||||
artifacts/hanzo-ai-figma/*
|
||||
artifacts/hanzo-ai-sketch/*
|
||||
artifacts/hanzo-ai-teams/*
|
||||
artifacts/hanzo-ai-zendesk/*
|
||||
artifacts/hanzo-ai-desktop-*/*
|
||||
artifacts/hanzo-ai-jupyter/*
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.30",
|
||||
"version": "1.9.31",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
Copyright 2025 Hanzo Industries Inc
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,236 +0,0 @@
|
||||
# @hanzo/ai
|
||||
|
||||
The AI Toolkit for TypeScript - AgentKit with Model Context Protocol (MCP) support.
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **Multi-Provider Support**: OpenAI, Anthropic, Google, Mistral, Bedrock, Vertex AI, Cohere, and more
|
||||
- 🔧 **MCP Integration**: Full Model Context Protocol support for tool calling
|
||||
- 🌐 **Agent Networks**: Create and orchestrate multiple AI agents
|
||||
- 📊 **Observability**: Built-in telemetry with OpenTelemetry support
|
||||
- 🔄 **Streaming**: First-class streaming support for all providers
|
||||
- 📝 **Type Safety**: Full TypeScript support with comprehensive types
|
||||
- 🎯 **Unified API**: Consistent interface across all providers
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @hanzo/ai
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Text Generation
|
||||
|
||||
```typescript
|
||||
import { generateText, openai } from '@hanzo/ai';
|
||||
|
||||
const result = await generateText({
|
||||
model: openai({ apiKey: process.env.OPENAI_API_KEY })('gpt-4'),
|
||||
prompt: 'What is the meaning of life?',
|
||||
maxTokens: 500
|
||||
});
|
||||
|
||||
console.log(result.text);
|
||||
```
|
||||
|
||||
### Creating an Agent
|
||||
|
||||
```typescript
|
||||
import { createAgent, anthropic } from '@hanzo/ai';
|
||||
|
||||
const agent = createAgent({
|
||||
name: 'Assistant',
|
||||
model: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })('claude-3-sonnet'),
|
||||
instructions: 'You are a helpful assistant.',
|
||||
tools: [
|
||||
{
|
||||
name: 'calculate',
|
||||
description: 'Perform mathematical calculations',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
expression: { type: 'string' }
|
||||
}
|
||||
},
|
||||
handler: async ({ expression }) => {
|
||||
return { result: eval(expression) };
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const response = await agent.run('What is 2 + 2?');
|
||||
```
|
||||
|
||||
### Agent Networks
|
||||
|
||||
```typescript
|
||||
import { createNetwork, createAgent, openai, anthropic } from '@hanzo/ai';
|
||||
|
||||
const network = createNetwork({
|
||||
agents: [
|
||||
createAgent({
|
||||
name: 'researcher',
|
||||
model: openai({ apiKey: process.env.OPENAI_API_KEY })('gpt-4'),
|
||||
instructions: 'You are a research specialist.'
|
||||
}),
|
||||
createAgent({
|
||||
name: 'writer',
|
||||
model: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })('claude-3-sonnet'),
|
||||
instructions: 'You are a creative writer.'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Agents can collaborate
|
||||
const result = await network.run('Research and write a short story about AI');
|
||||
```
|
||||
|
||||
### Streaming
|
||||
|
||||
```typescript
|
||||
import { generateStream, google } from '@hanzo/ai';
|
||||
|
||||
const stream = await generateStream({
|
||||
model: google({ apiKey: process.env.GOOGLE_API_KEY })('gemini-pro'),
|
||||
prompt: 'Tell me a story',
|
||||
maxTokens: 1000
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
process.stdout.write(chunk.text);
|
||||
}
|
||||
```
|
||||
|
||||
### Object Generation
|
||||
|
||||
```typescript
|
||||
import { generateObject, openai } from '@hanzo/ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
interests: z.array(z.string())
|
||||
});
|
||||
|
||||
const result = await generateObject({
|
||||
model: openai({ apiKey: process.env.OPENAI_API_KEY })('gpt-4'),
|
||||
prompt: 'Generate a random person profile',
|
||||
schema
|
||||
});
|
||||
|
||||
console.log(result.object); // Typed as { name: string, age: number, interests: string[] }
|
||||
```
|
||||
|
||||
## MCP Support
|
||||
|
||||
@hanzo/ai includes full Model Context Protocol support:
|
||||
|
||||
```typescript
|
||||
import { createAgent } from '@hanzo/ai';
|
||||
import { createMCPClient } from '@hanzo/ai/mcp';
|
||||
|
||||
// Connect to an MCP server
|
||||
const mcpClient = await createMCPClient({
|
||||
command: 'node',
|
||||
args: ['path/to/mcp-server.js']
|
||||
});
|
||||
|
||||
// Use MCP tools with an agent
|
||||
const agent = createAgent({
|
||||
name: 'mcp-agent',
|
||||
model: openai({ apiKey: process.env.OPENAI_API_KEY })('gpt-4'),
|
||||
tools: await mcpClient.getTools()
|
||||
});
|
||||
```
|
||||
|
||||
## Providers
|
||||
|
||||
Supported providers with their respective models:
|
||||
|
||||
- **OpenAI**: GPT-4, GPT-3.5, o1-preview, o1-mini
|
||||
- **Anthropic**: Claude 3 (Opus, Sonnet, Haiku), Claude 2
|
||||
- **Google**: Gemini Pro, Gemini Ultra, PaLM
|
||||
- **Mistral**: Mistral Large, Medium, Small, Mixtral
|
||||
- **AWS Bedrock**: Access to multiple models
|
||||
- **Google Vertex AI**: Enterprise Google AI
|
||||
- **Cohere**: Command, Generate, Embed
|
||||
- **Hanzo**: Custom Hanzo models
|
||||
|
||||
## Telemetry
|
||||
|
||||
Built-in telemetry support with OpenTelemetry:
|
||||
|
||||
```typescript
|
||||
import { createHanzoCloudTelemetry } from '@hanzo/ai/telemetry';
|
||||
|
||||
// Initialize telemetry
|
||||
const telemetry = createHanzoCloudTelemetry({
|
||||
apiKey: process.env.HANZO_API_KEY,
|
||||
serviceName: 'my-ai-app'
|
||||
});
|
||||
|
||||
// All AI operations are automatically traced
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Providers
|
||||
|
||||
```typescript
|
||||
import { createProvider } from '@hanzo/ai';
|
||||
|
||||
const customProvider = createProvider({
|
||||
name: 'custom',
|
||||
generateText: async ({ prompt, maxTokens }) => {
|
||||
// Your implementation
|
||||
return { text: 'Response' };
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Tool Creation
|
||||
|
||||
```typescript
|
||||
import { createTool } from '@hanzo/ai';
|
||||
|
||||
const weatherTool = createTool({
|
||||
name: 'get_weather',
|
||||
description: 'Get current weather for a location',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: { type: 'string' }
|
||||
},
|
||||
required: ['location']
|
||||
},
|
||||
handler: async ({ location }) => {
|
||||
// Implementation
|
||||
return { temperature: 72, condition: 'sunny' };
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
import { createState } from '@hanzo/ai';
|
||||
|
||||
const state = createState({
|
||||
messages: [],
|
||||
context: {}
|
||||
});
|
||||
|
||||
// Use with agents for conversation memory
|
||||
const agent = createAgent({
|
||||
name: 'stateful-agent',
|
||||
model: openai({ apiKey: process.env.OPENAI_API_KEY })('gpt-4'),
|
||||
state
|
||||
});
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © Hanzo AI
|
||||
@@ -1,369 +0,0 @@
|
||||
# Telemetry & Observability
|
||||
|
||||
@hanzo/ai includes comprehensive telemetry and observability features that integrate seamlessly with Hanzo Cloud's monitoring platform.
|
||||
|
||||
## Overview
|
||||
|
||||
The telemetry system provides:
|
||||
- Distributed tracing with OpenTelemetry
|
||||
- Structured logging with Winston
|
||||
- Metrics collection (counters, gauges, histograms)
|
||||
- Session tracking
|
||||
- Error tracking and reporting
|
||||
- Performance monitoring
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Local Telemetry
|
||||
|
||||
```typescript
|
||||
import { createAgent, Telemetry } from '@hanzo/ai';
|
||||
|
||||
// Create a basic telemetry instance
|
||||
const telemetry = new Telemetry({
|
||||
serviceName: 'my-ai-service',
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// Use with agents
|
||||
const agent = createAgent({
|
||||
name: 'my-agent',
|
||||
// ... other config
|
||||
});
|
||||
|
||||
const result = await agent.run({
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
context: { telemetry }
|
||||
});
|
||||
```
|
||||
|
||||
### Hanzo Cloud Integration
|
||||
|
||||
```typescript
|
||||
import { createHanzoCloudTelemetry } from '@hanzo/ai/telemetry/hanzo-cloud';
|
||||
|
||||
// Initialize Hanzo Cloud telemetry
|
||||
const telemetry = createHanzoCloudTelemetry({
|
||||
cloudUrl: 'https://cloud.hanzo.ai',
|
||||
apiKey: process.env.HANZO_CLOUD_API_KEY!,
|
||||
projectId: process.env.HANZO_PROJECT_ID!,
|
||||
environment: 'production',
|
||||
serviceName: 'customer-support-ai',
|
||||
serviceVersion: '1.0.0'
|
||||
});
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Distributed Tracing
|
||||
|
||||
Track execution flow across agents and networks:
|
||||
|
||||
```typescript
|
||||
// Trace a custom operation
|
||||
const result = await telemetry.trace(
|
||||
'process_order',
|
||||
async (span) => {
|
||||
span.setAttributes({
|
||||
'order.id': orderId,
|
||||
'order.amount': amount
|
||||
});
|
||||
|
||||
// Your logic here
|
||||
return processOrder(orderId);
|
||||
},
|
||||
{
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: {
|
||||
'service.operation': 'order_processing'
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Structured Logging
|
||||
|
||||
Log with automatic trace context:
|
||||
|
||||
```typescript
|
||||
telemetry.log('info', 'Processing customer request', {
|
||||
customerId: '12345',
|
||||
requestType: 'support'
|
||||
});
|
||||
|
||||
telemetry.log('error', 'Failed to process request', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
```
|
||||
|
||||
### Metrics Collection
|
||||
|
||||
Track key performance indicators:
|
||||
|
||||
```typescript
|
||||
// Counters
|
||||
telemetry.increment('api.requests', 1, {
|
||||
endpoint: '/chat',
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
// Gauges
|
||||
telemetry.gauge('queue.size', queueLength, {
|
||||
queue: 'processing'
|
||||
});
|
||||
|
||||
// Histograms
|
||||
telemetry.histogram('response.time', responseTime, {
|
||||
endpoint: '/chat'
|
||||
});
|
||||
```
|
||||
|
||||
### Agent & Network Metrics
|
||||
|
||||
Automatic metrics for agent executions:
|
||||
|
||||
- `agent.executions` - Count of agent runs
|
||||
- `agent.execution.duration` - Agent execution time
|
||||
- `agent.tokens.used` - Token usage per agent
|
||||
- `tool.executions` - Tool usage statistics
|
||||
- `network.iterations` - Network iteration count
|
||||
- `network.execution.duration` - Total network execution time
|
||||
|
||||
### Session Tracking
|
||||
|
||||
Track related executions:
|
||||
|
||||
```typescript
|
||||
// Create a session
|
||||
const sessionId = telemetry.createSession();
|
||||
|
||||
// All subsequent operations will be linked to this session
|
||||
const result = await network.run({
|
||||
messages: [...],
|
||||
telemetry
|
||||
});
|
||||
```
|
||||
|
||||
### Error Tracking
|
||||
|
||||
Automatic error capture with context:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await agent.run({ messages, telemetry });
|
||||
} catch (error) {
|
||||
// Errors are automatically recorded with span context
|
||||
telemetry.recordException(error);
|
||||
}
|
||||
```
|
||||
|
||||
## Network Telemetry
|
||||
|
||||
Networks provide additional telemetry:
|
||||
|
||||
```typescript
|
||||
const network = createNetwork({
|
||||
name: 'support_network',
|
||||
agents: [classifier, techSupport, billing],
|
||||
// ...
|
||||
});
|
||||
|
||||
// Automatic tracking of:
|
||||
// - Router decisions
|
||||
// - Agent handoffs
|
||||
// - State changes
|
||||
// - Iteration count
|
||||
// - Total execution time
|
||||
|
||||
const result = await network.run({
|
||||
messages: [...],
|
||||
telemetry
|
||||
});
|
||||
```
|
||||
|
||||
## MCP Server Telemetry
|
||||
|
||||
Track MCP server connections:
|
||||
|
||||
```typescript
|
||||
// Automatic tracking when MCP servers are connected
|
||||
const agent = createAgent({
|
||||
name: 'mcp-agent',
|
||||
mcpServers: [{
|
||||
name: 'file-system',
|
||||
transport: { type: 'stdio', command: 'mcp-fs' }
|
||||
}]
|
||||
});
|
||||
|
||||
// Metrics tracked:
|
||||
// - mcp.connections
|
||||
// - mcp.servers.active
|
||||
// - mcp.tool.calls
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Structured Attributes
|
||||
|
||||
```typescript
|
||||
span.setAttributes({
|
||||
'user.id': userId,
|
||||
'user.tier': 'premium',
|
||||
'request.type': 'chat',
|
||||
'request.model': 'gpt-4'
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Create Meaningful Spans
|
||||
|
||||
```typescript
|
||||
await telemetry.trace('user_request', async () => {
|
||||
await telemetry.trace('validate_input', validateInput);
|
||||
await telemetry.trace('process_with_ai', processAI);
|
||||
await telemetry.trace('format_response', formatResponse);
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Track Business Metrics
|
||||
|
||||
```typescript
|
||||
// Track business-relevant metrics
|
||||
telemetry.increment('revenue.processed', amount, {
|
||||
currency: 'USD',
|
||||
paymentMethod: 'stripe'
|
||||
});
|
||||
|
||||
telemetry.gauge('customer.satisfaction', score, {
|
||||
surveyType: 'nps'
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Use Session Context
|
||||
|
||||
```typescript
|
||||
// Link related operations
|
||||
const sessionId = telemetry.createSession();
|
||||
|
||||
for (const message of conversation) {
|
||||
await handleMessage(message, telemetry);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Proper Cleanup
|
||||
|
||||
```typescript
|
||||
// Always flush telemetry before shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
await telemetry.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Viewing Telemetry Data
|
||||
|
||||
### Hanzo Cloud Console
|
||||
|
||||
Access your telemetry data at:
|
||||
- Traces: `https://cloud.hanzo.ai/projects/{projectId}/traces`
|
||||
- Metrics: `https://cloud.hanzo.ai/projects/{projectId}/metrics`
|
||||
- Logs: `https://cloud.hanzo.ai/projects/{projectId}/logs`
|
||||
|
||||
### Local Development
|
||||
|
||||
In development, telemetry outputs to console with structured JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T10:30:45.123Z",
|
||||
"level": "info",
|
||||
"message": "Agent executed",
|
||||
"trace_id": "abc123",
|
||||
"span_id": "def456",
|
||||
"agent": "classifier",
|
||||
"duration": 245,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Impact
|
||||
|
||||
The telemetry system is designed for minimal overhead:
|
||||
- Async span processing
|
||||
- Batched metric collection
|
||||
- Configurable sampling
|
||||
- Automatic span pruning
|
||||
- Efficient memory usage
|
||||
|
||||
Disable in performance-critical paths:
|
||||
|
||||
```typescript
|
||||
const telemetry = new Telemetry({
|
||||
enabled: process.env.NODE_ENV === 'production'
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Traces
|
||||
|
||||
Check:
|
||||
1. Telemetry is enabled
|
||||
2. API key is valid
|
||||
3. Network connectivity to Hanzo Cloud
|
||||
4. Proper span nesting
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
- Reduce span attributes
|
||||
- Enable sampling
|
||||
- Decrease batch size
|
||||
- Check for span leaks
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
|
||||
```typescript
|
||||
const telemetry = createHanzoCloudTelemetry({
|
||||
// ...
|
||||
logLevel: 'debug'
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Span Processors
|
||||
|
||||
```typescript
|
||||
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
||||
|
||||
// Add custom processing
|
||||
const processor = new BatchSpanProcessor(customExporter);
|
||||
telemetry.addSpanProcessor(processor);
|
||||
```
|
||||
|
||||
### Context Propagation
|
||||
|
||||
```typescript
|
||||
// Get trace context for external services
|
||||
const headers = telemetry.getTraceContext();
|
||||
|
||||
// Make external request with trace context
|
||||
await fetch(url, {
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Region Support
|
||||
|
||||
```typescript
|
||||
const telemetry = createHanzoCloudTelemetry({
|
||||
cloudUrl: process.env.HANZO_CLOUD_URL || 'https://cloud.hanzo.ai',
|
||||
// Auto-detected from environment
|
||||
region: process.env.HANZO_CLOUD_REGION
|
||||
});
|
||||
```
|
||||
@@ -1,263 +0,0 @@
|
||||
/**
|
||||
* Example: Using Hanzo AI with Cloud Telemetry
|
||||
*
|
||||
* This example shows how to integrate agents and networks with Hanzo Cloud's
|
||||
* observability platform for comprehensive monitoring and debugging.
|
||||
*/
|
||||
|
||||
import { createAgent, createNetwork } from '@hanzo/ai';
|
||||
import { createHanzoCloudTelemetry } from '@hanzo/ai/telemetry/hanzo-cloud';
|
||||
import { commonTools } from '@hanzo/ai/tools';
|
||||
import { OpenAIProvider } from '@hanzo/ai/providers/openai';
|
||||
|
||||
// Initialize Hanzo Cloud telemetry
|
||||
const telemetry = createHanzoCloudTelemetry({
|
||||
cloudUrl: process.env.HANZO_CLOUD_URL || 'https://cloud.hanzo.ai',
|
||||
apiKey: process.env.HANZO_CLOUD_API_KEY!,
|
||||
projectId: process.env.HANZO_PROJECT_ID!,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
serviceName: 'customer-support-ai',
|
||||
serviceVersion: '1.0.0',
|
||||
logLevel: 'info'
|
||||
});
|
||||
|
||||
// Create a session for tracking related executions
|
||||
const sessionId = telemetry.createSession();
|
||||
console.log(`Started telemetry session: ${sessionId}`);
|
||||
|
||||
// Create agents with telemetry integration
|
||||
const classifierAgent = createAgent({
|
||||
name: 'classifier',
|
||||
description: 'Classifies customer inquiries',
|
||||
system: `You are a customer inquiry classifier. Analyze the customer's message and classify it into one of these categories:
|
||||
- technical_support
|
||||
- billing
|
||||
- product_info
|
||||
- complaint
|
||||
- other`,
|
||||
tools: [
|
||||
commonTools.done(),
|
||||
commonTools.handoff()
|
||||
]
|
||||
});
|
||||
|
||||
const techSupportAgent = createAgent({
|
||||
name: 'tech_support',
|
||||
description: 'Handles technical support issues',
|
||||
system: 'You are a technical support specialist. Help customers resolve technical issues with our products.',
|
||||
tools: [
|
||||
commonTools.done(),
|
||||
commonTools.askUser()
|
||||
]
|
||||
});
|
||||
|
||||
const billingAgent = createAgent({
|
||||
name: 'billing',
|
||||
description: 'Handles billing inquiries',
|
||||
system: 'You are a billing specialist. Help customers with payment, subscription, and invoice questions.',
|
||||
tools: [
|
||||
commonTools.done(),
|
||||
commonTools.remember(),
|
||||
commonTools.recall()
|
||||
]
|
||||
});
|
||||
|
||||
// Create network with telemetry
|
||||
const supportNetwork = createNetwork({
|
||||
name: 'customer_support',
|
||||
agents: [classifierAgent, techSupportAgent, billingAgent],
|
||||
defaultModel: new OpenAIProvider({
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
model: 'gpt-4'
|
||||
}),
|
||||
router: (context) => {
|
||||
// First iteration: always start with classifier
|
||||
if (context.iteration === 0) {
|
||||
return context.network.getAgent('classifier');
|
||||
}
|
||||
|
||||
// Check if classifier has determined the category
|
||||
const category = context.state.get('category');
|
||||
const nextAgent = context.state.get('nextAgent');
|
||||
|
||||
if (nextAgent) {
|
||||
context.state.delete('nextAgent'); // Clear for next iteration
|
||||
return context.network.getAgent(nextAgent);
|
||||
}
|
||||
|
||||
if (category === 'technical_support') {
|
||||
return context.network.getAgent('tech_support');
|
||||
} else if (category === 'billing') {
|
||||
return context.network.getAgent('billing');
|
||||
}
|
||||
|
||||
return undefined; // No more agents to run
|
||||
}
|
||||
});
|
||||
|
||||
// Example: Process customer inquiry with full telemetry
|
||||
async function handleCustomerInquiry(message: string) {
|
||||
// Create a span for the entire operation
|
||||
return telemetry.trace(
|
||||
'customer_inquiry',
|
||||
async (span) => {
|
||||
// Add customer context
|
||||
span.setAttributes({
|
||||
'customer.message.length': message.length,
|
||||
'customer.session.id': sessionId
|
||||
});
|
||||
|
||||
try {
|
||||
// Log the inquiry
|
||||
telemetry.log('info', 'Processing customer inquiry', {
|
||||
messagePreview: message.substring(0, 100)
|
||||
});
|
||||
|
||||
// Run the support network
|
||||
const result = await supportNetwork.run({
|
||||
messages: [
|
||||
{ role: 'user', content: message }
|
||||
],
|
||||
telemetry // Pass telemetry instance
|
||||
});
|
||||
|
||||
// Record success metrics
|
||||
telemetry.increment('customer.inquiries.processed', 1, {
|
||||
status: 'success',
|
||||
category: result.state.category || 'unknown'
|
||||
});
|
||||
|
||||
// Log the resolution
|
||||
telemetry.log('info', 'Customer inquiry resolved', {
|
||||
iterations: result.iterations,
|
||||
finalAgent: result.history[result.history.length - 1]?.agent,
|
||||
category: result.state.category
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Record failure metrics
|
||||
telemetry.increment('customer.inquiries.processed', 1, {
|
||||
status: 'error'
|
||||
});
|
||||
|
||||
telemetry.log('error', 'Failed to process customer inquiry', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
'inquiry.type': 'customer_support'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Example: Monitor streaming responses
|
||||
async function handleStreamingInquiry(message: string) {
|
||||
const stream = supportNetwork.stream({
|
||||
messages: [
|
||||
{ role: 'user', content: message }
|
||||
],
|
||||
telemetry
|
||||
});
|
||||
|
||||
let totalTokens = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// Track streaming metrics
|
||||
if (chunk.type === 'content') {
|
||||
totalTokens += chunk.content.length;
|
||||
telemetry.gauge('streaming.tokens.current', totalTokens, {
|
||||
agent: chunk.agent
|
||||
});
|
||||
} else if (chunk.type === 'agent:start') {
|
||||
telemetry.log('debug', `Agent ${chunk.agent} started at iteration ${chunk.iteration}`);
|
||||
} else if (chunk.type === 'agent:complete') {
|
||||
telemetry.histogram('agent.streaming.duration', chunk.duration, {
|
||||
agent: chunk.agent
|
||||
});
|
||||
}
|
||||
|
||||
// Process chunk...
|
||||
console.log(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// Example: Batch processing with telemetry
|
||||
async function processBatchInquiries(inquiries: string[]) {
|
||||
telemetry.log('info', `Starting batch processing of ${inquiries.length} inquiries`);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
inquiries.map((inquiry, index) =>
|
||||
telemetry.trace(
|
||||
`batch_inquiry_${index}`,
|
||||
() => handleCustomerInquiry(inquiry),
|
||||
{
|
||||
attributes: {
|
||||
'batch.index': index,
|
||||
'batch.total': inquiries.length
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Analyze results
|
||||
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
telemetry.gauge('batch.success.rate', successful / inquiries.length, {
|
||||
batchSize: inquiries.length
|
||||
});
|
||||
|
||||
telemetry.log('info', 'Batch processing complete', {
|
||||
total: inquiries.length,
|
||||
successful,
|
||||
failed
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Example usage
|
||||
async function main() {
|
||||
try {
|
||||
// Single inquiry
|
||||
const result = await handleCustomerInquiry(
|
||||
"I'm having trouble logging into my account. It says my password is incorrect but I'm sure it's right."
|
||||
);
|
||||
|
||||
console.log('Result:', result);
|
||||
|
||||
// Streaming inquiry
|
||||
await handleStreamingInquiry(
|
||||
"My last bill seems higher than usual. Can you explain the charges?"
|
||||
);
|
||||
|
||||
// Batch processing
|
||||
const batchResults = await processBatchInquiries([
|
||||
"How do I reset my password?",
|
||||
"What are your business hours?",
|
||||
"I want to cancel my subscription",
|
||||
"The app keeps crashing on startup"
|
||||
]);
|
||||
|
||||
console.log(`Processed ${batchResults.length} inquiries`);
|
||||
|
||||
} finally {
|
||||
// Ensure telemetry is flushed before exit
|
||||
await telemetry.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the example
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
export { handleCustomerInquiry, supportNetwork, telemetry };
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"name": "@hanzo/ai",
|
||||
"version": "0.1.1",
|
||||
"description": "The AI Toolkit for TypeScript - AgentKit with MCP support",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.mjs",
|
||||
"require": "./dist/server/index.js"
|
||||
},
|
||||
"./react": {
|
||||
"types": "./dist/react/index.d.ts",
|
||||
"import": "./dist/react/index.mjs",
|
||||
"require": "./dist/react/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"zod": "^3.22.0",
|
||||
"eventsource-parser": "^1.0.0",
|
||||
"nanoid": "^5.0.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/resources": "^2.9.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.41.1",
|
||||
"@opentelemetry/instrumentation": "^0.220.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.9.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"zod": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"llm",
|
||||
"agents",
|
||||
"mcp",
|
||||
"typescript",
|
||||
"hanzo"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/ai.git"
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
/**
|
||||
* Agent implementation with MCP support
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { Tool } from './tool';
|
||||
import { MCPServer } from '../mcp/types';
|
||||
import { ModelInterface } from '../types';
|
||||
import { Telemetry, SpanKind } from '../telemetry';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
system?: string;
|
||||
tools?: Tool[];
|
||||
mcpServers?: MCPServer[];
|
||||
model?: ModelInterface;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AgentContext {
|
||||
agent: Agent;
|
||||
network?: any; // Will be Network type
|
||||
state?: any;
|
||||
telemetry: Telemetry;
|
||||
}
|
||||
|
||||
export interface AgentRunOptions {
|
||||
messages: any[];
|
||||
model?: ModelInterface;
|
||||
context?: Partial<AgentContext>;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly system?: string;
|
||||
readonly tools: Map<string, Tool>;
|
||||
readonly mcpServers: MCPServer[];
|
||||
readonly model?: ModelInterface;
|
||||
readonly temperature?: number;
|
||||
readonly maxTokens?: number;
|
||||
readonly metadata: Record<string, any>;
|
||||
|
||||
private mcpTools: Map<string, any> = new Map();
|
||||
private initialized = false;
|
||||
|
||||
constructor(config: AgentConfig) {
|
||||
this.id = nanoid();
|
||||
this.name = config.name;
|
||||
this.description = config.description;
|
||||
this.system = config.system;
|
||||
this.tools = new Map();
|
||||
this.mcpServers = config.mcpServers || [];
|
||||
this.model = config.model;
|
||||
this.temperature = config.temperature;
|
||||
this.maxTokens = config.maxTokens;
|
||||
this.metadata = config.metadata || {};
|
||||
|
||||
// Register tools
|
||||
if (config.tools) {
|
||||
for (const tool of config.tools) {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Initialize MCP servers
|
||||
for (const server of this.mcpServers) {
|
||||
await this.connectMCPServer(server);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private async connectMCPServer(server: MCPServer): Promise<void> {
|
||||
// Import MCP client dynamically
|
||||
const { MCPClient } = await import('../mcp/client');
|
||||
const client = new MCPClient();
|
||||
|
||||
await client.connect({
|
||||
name: server.name,
|
||||
transport: server.transport
|
||||
});
|
||||
|
||||
// Get available tools from MCP server
|
||||
const tools = await client.listTools();
|
||||
|
||||
// Register MCP tools
|
||||
for (const tool of tools) {
|
||||
this.mcpTools.set(`${server.name}:${tool.name}`, {
|
||||
server,
|
||||
client,
|
||||
tool
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async run(options: AgentRunOptions): Promise<any> {
|
||||
await this.initialize();
|
||||
|
||||
const model = options.model || this.model;
|
||||
if (!model) {
|
||||
throw new Error(`No model specified for agent ${this.name}`);
|
||||
}
|
||||
|
||||
// Build context
|
||||
const context: AgentContext = {
|
||||
agent: this,
|
||||
network: options.context?.network,
|
||||
state: options.context?.state,
|
||||
telemetry: options.context?.telemetry || new Telemetry()
|
||||
};
|
||||
|
||||
// Prepare tools for the model
|
||||
const availableTools = this.getAllTools();
|
||||
|
||||
// Add system message if specified
|
||||
const messages = [...options.messages];
|
||||
if (this.system) {
|
||||
messages.unshift({
|
||||
role: 'system',
|
||||
content: this.system
|
||||
});
|
||||
}
|
||||
|
||||
// Execute with telemetry
|
||||
return context.telemetry.trace(
|
||||
`agent.${this.name}`,
|
||||
async (span) => {
|
||||
// Add agent metadata to span
|
||||
span.setAttributes({
|
||||
'agent.name': this.name,
|
||||
'agent.id': this.id,
|
||||
'agent.model': model.name || 'unknown',
|
||||
'agent.tools.count': this.getAllTools().size,
|
||||
'agent.mcp.servers': this.mcpServers.length,
|
||||
'agent.messages.count': messages.length
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (options.stream) {
|
||||
result = await this.runStream({ ...options, messages, model, context });
|
||||
} else {
|
||||
result = await this.runComplete({ ...options, messages, model, context });
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Record metrics
|
||||
if (context.telemetry && 'recordAgentExecution' in context.telemetry) {
|
||||
(context.telemetry as any).recordAgentExecution(
|
||||
this.name,
|
||||
duration,
|
||||
true,
|
||||
{
|
||||
model: model.name,
|
||||
messageCount: messages.length,
|
||||
toolsUsed: result.toolCalls?.length || 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Record failed execution
|
||||
if (context.telemetry && 'recordAgentExecution' in context.telemetry) {
|
||||
(context.telemetry as any).recordAgentExecution(
|
||||
this.name,
|
||||
duration,
|
||||
false,
|
||||
{
|
||||
model: model.name,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: {
|
||||
'agent.type': 'llm'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async runComplete(options: any): Promise<any> {
|
||||
const { messages, model, context } = options;
|
||||
|
||||
const response = await model.complete({
|
||||
messages,
|
||||
tools: Array.from(this.getAllTools().values()),
|
||||
temperature: this.temperature,
|
||||
maxTokens: this.maxTokens
|
||||
});
|
||||
|
||||
// Handle tool calls
|
||||
if (response.toolCalls && response.toolCalls.length > 0) {
|
||||
const toolResults = await this.executeToolCalls(response.toolCalls, context);
|
||||
|
||||
// Add tool results to messages
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
toolCalls: response.toolCalls
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
toolResults
|
||||
});
|
||||
|
||||
// Recursively call for the next response
|
||||
return this.runComplete({ ...options, messages });
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async *runStream(options: any): AsyncIterableIterator<any> {
|
||||
const { messages, model, context } = options;
|
||||
|
||||
const stream = await model.stream({
|
||||
messages,
|
||||
tools: Array.from(this.getAllTools().values()),
|
||||
temperature: this.temperature,
|
||||
maxTokens: this.maxTokens
|
||||
});
|
||||
|
||||
// Handle streaming with tool calls
|
||||
return this.handleStreamWithTools(stream, messages, context, options);
|
||||
}
|
||||
|
||||
private async *handleStreamWithTools(
|
||||
stream: AsyncIterableIterator<any>,
|
||||
messages: any[],
|
||||
context: AgentContext,
|
||||
options: any
|
||||
): AsyncIterableIterator<any> {
|
||||
let content = '';
|
||||
const toolCalls: any[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
yield chunk;
|
||||
|
||||
if (chunk.type === 'content') {
|
||||
content += chunk.content;
|
||||
} else if (chunk.type === 'tool_call') {
|
||||
toolCalls.push(chunk.toolCall);
|
||||
} else if (chunk.type === 'done' && toolCalls.length > 0) {
|
||||
// Execute tool calls
|
||||
const toolResults = await this.executeToolCalls(toolCalls, context);
|
||||
|
||||
// Add to messages
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
toolCalls
|
||||
});
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
toolResults
|
||||
});
|
||||
|
||||
// Continue with next iteration
|
||||
const nextStream = await this.runStream({ ...options, messages });
|
||||
for await (const nextChunk of nextStream) {
|
||||
yield nextChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeToolCalls(toolCalls: any[], context: AgentContext): Promise<any[]> {
|
||||
const results = [];
|
||||
|
||||
for (const call of toolCalls) {
|
||||
try {
|
||||
const result = await this.executeTool(call.name, call.arguments, context);
|
||||
results.push({
|
||||
id: call.id,
|
||||
result
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
id: call.id,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async executeTool(name: string, args: any, context: AgentContext): Promise<any> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let result;
|
||||
let toolType: 'local' | 'mcp' = 'local';
|
||||
|
||||
// Check local tools first
|
||||
if (this.tools.has(name)) {
|
||||
const tool = this.tools.get(name)!;
|
||||
result = await tool.handler(args, context);
|
||||
} else {
|
||||
// Check MCP tools
|
||||
let found = false;
|
||||
for (const [key, mcpTool] of this.mcpTools) {
|
||||
const [serverName, toolName] = key.split(':');
|
||||
if (toolName === name || key === name) {
|
||||
toolType = 'mcp';
|
||||
result = await mcpTool.client.callTool({
|
||||
name: toolName,
|
||||
arguments: args
|
||||
});
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new Error(`Tool '${name}' not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Record tool usage metric
|
||||
if (context.telemetry && 'recordToolUsage' in context.telemetry) {
|
||||
(context.telemetry as any).recordToolUsage(
|
||||
name,
|
||||
this.name,
|
||||
duration,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Add telemetry event
|
||||
context.telemetry.recordEvent({
|
||||
name: 'tool.executed',
|
||||
attributes: {
|
||||
'tool.name': name,
|
||||
'tool.type': toolType,
|
||||
'tool.duration': duration,
|
||||
'agent.name': this.name
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Record failed tool execution
|
||||
if (context.telemetry && 'recordToolUsage' in context.telemetry) {
|
||||
(context.telemetry as any).recordToolUsage(
|
||||
name,
|
||||
this.name,
|
||||
duration,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getAllTools(): Map<string, any> {
|
||||
const allTools = new Map();
|
||||
|
||||
// Add local tools
|
||||
for (const [name, tool] of this.tools) {
|
||||
allTools.set(name, {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters
|
||||
});
|
||||
}
|
||||
|
||||
// Add MCP tools
|
||||
for (const [key, mcpTool] of this.mcpTools) {
|
||||
const [serverName, toolName] = key.split(':');
|
||||
allTools.set(toolName, {
|
||||
name: toolName,
|
||||
description: mcpTool.tool.description,
|
||||
parameters: mcpTool.tool.inputSchema
|
||||
});
|
||||
}
|
||||
|
||||
return allTools;
|
||||
}
|
||||
|
||||
clone(overrides?: Partial<AgentConfig>): Agent {
|
||||
return new Agent({
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
system: this.system,
|
||||
tools: Array.from(this.tools.values()),
|
||||
mcpServers: this.mcpServers,
|
||||
model: this.model,
|
||||
temperature: this.temperature,
|
||||
maxTokens: this.maxTokens,
|
||||
metadata: this.metadata,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgent(config: AgentConfig): Agent {
|
||||
return new Agent(config);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export async function embed(params: any): Promise<any> { return { embeddings: [] }; }
|
||||
@@ -1 +0,0 @@
|
||||
export async function generateObject(params: any): Promise<any> { return {}; }
|
||||
@@ -1 +0,0 @@
|
||||
export async function* generateStream(params: any): AsyncIterableIterator<any> { yield { type: 'done' }; }
|
||||
@@ -1 +0,0 @@
|
||||
export async function generateText(params: any): Promise<any> { return { text: '' }; }
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* Network implementation for agent collaboration
|
||||
*/
|
||||
|
||||
import { Agent } from './agent';
|
||||
import { State } from './state';
|
||||
import { Router } from './router';
|
||||
import { ModelInterface } from '../types';
|
||||
import { Telemetry, SpanKind } from '../telemetry';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface NetworkConfig {
|
||||
name: string;
|
||||
agents: Agent[];
|
||||
defaultModel?: ModelInterface;
|
||||
router?: Router | ((context: RouterContext) => Agent | undefined);
|
||||
state?: State;
|
||||
maxIterations?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RouterContext {
|
||||
network: Network;
|
||||
state: State;
|
||||
messages: any[];
|
||||
iteration: number;
|
||||
history: ExecutionHistory[];
|
||||
}
|
||||
|
||||
export interface ExecutionHistory {
|
||||
agent: string;
|
||||
input: any;
|
||||
output: any;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface NetworkRunOptions {
|
||||
messages: any[];
|
||||
stream?: boolean;
|
||||
onIteration?: (context: RouterContext) => void;
|
||||
telemetry?: Telemetry;
|
||||
}
|
||||
|
||||
export class Network extends EventEmitter {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly agents: Map<string, Agent>;
|
||||
readonly defaultModel?: ModelInterface;
|
||||
readonly router: Router;
|
||||
readonly state: State;
|
||||
readonly maxIterations: number;
|
||||
readonly metadata: Record<string, any>;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
constructor(config: NetworkConfig) {
|
||||
super();
|
||||
this.id = nanoid();
|
||||
this.name = config.name;
|
||||
this.agents = new Map();
|
||||
this.defaultModel = config.defaultModel;
|
||||
this.state = config.state || new State();
|
||||
this.maxIterations = config.maxIterations || 10;
|
||||
this.metadata = config.metadata || {};
|
||||
|
||||
// Register agents
|
||||
for (const agent of config.agents) {
|
||||
this.agents.set(agent.name, agent);
|
||||
}
|
||||
|
||||
// Setup router
|
||||
if (typeof config.router === 'function') {
|
||||
this.router = new Router({ handler: config.router });
|
||||
} else if (config.router) {
|
||||
this.router = config.router;
|
||||
} else {
|
||||
// Default router - just use first agent
|
||||
this.router = new Router({
|
||||
handler: () => config.agents[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Initialize all agents
|
||||
const initPromises = Array.from(this.agents.values()).map(agent =>
|
||||
agent.initialize()
|
||||
);
|
||||
|
||||
await Promise.all(initPromises);
|
||||
|
||||
this.initialized = true;
|
||||
this.emit('initialized');
|
||||
}
|
||||
|
||||
async run(options: NetworkRunOptions): Promise<any> {
|
||||
await this.initialize();
|
||||
|
||||
const telemetry = options.telemetry || new Telemetry();
|
||||
const history: ExecutionHistory[] = [];
|
||||
let messages = [...options.messages];
|
||||
|
||||
return telemetry.trace(
|
||||
`network.${this.name}`,
|
||||
async (span) => {
|
||||
const networkStartTime = Date.now();
|
||||
const agentExecutions = new Map<string, number>();
|
||||
|
||||
// Add network metadata to span
|
||||
span.setAttributes({
|
||||
'network.name': this.name,
|
||||
'network.id': this.id,
|
||||
'network.agents.count': this.agents.size,
|
||||
'network.maxIterations': this.maxIterations,
|
||||
'network.messages.initial': messages.length
|
||||
});
|
||||
|
||||
try {
|
||||
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
|
||||
// Build router context
|
||||
const context: RouterContext = {
|
||||
network: this,
|
||||
state: this.state,
|
||||
messages,
|
||||
iteration,
|
||||
history
|
||||
};
|
||||
|
||||
// Call iteration callback if provided
|
||||
if (options.onIteration) {
|
||||
options.onIteration(context);
|
||||
}
|
||||
|
||||
// Get next agent from router with telemetry
|
||||
const nextAgent = await telemetry.trace(
|
||||
`router.${this.name}`,
|
||||
async () => this.router.route(context),
|
||||
{
|
||||
kind: SpanKind.INTERNAL,
|
||||
attributes: { 'router.iteration': iteration }
|
||||
}
|
||||
);
|
||||
|
||||
if (!nextAgent) {
|
||||
// No more agents to run
|
||||
this.emit('complete', { history, state: this.state });
|
||||
telemetry.recordEvent({
|
||||
name: 'network.complete',
|
||||
attributes: {
|
||||
'network.name': this.name,
|
||||
'network.iterations': iteration,
|
||||
'network.reason': 'no_next_agent'
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Track agent executions
|
||||
agentExecutions.set(
|
||||
nextAgent.name,
|
||||
(agentExecutions.get(nextAgent.name) || 0) + 1
|
||||
);
|
||||
|
||||
this.emit('agent:start', { agent: nextAgent.name, iteration });
|
||||
|
||||
// Run agent
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const agentContext = {
|
||||
network: this,
|
||||
state: this.state,
|
||||
telemetry
|
||||
};
|
||||
|
||||
const result = await nextAgent.run({
|
||||
messages,
|
||||
model: this.defaultModel,
|
||||
context: agentContext,
|
||||
stream: options.stream
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Add to history
|
||||
const historyEntry: ExecutionHistory = {
|
||||
agent: nextAgent.name,
|
||||
input: messages[messages.length - 1],
|
||||
output: result,
|
||||
timestamp: Date.now(),
|
||||
duration
|
||||
};
|
||||
|
||||
history.push(historyEntry);
|
||||
|
||||
// Update messages
|
||||
if (result.content) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: result.content,
|
||||
metadata: {
|
||||
agent: nextAgent.name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.emit('agent:complete', {
|
||||
agent: nextAgent.name,
|
||||
iteration,
|
||||
result,
|
||||
duration
|
||||
});
|
||||
|
||||
// Check for completion
|
||||
if (this.state.kv.get('complete') === true) {
|
||||
this.emit('complete', { history, state: this.state });
|
||||
telemetry.recordEvent({
|
||||
name: 'network.complete',
|
||||
attributes: {
|
||||
'network.name': this.name,
|
||||
'network.iterations': iteration + 1,
|
||||
'network.reason': 'state_complete'
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.emit('agent:error', {
|
||||
agent: nextAgent.name,
|
||||
iteration,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const networkDuration = Date.now() - networkStartTime;
|
||||
|
||||
// Record network execution metrics
|
||||
if (telemetry && 'recordNetworkExecution' in telemetry) {
|
||||
(telemetry as any).recordNetworkExecution(
|
||||
this.name,
|
||||
history.length,
|
||||
networkDuration,
|
||||
agentExecutions
|
||||
);
|
||||
}
|
||||
|
||||
// Return final result
|
||||
return {
|
||||
messages,
|
||||
history,
|
||||
state: this.state.toJSON(),
|
||||
iterations: history.length
|
||||
};
|
||||
} catch (error) {
|
||||
const networkDuration = Date.now() - networkStartTime;
|
||||
|
||||
// Record failed network execution
|
||||
telemetry.recordEvent({
|
||||
name: 'network.error',
|
||||
attributes: {
|
||||
'network.name': this.name,
|
||||
'network.duration': networkDuration,
|
||||
'network.iterations': history.length,
|
||||
'error.message': error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: SpanKind.SERVER,
|
||||
attributes: {
|
||||
'network.type': 'agent_network'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async *stream(options: NetworkRunOptions): AsyncIterableIterator<any> {
|
||||
await this.initialize();
|
||||
|
||||
const telemetry = options.telemetry || new Telemetry();
|
||||
const history: ExecutionHistory[] = [];
|
||||
let messages = [...options.messages];
|
||||
|
||||
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
|
||||
// Build router context
|
||||
const context: RouterContext = {
|
||||
network: this,
|
||||
state: this.state,
|
||||
messages,
|
||||
iteration,
|
||||
history
|
||||
};
|
||||
|
||||
// Get next agent from router
|
||||
const nextAgent = await this.router.route(context);
|
||||
|
||||
if (!nextAgent) {
|
||||
// No more agents to run
|
||||
yield { type: 'complete', history, state: this.state.toJSON() };
|
||||
break;
|
||||
}
|
||||
|
||||
yield { type: 'agent:start', agent: nextAgent.name, iteration };
|
||||
|
||||
// Run agent
|
||||
const startTime = Date.now();
|
||||
const agentContext = {
|
||||
network: this,
|
||||
state: this.state,
|
||||
telemetry
|
||||
};
|
||||
|
||||
// Stream from agent
|
||||
const stream = await nextAgent.run({
|
||||
messages,
|
||||
model: this.defaultModel,
|
||||
context: agentContext,
|
||||
stream: true
|
||||
});
|
||||
|
||||
let content = '';
|
||||
|
||||
for await (const chunk of stream) {
|
||||
yield { ...chunk, agent: nextAgent.name };
|
||||
|
||||
if (chunk.type === 'content') {
|
||||
content += chunk.content;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Add to history
|
||||
const historyEntry: ExecutionHistory = {
|
||||
agent: nextAgent.name,
|
||||
input: messages[messages.length - 1],
|
||||
output: { content },
|
||||
timestamp: Date.now(),
|
||||
duration
|
||||
};
|
||||
|
||||
history.push(historyEntry);
|
||||
|
||||
// Update messages
|
||||
if (content) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
metadata: {
|
||||
agent: nextAgent.name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'agent:complete',
|
||||
agent: nextAgent.name,
|
||||
iteration,
|
||||
duration
|
||||
};
|
||||
|
||||
// Check for completion
|
||||
if (this.state.kv.get('complete') === true) {
|
||||
yield { type: 'complete', history, state: this.state.toJSON() };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAgent(name: string): Agent | undefined {
|
||||
return this.agents.get(name);
|
||||
}
|
||||
|
||||
addAgent(agent: Agent): void {
|
||||
this.agents.set(agent.name, agent);
|
||||
this.emit('agent:added', { agent: agent.name });
|
||||
}
|
||||
|
||||
removeAgent(name: string): void {
|
||||
if (this.agents.delete(name)) {
|
||||
this.emit('agent:removed', { agent: name });
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state.reset();
|
||||
this.emit('reset');
|
||||
}
|
||||
|
||||
getMetrics(): {
|
||||
totalIterations: number;
|
||||
agentExecutions: Map<string, number>;
|
||||
averageDuration: number;
|
||||
errors: number;
|
||||
} {
|
||||
// This would be populated from telemetry in a real implementation
|
||||
return {
|
||||
totalIterations: 0,
|
||||
agentExecutions: new Map(),
|
||||
averageDuration: 0,
|
||||
errors: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createNetwork(config: NetworkConfig): Network {
|
||||
return new Network(config);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Router implementation for agent networks
|
||||
*/
|
||||
|
||||
import { Agent } from './agent';
|
||||
import { Network } from './network';
|
||||
import { State } from './state';
|
||||
|
||||
export interface RouterConfig {
|
||||
handler: (context: RouterContext) => Agent | undefined | Promise<Agent | undefined>;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RouterContext {
|
||||
network: Network;
|
||||
state: State;
|
||||
messages: any[];
|
||||
iteration: number;
|
||||
history: any[];
|
||||
}
|
||||
|
||||
export class Router {
|
||||
private handler: RouterConfig['handler'];
|
||||
readonly metadata: Record<string, any>;
|
||||
|
||||
constructor(config: RouterConfig) {
|
||||
this.handler = config.handler;
|
||||
this.metadata = config.metadata || {};
|
||||
}
|
||||
|
||||
async route(context: RouterContext): Promise<Agent | undefined> {
|
||||
return Promise.resolve(this.handler(context));
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouter(config: RouterConfig): Router {
|
||||
return new Router(config);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* State management for agent networks
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface StateConfig {
|
||||
schema?: z.ZodSchema;
|
||||
initial?: Record<string, any>;
|
||||
persistent?: boolean;
|
||||
}
|
||||
|
||||
export interface StateChange {
|
||||
key: string;
|
||||
oldValue: any;
|
||||
newValue: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class State extends EventEmitter {
|
||||
readonly kv: Map<string, any>;
|
||||
readonly history: StateChange[];
|
||||
readonly schema?: z.ZodSchema;
|
||||
private readonly persistent: boolean;
|
||||
|
||||
constructor(config: StateConfig = {}) {
|
||||
super();
|
||||
this.kv = new Map();
|
||||
this.history = [];
|
||||
this.schema = config.schema;
|
||||
this.persistent = config.persistent || false;
|
||||
|
||||
// Initialize with initial values
|
||||
if (config.initial) {
|
||||
for (const [key, value] of Object.entries(config.initial)) {
|
||||
this.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Load from persistence if enabled
|
||||
if (this.persistent) {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
set(key: string, value: any): void {
|
||||
// Validate against schema if provided
|
||||
if (this.schema) {
|
||||
const result = this.schema.safeParse({ ...this.toJSON(), [key]: value });
|
||||
if (!result.success) {
|
||||
throw new Error(`State validation failed: ${result.error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const oldValue = this.kv.get(key);
|
||||
this.kv.set(key, value);
|
||||
|
||||
// Record change
|
||||
const change: StateChange = {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: value,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.history.push(change);
|
||||
|
||||
// Emit change event
|
||||
this.emit('change', change);
|
||||
this.emit(`change:${key}`, { oldValue, newValue: value });
|
||||
|
||||
// Persist if enabled
|
||||
if (this.persistent) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
get(key: string): any {
|
||||
return this.kv.get(key);
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.kv.has(key);
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
const oldValue = this.kv.get(key);
|
||||
const deleted = this.kv.delete(key);
|
||||
|
||||
if (deleted) {
|
||||
const change: StateChange = {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: undefined,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.history.push(change);
|
||||
this.emit('change', change);
|
||||
this.emit(`change:${key}`, { oldValue, newValue: undefined });
|
||||
|
||||
if (this.persistent) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
const oldState = this.toJSON();
|
||||
this.kv.clear();
|
||||
|
||||
// Record all deletions
|
||||
for (const key of Object.keys(oldState)) {
|
||||
const change: StateChange = {
|
||||
key,
|
||||
oldValue: oldState[key],
|
||||
newValue: undefined,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
this.history.push(change);
|
||||
}
|
||||
|
||||
this.emit('clear', { oldState });
|
||||
|
||||
if (this.persistent) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.clear();
|
||||
this.history.length = 0;
|
||||
this.emit('reset');
|
||||
}
|
||||
|
||||
toJSON(): Record<string, any> {
|
||||
const obj: Record<string, any> = {};
|
||||
for (const [key, value] of this.kv) {
|
||||
obj[key] = value;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
fromJSON(data: Record<string, any>): void {
|
||||
this.clear();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
this.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
getHistory(key?: string): StateChange[] {
|
||||
if (key) {
|
||||
return this.history.filter(change => change.key === key);
|
||||
}
|
||||
return [...this.history];
|
||||
}
|
||||
|
||||
rollback(steps: number = 1): void {
|
||||
if (steps > this.history.length) {
|
||||
throw new Error('Cannot rollback more steps than history length');
|
||||
}
|
||||
|
||||
// Get changes to rollback
|
||||
const changesToRollback = this.history.slice(-steps);
|
||||
|
||||
// Apply rollback
|
||||
for (const change of changesToRollback.reverse()) {
|
||||
if (change.oldValue === undefined) {
|
||||
this.kv.delete(change.key);
|
||||
} else {
|
||||
this.kv.set(change.key, change.oldValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove rolled back changes from history
|
||||
this.history.length = this.history.length - steps;
|
||||
|
||||
this.emit('rollback', { steps, changes: changesToRollback });
|
||||
|
||||
if (this.persistent) {
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
// Persistence methods
|
||||
private save(): void {
|
||||
// Only available in browser environments
|
||||
if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) {
|
||||
const data = {
|
||||
state: this.toJSON(),
|
||||
history: this.history
|
||||
};
|
||||
(globalThis as any).localStorage.setItem('hanzo-ai-state', JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
// Only available in browser environments
|
||||
if (typeof globalThis !== 'undefined' && 'localStorage' in globalThis) {
|
||||
const stored = (globalThis as any).localStorage.getItem('hanzo-ai-state');
|
||||
if (stored) {
|
||||
try {
|
||||
const data = JSON.parse(stored);
|
||||
this.fromJSON(data.state);
|
||||
this.history.push(...(data.history || []));
|
||||
} catch (error) {
|
||||
console.error('Failed to load state from storage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
compute<T>(key: string, fn: (state: Record<string, any>) => T): T {
|
||||
const state = this.toJSON();
|
||||
const result = fn(state);
|
||||
|
||||
// Cache computed value
|
||||
this.set(`_computed_${key}`, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
watch(key: string, callback: (value: any) => void): () => void {
|
||||
const handler = ({ newValue }: any) => callback(newValue);
|
||||
this.on(`change:${key}`, handler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.off(`change:${key}`, handler);
|
||||
};
|
||||
}
|
||||
|
||||
// State machine helpers
|
||||
transition(from: string, to: string, stateKey: string = 'state'): boolean {
|
||||
const current = this.get(stateKey);
|
||||
|
||||
if (current === from) {
|
||||
this.set(stateKey, to);
|
||||
this.emit('transition', { from, to, stateKey });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
inState(state: string, stateKey: string = 'state'): boolean {
|
||||
return this.get(stateKey) === state;
|
||||
}
|
||||
}
|
||||
|
||||
export function createState(config?: StateConfig): State {
|
||||
return new State(config);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Tool implementation for agents
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { AgentContext } from './agent';
|
||||
|
||||
export interface ToolConfig<TParams = any, TResult = any> {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: z.ZodSchema<TParams>;
|
||||
handler: (params: TParams, context: AgentContext) => Promise<TResult> | TResult;
|
||||
examples?: Array<{
|
||||
input: TParams;
|
||||
output: TResult;
|
||||
description?: string;
|
||||
}>;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class Tool<TParams = any, TResult = any> {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly parameters: z.ZodSchema<TParams>;
|
||||
readonly handler: (params: TParams, context: AgentContext) => Promise<TResult> | TResult;
|
||||
readonly examples?: ToolConfig<TParams, TResult>['examples'];
|
||||
readonly metadata: Record<string, any>;
|
||||
|
||||
constructor(config: ToolConfig<TParams, TResult>) {
|
||||
this.name = config.name;
|
||||
this.description = config.description;
|
||||
this.parameters = config.parameters;
|
||||
this.handler = config.handler;
|
||||
this.examples = config.examples;
|
||||
this.metadata = config.metadata || {};
|
||||
}
|
||||
|
||||
async execute(params: any, context: AgentContext): Promise<TResult> {
|
||||
// Validate parameters
|
||||
const result = this.parameters.safeParse(params);
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid parameters for tool '${this.name}': ${result.error.message}`);
|
||||
}
|
||||
|
||||
// Execute handler
|
||||
return Promise.resolve(this.handler(result.data, context));
|
||||
}
|
||||
|
||||
getSchema(): any {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
parameters: this.zodToJsonSchema(this.parameters)
|
||||
};
|
||||
}
|
||||
|
||||
private zodToJsonSchema(schema: z.ZodSchema): any {
|
||||
// This is a simplified version - in production you'd use a proper converter
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const shape = schema.shape;
|
||||
const properties: any = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
properties[key] = this.zodToJsonSchema(value as z.ZodSchema);
|
||||
|
||||
// Check if field is required
|
||||
if (!(value as any).isOptional()) {
|
||||
required.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
required: required.length > 0 ? required : undefined
|
||||
};
|
||||
} else if (schema instanceof z.ZodString) {
|
||||
return { type: 'string' };
|
||||
} else if (schema instanceof z.ZodNumber) {
|
||||
return { type: 'number' };
|
||||
} else if (schema instanceof z.ZodBoolean) {
|
||||
return { type: 'boolean' };
|
||||
} else if (schema instanceof z.ZodArray) {
|
||||
return {
|
||||
type: 'array',
|
||||
items: this.zodToJsonSchema((schema as any)._def.type)
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return { type: 'any' };
|
||||
}
|
||||
}
|
||||
|
||||
export function createTool<TParams = any, TResult = any>(
|
||||
config: ToolConfig<TParams, TResult>
|
||||
): Tool<TParams, TResult> {
|
||||
return new Tool(config);
|
||||
}
|
||||
|
||||
// Common tool patterns
|
||||
export const commonTools = {
|
||||
done: (onDone?: (result: any, context: AgentContext) => void) =>
|
||||
createTool({
|
||||
name: 'done',
|
||||
description: 'Call this tool when you are finished with the task.',
|
||||
parameters: z.object({
|
||||
answer: z.string().describe("Final answer or result"),
|
||||
summary: z.string().optional().describe("Brief summary of what was accomplished")
|
||||
}),
|
||||
handler: async (params, context) => {
|
||||
context.network?.state.set('complete', true);
|
||||
context.network?.state.set('answer', params.answer);
|
||||
if (params.summary) {
|
||||
context.network?.state.set('summary', params.summary);
|
||||
}
|
||||
|
||||
if (onDone) {
|
||||
onDone(params, context);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}),
|
||||
|
||||
handoff: () =>
|
||||
createTool({
|
||||
name: 'handoff',
|
||||
description: 'Hand off the conversation to another agent.',
|
||||
parameters: z.object({
|
||||
agent: z.string().describe("Name of the agent to hand off to"),
|
||||
context: z.string().describe("Context to provide to the next agent"),
|
||||
priority: z.boolean().optional().describe("Whether this is a priority handoff")
|
||||
}),
|
||||
handler: async (params, context) => {
|
||||
if (!context.network) {
|
||||
throw new Error('Handoff requires a network context');
|
||||
}
|
||||
|
||||
context.network.state.set('nextAgent', params.agent);
|
||||
context.network.state.set('handoffContext', params.context);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
agent: params.agent
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
askUser: () =>
|
||||
createTool({
|
||||
name: 'ask_user',
|
||||
description: 'Ask the user for additional information or clarification.',
|
||||
parameters: z.object({
|
||||
question: z.string().describe("The question to ask the user"),
|
||||
context: z.string().optional().describe("Additional context for the question"),
|
||||
options: z.array(z.string()).optional().describe("Multiple choice options if applicable")
|
||||
}),
|
||||
handler: async (params, context) => {
|
||||
context.network?.state.set('waitingForUser', true);
|
||||
context.network?.state.set('userQuestion', params);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
waiting: true
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
remember: () =>
|
||||
createTool({
|
||||
name: 'remember',
|
||||
description: 'Store information in long-term memory.',
|
||||
parameters: z.object({
|
||||
key: z.string().describe("Memory key"),
|
||||
value: z.any().describe("Value to remember"),
|
||||
category: z.string().optional().describe("Category for organization"),
|
||||
ttl: z.number().optional().describe("Time to live in seconds")
|
||||
}),
|
||||
handler: async (params, context) => {
|
||||
const memory = context.network?.state.get('memory') || {};
|
||||
memory[params.key] = {
|
||||
value: params.value,
|
||||
category: params.category,
|
||||
timestamp: Date.now(),
|
||||
ttl: params.ttl
|
||||
};
|
||||
|
||||
context.network?.state.set('memory', memory);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}),
|
||||
|
||||
recall: () =>
|
||||
createTool({
|
||||
name: 'recall',
|
||||
description: 'Retrieve information from long-term memory.',
|
||||
parameters: z.object({
|
||||
key: z.string().optional().describe("Specific memory key"),
|
||||
category: z.string().optional().describe("Category to search"),
|
||||
query: z.string().optional().describe("Search query")
|
||||
}),
|
||||
handler: async (params, context) => {
|
||||
const memory = context.network?.state.get('memory') || {};
|
||||
|
||||
if (params.key) {
|
||||
return memory[params.key]?.value || null;
|
||||
}
|
||||
|
||||
// Search by category or query
|
||||
const results: any[] = [];
|
||||
for (const [key, item] of Object.entries(memory)) {
|
||||
const memItem = item as any;
|
||||
|
||||
// Check TTL
|
||||
if (memItem.ttl && Date.now() - memItem.timestamp > memItem.ttl * 1000) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (params.category && memItem.category === params.category) {
|
||||
results.push({ key, ...memItem });
|
||||
} else if (params.query) {
|
||||
const searchStr = JSON.stringify(memItem.value).toLowerCase();
|
||||
if (searchStr.includes(params.query.toLowerCase())) {
|
||||
results.push({ key, ...memItem });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
})
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Error types for @hanzo/ai
|
||||
*/
|
||||
|
||||
export class HanzoAIError extends Error {
|
||||
constructor(message: string, public code?: string) {
|
||||
super(message);
|
||||
this.name = 'HanzoAIError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentError extends HanzoAIError {
|
||||
constructor(message: string, public agentName: string) {
|
||||
super(message, 'AGENT_ERROR');
|
||||
this.name = 'AgentError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends HanzoAIError {
|
||||
constructor(message: string, public networkName: string) {
|
||||
super(message, 'NETWORK_ERROR');
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolError extends HanzoAIError {
|
||||
constructor(message: string, public toolName: string) {
|
||||
super(message, 'TOOL_ERROR');
|
||||
this.name = 'ToolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class MCPError extends HanzoAIError {
|
||||
constructor(message: string, public serverName?: string) {
|
||||
super(message, 'MCP_ERROR');
|
||||
this.name = 'MCPError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends HanzoAIError {
|
||||
constructor(message: string, public field?: string) {
|
||||
super(message, 'VALIDATION_ERROR');
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* @hanzo/ai - The AI Toolkit for TypeScript
|
||||
* AgentKit with MCP support and multi-provider integration
|
||||
*/
|
||||
|
||||
// Core exports
|
||||
export { createAgent } from './core/agent';
|
||||
export { createNetwork } from './core/network';
|
||||
export { createTool } from './core/tool';
|
||||
export { createRouter } from './core/router';
|
||||
export { createState } from './core/state';
|
||||
|
||||
// Provider exports
|
||||
export { anthropic } from './providers/anthropic';
|
||||
export { openai } from './providers/openai';
|
||||
export { google } from './providers/google';
|
||||
export { mistral } from './providers/mistral';
|
||||
export { bedrock } from './providers/bedrock';
|
||||
export { vertex } from './providers/vertex';
|
||||
export { cohere } from './providers/cohere';
|
||||
export { hanzo } from './providers/hanzo';
|
||||
|
||||
// Core functionality
|
||||
export { generateText } from './core/generate/text';
|
||||
export { generateStream } from './core/generate/stream';
|
||||
export { generateObject } from './core/generate/object';
|
||||
export { embed } from './core/embed';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Utilities
|
||||
export { createCompletionId } from './utils/id';
|
||||
export { parseStreamPart } from './utils/stream';
|
||||
export { validateSchema } from './utils/schema';
|
||||
|
||||
// Errors
|
||||
export * from './errors';
|
||||
|
||||
// MCP exports
|
||||
export * from './mcp';
|
||||
|
||||
// Tracing
|
||||
export * from './telemetry';
|
||||
export { createHanzoCloudTelemetry, HanzoCloudTelemetry } from './telemetry/hanzo-cloud';
|
||||
@@ -1,461 +0,0 @@
|
||||
/**
|
||||
* MCP Server wrapper for Agents and Networks
|
||||
* Allows any agent or network to be exposed as an MCP server
|
||||
*/
|
||||
|
||||
import { Server as MCPServer } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { Agent } from '../core/agent';
|
||||
import { Network } from '../core/network';
|
||||
import { Tool } from '../core/tool';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface AgentServerConfig {
|
||||
agent?: Agent;
|
||||
network?: Network;
|
||||
name?: string;
|
||||
version?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class AgentMCPServer {
|
||||
private server: MCPServer;
|
||||
private agent?: Agent;
|
||||
private network?: Network;
|
||||
|
||||
constructor(config: AgentServerConfig) {
|
||||
if (!config.agent && !config.network) {
|
||||
throw new Error('Either agent or network must be provided');
|
||||
}
|
||||
|
||||
this.agent = config.agent;
|
||||
this.network = config.network;
|
||||
|
||||
const name = config.name || this.agent?.name || this.network?.name || 'hanzo-agent';
|
||||
const version = config.version || '1.0.0';
|
||||
|
||||
this.server = new MCPServer({
|
||||
name,
|
||||
version,
|
||||
metadata: config.metadata
|
||||
});
|
||||
|
||||
this.setupTools();
|
||||
this.setupResources();
|
||||
this.setupPrompts();
|
||||
}
|
||||
|
||||
private setupTools(): void {
|
||||
if (this.agent) {
|
||||
this.setupAgentTools();
|
||||
} else if (this.network) {
|
||||
this.setupNetworkTools();
|
||||
}
|
||||
}
|
||||
|
||||
private setupAgentTools(): void {
|
||||
if (!this.agent) return;
|
||||
|
||||
// Expose agent's run method as a tool
|
||||
this.server.setRequestHandler('tools/list', async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: `${this.agent.name}_chat`,
|
||||
description: `Chat with ${this.agent.name} agent`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Message to send to the agent'
|
||||
},
|
||||
context: {
|
||||
type: 'object',
|
||||
description: 'Optional context',
|
||||
properties: {
|
||||
history: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
description: 'Previous messages'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['message']
|
||||
}
|
||||
},
|
||||
// Expose all agent tools
|
||||
...Array.from(this.agent.tools.values()).map(tool => ({
|
||||
name: `${this.agent!.name}_${tool.name}`,
|
||||
description: tool.description,
|
||||
inputSchema: this.toolToJsonSchema(tool)
|
||||
}))
|
||||
]
|
||||
}));
|
||||
|
||||
// Handle tool calls
|
||||
this.server.setRequestHandler('tools/call', async (request) => {
|
||||
const { name, arguments: args } = request.params as any;
|
||||
|
||||
if (name === `${this.agent!.name}_chat`) {
|
||||
const result = await this.agent!.run({
|
||||
messages: [
|
||||
{ role: 'user', content: args.message }
|
||||
],
|
||||
context: args.context
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result.content || JSON.stringify(result)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's one of the agent's tools
|
||||
const toolName = name.replace(`${this.agent!.name}_`, '');
|
||||
const tool = this.agent!.tools.get(toolName);
|
||||
|
||||
if (tool) {
|
||||
const result = await tool.execute(args, {
|
||||
agent: this.agent!,
|
||||
telemetry: {} as any
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Tool '${name}' not found`);
|
||||
});
|
||||
}
|
||||
|
||||
private setupNetworkTools(): void {
|
||||
if (!this.network) return;
|
||||
|
||||
this.server.setRequestHandler('tools/list', async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: `${this.network.name}_run`,
|
||||
description: `Run a task through the ${this.network.name} network`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Task or message for the network'
|
||||
},
|
||||
stream: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to stream responses'
|
||||
}
|
||||
},
|
||||
required: ['message']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: `${this.network.name}_state_get`,
|
||||
description: `Get a value from the network's state`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'State key to retrieve'
|
||||
}
|
||||
},
|
||||
required: ['key']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: `${this.network.name}_state_set`,
|
||||
description: `Set a value in the network's state`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'State key'
|
||||
},
|
||||
value: {
|
||||
description: 'Value to set'
|
||||
}
|
||||
},
|
||||
required: ['key', 'value']
|
||||
}
|
||||
},
|
||||
// Expose individual agents as tools
|
||||
...Array.from(this.network.agents.values()).map(agent => ({
|
||||
name: `${this.network!.name}_agent_${agent.name}`,
|
||||
description: `Run task through ${agent.name} agent in the network`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Message for the agent'
|
||||
}
|
||||
},
|
||||
required: ['message']
|
||||
}
|
||||
}))
|
||||
]
|
||||
}));
|
||||
|
||||
this.server.setRequestHandler('tools/call', async (request) => {
|
||||
const { name, arguments: args } = request.params as any;
|
||||
|
||||
if (name === `${this.network!.name}_run`) {
|
||||
const result = await this.network!.run({
|
||||
messages: [
|
||||
{ role: 'user', content: args.message }
|
||||
],
|
||||
stream: args.stream
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (name === `${this.network!.name}_state_get`) {
|
||||
const value = this.network!.state.get(args.key);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(value)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (name === `${this.network!.name}_state_set`) {
|
||||
this.network!.state.set(args.key, args.value);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'State updated'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Check for agent-specific tools
|
||||
const agentMatch = name.match(new RegExp(`^${this.network!.name}_agent_(.+)$`));
|
||||
if (agentMatch) {
|
||||
const agentName = agentMatch[1];
|
||||
const agent = this.network!.getAgent(agentName);
|
||||
|
||||
if (agent) {
|
||||
const result = await agent.run({
|
||||
messages: [
|
||||
{ role: 'user', content: args.message }
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result.content || JSON.stringify(result)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Tool '${name}' not found`);
|
||||
});
|
||||
}
|
||||
|
||||
private setupResources(): void {
|
||||
this.server.setRequestHandler('resources/list', async () => ({
|
||||
resources: [
|
||||
{
|
||||
uri: `agent://${this.agent?.name || this.network?.name}/state`,
|
||||
name: 'State',
|
||||
description: 'Current state of the agent/network',
|
||||
mimeType: 'application/json'
|
||||
},
|
||||
{
|
||||
uri: `agent://${this.agent?.name || this.network?.name}/metrics`,
|
||||
name: 'Metrics',
|
||||
description: 'Performance metrics',
|
||||
mimeType: 'application/json'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
this.server.setRequestHandler('resources/read', async (request) => {
|
||||
const { uri } = request.params as any;
|
||||
|
||||
if (uri.endsWith('/state')) {
|
||||
const state = this.network?.state.toJSON() || {
|
||||
agent: this.agent?.name,
|
||||
metadata: this.agent?.metadata
|
||||
};
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(state, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (uri.endsWith('/metrics')) {
|
||||
const metrics = this.network?.getMetrics() || {
|
||||
agent: this.agent?.name,
|
||||
calls: 0 // Would be tracked in real implementation
|
||||
};
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(metrics, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Resource '${uri}' not found`);
|
||||
});
|
||||
}
|
||||
|
||||
private setupPrompts(): void {
|
||||
const prompts = [];
|
||||
|
||||
if (this.agent && this.agent.system) {
|
||||
prompts.push({
|
||||
name: `${this.agent.name}_system`,
|
||||
description: `System prompt for ${this.agent.name}`,
|
||||
arguments: []
|
||||
});
|
||||
}
|
||||
|
||||
this.server.setRequestHandler('prompts/list', async () => ({
|
||||
prompts
|
||||
}));
|
||||
|
||||
this.server.setRequestHandler('prompts/get', async (request) => {
|
||||
const { name } = request.params as any;
|
||||
|
||||
if (name === `${this.agent?.name}_system`) {
|
||||
return {
|
||||
description: `System prompt for ${this.agent.name}`,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: this.agent!.system!
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Prompt '${name}' not found`);
|
||||
});
|
||||
}
|
||||
|
||||
private toolToJsonSchema(tool: Tool): any {
|
||||
// Convert Zod schema to JSON Schema
|
||||
// This is simplified - would use a proper converter in production
|
||||
return {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
}
|
||||
|
||||
async startHttp(port: number = 3000): Promise<void> {
|
||||
// HTTP transport implementation
|
||||
const { createServer } = await import('http');
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk);
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const request = JSON.parse(body);
|
||||
const response = await this.handleHttpRequest(request);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(response));
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: String(error) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port);
|
||||
console.log(`Agent MCP server listening on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
private async handleHttpRequest(request: any): Promise<any> {
|
||||
// Route to appropriate handler based on request method
|
||||
const [namespace, method] = request.method.split('/');
|
||||
|
||||
if (namespace === 'tools') {
|
||||
if (method === 'list') {
|
||||
return this.server.handleRequest({ ...request, method: 'tools/list' });
|
||||
} else if (method === 'call') {
|
||||
return this.server.handleRequest({ ...request, method: 'tools/call' });
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unknown method: ${request.method}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create and start an MCP server for an agent/network
|
||||
export function exposeAsMCP(
|
||||
agentOrNetwork: Agent | Network,
|
||||
options?: {
|
||||
transport?: 'stdio' | 'http';
|
||||
port?: number;
|
||||
name?: string;
|
||||
}
|
||||
): AgentMCPServer {
|
||||
const server = new AgentMCPServer({
|
||||
agent: agentOrNetwork instanceof Agent ? agentOrNetwork : undefined,
|
||||
network: agentOrNetwork instanceof Network ? agentOrNetwork : undefined,
|
||||
name: options?.name
|
||||
});
|
||||
|
||||
if (options?.transport === 'http') {
|
||||
server.startHttp(options.port);
|
||||
} else {
|
||||
server.start();
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* MCP Client implementation
|
||||
*/
|
||||
|
||||
import { MCPTransport, MCPTool, MCPResource, MCPPrompt } from './types';
|
||||
|
||||
export interface MCPClientConfig {
|
||||
name: string;
|
||||
transport: MCPTransport;
|
||||
}
|
||||
|
||||
export class MCPClient {
|
||||
private config: MCPClientConfig;
|
||||
|
||||
constructor(config?: MCPClientConfig) {
|
||||
this.config = config || { name: 'default', transport: { type: 'stdio' } };
|
||||
}
|
||||
|
||||
async connect(config: MCPClientConfig): Promise<void> {
|
||||
this.config = config;
|
||||
// Implementation would connect to MCP server
|
||||
}
|
||||
|
||||
async listTools(): Promise<MCPTool[]> {
|
||||
// Implementation would fetch tools from server
|
||||
return [];
|
||||
}
|
||||
|
||||
async listResources(): Promise<MCPResource[]> {
|
||||
// Implementation would fetch resources from server
|
||||
return [];
|
||||
}
|
||||
|
||||
async listPrompts(): Promise<MCPPrompt[]> {
|
||||
// Implementation would fetch prompts from server
|
||||
return [];
|
||||
}
|
||||
|
||||
async callTool(params: { name: string; arguments: any }): Promise<any> {
|
||||
// Implementation would call tool on server
|
||||
return {};
|
||||
}
|
||||
|
||||
async readResource(uri: string): Promise<any> {
|
||||
// Implementation would read resource from server
|
||||
return {};
|
||||
}
|
||||
|
||||
async getPrompt(name: string, args?: any): Promise<any> {
|
||||
// Implementation would get prompt from server
|
||||
return {};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
// Implementation would disconnect from server
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* MCP exports
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './client';
|
||||
export * from './agent-server';
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* MCP types for @hanzo/ai
|
||||
*/
|
||||
|
||||
export interface MCPServer {
|
||||
name: string;
|
||||
transport: MCPTransport;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MCPTransport {
|
||||
type: 'stdio' | 'http' | 'websocket';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MCPTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: any; // JSON Schema
|
||||
}
|
||||
|
||||
export interface MCPResource {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface MCPPrompt {
|
||||
name: string;
|
||||
description: string;
|
||||
arguments?: any[];
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Anthropic provider
|
||||
*/
|
||||
|
||||
import { ModelInterface } from '../types';
|
||||
|
||||
export const anthropic = (config: { apiKey: string; model?: string }): ModelInterface => {
|
||||
return {
|
||||
name: config.model || 'claude-3-opus-20240229',
|
||||
async complete(params) {
|
||||
// Implementation would call Anthropic API
|
||||
return { content: 'Claude response' };
|
||||
},
|
||||
async *stream(params) {
|
||||
// Implementation would stream from Anthropic
|
||||
yield { type: 'content', content: 'Claude' };
|
||||
yield { type: 'done' };
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const bedrock = (config: any) => ({ name: 'bedrock', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1 +0,0 @@
|
||||
export const cohere = (config: any) => ({ name: 'cohere', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1 +0,0 @@
|
||||
export const google = (config: any) => ({ name: 'gemini', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1 +0,0 @@
|
||||
export const hanzo = (config: any) => ({ name: 'hanzo', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1 +0,0 @@
|
||||
export const mistral = (config: any) => ({ name: 'mistral', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* OpenAI provider
|
||||
*/
|
||||
|
||||
import { ModelInterface } from '../types';
|
||||
|
||||
export const openai = (config: { apiKey: string; model?: string }): ModelInterface => {
|
||||
return {
|
||||
name: config.model || 'gpt-4',
|
||||
async complete(params) {
|
||||
// Implementation would call OpenAI API
|
||||
return { content: 'OpenAI response' };
|
||||
},
|
||||
async *stream(params) {
|
||||
// Implementation would stream from OpenAI
|
||||
yield { type: 'content', content: 'OpenAI' };
|
||||
yield { type: 'done' };
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const vertex = (config: any) => ({ name: 'vertex', async complete(p: any) { return { content: '' }; }, async *stream(p: any) { yield { type: 'done' as const }; } });
|
||||
@@ -1,18 +0,0 @@
|
||||
// Server exports for @hanzo/ai
|
||||
// This module provides server-side specific functionality
|
||||
|
||||
export { Agent } from '../core/agent'
|
||||
export { Network } from '../core/network'
|
||||
export { Router, type RouterContext } from '../core/router'
|
||||
export { State } from '../core/state'
|
||||
export { AgentMCPServer } from '../mcp/agent-server'
|
||||
|
||||
// Re-export providers for server use
|
||||
export * from '../providers/anthropic'
|
||||
export * from '../providers/openai'
|
||||
export * from '../providers/google'
|
||||
export * from '../providers/bedrock'
|
||||
export * from '../providers/vertex'
|
||||
export * from '../providers/cohere'
|
||||
export * from '../providers/mistral'
|
||||
export * from '../providers/hanzo'
|
||||
@@ -1,319 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HanzoCloudTelemetry } from '../hanzo-cloud';
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('HanzoCloudTelemetry', () => {
|
||||
let telemetry: HanzoCloudTelemetry;
|
||||
const mockConfig = {
|
||||
cloudUrl: 'https://test.hanzo.ai',
|
||||
apiKey: 'test-api-key',
|
||||
projectId: 'test-project',
|
||||
environment: 'test',
|
||||
serviceName: 'test-service'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({})
|
||||
});
|
||||
|
||||
telemetry = new HanzoCloudTelemetry(mockConfig);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await telemetry.shutdown();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with config', () => {
|
||||
expect(telemetry).toBeInstanceOf(HanzoCloudTelemetry);
|
||||
});
|
||||
|
||||
it('should log initialization', async () => {
|
||||
// Just verify that telemetry initializes without errors
|
||||
// The log output is visible in stdout which confirms it works
|
||||
const newTelemetry = new HanzoCloudTelemetry(mockConfig);
|
||||
expect(newTelemetry).toBeInstanceOf(HanzoCloudTelemetry);
|
||||
|
||||
await newTelemetry.shutdown();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging', () => {
|
||||
it('should log with trace context', async () => {
|
||||
await telemetry.trace('test-operation', async () => {
|
||||
telemetry.log('info', 'Test message', { custom: 'data' });
|
||||
});
|
||||
});
|
||||
|
||||
it('should support all log levels', () => {
|
||||
telemetry.log('debug', 'Debug message');
|
||||
telemetry.log('info', 'Info message');
|
||||
telemetry.log('warn', 'Warning message');
|
||||
telemetry.log('error', 'Error message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent metrics', () => {
|
||||
it('should record agent execution', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
const histogramSpy = vi.spyOn(telemetry, 'histogram');
|
||||
const logSpy = vi.spyOn(telemetry, 'log');
|
||||
|
||||
telemetry.recordAgentExecution('test-agent', 1000, true, {
|
||||
model: 'gpt-4',
|
||||
tokens: 150
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('agent.executions', 1, {
|
||||
agent: 'test-agent',
|
||||
success: 'true'
|
||||
});
|
||||
|
||||
expect(histogramSpy).toHaveBeenCalledWith('agent.execution.duration', 1000, {
|
||||
agent: 'test-agent'
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('agent.tokens.used', 150, {
|
||||
agent: 'test-agent',
|
||||
model: 'gpt-4'
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Agent test-agent executed',
|
||||
expect.objectContaining({
|
||||
agent: 'test-agent',
|
||||
duration: 1000,
|
||||
success: true,
|
||||
model: 'gpt-4',
|
||||
tokens: 150
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should record failed agent execution', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
|
||||
telemetry.recordAgentExecution('test-agent', 500, false, {
|
||||
error: 'Test error'
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('agent.executions', 1, {
|
||||
agent: 'test-agent',
|
||||
success: 'false'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('network metrics', () => {
|
||||
it('should record network execution', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
const histogramSpy = vi.spyOn(telemetry, 'histogram');
|
||||
const logSpy = vi.spyOn(telemetry, 'log');
|
||||
|
||||
const agentExecutions = new Map([
|
||||
['agent1', 3],
|
||||
['agent2', 2]
|
||||
]);
|
||||
|
||||
telemetry.recordNetworkExecution('test-network', 5, 3000, agentExecutions);
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('network.executions', 1, {
|
||||
network: 'test-network'
|
||||
});
|
||||
|
||||
expect(histogramSpy).toHaveBeenCalledWith('network.iterations', 5, {
|
||||
network: 'test-network'
|
||||
});
|
||||
|
||||
expect(histogramSpy).toHaveBeenCalledWith('network.execution.duration', 3000, {
|
||||
network: 'test-network'
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('network.agent.executions', 3, {
|
||||
network: 'test-network',
|
||||
agent: 'agent1'
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('network.agent.executions', 2, {
|
||||
network: 'test-network',
|
||||
agent: 'agent2'
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Network test-network completed',
|
||||
expect.objectContaining({
|
||||
network: 'test-network',
|
||||
iterations: 5,
|
||||
duration: 3000,
|
||||
agents: { agent1: 3, agent2: 2 }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool metrics', () => {
|
||||
it('should record tool usage', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
const histogramSpy = vi.spyOn(telemetry, 'histogram');
|
||||
|
||||
telemetry.recordToolUsage('search', 'agent1', 100, true);
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('tool.executions', 1, {
|
||||
tool: 'search',
|
||||
agent: 'agent1',
|
||||
success: 'true'
|
||||
});
|
||||
|
||||
expect(histogramSpy).toHaveBeenCalledWith('tool.execution.duration', 100, {
|
||||
tool: 'search',
|
||||
agent: 'agent1'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP metrics', () => {
|
||||
it('should record MCP connection', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
const gaugeSpy = vi.spyOn(telemetry, 'gauge');
|
||||
const logSpy = vi.spyOn(telemetry, 'log');
|
||||
|
||||
telemetry.recordMCPConnection('file-system', true, {
|
||||
transport: 'stdio'
|
||||
});
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('mcp.connections', 1, {
|
||||
server: 'file-system',
|
||||
success: 'true'
|
||||
});
|
||||
|
||||
expect(gaugeSpy).toHaveBeenCalledWith('mcp.servers.active', 1, {
|
||||
server: 'file-system'
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'MCP server file-system connection established',
|
||||
expect.objectContaining({
|
||||
server: 'file-system',
|
||||
transport: 'stdio'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should record failed MCP connection', () => {
|
||||
const incrementSpy = vi.spyOn(telemetry, 'increment');
|
||||
const gaugeSpy = vi.spyOn(telemetry, 'gauge');
|
||||
|
||||
telemetry.recordMCPConnection('file-system', false);
|
||||
|
||||
expect(incrementSpy).toHaveBeenCalledWith('mcp.connections', 1, {
|
||||
server: 'file-system',
|
||||
success: 'false'
|
||||
});
|
||||
|
||||
expect(gaugeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessions', () => {
|
||||
it('should create sessions', () => {
|
||||
const setAttributesSpy = vi.spyOn(telemetry, 'setAttributes');
|
||||
|
||||
const sessionId = telemetry.createSession();
|
||||
|
||||
expect(sessionId).toMatch(/^session_\d+_[a-z0-9]+$/);
|
||||
expect(setAttributesSpy).toHaveBeenCalledWith({
|
||||
'hanzo.session.id': sessionId
|
||||
});
|
||||
});
|
||||
|
||||
it('should use provided session ID', () => {
|
||||
const setAttributesSpy = vi.spyOn(telemetry, 'setAttributes');
|
||||
|
||||
const sessionId = telemetry.createSession('custom-session-123');
|
||||
|
||||
expect(sessionId).toBe('custom-session-123');
|
||||
expect(setAttributesSpy).toHaveBeenCalledWith({
|
||||
'hanzo.session.id': 'custom-session-123'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('metrics flushing', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should buffer and flush metrics', async () => {
|
||||
// Record some metrics
|
||||
telemetry.increment('test.counter', 1);
|
||||
telemetry.increment('test.counter', 2);
|
||||
telemetry.gauge('test.gauge', 10);
|
||||
telemetry.gauge('test.gauge', 20);
|
||||
telemetry.histogram('test.histogram', 100);
|
||||
telemetry.histogram('test.histogram', 200);
|
||||
|
||||
// Manually trigger flush
|
||||
await (telemetry as any).flushMetrics(mockConfig);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://test.hanzo.ai/v1/metrics',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'test-api-key',
|
||||
'x-project-id': 'test-project'
|
||||
}),
|
||||
body: expect.stringContaining('metrics')
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle metrics send failure', async () => {
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error'
|
||||
});
|
||||
|
||||
// Add some metrics to flush
|
||||
telemetry.increment('test.counter', 1);
|
||||
|
||||
// Trigger metrics flush - should not throw
|
||||
await expect(
|
||||
(telemetry as any).flushMetrics(mockConfig)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// The error log is visible in stdout which confirms error handling works
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
// Add some metrics to flush
|
||||
telemetry.increment('test.counter', 1);
|
||||
|
||||
// Trigger metrics flush - should not throw
|
||||
await expect(
|
||||
(telemetry as any).flushMetrics(mockConfig)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
// The error log is visible in stdout which confirms error handling works
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Telemetry } from '../index';
|
||||
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
|
||||
|
||||
describe('Telemetry', () => {
|
||||
let telemetry: Telemetry;
|
||||
|
||||
beforeEach(() => {
|
||||
telemetry = new Telemetry({
|
||||
serviceName: 'test-service',
|
||||
enabled: true
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await telemetry.shutdown();
|
||||
});
|
||||
|
||||
describe('span operations', () => {
|
||||
it('should create and end spans', () => {
|
||||
const span = telemetry.startSpan('test-span', {
|
||||
kind: SpanKind.INTERNAL,
|
||||
attributes: { 'test.attr': 'value' }
|
||||
});
|
||||
|
||||
expect(span).toBeDefined();
|
||||
expect(span.end).toBeInstanceOf(Function);
|
||||
|
||||
telemetry.endSpan('test-span', { code: SpanStatusCode.OK });
|
||||
});
|
||||
|
||||
it('should trace async operations', async () => {
|
||||
const result = await telemetry.trace(
|
||||
'async-operation',
|
||||
async (span) => {
|
||||
span.setAttribute('operation.type', 'test');
|
||||
return 'success';
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe('success');
|
||||
});
|
||||
|
||||
it('should trace sync operations', () => {
|
||||
const result = telemetry.traceSync(
|
||||
'sync-operation',
|
||||
(span) => {
|
||||
span.setAttribute('operation.type', 'test');
|
||||
return 42;
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('should handle errors in traced operations', async () => {
|
||||
const error = new Error('Test error');
|
||||
|
||||
await expect(
|
||||
telemetry.trace('failing-operation', async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toThrow('Test error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('events and attributes', () => {
|
||||
it('should record events', () => {
|
||||
const span = telemetry.startSpan('test-span');
|
||||
|
||||
telemetry.recordEvent({
|
||||
name: 'test.event',
|
||||
attributes: { 'event.type': 'test' },
|
||||
timestamp: Date.now()
|
||||
}, span);
|
||||
|
||||
telemetry.endSpan('test-span');
|
||||
});
|
||||
|
||||
it('should set attributes', () => {
|
||||
const span = telemetry.startSpan('test-span');
|
||||
|
||||
telemetry.setAttributes({
|
||||
'attr1': 'value1',
|
||||
'attr2': 42,
|
||||
'attr3': true
|
||||
}, span);
|
||||
|
||||
telemetry.endSpan('test-span');
|
||||
});
|
||||
|
||||
it('should record exceptions', () => {
|
||||
const span = telemetry.startSpan('test-span');
|
||||
const error = new Error('Test exception');
|
||||
|
||||
telemetry.recordException(error, span);
|
||||
|
||||
telemetry.endSpan('test-span');
|
||||
});
|
||||
});
|
||||
|
||||
describe('metrics', () => {
|
||||
it('should emit metric events', () => {
|
||||
const metricHandler = vi.fn();
|
||||
telemetry.on('metric', metricHandler);
|
||||
|
||||
telemetry.increment('test.counter', 1, { tag: 'value' });
|
||||
|
||||
expect(metricHandler).toHaveBeenCalledWith({
|
||||
name: 'test.counter',
|
||||
value: 1,
|
||||
type: 'counter',
|
||||
tags: expect.objectContaining({ tag: 'value' }),
|
||||
timestamp: expect.any(Number)
|
||||
});
|
||||
});
|
||||
|
||||
it('should record gauges', () => {
|
||||
const metricHandler = vi.fn();
|
||||
telemetry.on('metric', metricHandler);
|
||||
|
||||
telemetry.gauge('test.gauge', 42);
|
||||
|
||||
expect(metricHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'test.gauge',
|
||||
value: 42,
|
||||
type: 'gauge'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should record histograms', () => {
|
||||
const metricHandler = vi.fn();
|
||||
telemetry.on('metric', metricHandler);
|
||||
|
||||
telemetry.histogram('test.histogram', 123);
|
||||
|
||||
expect(metricHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'test.histogram',
|
||||
value: 123,
|
||||
type: 'histogram'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context propagation', () => {
|
||||
it('should get trace context', async () => {
|
||||
await telemetry.trace('test-op', async () => {
|
||||
const context = telemetry.getTraceContext();
|
||||
|
||||
expect(context).toBeInstanceOf(Object);
|
||||
// In test environment with noop tracer, context might be empty
|
||||
// This is expected behavior
|
||||
});
|
||||
});
|
||||
|
||||
it('should create child telemetry instances', () => {
|
||||
const child = telemetry.createChild('child-service', {
|
||||
'child.attr': 'value'
|
||||
});
|
||||
|
||||
expect(child).toBeInstanceOf(Telemetry);
|
||||
expect(child).not.toBe(telemetry);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('should flush pending data', async () => {
|
||||
const flushHandler = vi.fn();
|
||||
telemetry.on('flush', flushHandler);
|
||||
|
||||
// Create some spans
|
||||
telemetry.startSpan('span1');
|
||||
telemetry.startSpan('span2');
|
||||
|
||||
await telemetry.flush();
|
||||
|
||||
expect(flushHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should shutdown cleanly', async () => {
|
||||
const span = telemetry.startSpan('test-span');
|
||||
|
||||
await telemetry.shutdown();
|
||||
|
||||
// Should not throw
|
||||
telemetry.endSpan('test-span');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled telemetry', () => {
|
||||
it('should not create real spans when disabled', () => {
|
||||
const disabledTelemetry = new Telemetry({
|
||||
serviceName: 'test',
|
||||
enabled: false
|
||||
});
|
||||
|
||||
const span = disabledTelemetry.startSpan('test-span');
|
||||
|
||||
// Should return a no-op span
|
||||
expect(span).toBeDefined();
|
||||
expect(span.end).toBeInstanceOf(Function);
|
||||
|
||||
// Should not throw
|
||||
span.end();
|
||||
});
|
||||
|
||||
it('should not emit metrics when disabled', () => {
|
||||
const disabledTelemetry = new Telemetry({
|
||||
serviceName: 'test',
|
||||
enabled: false
|
||||
});
|
||||
|
||||
const metricHandler = vi.fn();
|
||||
disabledTelemetry.on('metric', metricHandler);
|
||||
|
||||
disabledTelemetry.increment('test.counter');
|
||||
|
||||
expect(metricHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,363 +0,0 @@
|
||||
/**
|
||||
* Hanzo Cloud telemetry integration
|
||||
* Connects to Hanzo Cloud's observability platform for centralized monitoring
|
||||
*/
|
||||
|
||||
import { Telemetry, TelemetryConfig } from './index';
|
||||
import * as opentelemetry from '@opentelemetry/api';
|
||||
import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
import { registerInstrumentations } from '@opentelemetry/instrumentation';
|
||||
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
||||
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import winston from 'winston';
|
||||
|
||||
export interface HanzoCloudConfig extends TelemetryConfig {
|
||||
cloudUrl: string;
|
||||
apiKey: string;
|
||||
projectId: string;
|
||||
environment?: string;
|
||||
serviceName?: string;
|
||||
serviceVersion?: string;
|
||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||
}
|
||||
|
||||
export class HanzoCloudTelemetry extends Telemetry {
|
||||
private logger: winston.Logger;
|
||||
private exporter?: OTLPTraceExporter;
|
||||
private provider?: NodeTracerProvider;
|
||||
private metricsBuffer: Map<string, any[]> = new Map();
|
||||
private flushInterval?: NodeJS.Timeout;
|
||||
|
||||
constructor(config: HanzoCloudConfig) {
|
||||
super(config);
|
||||
|
||||
// Setup Winston logger with Hanzo Cloud format
|
||||
this.logger = this.createLogger(config);
|
||||
|
||||
// Initialize OpenTelemetry
|
||||
this.initializeOpenTelemetry(config);
|
||||
|
||||
// Start metrics flush interval
|
||||
this.startMetricsFlush(config);
|
||||
|
||||
// Log initialization
|
||||
this.logger.info('Hanzo Cloud telemetry initialized', {
|
||||
projectId: config.projectId,
|
||||
environment: config.environment || 'development',
|
||||
serviceName: config.serviceName || 'hanzo-ai'
|
||||
});
|
||||
}
|
||||
|
||||
private createLogger(config: HanzoCloudConfig): winston.Logger {
|
||||
// Tracing format that adds trace context to logs
|
||||
const tracingFormat = winston.format((info) => {
|
||||
const span = opentelemetry.trace.getActiveSpan();
|
||||
if (span) {
|
||||
const { spanId, traceId } = span.spanContext();
|
||||
info['trace_id'] = traceId;
|
||||
info['span_id'] = spanId;
|
||||
info['project_id'] = config.projectId;
|
||||
info['service.name'] = config.serviceName || 'hanzo-ai';
|
||||
}
|
||||
return info;
|
||||
});
|
||||
|
||||
return winston.createLogger({
|
||||
level: config.logLevel || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.timestamp(),
|
||||
tracingFormat(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: {
|
||||
service: config.serviceName || 'hanzo-ai',
|
||||
environment: config.environment || 'development',
|
||||
projectId: config.projectId
|
||||
},
|
||||
transports: [
|
||||
new winston.transports.Console(),
|
||||
// Could add HTTP transport to send logs to Hanzo Cloud
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private initializeOpenTelemetry(config: HanzoCloudConfig): void {
|
||||
// Enable OpenTelemetry debugging in development
|
||||
if (config.environment === 'development') {
|
||||
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO);
|
||||
}
|
||||
|
||||
// Create resource
|
||||
const resource = resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: config.serviceName || 'hanzo-ai',
|
||||
[ATTR_SERVICE_VERSION]: config.serviceVersion || '1.0.0',
|
||||
// stable semconv dropped deployment.environment; keep the raw key
|
||||
'deployment.environment': config.environment || 'development',
|
||||
'hanzo.project.id': config.projectId,
|
||||
'hanzo.cloud.region': process.env.HANZO_CLOUD_REGION || 'us-east-1'
|
||||
});
|
||||
|
||||
// Create OTLP exporter
|
||||
this.exporter = new OTLPTraceExporter({
|
||||
url: `${config.cloudUrl}/v1/traces`,
|
||||
headers: {
|
||||
'x-api-key': config.apiKey,
|
||||
'x-project-id': config.projectId
|
||||
}
|
||||
});
|
||||
|
||||
// Create provider with batch processor (SDK 2.x: processors are
|
||||
// constructor-only; addSpanProcessor was removed)
|
||||
this.provider = new NodeTracerProvider({
|
||||
resource,
|
||||
spanProcessors: [
|
||||
new BatchSpanProcessor(this.exporter, {
|
||||
maxQueueSize: 1000,
|
||||
maxExportBatchSize: 512,
|
||||
scheduledDelayMillis: 5000,
|
||||
exportTimeoutMillis: 30000
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Register as global provider
|
||||
this.provider.register();
|
||||
|
||||
// Register instrumentations for automatic tracing
|
||||
registerInstrumentations({
|
||||
instrumentations: [
|
||||
// Add instrumentations as needed
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private startMetricsFlush(config: HanzoCloudConfig): void {
|
||||
// Flush metrics every 30 seconds
|
||||
this.flushInterval = setInterval(() => {
|
||||
this.flushMetrics(config);
|
||||
}, 30000);
|
||||
|
||||
// Listen for metric events
|
||||
this.on('metric', (metric) => {
|
||||
const key = `${metric.name}:${metric.type}`;
|
||||
if (!this.metricsBuffer.has(key)) {
|
||||
this.metricsBuffer.set(key, []);
|
||||
}
|
||||
this.metricsBuffer.get(key)!.push(metric);
|
||||
});
|
||||
}
|
||||
|
||||
private async flushMetrics(config: HanzoCloudConfig): Promise<void> {
|
||||
if (this.metricsBuffer.size === 0) return;
|
||||
|
||||
const metrics = Array.from(this.metricsBuffer.entries()).map(([key, values]) => {
|
||||
const [name, type] = key.split(':');
|
||||
|
||||
// Aggregate metrics
|
||||
if (type === 'counter') {
|
||||
const sum = values.reduce((acc, m) => acc + m.value, 0);
|
||||
return { name, type, value: sum, tags: values[0].tags };
|
||||
} else if (type === 'gauge') {
|
||||
// Use latest value for gauges
|
||||
return values[values.length - 1];
|
||||
} else if (type === 'histogram') {
|
||||
// Calculate percentiles for histograms
|
||||
const sorted = values.map(m => m.value).sort((a, b) => a - b);
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
value: {
|
||||
count: sorted.length,
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
p50: sorted[Math.floor(sorted.length * 0.5)],
|
||||
p95: sorted[Math.floor(sorted.length * 0.95)],
|
||||
p99: sorted[Math.floor(sorted.length * 0.99)]
|
||||
},
|
||||
tags: values[0].tags
|
||||
};
|
||||
}
|
||||
return values[0];
|
||||
});
|
||||
|
||||
// Send to Hanzo Cloud
|
||||
try {
|
||||
const response = await fetch(`${config.cloudUrl}/v1/metrics`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': config.apiKey,
|
||||
'x-project-id': config.projectId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metrics,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.error('Failed to send metrics to Hanzo Cloud', {
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
}
|
||||
|
||||
// Clear buffer after successful send
|
||||
this.metricsBuffer.clear();
|
||||
} catch (error) {
|
||||
this.logger.error('Error sending metrics to Hanzo Cloud', { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message with trace context
|
||||
*/
|
||||
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, meta?: any): void {
|
||||
this.logger[level](message, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an agent execution
|
||||
*/
|
||||
recordAgentExecution(agentName: string, duration: number, success: boolean, metadata?: any): void {
|
||||
this.increment('agent.executions', 1, {
|
||||
agent: agentName,
|
||||
success: success ? 'true' : 'false'
|
||||
});
|
||||
|
||||
this.histogram('agent.execution.duration', duration, {
|
||||
agent: agentName
|
||||
});
|
||||
|
||||
if (metadata?.tokens) {
|
||||
this.increment('agent.tokens.used', metadata.tokens, {
|
||||
agent: agentName,
|
||||
model: metadata.model || 'unknown'
|
||||
});
|
||||
}
|
||||
|
||||
this.log('info', `Agent ${agentName} executed`, {
|
||||
agent: agentName,
|
||||
duration,
|
||||
success,
|
||||
...metadata
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a network execution
|
||||
*/
|
||||
recordNetworkExecution(
|
||||
networkName: string,
|
||||
iterations: number,
|
||||
duration: number,
|
||||
agentExecutions: Map<string, number>
|
||||
): void {
|
||||
this.increment('network.executions', 1, {
|
||||
network: networkName
|
||||
});
|
||||
|
||||
this.histogram('network.iterations', iterations, {
|
||||
network: networkName
|
||||
});
|
||||
|
||||
this.histogram('network.execution.duration', duration, {
|
||||
network: networkName
|
||||
});
|
||||
|
||||
// Record per-agent metrics within the network
|
||||
for (const [agent, count] of agentExecutions) {
|
||||
this.increment('network.agent.executions', count, {
|
||||
network: networkName,
|
||||
agent
|
||||
});
|
||||
}
|
||||
|
||||
this.log('info', `Network ${networkName} completed`, {
|
||||
network: networkName,
|
||||
iterations,
|
||||
duration,
|
||||
agents: Object.fromEntries(agentExecutions)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record tool usage
|
||||
*/
|
||||
recordToolUsage(toolName: string, agentName: string, duration: number, success: boolean): void {
|
||||
this.increment('tool.executions', 1, {
|
||||
tool: toolName,
|
||||
agent: agentName,
|
||||
success: success ? 'true' : 'false'
|
||||
});
|
||||
|
||||
this.histogram('tool.execution.duration', duration, {
|
||||
tool: toolName,
|
||||
agent: agentName
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record MCP server connection
|
||||
*/
|
||||
recordMCPConnection(serverName: string, success: boolean, metadata?: any): void {
|
||||
this.increment('mcp.connections', 1, {
|
||||
server: serverName,
|
||||
success: success ? 'true' : 'false'
|
||||
});
|
||||
|
||||
if (success) {
|
||||
this.gauge('mcp.servers.active', 1, {
|
||||
server: serverName
|
||||
});
|
||||
}
|
||||
|
||||
this.log('info', `MCP server ${serverName} connection ${success ? 'established' : 'failed'}`, {
|
||||
server: serverName,
|
||||
...metadata
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session for tracking related executions
|
||||
*/
|
||||
createSession(sessionId?: string): string {
|
||||
const id = sessionId || `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
this.setAttributes({ 'hanzo.session.id': id });
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced shutdown with cleanup
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
// Clear intervals
|
||||
if (this.flushInterval) {
|
||||
clearInterval(this.flushInterval);
|
||||
}
|
||||
|
||||
// Flush remaining metrics
|
||||
await this.flushMetrics(this.config as HanzoCloudConfig);
|
||||
|
||||
// Shutdown OpenTelemetry
|
||||
if (this.provider) {
|
||||
await this.provider.shutdown();
|
||||
}
|
||||
|
||||
// Call parent shutdown
|
||||
await super.shutdown();
|
||||
|
||||
this.logger.info('Hanzo Cloud telemetry shut down');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Hanzo Cloud telemetry instance
|
||||
*/
|
||||
export function createHanzoCloudTelemetry(config: HanzoCloudConfig): HanzoCloudTelemetry {
|
||||
return new HanzoCloudTelemetry(config);
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
/**
|
||||
* Telemetry integration for Hanzo AI
|
||||
* Provides distributed tracing, logging, and metrics for agent executions
|
||||
*/
|
||||
|
||||
import * as opentelemetry from '@opentelemetry/api';
|
||||
import { Span, SpanStatusCode, SpanKind } from '@opentelemetry/api';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface TelemetryConfig {
|
||||
serviceName?: string;
|
||||
enabled?: boolean;
|
||||
cloudUrl?: string;
|
||||
apiKey?: string;
|
||||
projectId?: string;
|
||||
sessionId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SpanContext {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
traceFlags?: number;
|
||||
}
|
||||
|
||||
export interface TelemetryEvent {
|
||||
name: string;
|
||||
attributes?: Record<string, any>;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export class Telemetry extends EventEmitter {
|
||||
private tracer: opentelemetry.Tracer;
|
||||
private enabled: boolean;
|
||||
private config: TelemetryConfig;
|
||||
private activeSpans: Map<string, Span> = new Map();
|
||||
|
||||
constructor(config: TelemetryConfig = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
serviceName: 'hanzo-ai',
|
||||
enabled: true,
|
||||
...config
|
||||
};
|
||||
|
||||
this.enabled = this.config.enabled ?? true;
|
||||
this.tracer = opentelemetry.trace.getTracer(
|
||||
this.config.serviceName || 'hanzo-ai',
|
||||
'1.0.0'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new span for tracing
|
||||
*/
|
||||
startSpan(
|
||||
name: string,
|
||||
options?: {
|
||||
kind?: SpanKind;
|
||||
attributes?: Record<string, any>;
|
||||
parent?: Span | SpanContext;
|
||||
}
|
||||
): Span {
|
||||
if (!this.enabled) {
|
||||
return opentelemetry.trace.getTracer('noop').startSpan('noop');
|
||||
}
|
||||
|
||||
const spanOptions: opentelemetry.SpanOptions = {
|
||||
kind: options?.kind || SpanKind.INTERNAL,
|
||||
attributes: {
|
||||
'service.name': this.config.serviceName,
|
||||
'hanzo.ai.version': '1.0.0',
|
||||
...this.config.metadata,
|
||||
...options?.attributes
|
||||
}
|
||||
};
|
||||
|
||||
// Handle parent span
|
||||
let context = opentelemetry.context.active();
|
||||
if (options?.parent) {
|
||||
if ('spanContext' in options.parent) {
|
||||
// It's a Span
|
||||
context = opentelemetry.trace.setSpan(context, options.parent);
|
||||
} else {
|
||||
// It's a SpanContext - need to create a parent span
|
||||
const parentSpan = this.tracer.startSpan('parent', {
|
||||
context: opentelemetry.trace.setSpanContext(
|
||||
opentelemetry.ROOT_CONTEXT,
|
||||
options.parent as SpanContext
|
||||
)
|
||||
});
|
||||
context = opentelemetry.trace.setSpan(context, parentSpan);
|
||||
}
|
||||
}
|
||||
|
||||
const span = this.tracer.startSpan(name, spanOptions, context);
|
||||
this.activeSpans.set(name, span);
|
||||
|
||||
// Add default attributes
|
||||
if (this.config.projectId) {
|
||||
span.setAttribute('hanzo.project.id', this.config.projectId);
|
||||
}
|
||||
if (this.config.sessionId) {
|
||||
span.setAttribute('hanzo.session.id', this.config.sessionId);
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
/**
|
||||
* End a span
|
||||
*/
|
||||
endSpan(name: string, status?: { code: SpanStatusCode; message?: string }): void {
|
||||
const span = this.activeSpans.get(name);
|
||||
if (span) {
|
||||
if (status) {
|
||||
span.setStatus(status);
|
||||
}
|
||||
span.end();
|
||||
this.activeSpans.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace an async operation
|
||||
*/
|
||||
async trace<T>(
|
||||
name: string,
|
||||
fn: (span: Span) => Promise<T>,
|
||||
options?: {
|
||||
kind?: SpanKind;
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
): Promise<T> {
|
||||
const span = this.startSpan(name, options);
|
||||
|
||||
try {
|
||||
const result = await fn(span);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.recordException(error as Error, span);
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
this.activeSpans.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace a sync operation
|
||||
*/
|
||||
traceSync<T>(
|
||||
name: string,
|
||||
fn: (span: Span) => T,
|
||||
options?: {
|
||||
kind?: SpanKind;
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
): T {
|
||||
const span = this.startSpan(name, options);
|
||||
|
||||
try {
|
||||
const result = fn(span);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.recordException(error as Error, span);
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
this.activeSpans.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an exception in the current or specified span
|
||||
*/
|
||||
recordException(error: Error, span?: Span): void {
|
||||
const activeSpan = span || this.getCurrentSpan();
|
||||
if (!activeSpan) return;
|
||||
|
||||
activeSpan.recordException({
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
// Add error attributes for observability platforms
|
||||
activeSpan.setAttributes({
|
||||
'error.type': error.name,
|
||||
'error.message': error.message,
|
||||
'error.stack': error.stack
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an event in the current or specified span
|
||||
*/
|
||||
recordEvent(event: TelemetryEvent, span?: Span): void {
|
||||
const activeSpan = span || this.getCurrentSpan();
|
||||
if (!activeSpan) return;
|
||||
|
||||
activeSpan.addEvent(event.name, event.attributes, event.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set attributes on the current or specified span
|
||||
*/
|
||||
setAttributes(attributes: Record<string, any>, span?: Span): void {
|
||||
const activeSpan = span || this.getCurrentSpan();
|
||||
if (!activeSpan) return;
|
||||
|
||||
activeSpan.setAttributes(attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active span
|
||||
*/
|
||||
getCurrentSpan(): Span | undefined {
|
||||
return opentelemetry.trace.getActiveSpan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current trace context for propagation
|
||||
*/
|
||||
getTraceContext(): Record<string, string> {
|
||||
const span = this.getCurrentSpan();
|
||||
if (!span) return {};
|
||||
|
||||
const carrier: Record<string, string> = {};
|
||||
opentelemetry.propagation.inject(
|
||||
opentelemetry.trace.setSpan(opentelemetry.context.active(), span),
|
||||
carrier
|
||||
);
|
||||
|
||||
return carrier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child telemetry instance with inherited context
|
||||
*/
|
||||
createChild(name: string, attributes?: Record<string, any>): Telemetry {
|
||||
return new Telemetry({
|
||||
...this.config,
|
||||
metadata: {
|
||||
...this.config.metadata,
|
||||
...attributes,
|
||||
parent: name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record metrics
|
||||
*/
|
||||
recordMetric(
|
||||
name: string,
|
||||
value: number,
|
||||
type: 'counter' | 'gauge' | 'histogram' = 'gauge',
|
||||
tags?: Record<string, string | number>
|
||||
): void {
|
||||
if (!this.enabled) return;
|
||||
|
||||
// Emit metric event for collection
|
||||
this.emit('metric', {
|
||||
name,
|
||||
value,
|
||||
type,
|
||||
tags: {
|
||||
...this.config.metadata,
|
||||
...tags
|
||||
},
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment a counter metric
|
||||
*/
|
||||
increment(name: string, value: number = 1, tags?: Record<string, string | number>): void {
|
||||
this.recordMetric(name, value, 'counter', tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a gauge metric
|
||||
*/
|
||||
gauge(name: string, value: number, tags?: Record<string, string | number>): void {
|
||||
this.recordMetric(name, value, 'gauge', tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a histogram metric
|
||||
*/
|
||||
histogram(name: string, value: number, tags?: Record<string, string | number>): void {
|
||||
this.recordMetric(name, value, 'histogram', tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all pending telemetry data
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
// End all active spans
|
||||
for (const [name, span] of this.activeSpans) {
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
span.end();
|
||||
}
|
||||
this.activeSpans.clear();
|
||||
|
||||
// Emit flush event for collectors
|
||||
this.emit('flush');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown telemetry
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
await this.flush();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Global telemetry instance
|
||||
let globalTelemetry: Telemetry | undefined;
|
||||
|
||||
/**
|
||||
* Get or create the global telemetry instance
|
||||
*/
|
||||
export function getTelemetry(config?: TelemetryConfig): Telemetry {
|
||||
if (!globalTelemetry) {
|
||||
globalTelemetry = new Telemetry(config);
|
||||
}
|
||||
return globalTelemetry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the global telemetry instance
|
||||
*/
|
||||
export function setTelemetry(telemetry: Telemetry): void {
|
||||
globalTelemetry = telemetry;
|
||||
}
|
||||
|
||||
// Export types
|
||||
export { Span, SpanStatusCode, SpanKind } from '@opentelemetry/api';
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Core types for @hanzo/ai
|
||||
*/
|
||||
|
||||
export interface ModelInterface {
|
||||
name?: string;
|
||||
complete(params: CompletionParams): Promise<CompletionResponse>;
|
||||
stream(params: CompletionParams): AsyncIterableIterator<StreamChunk>;
|
||||
}
|
||||
|
||||
export interface CompletionParams {
|
||||
messages: Message[];
|
||||
tools?: Tool[];
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export interface CompletionResponse {
|
||||
content?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamChunk {
|
||||
type: 'content' | 'tool_call' | 'done';
|
||||
content?: string;
|
||||
toolCall?: ToolCall;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
toolResults?: ToolResult[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: any; // JSON Schema
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: any;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
id: string;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
/**
|
||||
* Creates a unique completion ID
|
||||
*/
|
||||
export function createCompletionId(): string {
|
||||
return `cmpl-${nanoid()}`;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { z, ZodSchema } from 'zod';
|
||||
|
||||
/**
|
||||
* Validates data against a schema
|
||||
*/
|
||||
export function validateSchema<T>(
|
||||
schema: ZodSchema<T>,
|
||||
data: unknown
|
||||
): { success: true; data: T } | { success: false; error: z.ZodError } {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
} else {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createParser, ParsedEvent } from 'eventsource-parser';
|
||||
|
||||
export interface StreamPart {
|
||||
type: 'text' | 'function_call' | 'tool_calls' | 'data' | 'error' | 'done';
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a stream part from a server-sent event
|
||||
*/
|
||||
export function parseStreamPart(data: string): StreamPart | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an event source parser
|
||||
*/
|
||||
export function createEventSourceParser(onParse: (event: ParsedEvent) => void) {
|
||||
return createParser(onParse);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
'server/index': 'src/server/index.ts',
|
||||
},
|
||||
format: ['cjs', 'esm'],
|
||||
dts: false, // Temporarily disable type generation - needs fixing
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ['react'],
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/mockData.ts',
|
||||
'**/__tests__/**'
|
||||
]
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.9.30",
|
||||
"version": "1.9.31",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Canva Apps SDK configuration, read by `@canva/cli` (`canva apps preview`).
|
||||
# Copy to `.env` and fill in the App ID from your app in the Canva Developer
|
||||
# Portal (https://www.canva.com/developers/apps). Never commit `.env`.
|
||||
|
||||
# The App ID from the Developer Portal (App details → "App ID").
|
||||
CANVA_APP_ID=
|
||||
|
||||
# The panel dev server / bundle origin the CLI serves. Default matches build.js
|
||||
# output served at localhost:8080; the CLI can also proxy its own dev server.
|
||||
CANVA_APP_ORIGIN=http://localhost:8080
|
||||
|
||||
# Optional: a backend base if you host a Hanzo-side token exchange. Not required —
|
||||
# this app takes a user-pasted hk- key and calls api.hanzo.ai directly via
|
||||
# @hanzo/ai, so no backend is needed for the core flow.
|
||||
CANVA_BACKEND_HOST=
|
||||
@@ -0,0 +1,111 @@
|
||||
# Hanzo AI for Canva
|
||||
|
||||
A Canva **Apps SDK** side-panel app that puts Hanzo's models next to your design.
|
||||
Non-designers, marketers, and SMBs — Canva's core audience — get copywriting help
|
||||
without leaving the canvas:
|
||||
|
||||
- **Generate copy** — headlines, captions, and marketing copy from a short brief.
|
||||
- **Rewrite selected text** — tighten, restyle, or restate the text you selected, in place.
|
||||
- **Translate** — the selected text into any language, tone preserved.
|
||||
- **Content ideas** — a numbered list of angles for a topic; click one to add it.
|
||||
- **Ask** — a question about the design or its copy.
|
||||
|
||||
It reads the current selection and page via `@canva/design`, runs the model gateway
|
||||
through the **published `@hanzo/ai`** headless client over `api.hanzo.ai`, and adds or
|
||||
replaces text elements with the result. You paste your own Hanzo `hk-` key; the app
|
||||
holds no secret and calls no `/api/` path — only `api.hanzo.ai/v1/...`.
|
||||
|
||||
## Architecture — one way to do everything
|
||||
|
||||
| Module | Responsibility | Depends on |
|
||||
| --- | --- | --- |
|
||||
| `src/design-core.ts` | **PURE.** The action catalog, prompt builders, design-context assembly + truncation, and response parsing. No SDK, no DOM. Fully unit-tested. | — |
|
||||
| `src/hanzo.ts` | Thin wrapper over `@hanzo/ai` — the only place the model gateway is called. | `@hanzo/ai` |
|
||||
| `src/canva.ts` | Thin adapter over `@canva/design` — read the selection, replace it, insert a new text element. | `@canva/design` |
|
||||
| `src/config.ts` | Gateway base URL, default model, context budget, `hk-` key guard. | — |
|
||||
| `src/app.tsx` | The App UI Kit panel — binds the three above to widgets; holds only UI state. | `@canva/app-ui-kit` |
|
||||
| `src/index.tsx` | Entry — mounts `App` under `AppUiProvider`. | `react-dom`, `@canva/app-ui-kit` |
|
||||
|
||||
The design-core mirrors [`@hanzo/figma`](../figma)'s design-core so the two design
|
||||
apps expose the same five actions with the same context/prompt/parse contracts.
|
||||
|
||||
### The read-selection → insert-text flow
|
||||
|
||||
1. On mount, `canva.ts` `onSelectionChange` subscribes to `selection.registerOnChange({ scope: 'plaintext' })`. Each change yields a `SelectionSnapshot` (`count`, joined `text`); the panel gates **Rewrite** / **Translate** on `count > 0` and shows a preview of the selected text.
|
||||
2. You pick an action, add a brief/question/language as needed, and press **Run**.
|
||||
3. `design-core.buildActionMessages(id, inputs, { selection })` resolves the action's task, fences the observed design as **data** (with an honest truncation note if it was cut), and returns the OpenAI-shaped message list.
|
||||
4. `hanzo.runChat(messages, { token, model })` calls `@hanzo/ai` `chat.completions.create` (non-streaming) and returns the assistant text.
|
||||
5. `design-core.parseCopy` / `parseIdeas` strips code fences, wrapping quotes, and list markers into paste-ready text.
|
||||
6. You press **Replace selection** (Rewrite → `canva.replaceSelectedText`, which reads a fresh draft, overwrites `draft.contents[].text`, and `draft.save()`s) or **Add to design** (everything else → `canva.insertText` → `addElementAtCursor` with a new `TextElement`). Ideas insert one line per click.
|
||||
|
||||
## Develop locally
|
||||
|
||||
Prerequisites: Node 18+, pnpm, and a Canva account.
|
||||
|
||||
### 1. Create the app in the Canva Developer Portal
|
||||
|
||||
1. Go to <https://www.canva.com/developers/apps> and **Create an app**.
|
||||
2. Under **App details**, copy the **App ID**.
|
||||
3. Under **Add features → Editing**, enable the app to run in the editor side panel (this app needs the *Design editing* scope: read the selection, add/replace elements).
|
||||
4. Under **Configure your app**, set the **Development URL** to the CLI/preview origin (default `http://localhost:8080`).
|
||||
|
||||
### 2. Configure
|
||||
|
||||
```bash
|
||||
cd packages/canva
|
||||
cp .env.template .env
|
||||
# put your App ID into CANVA_APP_ID
|
||||
```
|
||||
|
||||
### 3. Install, test, build
|
||||
|
||||
```bash
|
||||
pnpm install # from the repo root (workspace)
|
||||
pnpm --filter @hanzo/canva test # vitest — the pure design-core + hanzo shaping
|
||||
pnpm --filter @hanzo/canva typecheck # tsc --noEmit, strict
|
||||
pnpm --filter @hanzo/canva build # esbuild → dist/{index.html,app.js,app.css}
|
||||
```
|
||||
|
||||
### 4. Preview in Canva
|
||||
|
||||
Serve the bundle and point the Developer Portal's Development URL at it. With the
|
||||
Canva CLI:
|
||||
|
||||
```bash
|
||||
npx @canva/cli@latest login
|
||||
pnpm --filter @hanzo/canva watch # rebuilds dist/ on change
|
||||
npx @canva/cli@latest apps preview # opens the app in the Canva editor, hot-reloading
|
||||
```
|
||||
|
||||
Open any design, launch **Hanzo AI** from the app side panel, paste your `hk-` key,
|
||||
pick a model, and run an action. Select text on the canvas to enable **Rewrite** and
|
||||
**Translate**.
|
||||
|
||||
### Getting a Hanzo `hk-` key
|
||||
|
||||
Sign in at <https://hanzo.id> and mint an API key (prefixed `hk-`). The panel
|
||||
validates the prefix before use and sends it as the bearer to `api.hanzo.ai`. The key
|
||||
lives only in the panel's session state — it is never persisted or bundled.
|
||||
|
||||
## Submit to the Canva Apps Marketplace
|
||||
|
||||
1. Finish and test the app in preview; ensure `pnpm build` is clean and `dist/` loads.
|
||||
2. Host `dist/` on your production origin (behind `hanzoai/ingress` + the `hanzoai/static` plugin — never nginx/caddy) and set the app's production **App source URL** to it.
|
||||
3. In the Developer Portal, complete **App listing** (name, icon, description, screenshots) and **Submit for review**.
|
||||
4. Canva reviews for the requested scopes (Design editing here) and UX. Address feedback and resubmit; on approval the app is published to the Apps Marketplace.
|
||||
|
||||
## Tests
|
||||
|
||||
`test/design-core.test.ts` covers the pure core: whitespace collapse, context
|
||||
assembly + budget truncation, message fencing (data vs. task, the truncation note,
|
||||
the no-context path), the five prompt builders (brief/tweak/language/count/question
|
||||
embedding and the count clamp), the `id → messages` path, and the response parsers
|
||||
(fence/quote stripping, idempotence, list-marker stripping). `test/hanzo.test.ts`
|
||||
asserts the `@hanzo/ai` request shaping against a mock client (model, `stream:false`,
|
||||
temperature, messages, abort signal, empty-content error, model listing).
|
||||
`test/config.test.ts` covers the `/v1` endpoint builders and the `hk-` key guard.
|
||||
|
||||
```
|
||||
Test Files 3 passed (3)
|
||||
Tests 40 passed (40)
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
// Build @hanzo/canva into dist/: bundle the App UI Kit side-panel app
|
||||
// (src/index.tsx) as ESM for the browser and stamp index.html with the entry. No
|
||||
// framework — esbuild + Node stdlib only, mirroring @hanzo/shopify's build. The
|
||||
// Canva CLI (`canva apps preview`) serves this dist/ (or points at its own dev
|
||||
// server); either way the app is a static bundle Canva loads in the app iframe.
|
||||
// Every model call goes to api.hanzo.ai via @hanzo/ai; the app holds no secret —
|
||||
// the user pastes their own hk- key.
|
||||
//
|
||||
// node build.js → production build (dist/index.html, dist/app.js, dist/app.css)
|
||||
// node build.js --watch → rebuild on change
|
||||
|
||||
import esbuild from 'esbuild';
|
||||
import { rmSync, mkdirSync, readFileSync, writeFileSync, existsSync } 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 the panel as ESM for the browser. React + App UI Kit are bundled in
|
||||
// (the page is served standalone, no import map); the App UI Kit stylesheet
|
||||
// import emits dist/app.css.
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: { app: join(src, 'index.tsx') },
|
||||
outdir: dist,
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
|
||||
jsx: 'automatic',
|
||||
loader: { '.css': 'css', '.svg': 'dataurl', '.png': 'dataurl', '.woff': 'dataurl', '.woff2': 'dataurl' },
|
||||
define: { 'process.env.NODE_ENV': JSON.stringify(watch ? 'development' : 'production') },
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
logLevel: 'info',
|
||||
});
|
||||
await ctx.rebuild();
|
||||
|
||||
// Stamp index.html with the entry.
|
||||
const html = readFileSync(join(src, 'index.html'), 'utf8').split('__ENTRY__').join('app.js');
|
||||
writeFileSync(join(dist, 'index.html'), html);
|
||||
|
||||
console.log('Hanzo AI for Canva built -> dist/ (App: dist/index.html)');
|
||||
|
||||
if (watch) {
|
||||
await ctx.watch();
|
||||
console.log('watching...');
|
||||
} else {
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
build()
|
||||
.then(() => {
|
||||
if (!watch && !existsSync(join(dist, 'app.css'))) {
|
||||
// The App UI Kit ships its stylesheet via '@canva/app-ui-kit/styles.css'
|
||||
// (imported in index.tsx); esbuild bundles it to app.css. If a future path
|
||||
// changes, warn rather than ship an unstyled app.
|
||||
console.warn('note: dist/app.css not emitted — check the App UI Kit styles import in index.tsx');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@hanzo/canva",
|
||||
"version": "0.1.0",
|
||||
"description": "Hanzo AI for Canva — a side-panel Apps SDK app: generate marketing copy, rewrite the selected text, translate, brainstorm content ideas, and ask about the design. An @canva/app-ui-kit React panel that reads the selection/page via @canva/design and adds/replaces text elements, running the model gateway through the published @hanzo/ai over api.hanzo.ai.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"watch": "node build.js --watch",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/ai": "^0.2.0",
|
||||
"@hanzo/iam": "^0.13.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@canva/app-ui-kit": "^5.0.0",
|
||||
"@canva/asset": "^2.0.0",
|
||||
"@canva/design": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@canva/app-ui-kit": "^5.12.0",
|
||||
"@canva/asset": "^2.3.0",
|
||||
"@canva/design": "^2.10.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"esbuild": "^0.25.8",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"hanzo",
|
||||
"canva",
|
||||
"apps-sdk",
|
||||
"copywriting",
|
||||
"marketing-copy",
|
||||
"translate",
|
||||
"ai",
|
||||
"design",
|
||||
"app-ui-kit"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/extension.git",
|
||||
"directory": "packages/canva"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// The Hanzo AI assistant panel — a Canva App UI Kit React component. All the
|
||||
// logic-heavy work lives in tested, pure modules: the action catalog + prompt
|
||||
// assembly + response parsing in design-core.ts, the model call in hanzo.ts
|
||||
// (@hanzo/ai), and the design I/O in canva.ts (@canva/design). This component
|
||||
// only binds them to App UI Kit widgets and holds the panel's small UI state
|
||||
// (the pasted key, the picked model, the chosen action, the instruction, the
|
||||
// output). It never speaks to api.hanzo.ai or the Canva SDK directly.
|
||||
//
|
||||
// Flow: pick an action → (for generate/ideas/ask) type a brief, (for rewrite/
|
||||
// translate) select text on the canvas → Run → design-core builds the request
|
||||
// from the observed selection/page → hanzo.ts calls the model → design-core
|
||||
// parses the reply → the panel shows it and inserts it via canva.ts (replace the
|
||||
// selection in place, or add a new text element).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
FormField,
|
||||
LoadingIndicator,
|
||||
MultilineInput,
|
||||
Rows,
|
||||
Select,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@canva/app-ui-kit';
|
||||
import {
|
||||
buildActionMessages,
|
||||
designActionList,
|
||||
parseCopy,
|
||||
parseIdeas,
|
||||
DESIGN_ACTIONS,
|
||||
type ActionInputs,
|
||||
type DesignActionId,
|
||||
type DesignContext,
|
||||
} from './design-core.js';
|
||||
import { runChat, listModels } from './hanzo.js';
|
||||
import { onSelectionChange, replaceSelectedText, insertText, type SelectionSnapshot } from './canva.js';
|
||||
import { isHanzoKey, DEFAULT_MODEL } from './config.js';
|
||||
|
||||
// The catalog the panel renders, derived from design-core so labels/flags never
|
||||
// drift from what will actually run.
|
||||
const ACTIONS = designActionList();
|
||||
|
||||
type Status = { tone: 'positive' | 'critical' | 'info'; text: string } | null;
|
||||
|
||||
export function App(): JSX.Element {
|
||||
const [key, setKey] = useState('');
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [model, setModel] = useState(DEFAULT_MODEL);
|
||||
const [action, setAction] = useState<DesignActionId>('generate');
|
||||
const [instruction, setInstruction] = useState('');
|
||||
const [language, setLanguage] = useState('Spanish');
|
||||
const [selection, setSelection] = useState<SelectionSnapshot>({ count: 0, text: '' });
|
||||
const [output, setOutput] = useState('');
|
||||
const [ideas, setIdeas] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<Status>(null);
|
||||
|
||||
const keyValid = isHanzoKey(key);
|
||||
const spec = DESIGN_ACTIONS[action];
|
||||
|
||||
// Track the live plaintext selection so rewrite/translate know what they act on
|
||||
// and the panel can gate those actions until something is selected.
|
||||
useEffect(() => onSelectionChange(setSelection), []);
|
||||
|
||||
// Load the model catalog once a valid key is pasted (org-scoped by the bearer).
|
||||
useEffect(() => {
|
||||
if (!keyValid) return;
|
||||
let live = true;
|
||||
listModels({ token: key })
|
||||
.then((ids) => {
|
||||
if (!live) return;
|
||||
setModels(ids);
|
||||
if (ids.length && !ids.includes(model)) setModel(ids[0]);
|
||||
})
|
||||
.catch(() => live && setModels([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keyValid, key]);
|
||||
|
||||
// The design context we can observe right now — handed to design-core to fence
|
||||
// as data. The page text is left to a future read; the selection is the field
|
||||
// rewrite/translate act on and is always current.
|
||||
const context = useMemo<DesignContext>(
|
||||
() => ({ selection: selection.text || undefined }),
|
||||
[selection.text],
|
||||
);
|
||||
|
||||
const inputs = useMemo<ActionInputs>(
|
||||
() => ({ instruction, language }),
|
||||
[instruction, language],
|
||||
);
|
||||
|
||||
// canRun encodes the action's preconditions: a valid key, plus a selection for
|
||||
// selection-actions and an instruction for instruction-actions.
|
||||
const missing =
|
||||
!keyValid
|
||||
? 'Paste your Hanzo hk- key to begin.'
|
||||
: spec.needsSelection && selection.count === 0
|
||||
? 'Select text on your design to run this action.'
|
||||
: spec.needsInstruction && instruction.trim() === ''
|
||||
? `Enter ${action === 'ask' ? 'a question' : 'a brief'} to run this action.`
|
||||
: '';
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (missing) {
|
||||
setStatus({ tone: 'info', text: missing });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setStatus(null);
|
||||
setOutput('');
|
||||
setIdeas([]);
|
||||
try {
|
||||
const messages = buildActionMessages(action, inputs, context);
|
||||
const reply = await runChat(messages, { token: key, model });
|
||||
if (action === 'ideas') {
|
||||
setIdeas(parseIdeas(reply));
|
||||
} else {
|
||||
setOutput(parseCopy(reply));
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus({ tone: 'critical', text: e instanceof Error ? e.message : 'Generation failed' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [missing, action, inputs, context, key, model]);
|
||||
|
||||
// Insert the generated copy. Rewrite replaces the selection in place; every
|
||||
// other action adds a new text element at the cursor.
|
||||
const place = useCallback(
|
||||
async (text: string) => {
|
||||
setBusy(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
if (action === 'rewrite') {
|
||||
await replaceSelectedText(text);
|
||||
setStatus({ tone: 'positive', text: 'Replaced the selected text.' });
|
||||
} else {
|
||||
await insertText(text, action === 'generate' ? 32 : 18);
|
||||
setStatus({ tone: 'positive', text: 'Added to your design.' });
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus({ tone: 'critical', text: e instanceof Error ? e.message : 'Could not add to design' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[action],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box padding="2u">
|
||||
<Rows spacing="2u">
|
||||
<Title size="small">Hanzo AI</Title>
|
||||
|
||||
<FormField
|
||||
label="Hanzo API key"
|
||||
description={keyValid ? 'Key looks valid.' : 'Paste your hk- key from hanzo.id'}
|
||||
control={(props) => (
|
||||
<TextInput {...props} placeholder="hk-..." value={key} onChange={setKey} />
|
||||
)}
|
||||
/>
|
||||
|
||||
{models.length > 0 && (
|
||||
<FormField
|
||||
label="Model"
|
||||
control={() => (
|
||||
<Select
|
||||
stretch
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={models.map((id) => ({ value: id, label: id }))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Action"
|
||||
control={() => (
|
||||
<Select
|
||||
stretch
|
||||
value={action}
|
||||
onChange={(v) => setAction(v as DesignActionId)}
|
||||
options={ACTIONS.map((a) => ({ value: a.id, label: a.label }))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{action === 'translate' ? (
|
||||
<FormField
|
||||
label="Target language"
|
||||
control={(props) => (
|
||||
<TextInput {...props} placeholder="Spanish" value={language} onChange={setLanguage} />
|
||||
)}
|
||||
/>
|
||||
) : spec.needsInstruction ? (
|
||||
<FormField
|
||||
label={action === 'ask' ? 'Question' : action === 'ideas' ? 'Topic' : 'Brief'}
|
||||
control={() => (
|
||||
<MultilineInput
|
||||
autoGrow
|
||||
minRows={2}
|
||||
placeholder={
|
||||
action === 'ask'
|
||||
? 'What tone should this poster use?'
|
||||
: action === 'ideas'
|
||||
? 'Summer sale for a coffee brand'
|
||||
: 'A bold headline for our spring launch'
|
||||
}
|
||||
value={instruction}
|
||||
onChange={setInstruction}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{spec.needsSelection && (
|
||||
<Text size="small" tone="tertiary">
|
||||
{selection.count > 0
|
||||
? `Selected: ${selection.text.slice(0, 80)}${selection.text.length > 80 ? '…' : ''}`
|
||||
: 'Nothing selected — select text on your design.'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button variant="primary" onClick={() => void run()} disabled={busy} stretch loading={busy}>
|
||||
{spec.label}
|
||||
</Button>
|
||||
|
||||
{busy && <LoadingIndicator />}
|
||||
{status && <Alert tone={status.tone}>{status.text}</Alert>}
|
||||
|
||||
{output && (
|
||||
<Rows spacing="1u">
|
||||
<FormField
|
||||
label="Result"
|
||||
control={() => (
|
||||
<MultilineInput autoGrow minRows={3} value={output} onChange={setOutput} />
|
||||
)}
|
||||
/>
|
||||
<Button variant="primary" onClick={() => void place(output)} disabled={busy} stretch>
|
||||
{action === 'rewrite' ? 'Replace selection' : 'Add to design'}
|
||||
</Button>
|
||||
</Rows>
|
||||
)}
|
||||
|
||||
{ideas.length > 0 && (
|
||||
<Rows spacing="1u">
|
||||
<Text size="small" tone="tertiary">Content ideas</Text>
|
||||
{ideas.map((idea, i) => (
|
||||
<Button key={i} variant="secondary" onClick={() => void place(idea)} disabled={busy} stretch>
|
||||
{idea}
|
||||
</Button>
|
||||
))}
|
||||
</Rows>
|
||||
)}
|
||||
</Rows>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// The thin adapter over @canva/design — the only place the Canva Apps SDK is
|
||||
// touched. It turns Canva's event-driven, draft-based design model into the two
|
||||
// operations the panel needs: OBSERVE the current selection (its plaintext + a
|
||||
// mutable handle to overwrite it) and INSERT AI output onto the canvas (add a new
|
||||
// text element, or replace the selected text in place). All the AI logic lives in
|
||||
// design-core.ts (pure) and hanzo.ts (@hanzo/ai); this module carries no prompts
|
||||
// and no model calls — it is pure design I/O.
|
||||
//
|
||||
// Canva's contract (@canva/design 2.x):
|
||||
// - `selection.registerOnChange({ scope: 'plaintext', onChange })` fires with a
|
||||
// `count` and a `read()` that yields a draft whose `contents[].text` are the
|
||||
// selected text runs; mutating them and `draft.save()` writes them back.
|
||||
// - `addElementAtCursor(textElement)` / `addNativeElement(textElement)` add a
|
||||
// new text element at the user's cursor / onto the current page.
|
||||
// - `getCurrentPageContext()` yields the page dimensions (used only to size a
|
||||
// from-scratch insert sensibly).
|
||||
|
||||
import {
|
||||
selection,
|
||||
addElementAtCursor,
|
||||
type TextElement,
|
||||
} from '@canva/design';
|
||||
|
||||
// ---- Selection observation ------------------------------------------------
|
||||
|
||||
// SelectionSnapshot is what the panel needs to reason about the current
|
||||
// selection without holding a Canva draft: how many text elements are selected
|
||||
// and their combined plaintext (for context assembly and for showing the user
|
||||
// what rewrite/translate will act on). A draft is short-lived and must be
|
||||
// re-read at write time, so we never stash it — see replaceSelectedText.
|
||||
export interface SelectionSnapshot {
|
||||
/** Number of selected plaintext elements. 0 means nothing text is selected. */
|
||||
count: number;
|
||||
/** The selected text runs joined with newlines — '' when count is 0. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
// onSelectionChange subscribes to plaintext selection changes and calls back with
|
||||
// a SelectionSnapshot each time. It reads the draft only to extract the text (it
|
||||
// never mutates or saves here) and returns the SDK's unsubscribe function so the
|
||||
// panel can tear the listener down on unmount. This is the panel's single source
|
||||
// of "what is selected right now".
|
||||
export function onSelectionChange(cb: (snap: SelectionSnapshot) => void): () => void {
|
||||
return selection.registerOnChange({
|
||||
scope: 'plaintext',
|
||||
onChange: async (event) => {
|
||||
if (event.count === 0) {
|
||||
cb({ count: 0, text: '' });
|
||||
return;
|
||||
}
|
||||
const draft = await event.read();
|
||||
const text = draft.contents.map((c) => c.text).join('\n');
|
||||
cb({ count: event.count, text });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Writing output onto the canvas ---------------------------------------
|
||||
|
||||
// replaceSelectedText overwrites the currently-selected plaintext with `text`.
|
||||
// Canva requires the write to happen inside a fresh draft read (the snapshot the
|
||||
// panel holds may be stale), so we register a one-shot listener, read the current
|
||||
// draft, write every selected run — the first run gets the full replacement and
|
||||
// any further runs are cleared so N selected elements collapse to the one new
|
||||
// value — save, and unsubscribe. Resolves once saved; rejects if nothing is
|
||||
// selected when the write is attempted.
|
||||
export function replaceSelectedText(text: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let done = false;
|
||||
const unsubscribe = selection.registerOnChange({
|
||||
scope: 'plaintext',
|
||||
onChange: async (event) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
try {
|
||||
if (event.count === 0) {
|
||||
reject(new Error('No text is selected to replace'));
|
||||
return;
|
||||
}
|
||||
const draft = await event.read();
|
||||
draft.contents.forEach((c, i) => {
|
||||
c.text = i === 0 ? text : '';
|
||||
});
|
||||
await draft.save();
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// insertText adds a new text element carrying `text` at the user's cursor. Used
|
||||
// for generate / ideas / translate-as-new — anything that adds copy rather than
|
||||
// editing the selection. `fontSize` sizes it (a headline larger than a caption);
|
||||
// Canva positions it at the cursor.
|
||||
export function insertText(text: string, fontSize = 24): Promise<void> {
|
||||
const element: TextElement = {
|
||||
type: 'text',
|
||||
children: [text],
|
||||
fontSize,
|
||||
};
|
||||
return addElementAtCursor(element);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Canva app config — the api.hanzo.ai model gateway (default model + endpoints)
|
||||
// and the design-context character budget. Endpoints and the bearer choice
|
||||
// mirror @hanzo/shopify / @hanzo/figma so the productivity suite stays DRY; the
|
||||
// design-specific pieces (context assembly, the AI actions, response parsing)
|
||||
// live in design-core.ts, and the two thin adapters — @hanzo/ai in hanzo.ts and
|
||||
// @canva/design in canva.ts — carry the I/O.
|
||||
|
||||
// ---- 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 per-request via the 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 (owner-scoping validation via @hanzo/iam).
|
||||
// The Canva app itself pastes an `hk-` key; these exist so a Hanzo-hosted
|
||||
// backend can validate one under the shared <org>-<app> naming scheme.
|
||||
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
|
||||
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-canva';
|
||||
|
||||
// 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`;
|
||||
}
|
||||
|
||||
// ---- Design context budget ------------------------------------------------
|
||||
//
|
||||
// A Canva design's on-page text (the selection plus the surrounding page text
|
||||
// we assemble as context) can run long — a poster's body copy, a slide's notes.
|
||||
// This caps the characters of design context we attach to any one request so it
|
||||
// fits comfortably in a model window alongside the reply. Honest truncation,
|
||||
// never silent drop (design-core marks `truncated`).
|
||||
export const DESIGN_CHAR_BUDGET = 12_000;
|
||||
|
||||
// A Hanzo API key is minted by hanzo.id and is always prefixed `hk-`. isHanzoKey
|
||||
// is the boundary guard: the panel validates a 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 trailing 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,335 @@
|
||||
// The design action catalog and everything pure around it: the prompt builders
|
||||
// for copy / rewrite / translate / ideas / ask, the design-context assembly and
|
||||
// truncation, the message shaping @hanzo/ai takes, and the response parsing that
|
||||
// turns a model reply into paste-ready text. NO @canva imports, NO @hanzo/ai
|
||||
// imports, NO DOM — this module is host-agnostic and fully unit-testable. The
|
||||
// panel (app.tsx) reads a selection / page via canva.ts, hands the text here to
|
||||
// build a request, sends it through hanzo.ts, and parses the reply back here
|
||||
// before inserting it via canva.ts.
|
||||
//
|
||||
// It mirrors @hanzo/figma's design-core so the two design apps stay consistent:
|
||||
// the same five action families, the same context/prompt/parse contracts. There
|
||||
// is exactly 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 design context provided. It forbids
|
||||
// inventing facts about the brand or product (the failure mode that makes a
|
||||
// marketing assistant dangerous — fake claims, fake prices, fake stats), and
|
||||
// keeps output ready to drop onto the canvas. Brand-neutral, designer-facing.
|
||||
export const SYSTEM_PROMPT =
|
||||
'You are Hanzo AI, a copywriting assistant embedded in Canva. You help create ' +
|
||||
'marketing copy — headlines, captions, body copy, calls to action — and edit ' +
|
||||
'the text already on the design. Work from the design context provided below; ' +
|
||||
'never invent facts, prices, statistics, dates, or claims that are not ' +
|
||||
'supported by it or by the user\'s instruction. Write in clear, natural, ' +
|
||||
'on-brand prose sized for a design surface — punchy for headlines, concise for ' +
|
||||
'captions. Return ONLY the requested text, ready to drop onto the canvas — no ' +
|
||||
'preamble, no explanation, no surrounding quotes, no markdown fences.';
|
||||
|
||||
// ---- Design context assembly ----------------------------------------------
|
||||
//
|
||||
// The panel gathers what it can see about the design — the selected text (the
|
||||
// primary subject of rewrite/translate) and the other text on the current page
|
||||
// (surrounding voice/context) — and hands it here as a DesignContext. We render
|
||||
// it into a compact, labeled block the model reads as data (never as
|
||||
// instructions), and cap the whole block at the budget. Honest truncation.
|
||||
|
||||
// What the panel can observe about the design and pass to a prompt builder. All
|
||||
// fields optional: a blank design has none, a fresh "generate" has only a brief.
|
||||
export interface DesignContext {
|
||||
/** The user's selected text — the subject of rewrite/translate. */
|
||||
selection?: string;
|
||||
/** Other text on the current page, in reading order — surrounding voice. */
|
||||
pageText?: string[];
|
||||
/** The design's title, if the app can read it. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// The result of rendering a DesignContext down to what we attach: the text and
|
||||
// whether anything was truncated (so the prompt and UI can say so honestly).
|
||||
export interface RenderedContext {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
// collapseWhitespace normalizes a text fragment for the context block: collapse
|
||||
// runs of whitespace (including the newlines Canva stores between text lines)
|
||||
// into single spaces and trim. Pure. The model does not need the layout, and
|
||||
// raw newlines waste budget and blur the data/instruction boundary.
|
||||
export function collapseWhitespace(s: string): string {
|
||||
return s.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// buildDesignContext renders the observable design into a labeled block, capped
|
||||
// at `budget` characters. The selection is the field the model most needs (it is
|
||||
// what rewrite/translate act on), so it is rendered first and the surrounding
|
||||
// page text — the longest, least essential field — is what truncation trims.
|
||||
// Pure and total. `truncated` is true whenever the render was cut to fit.
|
||||
export function buildDesignContext(ctx: DesignContext, budget: number = DESIGN_CHAR_BUDGET): RenderedContext {
|
||||
const lines: string[] = [];
|
||||
if (ctx.title) {
|
||||
const t = collapseWhitespace(ctx.title);
|
||||
if (t) lines.push(`Design title: ${t}`);
|
||||
}
|
||||
if (ctx.selection) {
|
||||
const sel = collapseWhitespace(ctx.selection);
|
||||
if (sel) lines.push(`Selected text: ${sel}`);
|
||||
}
|
||||
if (ctx.pageText && ctx.pageText.length) {
|
||||
const page = ctx.pageText.map(collapseWhitespace).filter(Boolean);
|
||||
if (page.length) lines.push(`Other text on this page: ${page.join(' | ')}`);
|
||||
}
|
||||
|
||||
const full = lines.join('\n');
|
||||
if (full.length <= budget) return { text: full, truncated: false };
|
||||
return { text: full.slice(0, budget), truncated: true };
|
||||
}
|
||||
|
||||
// DESIGN_CHAR_BUDGET default mirrors config.ts. Re-declared here as the builder's
|
||||
// own default so design-core stays importable with zero config dependency (the
|
||||
// panel passes the config value explicitly when it wants to; tests do not).
|
||||
const DESIGN_CHAR_BUDGET = 12_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 design.
|
||||
function contextNote(truncated: boolean): string {
|
||||
return truncated
|
||||
? 'Note: the design context below was truncated to fit — answer only from what is shown.'
|
||||
: '';
|
||||
}
|
||||
|
||||
// buildMessages fences the design context 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 (a from-scratch
|
||||
// generate with only a brief), 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
|
||||
? `---- design ----\n${rendered.text}\n---- end design ----\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 (a brief, a target language, a topic) and returns the task string that
|
||||
// is layered onto the fenced context by buildMessages. Some actions ignore the
|
||||
// context (generate, ideas, ask-with-no-selection); some require the selection
|
||||
// (rewrite, translate). Keeping each action's task in one builder means the
|
||||
// panel, the picker, and any test resolve prompts one way.
|
||||
|
||||
// The inputs a design action may take. All optional; each builder reads only the
|
||||
// fields it needs and ignores the rest. `instruction` is the user's freeform
|
||||
// text (the brief for generate, the question for ask, the tweak for rewrite);
|
||||
// `language` is the target for translate; `count` bounds an ideas list.
|
||||
export interface ActionInputs {
|
||||
instruction?: string;
|
||||
language?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
// clampCount bounds an ideas request to a sane, promptable range. Pure.
|
||||
function clampCount(count: number | undefined): number {
|
||||
if (!count || !Number.isFinite(count)) return 5;
|
||||
return Math.max(1, Math.min(10, Math.floor(count)));
|
||||
}
|
||||
|
||||
// The five design actions. Each `build` returns the task string. `needsSelection`
|
||||
// marks the actions that operate on the selected text (the panel disables them
|
||||
// until something is selected). `needsInstruction` marks the actions that
|
||||
// require a user brief/question (generate, ask) so the panel can validate before
|
||||
// sending. Frozen so the catalog is immutable at runtime.
|
||||
export const DESIGN_ACTIONS = {
|
||||
generate: {
|
||||
label: 'Generate copy',
|
||||
needsSelection: false,
|
||||
needsInstruction: true,
|
||||
build: (i: ActionInputs): string => {
|
||||
const brief = (i.instruction ?? '').trim();
|
||||
return (
|
||||
`Write marketing copy for this design based on the brief: "${brief}". ` +
|
||||
'Return polished, on-brand copy sized for a design surface — a headline, ' +
|
||||
'a short caption, or a few tight lines as the brief implies. Ground every ' +
|
||||
'claim in the brief and the design context. Return only the copy.'
|
||||
);
|
||||
},
|
||||
},
|
||||
rewrite: {
|
||||
label: 'Rewrite selected text',
|
||||
needsSelection: 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 persuasive, keeping the same meaning and roughly the same length.';
|
||||
return (
|
||||
`Rewrite the selected text above. ${how} Do NOT add any fact not present ` +
|
||||
'in the design context or the instruction. Return only the rewritten text.'
|
||||
);
|
||||
},
|
||||
},
|
||||
translate: {
|
||||
label: 'Translate',
|
||||
needsSelection: true,
|
||||
needsInstruction: false,
|
||||
build: (i: ActionInputs): string => {
|
||||
const lang = (i.language ?? '').trim() || 'Spanish';
|
||||
return (
|
||||
`Translate the selected text above into ${lang}. Preserve the tone, ` +
|
||||
'intent, and any brand names verbatim; localize idioms naturally rather ' +
|
||||
'than translating word for word. Return only the translated text.'
|
||||
);
|
||||
},
|
||||
},
|
||||
ideas: {
|
||||
label: 'Content ideas',
|
||||
needsSelection: false,
|
||||
needsInstruction: true,
|
||||
build: (i: ActionInputs): string => {
|
||||
const topic = (i.instruction ?? '').trim();
|
||||
const n = clampCount(i.count);
|
||||
return (
|
||||
`Suggest ${n} distinct content ideas for this design about: "${topic}". ` +
|
||||
'Each idea a single short line (a headline or angle), grounded in the ' +
|
||||
'topic and the design context. Return a numbered list, nothing else.'
|
||||
);
|
||||
},
|
||||
},
|
||||
ask: {
|
||||
label: 'Ask',
|
||||
needsSelection: false,
|
||||
needsInstruction: true,
|
||||
build: (i: ActionInputs): string => {
|
||||
const q = (i.instruction ?? '').trim();
|
||||
return (
|
||||
`Answer this question about the design or its copy: "${q}". Answer only ` +
|
||||
'from the design context and general copywriting knowledge; if the ' +
|
||||
'context does not support an answer, say so plainly.'
|
||||
);
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
// A design action id from the catalog.
|
||||
export type DesignActionId = keyof typeof DESIGN_ACTIONS;
|
||||
|
||||
// isDesignActionId narrows an arbitrary string to a known id. Boundary guard —
|
||||
// the panel validates an inbound id here before running it.
|
||||
export function isDesignActionId(id: string): id is DesignActionId {
|
||||
return Object.prototype.hasOwnProperty.call(DESIGN_ACTIONS, id);
|
||||
}
|
||||
|
||||
// designActionList is the ordered catalog for building the UI, derived from the
|
||||
// action map so the panel and the catalog can never drift.
|
||||
export function designActionList(): Array<{
|
||||
id: DesignActionId;
|
||||
label: string;
|
||||
needsSelection: boolean;
|
||||
needsInstruction: boolean;
|
||||
}> {
|
||||
return (Object.keys(DESIGN_ACTIONS) as DesignActionId[]).map((id) => {
|
||||
const a = DESIGN_ACTIONS[id];
|
||||
return { id, label: a.label, needsSelection: a.needsSelection, 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 panel calls.
|
||||
export function actionTask(id: string, inputs: ActionInputs = {}): string {
|
||||
if (!isDesignActionId(id)) throw new Error(`Unknown design action: ${id}`);
|
||||
return DESIGN_ACTIONS[id].build(inputs);
|
||||
}
|
||||
|
||||
// buildActionMessages is the single path from an (action id, inputs, observed
|
||||
// design) 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 panel calls exactly this, then hands the result to hanzo.ts.
|
||||
export function buildActionMessages(
|
||||
id: string,
|
||||
inputs: ActionInputs,
|
||||
ctx: DesignContext,
|
||||
budget: number = DESIGN_CHAR_BUDGET,
|
||||
): ChatMessage[] {
|
||||
const task = actionTask(id, inputs);
|
||||
return buildMessages(task, buildDesignContext(ctx, budget));
|
||||
}
|
||||
|
||||
// ---- Response parsing -----------------------------------------------------
|
||||
//
|
||||
// Models sometimes wrap paste-ready text despite the system prompt: a leading
|
||||
// "Here's your headline:", a Markdown code fence, or matched surrounding quotes.
|
||||
// parseCopy strips those so the text drops cleanly onto the canvas. 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 headline. 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);
|
||||
// Only strip if the inner text has no unescaped copy of the closing quote,
|
||||
// i.e. the quotes truly wrap the whole thing rather than being content.
|
||||
if (!inner.includes(close)) return inner;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// parseCopy 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 panel uses before inserting.
|
||||
export function parseCopy(raw: string): string {
|
||||
let s = raw.trim();
|
||||
s = stripFence(s).trim();
|
||||
s = stripWrappingQuotes(s).trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
// parseIdeas turns a raw "ideas" reply — a numbered or bulleted list — into a
|
||||
// clean array of idea lines, dropping the list markers so each idea can be shown
|
||||
// as its own item and inserted individually. Pure. Blank lines and pure-marker
|
||||
// lines are dropped; a reply with no list structure yields its non-empty lines.
|
||||
export function parseIdeas(raw: string): string[] {
|
||||
return raw
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^\s*(?:\d+[.)]|[-*•])\s+/, '').trim())
|
||||
.map((line) => stripWrappingQuotes(line).trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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 the two
|
||||
// primitives the panel needs: run a built message list to text, and list models.
|
||||
// The design-aware layer (context, prompts, parsing) is entirely in
|
||||
// design-core.ts; the Canva-aware layer (read selection, insert text) is in
|
||||
// canva.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 './design-core.js';
|
||||
|
||||
export interface AskOptions {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
/** Hanzo `hk-` key pasted by the user; 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 path from a built message list to the model. Every Canva
|
||||
// surface (every action) funnels here. Non-streaming: the panel renders the
|
||||
// final text and inserts it. token may be empty (the gateway serves anonymous/
|
||||
// limited models), but the panel 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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hanzo AI for Canva</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="__ENTRY__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
// The Canva app entry — mounts the App UI Kit panel into the side-panel root
|
||||
// under AppUiProvider (which supplies the Canva theme + tokens the App UI Kit
|
||||
// components read). Canva loads this bundle in the app iframe; the element with
|
||||
// id `root` is the panel's mount point (see index.html). This file is the ONLY
|
||||
// DOM entry — all UI is in app.tsx, all logic in the pure/thin modules.
|
||||
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { AppUiProvider } from '@canva/app-ui-kit';
|
||||
import '@canva/app-ui-kit/styles.css';
|
||||
import { App } from './app.js';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('Missing #root mount element');
|
||||
|
||||
createRoot(root).render(
|
||||
<AppUiProvider>
|
||||
<App />
|
||||
</AppUiProvider>,
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
HANZO_API_BASE_URL,
|
||||
DEFAULT_MODEL,
|
||||
chatCompletionsURL,
|
||||
modelsURL,
|
||||
isHanzoKey,
|
||||
} from '../src/config';
|
||||
|
||||
describe('gateway config', () => {
|
||||
it('points at api.hanzo.ai with /v1 endpoints (never /api/)', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHanzoKey', () => {
|
||||
it('accepts a well-formed hk- key (trimming whitespace)', () => {
|
||||
expect(isHanzoKey('hk-abcdef0123456789ABCDEF')).toBe(true);
|
||||
expect(isHanzoKey(' hk-abcdef0123456789ABCDEF ')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the wrong prefix, too-short, or non-string', () => {
|
||||
expect(isHanzoKey('sk-abcdef0123456789ABCDEF')).toBe(false);
|
||||
expect(isHanzoKey('hk-short')).toBe(false);
|
||||
expect(isHanzoKey('')).toBe(false);
|
||||
expect(isHanzoKey(undefined)).toBe(false);
|
||||
expect(isHanzoKey(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
SYSTEM_PROMPT,
|
||||
collapseWhitespace,
|
||||
buildDesignContext,
|
||||
buildMessages,
|
||||
buildActionMessages,
|
||||
DESIGN_ACTIONS,
|
||||
designActionList,
|
||||
isDesignActionId,
|
||||
actionTask,
|
||||
parseCopy,
|
||||
parseIdeas,
|
||||
type ChatMessage,
|
||||
type DesignContext,
|
||||
} from '../src/design-core';
|
||||
|
||||
describe('collapseWhitespace', () => {
|
||||
it('collapses runs of whitespace and newlines and trims', () => {
|
||||
expect(collapseWhitespace(' a\n\n b\t c ')).toBe('a b c');
|
||||
});
|
||||
it('is idempotent on clean text', () => {
|
||||
expect(collapseWhitespace('already clean')).toBe('already clean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDesignContext', () => {
|
||||
it('renders title, selection, then page text as labeled data', () => {
|
||||
const ctx: DesignContext = {
|
||||
title: 'Spring Launch',
|
||||
selection: 'Big savings\nthis week',
|
||||
pageText: ['Shop now', ' Limited time '],
|
||||
};
|
||||
const r = buildDesignContext(ctx);
|
||||
expect(r.truncated).toBe(false);
|
||||
expect(r.text).toContain('Design title: Spring Launch');
|
||||
expect(r.text).toContain('Selected text: Big savings this week');
|
||||
expect(r.text).toContain('Other text on this page: Shop now | Limited time');
|
||||
// Selection comes before page text (it is what the model most needs).
|
||||
expect(r.text.indexOf('Selected text')).toBeLessThan(r.text.indexOf('Other text on this page'));
|
||||
});
|
||||
|
||||
it('omits absent fields and yields empty text for an empty design', () => {
|
||||
expect(buildDesignContext({})).toEqual({ text: '', truncated: false });
|
||||
const onlySel = buildDesignContext({ selection: 'x' });
|
||||
expect(onlySel.text).toBe('Selected text: x');
|
||||
});
|
||||
|
||||
it('drops blank page-text entries', () => {
|
||||
const r = buildDesignContext({ pageText: ['', ' ', 'kept'] });
|
||||
expect(r.text).toBe('Other text on this page: kept');
|
||||
});
|
||||
|
||||
it('truncates to the budget and flags it', () => {
|
||||
const r = buildDesignContext({ selection: 'x'.repeat(100) }, 40);
|
||||
expect(r.truncated).toBe(true);
|
||||
expect(r.text.length).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMessages', () => {
|
||||
it('system grounds, design is fenced as data, task last', () => {
|
||||
const msgs = buildMessages('Do the thing.', { text: 'Selected text: hi', truncated: false });
|
||||
expect(msgs[0]).toEqual<ChatMessage>({ role: 'system', content: SYSTEM_PROMPT });
|
||||
expect(msgs[1].role).toBe('user');
|
||||
expect(msgs[1].content).toContain('---- design ----');
|
||||
expect(msgs[1].content).toContain('Selected text: hi');
|
||||
expect(msgs[1].content).toContain('---- end design ----');
|
||||
expect(msgs[1].content).toContain('---- task ----\nDo the thing.');
|
||||
});
|
||||
|
||||
it('omits the fence when there is no context', () => {
|
||||
const msgs = buildMessages('Generate.', { text: '', truncated: false });
|
||||
expect(msgs[1].content).not.toContain('---- design ----');
|
||||
expect(msgs[1].content).toBe('---- task ----\nGenerate.');
|
||||
});
|
||||
|
||||
it('prepends an honest truncation note when the context was cut', () => {
|
||||
const msgs = buildMessages('Rewrite.', { text: 'partial', truncated: true });
|
||||
expect(msgs[1].content).toContain('truncated to fit');
|
||||
expect(msgs[1].content.indexOf('truncated to fit')).toBeLessThan(msgs[1].content.indexOf('---- design ----'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('action catalog', () => {
|
||||
it('exposes the five design actions in order', () => {
|
||||
expect(Object.keys(DESIGN_ACTIONS)).toEqual(['generate', 'rewrite', 'translate', 'ideas', 'ask']);
|
||||
});
|
||||
|
||||
it('lists are derived from the catalog (labels/flags never drift)', () => {
|
||||
const list = designActionList();
|
||||
expect(list.map((a) => a.id)).toEqual(Object.keys(DESIGN_ACTIONS));
|
||||
expect(list.find((a) => a.id === 'rewrite')?.needsSelection).toBe(true);
|
||||
expect(list.find((a) => a.id === 'translate')?.needsSelection).toBe(true);
|
||||
expect(list.find((a) => a.id === 'generate')?.needsSelection).toBe(false);
|
||||
expect(list.find((a) => a.id === 'generate')?.needsInstruction).toBe(true);
|
||||
expect(list.find((a) => a.id === 'rewrite')?.needsInstruction).toBe(false);
|
||||
expect(list[0].label).toBe(DESIGN_ACTIONS.generate.label);
|
||||
});
|
||||
|
||||
it('narrows known ids and rejects unknown', () => {
|
||||
expect(isDesignActionId('generate')).toBe(true);
|
||||
expect(isDesignActionId('nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionTask (prompt builders)', () => {
|
||||
it('generate embeds the brief', () => {
|
||||
const t = actionTask('generate', { instruction: 'Bold spring headline' });
|
||||
expect(t).toContain('Bold spring headline');
|
||||
expect(t).toContain('Return only the copy');
|
||||
});
|
||||
|
||||
it('rewrite with no instruction uses the default improve prompt', () => {
|
||||
const t = actionTask('rewrite', {});
|
||||
expect(t).toContain('clearer, tighter, and more persuasive');
|
||||
expect(t).toContain('Do NOT add any fact');
|
||||
});
|
||||
|
||||
it('rewrite with an instruction embeds the tweak', () => {
|
||||
expect(actionTask('rewrite', { instruction: 'make it playful' })).toContain('Rewrite it as follows: make it playful');
|
||||
});
|
||||
|
||||
it('translate embeds the language and defaults to Spanish', () => {
|
||||
expect(actionTask('translate', { language: 'French' })).toContain('into French');
|
||||
expect(actionTask('translate', {})).toContain('into Spanish');
|
||||
});
|
||||
|
||||
it('ideas clamps the count into [1,10] and defaults to 5', () => {
|
||||
expect(actionTask('ideas', { instruction: 'coffee', count: 3 })).toContain('Suggest 3 distinct content ideas');
|
||||
expect(actionTask('ideas', { instruction: 'coffee', count: 99 })).toContain('Suggest 10 distinct content ideas');
|
||||
expect(actionTask('ideas', { instruction: 'coffee', count: 0 })).toContain('Suggest 5 distinct content ideas');
|
||||
expect(actionTask('ideas', { instruction: 'coffee' })).toContain('Suggest 5 distinct content ideas');
|
||||
});
|
||||
|
||||
it('ask embeds the question', () => {
|
||||
expect(actionTask('ask', { instruction: 'What tone?' })).toContain('What tone?');
|
||||
});
|
||||
|
||||
it('throws on an unknown id', () => {
|
||||
expect(() => actionTask('bogus')).toThrow(/Unknown design action/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildActionMessages (id + inputs + design -> messages)', () => {
|
||||
it('resolves the task and fences the selection', () => {
|
||||
const msgs = buildActionMessages('rewrite', { instruction: 'shorter' }, { selection: 'Buy now, save big' });
|
||||
expect(msgs[0].content).toBe(SYSTEM_PROMPT);
|
||||
expect(msgs[1].content).toContain('Selected text: Buy now, save big');
|
||||
expect(msgs[1].content).toContain('Rewrite it as follows: shorter');
|
||||
});
|
||||
|
||||
it('a from-scratch generate has no fence, only the task', () => {
|
||||
const msgs = buildActionMessages('generate', { instruction: 'launch headline' }, {});
|
||||
expect(msgs[1].content).not.toContain('---- design ----');
|
||||
expect(msgs[1].content).toContain('launch headline');
|
||||
});
|
||||
|
||||
it('honors the passed budget (truncation note appears)', () => {
|
||||
const msgs = buildActionMessages('rewrite', {}, { selection: 'x'.repeat(500) }, 50);
|
||||
expect(msgs[1].content).toContain('truncated to fit');
|
||||
});
|
||||
|
||||
it('rejects an unknown id', () => {
|
||||
expect(() => buildActionMessages('nope', {}, {})).toThrow(/Unknown design action/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCopy', () => {
|
||||
it('returns clean text unchanged (idempotent)', () => {
|
||||
expect(parseCopy('Spring is here.')).toBe('Spring is here.');
|
||||
expect(parseCopy(parseCopy('Spring is here.'))).toBe('Spring is here.');
|
||||
});
|
||||
|
||||
it('strips a wrapping markdown code fence', () => {
|
||||
expect(parseCopy('```\nHeadline copy\n```')).toBe('Headline copy');
|
||||
expect(parseCopy('```text\nHeadline copy\n```')).toBe('Headline copy');
|
||||
});
|
||||
|
||||
it('strips matched surrounding quotes (straight and curly)', () => {
|
||||
expect(parseCopy('"Big sale today"')).toBe('Big sale today');
|
||||
expect(parseCopy('“Big sale today”')).toBe('Big sale today');
|
||||
});
|
||||
|
||||
it('does not strip an internal or unmatched quote', () => {
|
||||
expect(parseCopy('The "best" coffee')).toBe('The "best" coffee');
|
||||
expect(parseCopy('"unterminated')).toBe('"unterminated');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(parseCopy(' Hello ')).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIdeas', () => {
|
||||
it('strips numbered and bulleted list markers', () => {
|
||||
const out = parseIdeas('1. First idea\n2) Second idea\n- Third idea\n* Fourth\n• Fifth');
|
||||
expect(out).toEqual(['First idea', 'Second idea', 'Third idea', 'Fourth', 'Fifth']);
|
||||
});
|
||||
|
||||
it('drops blank lines and strips per-line wrapping quotes', () => {
|
||||
expect(parseIdeas('\n1. "Quoted idea"\n\n2. Plain\n')).toEqual(['Quoted idea', 'Plain']);
|
||||
});
|
||||
|
||||
it('falls back to non-empty lines when there is no list structure', () => {
|
||||
expect(parseIdeas('Just one line')).toEqual(['Just one line']);
|
||||
expect(parseIdeas('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { AiClient } from '@hanzo/ai';
|
||||
import { runChat, listModels } from '../src/hanzo';
|
||||
import type { ChatMessage } from '../src/design-core';
|
||||
|
||||
// A fake AiClient capturing the params passed to chat.completions.create and
|
||||
// models.list. This is how we assert request shaping without a network.
|
||||
function fakeClient(reply: string, models: string[] = ['zen5', 'zen5-mini']): {
|
||||
client: AiClient;
|
||||
calls: { params: any; options: any }[];
|
||||
} {
|
||||
const calls: { params: any; options: any }[] = [];
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async (params: any, options: any) => {
|
||||
calls.push({ params, options });
|
||||
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 (request shaping over @hanzo/ai)', () => {
|
||||
it('sends model + messages, stream:false, temperature, and returns the content', async () => {
|
||||
const f = fakeClient('Generated copy.');
|
||||
const out = await runChat(MESSAGES, { client: f.client, model: 'zen5-mini', temperature: 0.4 });
|
||||
expect(out).toBe('Generated copy.');
|
||||
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.4);
|
||||
expect(f.calls[0].params.messages).toEqual(MESSAGES);
|
||||
});
|
||||
|
||||
it('defaults the model to zen5 when none is given', async () => {
|
||||
const f = fakeClient('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('');
|
||||
await expect(runChat(MESSAGES, { client: f.client })).rejects.toThrow(/no content/);
|
||||
});
|
||||
|
||||
it('forwards the abort signal in options', async () => {
|
||||
const f = fakeClient('x');
|
||||
const controller = new AbortController();
|
||||
await runChat(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('x', ['zen5', 'zen5-mini', 'zen5-coder']);
|
||||
expect(await listModels({ client: f.client })).toEqual(['zen5', 'zen5-mini', 'zen5-coder']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
# @hanzo/cards
|
||||
|
||||
One canonical panel spec, three host card formats.
|
||||
|
||||
`@hanzo/cards` is the **presentation layer** for Hanzo's card-host chat surfaces
|
||||
— Slack, Microsoft Teams, and Google Workspace. You describe a Hanzo assistant
|
||||
panel once as a canonical **`PanelSpec`**, and each host emitter turns it into
|
||||
that host's native card JSON. The assistant looks and behaves the same
|
||||
everywhere while rendering host-natively.
|
||||
|
||||
It is a **pure library**: no network, no host SDK runtime dependencies, no
|
||||
secrets. It does **not** call the AI or auth — those are `@hanzo/ai` (the
|
||||
headless AI client) and `@hanzo/iam` (identity/tokens). `@hanzo/cards` only
|
||||
shapes UI. A host adapter builds the `PanelSpec` (usually via `assistantPanel`),
|
||||
calls the emitter, and posts the result with its own host SDK.
|
||||
|
||||
```
|
||||
PanelSpec ──▶ toSlackBlocks ──▶ Slack Block Kit blocks[]
|
||||
──▶ toAdaptiveCard ──▶ Teams Adaptive Card (v1.5)
|
||||
──▶ toCardServiceJson ──▶ Google CardService JSON
|
||||
```
|
||||
|
||||
## The `PanelSpec` model
|
||||
|
||||
`PanelSpec` covers everything a Hanzo assistant panel needs:
|
||||
|
||||
| Field | Purpose |
|
||||
| --- | --- |
|
||||
| `title`, `subtitle`, `icon` | Header. `icon` is an emoji (inlined into the title) or a URL (rendered as an image where the host supports it). |
|
||||
| `modelPicker` | A model dropdown: `{ options: {id,label?}[], value, label? }`. Omit to hide. |
|
||||
| `quickActions` | Chips/buttons: `{ id, label, style? }[]` (e.g. Draft / Summarize / Explain). |
|
||||
| `output` | **Final** AI text as markdown (see streaming note below). |
|
||||
| `input` | Freeform prompt + submit: `{ placeholder?, value?, submitLabel?, multiline? }`. |
|
||||
| `footer` | Context footer, e.g. `via api.hanzo.ai`. |
|
||||
| `signedIn` | When `false`, renders a signed-out state: a sign-in prompt + button replace the widgets. |
|
||||
| `signInPrompt`, `signInLabel` | Text for the signed-out state. |
|
||||
|
||||
> **Streaming.** Card hosts are non-streaming. `output` is the **final** AI
|
||||
> text, never a partial stream. If your adapter streams, render intermediate
|
||||
> text in your own surface and build a `PanelSpec` only for the finished result.
|
||||
|
||||
### Stable action ids
|
||||
|
||||
Every emitted card wires interactions to **stable action id strings** — the
|
||||
contract between `@hanzo/cards` and your host handler. They are exported as
|
||||
`ActionId`:
|
||||
|
||||
| `ActionId` | Fires when |
|
||||
| --- | --- |
|
||||
| `hanzo_model_select` | User picks a model (value = model id). |
|
||||
| `hanzo_prompt_submit` | User submits the prompt input. |
|
||||
| `hanzo_quick_action` | User clicks a chip. Emitted as `hanzo_quick_action:<chipId>`. |
|
||||
| `hanzo_sign_in` | User clicks sign-in (signed-out state). |
|
||||
|
||||
The prompt input field id is `PROMPT_INPUT_ID` (`hanzo_prompt`).
|
||||
|
||||
### Building a spec
|
||||
|
||||
Use the `assistantPanel` builder so adapters don't hand-assemble:
|
||||
|
||||
```ts
|
||||
import { assistantPanel } from '@hanzo/cards';
|
||||
|
||||
const spec = assistantPanel({
|
||||
models: ['zen-eco-3b', { id: 'zen-omni-30b', label: 'Zen Omni 30B' }],
|
||||
model: 'zen-omni-30b', // selected; defaults to the first model
|
||||
actions: [
|
||||
{ id: 'draft', label: 'Draft', style: 'primary' },
|
||||
{ id: 'summarize', label: 'Summarize' },
|
||||
],
|
||||
output: '**Summary**\n- point one\n- point two',
|
||||
footer: 'via api.hanzo.ai', // default
|
||||
signedIn: true, // default
|
||||
});
|
||||
```
|
||||
|
||||
`assistantPanel({ signedIn: false })` yields the signed-out panel. You can also
|
||||
construct a `PanelSpec` object literal directly — the emitters take either.
|
||||
|
||||
## Emitters
|
||||
|
||||
### `toSlackBlocks(spec) → SlackBlock[]`
|
||||
|
||||
Slack Block Kit. Emits `header` / `section` for text, an `actions` block of
|
||||
`button`s for chips, a `section` with a `static_select` accessory for the model
|
||||
picker, an `input` block (`plain_text_input`, dispatching) for the prompt,
|
||||
`context` for the footer, and `divider`s. Pass it straight to `chat.postMessage`
|
||||
as `blocks`.
|
||||
|
||||
### `toAdaptiveCard(spec) → AdaptiveCard`
|
||||
|
||||
Microsoft Teams Adaptive Card (schema v1.5). Emits the
|
||||
`{$schema, type:"AdaptiveCard", version, body, actions}` envelope with
|
||||
`TextBlock`, `Input.ChoiceSet` (model picker), `ActionSet` of `Action.Submit`
|
||||
(chips), `Container` (output), and `Input.Text` (prompt). Each `Action.Submit`
|
||||
carries `data.action` = the stable ActionId so one handler dispatches uniformly.
|
||||
Attach it as an `application/vnd.microsoft.card.adaptive` attachment.
|
||||
|
||||
### `toCardServiceJson(spec) → CardServiceJson`
|
||||
|
||||
Google Workspace CardService. Apps Script's `CardService` builds cards from
|
||||
**runtime objects**, not JSON, so this emitter produces a documented declarative
|
||||
shape (`header` + `sections[].widgets[]`) that an Apps Script host maps to the
|
||||
matching `CardService.new*` calls. Widget `type` → factory:
|
||||
|
||||
| `type` | CardService factory |
|
||||
| --- | --- |
|
||||
| `textParagraph` | `newTextParagraph()` |
|
||||
| `selectionInput` (`DROPDOWN`) | `newSelectionInput().setType(DROPDOWN)` |
|
||||
| `textInput` | `newTextInput()` |
|
||||
| `buttonList` | `newButtonSet()` of `newTextButton()` |
|
||||
| `divider` | `newDivider()` |
|
||||
|
||||
Every action node carries `functionName` (the single dispatcher callback,
|
||||
`CALLBACK_FUNCTION` = `onHanzoAction`) and `parameters` (`action` = the stable
|
||||
ActionId + payload).
|
||||
|
||||
## How a host adapter consumes it
|
||||
|
||||
A host adapter imports the emitters, builds a spec, and posts with its own SDK.
|
||||
|
||||
### Slack
|
||||
|
||||
```ts
|
||||
import { assistantPanel, toSlackBlocks, ActionId, PROMPT_INPUT_ID } from '@hanzo/cards';
|
||||
import { WebClient } from '@slack/web-api'; // host SDK, not a dep of @hanzo/cards
|
||||
import { createAiClient } from '@hanzo/ai'; // the AI client lives in the adapter
|
||||
|
||||
const slack = new WebClient(botToken);
|
||||
const ai = createAiClient({ token }); // defaults to https://api.hanzo.ai
|
||||
|
||||
// Render a panel:
|
||||
const spec = assistantPanel({ models: ['zen-omni-30b'], actions: [{ id: 'draft', label: 'Draft' }] });
|
||||
await slack.chat.postMessage({ channel, blocks: toSlackBlocks(spec) });
|
||||
|
||||
// Handle interactions (Bolt-style): the action_id tells you what happened.
|
||||
app.action(ActionId.PromptSubmit, async ({ ack, body, respond }) => {
|
||||
await ack();
|
||||
const prompt = body.state.values[`${PROMPT_INPUT_ID}_block`][PROMPT_INPUT_ID].value;
|
||||
const res = await ai.chat.completions.create({
|
||||
model: 'zen-omni-30b',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
});
|
||||
const answer = res.choices[0].message.content;
|
||||
await respond({ blocks: toSlackBlocks(assistantPanel({ models: ['zen-omni-30b'], output: answer })) });
|
||||
});
|
||||
```
|
||||
|
||||
### Microsoft Teams
|
||||
|
||||
```ts
|
||||
import { assistantPanel, toAdaptiveCard } from '@hanzo/cards';
|
||||
import { CardFactory } from 'botbuilder'; // host SDK
|
||||
|
||||
async function sendPanel(context, output: string) {
|
||||
const card = toAdaptiveCard(assistantPanel({ models: ['zen-omni-30b'], output }));
|
||||
await context.sendActivity({ attachments: [CardFactory.adaptiveCard(card)] });
|
||||
}
|
||||
|
||||
// On a submit, `activity.value.action` is the stable ActionId; dispatch on it.
|
||||
```
|
||||
|
||||
### Google Workspace (Apps Script)
|
||||
|
||||
The JSON from `toCardServiceJson` is generated server-side (or bundled) and the
|
||||
Apps Script host walks `sections[].widgets[]`, calling the matching
|
||||
`CardService.new*` factory for each `type` and binding every `onClick`/`onChange`
|
||||
action to the single `onHanzoAction(e)` callback, dispatching on
|
||||
`e.parameters.action`.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
pnpm install --ignore-workspace
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm test # vitest run
|
||||
pnpm build # tsup → dist (cjs + esm + d.ts)
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@hanzo/cards",
|
||||
"version": "0.1.0",
|
||||
"description": "One canonical panel spec, three host card formats — Slack Block Kit, Microsoft Teams Adaptive Cards, and Google Workspace CardService — for Hanzo card-host chat surfaces.",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"keywords": [
|
||||
"hanzo",
|
||||
"cards",
|
||||
"slack",
|
||||
"block-kit",
|
||||
"adaptive-cards",
|
||||
"teams",
|
||||
"cardservice",
|
||||
"google-workspace"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/extension.git",
|
||||
"directory": "packages/cards"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Google Workspace CardService emitter.
|
||||
*
|
||||
* Google Apps Script's `CardService` builds cards from runtime objects
|
||||
* (`CardService.newCardBuilder()...`), not from JSON — the objects only exist
|
||||
* inside the Apps Script runtime. So this emitter produces a documented,
|
||||
* declarative JSON shape that mirrors the CardService object graph
|
||||
* (`header` + `sections[].widgets[]`): an Apps Script host maps each widget
|
||||
* node to the matching `CardService.new*` call.
|
||||
*
|
||||
* Widget node `type` maps to a CardService factory:
|
||||
* - `textParagraph` -> `newTextParagraph().setText()`
|
||||
* - `decoratedText` -> `newDecoratedText().setText().setTopLabel()`
|
||||
* - `selectionInput` -> `newSelectionInput().setType(DROPDOWN)...`
|
||||
* - `textInput` -> `newTextInput().setFieldName().setValue()`
|
||||
* - `buttonList` -> `newButtonSet()` of `newTextButton()`
|
||||
* - `divider` -> `newDivider()`
|
||||
* Every action node carries `functionName` (the Apps Script callback) and
|
||||
* `parameters` (the stable ActionId + payload).
|
||||
*
|
||||
* See https://developers.google.com/apps-script/reference/card-service
|
||||
*/
|
||||
import {
|
||||
ActionId,
|
||||
PROMPT_INPUT_ID,
|
||||
type ModelOption,
|
||||
type PanelSpec,
|
||||
type QuickAction,
|
||||
} from './spec';
|
||||
|
||||
/** An onClick action mapped to an Apps Script callback function. */
|
||||
export interface CardAction {
|
||||
/** Apps Script global function the host binds this action to. */
|
||||
functionName: string;
|
||||
/** Parameters passed to the callback. `action` is the stable ActionId. */
|
||||
parameters: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CardWidget {
|
||||
type:
|
||||
| 'textParagraph'
|
||||
| 'decoratedText'
|
||||
| 'selectionInput'
|
||||
| 'textInput'
|
||||
| 'buttonList'
|
||||
| 'divider';
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CardSection {
|
||||
/** Optional section header. */
|
||||
header?: string;
|
||||
widgets: CardWidget[];
|
||||
}
|
||||
|
||||
export interface CardHeader {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Image URL for the header icon, when the icon is a URL. */
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface CardServiceJson {
|
||||
header: CardHeader;
|
||||
sections: CardSection[];
|
||||
}
|
||||
|
||||
/** Every action routes through one Apps Script dispatcher callback. */
|
||||
export const CALLBACK_FUNCTION = 'onHanzoAction';
|
||||
|
||||
function action(actionId: string, extra: Record<string, string> = {}): CardAction {
|
||||
return { functionName: CALLBACK_FUNCTION, parameters: { action: actionId, ...extra } };
|
||||
}
|
||||
|
||||
function selectionItem(o: ModelOption, selectedValue: string): Record<string, unknown> {
|
||||
return { text: o.label ?? o.id, value: o.id, selected: o.id === selectedValue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the CardService JSON shape for a {@link PanelSpec}.
|
||||
*/
|
||||
export function toCardServiceJson(spec: PanelSpec): CardServiceJson {
|
||||
const header: CardHeader = { title: spec.title };
|
||||
if (spec.subtitle) header.subtitle = spec.subtitle;
|
||||
if (spec.icon && isUrl(spec.icon)) header.imageUrl = spec.icon;
|
||||
else if (spec.icon) header.title = `${spec.icon} ${spec.title}`;
|
||||
|
||||
const sections: CardSection[] = [];
|
||||
|
||||
if (spec.signedIn === false) {
|
||||
sections.push({
|
||||
widgets: [
|
||||
{ type: 'textParagraph', text: spec.signInPrompt ?? 'Sign in to Hanzo to use the assistant.' },
|
||||
{
|
||||
type: 'buttonList',
|
||||
buttons: [
|
||||
{ text: spec.signInLabel ?? 'Sign in', onClick: action(ActionId.SignIn) },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (spec.footer) {
|
||||
sections.push({ widgets: [{ type: 'textParagraph', text: spec.footer }] });
|
||||
}
|
||||
return { header, sections };
|
||||
}
|
||||
|
||||
// Model picker as a DROPDOWN selectionInput.
|
||||
if (spec.modelPicker && spec.modelPicker.options.length > 0) {
|
||||
const mp = spec.modelPicker;
|
||||
sections.push({
|
||||
widgets: [
|
||||
{
|
||||
type: 'selectionInput',
|
||||
selectionType: 'DROPDOWN',
|
||||
fieldName: ActionId.ModelSelect,
|
||||
label: mp.label ?? 'Model',
|
||||
onChangeAction: action(ActionId.ModelSelect),
|
||||
items: mp.options.map((o) => selectionItem(o, mp.value)),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Quick actions as a buttonList.
|
||||
if (spec.quickActions && spec.quickActions.length > 0) {
|
||||
sections.push({
|
||||
widgets: [
|
||||
{
|
||||
type: 'buttonList',
|
||||
buttons: spec.quickActions.map((a: QuickAction) => ({
|
||||
text: a.label,
|
||||
filled: a.style === 'primary',
|
||||
onClick: action(`${ActionId.QuickAction}:${a.id}`, { chip: a.id }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Output as a textParagraph (CardService renders limited HTML).
|
||||
if (spec.output) {
|
||||
sections.push({ header: 'Response', widgets: [{ type: 'textParagraph', text: spec.output }] });
|
||||
}
|
||||
|
||||
// Prompt input + submit button.
|
||||
if (spec.input) {
|
||||
const inp = spec.input;
|
||||
const textInput: CardWidget = {
|
||||
type: 'textInput',
|
||||
fieldName: PROMPT_INPUT_ID,
|
||||
title: inp.placeholder ?? 'Ask Hanzo',
|
||||
multiline: inp.multiline ?? true,
|
||||
};
|
||||
if (inp.value) textInput.value = inp.value;
|
||||
sections.push({
|
||||
widgets: [
|
||||
textInput,
|
||||
{
|
||||
type: 'buttonList',
|
||||
buttons: [
|
||||
{ text: inp.submitLabel ?? 'Send', filled: true, onClick: action(ActionId.PromptSubmit) },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Footer.
|
||||
if (spec.footer) {
|
||||
sections.push({ widgets: [{ type: 'textParagraph', text: spec.footer }] });
|
||||
}
|
||||
|
||||
return { header, sections };
|
||||
}
|
||||
|
||||
function isUrl(s: string): boolean {
|
||||
return /^https?:\/\//i.test(s);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `@hanzo/cards` — one canonical panel spec, three host card formats.
|
||||
*
|
||||
* Presentation layer for Hanzo's card-host chat surfaces (Slack, Microsoft
|
||||
* Teams, Google Workspace). A host adapter builds a {@link PanelSpec} (directly
|
||||
* or via {@link assistantPanel}) and emits the host's native card:
|
||||
*
|
||||
* ```ts
|
||||
* import { assistantPanel, toSlackBlocks } from '@hanzo/cards';
|
||||
* const spec = assistantPanel({ models: ['zen-eco-3b'], output: text });
|
||||
* await slack.chat.postMessage({ channel, blocks: toSlackBlocks(spec) });
|
||||
* ```
|
||||
*
|
||||
* Pure: no network, no host SDKs, no secrets. Emitters never call the AI or
|
||||
* auth — they only shape UI. Card hosts are non-streaming, so `PanelSpec.output`
|
||||
* is the FINAL AI text.
|
||||
*/
|
||||
export {
|
||||
ActionId,
|
||||
PROMPT_INPUT_ID,
|
||||
assistantPanel,
|
||||
type AssistantPanelOptions,
|
||||
type ModelOption,
|
||||
type ModelPicker,
|
||||
type PanelSpec,
|
||||
type PromptInput,
|
||||
type QuickAction,
|
||||
} from './spec';
|
||||
|
||||
export {
|
||||
toSlackBlocks,
|
||||
type SlackBlock,
|
||||
type SlackBlocks,
|
||||
type SlackOption,
|
||||
type SlackText,
|
||||
} from './slack';
|
||||
|
||||
export {
|
||||
ADAPTIVE_CARD_SCHEMA,
|
||||
ADAPTIVE_CARD_VERSION,
|
||||
toAdaptiveCard,
|
||||
type AdaptiveAction,
|
||||
type AdaptiveCard,
|
||||
type AdaptiveElement,
|
||||
} from './teams';
|
||||
|
||||
export {
|
||||
CALLBACK_FUNCTION,
|
||||
toCardServiceJson,
|
||||
type CardAction,
|
||||
type CardHeader,
|
||||
type CardSection,
|
||||
type CardServiceJson,
|
||||
type CardWidget,
|
||||
} from './cardservice';
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Slack Block Kit emitter.
|
||||
*
|
||||
* Turns a canonical {@link PanelSpec} into a Slack `blocks[]` array:
|
||||
* `header`/`section` for text, `actions` with `button`/`static_select`,
|
||||
* `input`, `context`, and `divider`. The result is the value a host adapter
|
||||
* passes as `blocks` to `chat.postMessage` / view payloads.
|
||||
*
|
||||
* See https://api.slack.com/reference/block-kit/blocks
|
||||
*/
|
||||
import {
|
||||
ActionId,
|
||||
PROMPT_INPUT_ID,
|
||||
type ModelOption,
|
||||
type PanelSpec,
|
||||
type QuickAction,
|
||||
} from './spec';
|
||||
|
||||
/** Minimal structural types for the Slack blocks we emit. */
|
||||
export interface SlackText {
|
||||
type: 'plain_text' | 'mrkdwn';
|
||||
text: string;
|
||||
emoji?: boolean;
|
||||
}
|
||||
|
||||
export interface SlackOption {
|
||||
text: { type: 'plain_text'; text: string };
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SlackBlock {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type SlackBlocks = SlackBlock[];
|
||||
|
||||
function plain(text: string): SlackText {
|
||||
return { type: 'plain_text', text, emoji: true };
|
||||
}
|
||||
|
||||
function mrkdwn(text: string): SlackText {
|
||||
return { type: 'mrkdwn', text };
|
||||
}
|
||||
|
||||
function modelOption(o: ModelOption): SlackOption {
|
||||
return { text: { type: 'plain_text', text: o.label ?? o.id }, value: o.id };
|
||||
}
|
||||
|
||||
function quickActionButton(a: QuickAction): SlackBlock {
|
||||
const button: SlackBlock = {
|
||||
type: 'button',
|
||||
text: plain(a.label),
|
||||
value: a.id,
|
||||
action_id: `${ActionId.QuickAction}:${a.id}`,
|
||||
};
|
||||
if (a.style === 'primary') button.style = 'primary';
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit Slack Block Kit `blocks[]` for a {@link PanelSpec}.
|
||||
*/
|
||||
export function toSlackBlocks(spec: PanelSpec): SlackBlocks {
|
||||
const blocks: SlackBlocks = [];
|
||||
|
||||
// Header: Slack `header` blocks are plain_text only; subtitle/icon go in a
|
||||
// context block beneath so no header information is lost.
|
||||
const headerText = spec.icon && !isUrl(spec.icon) ? `${spec.icon} ${spec.title}` : spec.title;
|
||||
blocks.push({ type: 'header', text: plain(headerText) });
|
||||
|
||||
const headerContext: SlackText[] = [];
|
||||
if (spec.icon && isUrl(spec.icon)) {
|
||||
blocks.push({ type: 'context', elements: [{ type: 'image', image_url: spec.icon, alt_text: spec.title }, mrkdwn(spec.subtitle ?? spec.title)] });
|
||||
} else if (spec.subtitle) {
|
||||
headerContext.push(mrkdwn(spec.subtitle));
|
||||
}
|
||||
if (headerContext.length > 0) {
|
||||
blocks.push({ type: 'context', elements: headerContext });
|
||||
}
|
||||
|
||||
if (spec.signedIn === false) {
|
||||
blocks.push({ type: 'divider' });
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: mrkdwn(spec.signInPrompt ?? 'Sign in to Hanzo to use the assistant.'),
|
||||
});
|
||||
blocks.push({
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
type: 'button',
|
||||
text: plain(spec.signInLabel ?? 'Sign in'),
|
||||
style: 'primary',
|
||||
action_id: ActionId.SignIn,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (spec.footer) {
|
||||
blocks.push({ type: 'context', elements: [mrkdwn(spec.footer)] });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
blocks.push({ type: 'divider' });
|
||||
|
||||
// Model picker: a section with a static_select accessory.
|
||||
const mp = spec.modelPicker;
|
||||
if (mp && mp.options.length > 0) {
|
||||
// `options[0]` exists under the length guard; `find` may miss, so fall back.
|
||||
const initial = mp.options.find((o) => o.id === mp.value) ?? mp.options[0]!;
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: mrkdwn(`*${mp.label ?? 'Model'}*`),
|
||||
accessory: {
|
||||
type: 'static_select',
|
||||
action_id: ActionId.ModelSelect,
|
||||
placeholder: plain(mp.label ?? 'Select a model'),
|
||||
options: mp.options.map(modelOption),
|
||||
initial_option: modelOption(initial),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Quick actions: a single actions block of buttons.
|
||||
if (spec.quickActions && spec.quickActions.length > 0) {
|
||||
blocks.push({
|
||||
type: 'actions',
|
||||
elements: spec.quickActions.map(quickActionButton),
|
||||
});
|
||||
}
|
||||
|
||||
// Output: markdown section. Slack mrkdwn is close enough to markdown.
|
||||
if (spec.output) {
|
||||
blocks.push({ type: 'section', text: mrkdwn(spec.output) });
|
||||
}
|
||||
|
||||
// Prompt input + submit.
|
||||
if (spec.input) {
|
||||
const inp = spec.input;
|
||||
blocks.push({
|
||||
type: 'input',
|
||||
block_id: `${PROMPT_INPUT_ID}_block`,
|
||||
dispatch_action: true,
|
||||
label: plain(inp.placeholder ?? 'Ask Hanzo'),
|
||||
element: {
|
||||
type: 'plain_text_input',
|
||||
action_id: PROMPT_INPUT_ID,
|
||||
multiline: inp.multiline ?? true,
|
||||
...(inp.value ? { initial_value: inp.value } : {}),
|
||||
...(inp.placeholder ? { placeholder: plain(inp.placeholder) } : {}),
|
||||
dispatch_action_config: { trigger_actions_on: ['on_enter_pressed'] },
|
||||
},
|
||||
});
|
||||
blocks.push({
|
||||
type: 'actions',
|
||||
elements: [
|
||||
{
|
||||
type: 'button',
|
||||
text: plain(inp.submitLabel ?? 'Send'),
|
||||
style: 'primary',
|
||||
action_id: ActionId.PromptSubmit,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Footer context.
|
||||
if (spec.footer) {
|
||||
blocks.push({ type: 'divider' });
|
||||
blocks.push({ type: 'context', elements: [mrkdwn(spec.footer)] });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function isUrl(s: string): boolean {
|
||||
return /^https?:\/\//i.test(s);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Canonical panel specification for Hanzo card-host chat surfaces.
|
||||
*
|
||||
* ONE `PanelSpec` describes a Hanzo assistant panel. Host emitters
|
||||
* (`toSlackBlocks`, `toAdaptiveCard`, `toCardServiceJson`) turn it into each
|
||||
* host's native card format so the assistant looks and behaves the same across
|
||||
* Slack, Microsoft Teams, and Google Workspace while rendering host-natively.
|
||||
*
|
||||
* This module is pure: no network, no host SDKs, no secrets. It only shapes UI.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Stable action ids the host wires to handlers. These strings are the contract
|
||||
* between `@hanzo/cards` and a host adapter — they appear verbatim in every
|
||||
* emitted card and must not change casually.
|
||||
*/
|
||||
export const ActionId = {
|
||||
/** User picked a model in the model picker. Value is the model id. */
|
||||
ModelSelect: 'hanzo_model_select',
|
||||
/** User submitted the freeform prompt input. */
|
||||
PromptSubmit: 'hanzo_prompt_submit',
|
||||
/** User clicked a quick-action chip. The chip's own id qualifies it. */
|
||||
QuickAction: 'hanzo_quick_action',
|
||||
/** User clicked the sign-in button in the signed-out state. */
|
||||
SignIn: 'hanzo_sign_in',
|
||||
} as const;
|
||||
|
||||
export type ActionId = (typeof ActionId)[keyof typeof ActionId];
|
||||
|
||||
/** Stable id for the freeform prompt input field. */
|
||||
export const PROMPT_INPUT_ID = 'hanzo_prompt';
|
||||
|
||||
/** A selectable model in the model picker. */
|
||||
export interface ModelOption {
|
||||
/** Model id sent to the AI client, e.g. `zen-eco-3b`. */
|
||||
id: string;
|
||||
/** Human label shown in the picker. Defaults to `id` when omitted. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** The model picker widget. */
|
||||
export interface ModelPicker {
|
||||
/** Models to choose from. */
|
||||
options: ModelOption[];
|
||||
/** Currently selected model id. Should match one of `options[].id`. */
|
||||
value: string;
|
||||
/** Label shown above/beside the picker. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** A quick-action chip/button (e.g. Draft, Summarize, Explain). */
|
||||
export interface QuickAction {
|
||||
/** Stable id passed back to the host handler as the chip's value. */
|
||||
id: string;
|
||||
/** Button text. */
|
||||
label: string;
|
||||
/** Style hint. Hosts map this to their nearest native emphasis. */
|
||||
style?: 'primary' | 'default';
|
||||
}
|
||||
|
||||
/** The freeform prompt input + its submit button. */
|
||||
export interface PromptInput {
|
||||
/** Placeholder text shown in the empty input. */
|
||||
placeholder?: string;
|
||||
/** Pre-filled value. */
|
||||
value?: string;
|
||||
/** Submit button text. Defaults to `Send`. */
|
||||
submitLabel?: string;
|
||||
/** Whether the input allows multiple lines. Defaults to `true`. */
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical Hanzo assistant panel.
|
||||
*
|
||||
* Card hosts are non-streaming: `output` is the FINAL AI text (markdown), never
|
||||
* a partial stream. A streaming adapter should render intermediate text in its
|
||||
* own surface and only build a `PanelSpec` for the finished result.
|
||||
*/
|
||||
export interface PanelSpec {
|
||||
/** Header title. */
|
||||
title: string;
|
||||
/** Optional header subtitle. */
|
||||
subtitle?: string;
|
||||
/**
|
||||
* Optional header icon. A URL renders as an image where the host supports it;
|
||||
* a short string (e.g. an emoji) renders as text otherwise.
|
||||
*/
|
||||
icon?: string;
|
||||
/** Model picker. Omit to hide it. */
|
||||
modelPicker?: ModelPicker;
|
||||
/** Quick-action chips. Empty/omitted hides the row. */
|
||||
quickActions?: QuickAction[];
|
||||
/**
|
||||
* Final AI output as markdown. Card hosts do not stream, so this is the
|
||||
* completed text. Omit when there is nothing to show yet.
|
||||
*/
|
||||
output?: string;
|
||||
/** Freeform prompt input + submit. Omit to hide it. */
|
||||
input?: PromptInput;
|
||||
/** Context footer text, e.g. `via api.hanzo.ai`. */
|
||||
footer?: string;
|
||||
/**
|
||||
* When `false`, the panel renders a signed-out state: a sign-in prompt and
|
||||
* button replace the interactive widgets. Defaults to `true`.
|
||||
*/
|
||||
signedIn?: boolean;
|
||||
/** Sign-in prompt text shown when `signedIn` is false. */
|
||||
signInPrompt?: string;
|
||||
/** Sign-in button text shown when `signedIn` is false. Defaults to `Sign in`. */
|
||||
signInLabel?: string;
|
||||
}
|
||||
|
||||
/** Options for {@link assistantPanel}. */
|
||||
export interface AssistantPanelOptions {
|
||||
/** Header title. Defaults to `Hanzo Assistant`. */
|
||||
title?: string;
|
||||
/** Header subtitle. */
|
||||
subtitle?: string;
|
||||
/** Header icon (URL or emoji). */
|
||||
icon?: string;
|
||||
/** Model ids or options for the picker. */
|
||||
models?: Array<string | ModelOption>;
|
||||
/** Currently selected model id. Defaults to the first model. */
|
||||
model?: string;
|
||||
/** Quick-action chips. */
|
||||
actions?: QuickAction[];
|
||||
/** Final AI output (markdown). */
|
||||
output?: string;
|
||||
/** Prompt input config, or `false` to hide the input. Defaults to shown. */
|
||||
input?: PromptInput | false;
|
||||
/** Context footer. Defaults to `via api.hanzo.ai`. */
|
||||
footer?: string;
|
||||
/** Signed-in state. Defaults to `true`. */
|
||||
signedIn?: boolean;
|
||||
/** Sign-in prompt text (signed-out state). */
|
||||
signInPrompt?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_FOOTER = 'via api.hanzo.ai';
|
||||
|
||||
function normalizeModels(models: Array<string | ModelOption>): ModelOption[] {
|
||||
return models.map((m) => (typeof m === 'string' ? { id: m } : m));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready-made builder for the standard Hanzo assistant panel so host adapters
|
||||
* don't hand-assemble a {@link PanelSpec}.
|
||||
*
|
||||
* Signed-out panels drop the interactive widgets and show a sign-in prompt.
|
||||
*/
|
||||
export function assistantPanel(opts: AssistantPanelOptions = {}): PanelSpec {
|
||||
const signedIn = opts.signedIn ?? true;
|
||||
const footer = opts.footer ?? DEFAULT_FOOTER;
|
||||
|
||||
if (!signedIn) {
|
||||
return {
|
||||
title: opts.title ?? 'Hanzo Assistant',
|
||||
subtitle: opts.subtitle,
|
||||
icon: opts.icon,
|
||||
footer,
|
||||
signedIn: false,
|
||||
signInPrompt:
|
||||
opts.signInPrompt ?? 'Sign in to Hanzo to use the assistant.',
|
||||
signInLabel: 'Sign in',
|
||||
};
|
||||
}
|
||||
|
||||
const models = normalizeModels(opts.models ?? []);
|
||||
const firstModel = models[0];
|
||||
const modelPicker: ModelPicker | undefined = firstModel
|
||||
? {
|
||||
options: models,
|
||||
value: opts.model ?? firstModel.id,
|
||||
label: 'Model',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const input: PromptInput | undefined =
|
||||
opts.input === false
|
||||
? undefined
|
||||
: {
|
||||
placeholder: 'Ask Hanzo…',
|
||||
submitLabel: 'Send',
|
||||
multiline: true,
|
||||
...(opts.input ?? {}),
|
||||
};
|
||||
|
||||
return {
|
||||
title: opts.title ?? 'Hanzo Assistant',
|
||||
subtitle: opts.subtitle,
|
||||
icon: opts.icon,
|
||||
modelPicker,
|
||||
quickActions: opts.actions,
|
||||
output: opts.output,
|
||||
input,
|
||||
footer,
|
||||
signedIn: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Microsoft Teams Adaptive Card emitter.
|
||||
*
|
||||
* Turns a canonical {@link PanelSpec} into an Adaptive Card (schema v1.5):
|
||||
* `TextBlock` for text, `Input.ChoiceSet` for the model picker,
|
||||
* `Input.Text` for the prompt, `ActionSet`/`Action.Submit` for buttons,
|
||||
* `Container` for grouping. The result is the JSON a host adapter attaches as
|
||||
* a `application/vnd.microsoft.card.adaptive` attachment.
|
||||
*
|
||||
* See https://adaptivecards.io/explorer/
|
||||
*/
|
||||
import {
|
||||
ActionId,
|
||||
PROMPT_INPUT_ID,
|
||||
type ModelOption,
|
||||
type PanelSpec,
|
||||
type QuickAction,
|
||||
} from './spec';
|
||||
|
||||
export const ADAPTIVE_CARD_SCHEMA =
|
||||
'http://adaptivecards.io/schemas/adaptive-card.json';
|
||||
export const ADAPTIVE_CARD_VERSION = '1.5';
|
||||
|
||||
/** Minimal structural types for the Adaptive Card elements we emit. */
|
||||
export interface AdaptiveElement {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AdaptiveAction {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AdaptiveCard {
|
||||
$schema: string;
|
||||
type: 'AdaptiveCard';
|
||||
version: string;
|
||||
body: AdaptiveElement[];
|
||||
actions: AdaptiveAction[];
|
||||
}
|
||||
|
||||
function choice(o: ModelOption): { title: string; value: string } {
|
||||
return { title: o.label ?? o.id, value: o.id };
|
||||
}
|
||||
|
||||
function submit(title: string, actionId: string, data: Record<string, unknown>, style?: 'positive' | 'default'): AdaptiveAction {
|
||||
const action: AdaptiveAction = {
|
||||
type: 'Action.Submit',
|
||||
title,
|
||||
// `id` is the Action id; `data.action` carries the stable ActionId so the
|
||||
// host handler can dispatch uniformly across hosts.
|
||||
id: actionId,
|
||||
data: { action: actionId, ...data },
|
||||
};
|
||||
if (style) action.style = style;
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a Teams Adaptive Card for a {@link PanelSpec}.
|
||||
*/
|
||||
export function toAdaptiveCard(spec: PanelSpec): AdaptiveCard {
|
||||
const body: AdaptiveElement[] = [];
|
||||
const actions: AdaptiveAction[] = [];
|
||||
|
||||
// Header: title + optional icon (URL) as a two-column set, subtitle beneath.
|
||||
if (spec.icon && isUrl(spec.icon)) {
|
||||
body.push({
|
||||
type: 'ColumnSet',
|
||||
columns: [
|
||||
{
|
||||
type: 'Column',
|
||||
width: 'auto',
|
||||
items: [{ type: 'Image', url: spec.icon, size: 'Small', altText: spec.title }],
|
||||
},
|
||||
{
|
||||
type: 'Column',
|
||||
width: 'stretch',
|
||||
verticalContentAlignment: 'Center',
|
||||
items: [{ type: 'TextBlock', text: spec.title, weight: 'Bolder', size: 'Large', wrap: true }],
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
const title = spec.icon ? `${spec.icon} ${spec.title}` : spec.title;
|
||||
body.push({ type: 'TextBlock', text: title, weight: 'Bolder', size: 'Large', wrap: true });
|
||||
}
|
||||
if (spec.subtitle) {
|
||||
body.push({ type: 'TextBlock', text: spec.subtitle, isSubtle: true, spacing: 'None', wrap: true });
|
||||
}
|
||||
|
||||
if (spec.signedIn === false) {
|
||||
body.push({
|
||||
type: 'TextBlock',
|
||||
text: spec.signInPrompt ?? 'Sign in to Hanzo to use the assistant.',
|
||||
wrap: true,
|
||||
separator: true,
|
||||
});
|
||||
actions.push(submit(spec.signInLabel ?? 'Sign in', ActionId.SignIn, {}, 'positive'));
|
||||
if (spec.footer) {
|
||||
body.push({ type: 'TextBlock', text: spec.footer, isSubtle: true, size: 'Small', wrap: true, separator: true });
|
||||
}
|
||||
return card(body, actions);
|
||||
}
|
||||
|
||||
// Model picker.
|
||||
if (spec.modelPicker && spec.modelPicker.options.length > 0) {
|
||||
const mp = spec.modelPicker;
|
||||
body.push({ type: 'TextBlock', text: mp.label ?? 'Model', weight: 'Bolder', spacing: 'Medium', wrap: true });
|
||||
body.push({
|
||||
type: 'Input.ChoiceSet',
|
||||
id: ActionId.ModelSelect,
|
||||
style: 'compact',
|
||||
value: mp.value,
|
||||
choices: mp.options.map(choice),
|
||||
});
|
||||
}
|
||||
|
||||
// Quick actions: an ActionSet inline in the body.
|
||||
if (spec.quickActions && spec.quickActions.length > 0) {
|
||||
body.push({
|
||||
type: 'ActionSet',
|
||||
spacing: 'Medium',
|
||||
actions: spec.quickActions.map((a: QuickAction) =>
|
||||
submit(a.label, `${ActionId.QuickAction}:${a.id}`, { chip: a.id }, a.style === 'primary' ? 'positive' : 'default'),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Output: a wrapped TextBlock. Adaptive Cards render a markdown subset.
|
||||
if (spec.output) {
|
||||
body.push({
|
||||
type: 'Container',
|
||||
separator: true,
|
||||
spacing: 'Medium',
|
||||
items: [{ type: 'TextBlock', text: spec.output, wrap: true }],
|
||||
});
|
||||
}
|
||||
|
||||
// Prompt input + submit button (card-level action).
|
||||
if (spec.input) {
|
||||
const inp = spec.input;
|
||||
body.push({
|
||||
type: 'Input.Text',
|
||||
id: PROMPT_INPUT_ID,
|
||||
placeholder: inp.placeholder ?? 'Ask Hanzo…',
|
||||
isMultiline: inp.multiline ?? true,
|
||||
spacing: 'Medium',
|
||||
...(inp.value ? { value: inp.value } : {}),
|
||||
});
|
||||
actions.push(submit(inp.submitLabel ?? 'Send', ActionId.PromptSubmit, {}, 'positive'));
|
||||
}
|
||||
|
||||
// Footer.
|
||||
if (spec.footer) {
|
||||
body.push({ type: 'TextBlock', text: spec.footer, isSubtle: true, size: 'Small', wrap: true, separator: true });
|
||||
}
|
||||
|
||||
return card(body, actions);
|
||||
}
|
||||
|
||||
function card(body: AdaptiveElement[], actions: AdaptiveAction[]): AdaptiveCard {
|
||||
return {
|
||||
$schema: ADAPTIVE_CARD_SCHEMA,
|
||||
type: 'AdaptiveCard',
|
||||
version: ADAPTIVE_CARD_VERSION,
|
||||
body,
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
function isUrl(s: string): boolean {
|
||||
return /^https?:\/\//i.test(s);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ActionId,
|
||||
PROMPT_INPUT_ID,
|
||||
CALLBACK_FUNCTION,
|
||||
toCardServiceJson,
|
||||
type CardWidget,
|
||||
type CardSection,
|
||||
} from '../src';
|
||||
import { richSpec, signedOutSpec } from './spec.test';
|
||||
|
||||
function widgets(sections: CardSection[]): CardWidget[] {
|
||||
return sections.flatMap((s) => s.widgets);
|
||||
}
|
||||
function widgetOfType(sections: CardSection[], type: CardWidget['type']): CardWidget[] {
|
||||
return widgets(sections).filter((w) => w.type === type);
|
||||
}
|
||||
|
||||
describe('toCardServiceJson — signed in', () => {
|
||||
const card = toCardServiceJson(richSpec);
|
||||
|
||||
it('has a header and sections array', () => {
|
||||
expect(card.header).toBeDefined();
|
||||
expect(Array.isArray(card.sections)).toBe(true);
|
||||
expect(card.sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders the title (emoji icon inlined) and subtitle', () => {
|
||||
expect(card.header.title).toContain('Hanzo Assistant');
|
||||
expect(card.header.title).toContain('🤖');
|
||||
expect(card.header.subtitle).toBe('Frontier AI, everywhere');
|
||||
});
|
||||
|
||||
it('renders the model picker as a DROPDOWN selectionInput with the current selection', () => {
|
||||
const sel = widgetOfType(card.sections, 'selectionInput')[0]!;
|
||||
expect(sel).toBeDefined();
|
||||
expect(sel.selectionType).toBe('DROPDOWN');
|
||||
expect(sel.fieldName).toBe(ActionId.ModelSelect);
|
||||
const items = sel.items as Array<{ value: string; selected: boolean }>;
|
||||
expect(items.map((i) => i.value)).toEqual(['zen-eco-3b', 'zen-omni-30b']);
|
||||
expect(items.find((i) => i.value === 'zen-omni-30b')!.selected).toBe(true);
|
||||
expect(items.find((i) => i.value === 'zen-eco-3b')!.selected).toBe(false);
|
||||
// onChange routes to the dispatcher with the ModelSelect action.
|
||||
const onChange = sel.onChangeAction as { functionName: string; parameters: { action: string } };
|
||||
expect(onChange.functionName).toBe(CALLBACK_FUNCTION);
|
||||
expect(onChange.parameters.action).toBe(ActionId.ModelSelect);
|
||||
});
|
||||
|
||||
it('renders quick actions as a buttonList wired to per-chip actions', () => {
|
||||
const buttonLists = widgetOfType(card.sections, 'buttonList');
|
||||
const chipList = buttonLists.find((bl) =>
|
||||
(bl.buttons as Array<{ onClick: { parameters: { action: string } } }>).some((b) =>
|
||||
b.onClick.parameters.action.startsWith(ActionId.QuickAction),
|
||||
),
|
||||
);
|
||||
expect(chipList).toBeDefined();
|
||||
const buttons = chipList!.buttons as Array<{
|
||||
text: string;
|
||||
filled: boolean;
|
||||
onClick: { functionName: string; parameters: { action: string; chip: string } };
|
||||
}>;
|
||||
expect(buttons).toHaveLength(3);
|
||||
expect(buttons.map((b) => b.text)).toEqual(['Draft', 'Summarize', 'Explain']);
|
||||
expect(buttons[0]!.filled).toBe(true); // primary
|
||||
expect(buttons[1]!.filled).toBe(false);
|
||||
expect(buttons[0]!.onClick.functionName).toBe(CALLBACK_FUNCTION);
|
||||
expect(buttons[0]!.onClick.parameters.action).toBe(`${ActionId.QuickAction}:draft`);
|
||||
expect(buttons[0]!.onClick.parameters.chip).toBe('draft');
|
||||
});
|
||||
|
||||
it('renders the output as a textParagraph', () => {
|
||||
const paras = widgetOfType(card.sections, 'textParagraph');
|
||||
expect(paras.some((p) => p.text === richSpec.output)).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the prompt input as a textInput and a submit button', () => {
|
||||
const textInput = widgetOfType(card.sections, 'textInput')[0]!;
|
||||
expect(textInput).toBeDefined();
|
||||
expect(textInput.fieldName).toBe(PROMPT_INPUT_ID);
|
||||
expect(textInput.multiline).toBe(true);
|
||||
|
||||
const submit = widgetOfType(card.sections, 'buttonList')
|
||||
.flatMap((bl) => bl.buttons as Array<{ onClick: { parameters: { action: string } } }>)
|
||||
.find((b) => b.onClick.parameters.action === ActionId.PromptSubmit);
|
||||
expect(submit).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders the footer as a textParagraph', () => {
|
||||
const paras = widgetOfType(card.sections, 'textParagraph');
|
||||
expect(paras.some((p) => p.text === 'via api.hanzo.ai')).toBe(true);
|
||||
});
|
||||
|
||||
it('is JSON-serializable', () => {
|
||||
expect(() => JSON.stringify(card)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCardServiceJson — signed out', () => {
|
||||
const card = toCardServiceJson(signedOutSpec);
|
||||
|
||||
it('shows the sign-in prompt and a SignIn button, no widgets', () => {
|
||||
const paras = widgetOfType(card.sections, 'textParagraph');
|
||||
expect(paras.some((p) => p.text === signedOutSpec.signInPrompt)).toBe(true);
|
||||
|
||||
const signIn = widgetOfType(card.sections, 'buttonList')
|
||||
.flatMap((bl) => bl.buttons as Array<{ onClick: { parameters: { action: string } } }>)
|
||||
.find((b) => b.onClick.parameters.action === ActionId.SignIn);
|
||||
expect(signIn).toBeDefined();
|
||||
|
||||
expect(widgetOfType(card.sections, 'selectionInput')).toHaveLength(0);
|
||||
expect(widgetOfType(card.sections, 'textInput')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCardServiceJson — image icon', () => {
|
||||
it('renders a URL icon as header.imageUrl and keeps the plain title', () => {
|
||||
const card = toCardServiceJson({ title: 'Hanzo', icon: 'https://hanzo.ai/logo.png', signedIn: true });
|
||||
expect(card.header.imageUrl).toBe('https://hanzo.ai/logo.png');
|
||||
expect(card.header.title).toBe('Hanzo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ActionId, PROMPT_INPUT_ID, toSlackBlocks, type SlackBlock } from '../src';
|
||||
import { richSpec, signedOutSpec } from './spec.test';
|
||||
|
||||
function byType(blocks: SlackBlock[], type: string): SlackBlock[] {
|
||||
return blocks.filter((b) => b.type === type);
|
||||
}
|
||||
|
||||
describe('toSlackBlocks — signed in', () => {
|
||||
const blocks = toSlackBlocks(richSpec);
|
||||
|
||||
it('emits a header block carrying the title (with emoji icon inlined)', () => {
|
||||
const header = byType(blocks, 'header')[0]!;
|
||||
expect(header).toBeDefined();
|
||||
expect(header.text).toMatchObject({ type: 'plain_text' });
|
||||
expect((header.text as { text: string }).text).toContain('Hanzo Assistant');
|
||||
expect((header.text as { text: string }).text).toContain('🤖');
|
||||
});
|
||||
|
||||
it('renders the subtitle in a context block', () => {
|
||||
const contexts = byType(blocks, 'context');
|
||||
const hasSubtitle = contexts.some((c) =>
|
||||
(c.elements as Array<{ text?: string }>).some((e) => e.text === 'Frontier AI, everywhere'),
|
||||
);
|
||||
expect(hasSubtitle).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the model picker as a section with a static_select accessory', () => {
|
||||
const section = byType(blocks, 'section').find(
|
||||
(s) => (s.accessory as { type?: string } | undefined)?.type === 'static_select',
|
||||
);
|
||||
expect(section).toBeDefined();
|
||||
const acc = section!.accessory as {
|
||||
type: string;
|
||||
action_id: string;
|
||||
options: Array<{ value: string }>;
|
||||
initial_option: { value: string };
|
||||
};
|
||||
expect(acc.type).toBe('static_select');
|
||||
expect(acc.action_id).toBe(ActionId.ModelSelect);
|
||||
expect(acc.options.map((o) => o.value)).toEqual(['zen-eco-3b', 'zen-omni-30b']);
|
||||
// Initial option reflects the current value.
|
||||
expect(acc.initial_option.value).toBe('zen-omni-30b');
|
||||
});
|
||||
|
||||
it('renders quick actions as buttons in an actions block', () => {
|
||||
const actionBlocks = byType(blocks, 'actions');
|
||||
const chipBlock = actionBlocks.find((a) =>
|
||||
(a.elements as Array<{ action_id: string }>).some((e) => e.action_id.startsWith(ActionId.QuickAction)),
|
||||
);
|
||||
expect(chipBlock).toBeDefined();
|
||||
const els = chipBlock!.elements as Array<{ type: string; value: string; action_id: string; style?: string }>;
|
||||
expect(els).toHaveLength(3);
|
||||
expect(els.map((e) => e.type)).toEqual(['button', 'button', 'button']);
|
||||
expect(els.map((e) => e.value)).toEqual(['draft', 'summarize', 'explain']);
|
||||
// Stable per-chip action id.
|
||||
expect(els[0]!.action_id).toBe(`${ActionId.QuickAction}:draft`);
|
||||
// Primary style is carried through.
|
||||
expect(els[0]!.style).toBe('primary');
|
||||
expect(els[1]!.style).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders the output as an mrkdwn section', () => {
|
||||
const outputSection = byType(blocks, 'section').find(
|
||||
(s) => (s.text as { text?: string } | undefined)?.text === richSpec.output,
|
||||
);
|
||||
expect(outputSection).toBeDefined();
|
||||
expect((outputSection!.text as { type: string }).type).toBe('mrkdwn');
|
||||
});
|
||||
|
||||
it('renders the prompt input block with a dispatching plain_text_input', () => {
|
||||
const input = byType(blocks, 'input')[0]!;
|
||||
expect(input).toBeDefined();
|
||||
const el = input.element as { type: string; action_id: string; multiline: boolean };
|
||||
expect(el.type).toBe('plain_text_input');
|
||||
expect(el.action_id).toBe(PROMPT_INPUT_ID);
|
||||
expect(el.multiline).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a submit button wired to PromptSubmit', () => {
|
||||
const submit = byType(blocks, 'actions')
|
||||
.flatMap((a) => a.elements as Array<{ action_id: string }>)
|
||||
.find((e) => e.action_id === ActionId.PromptSubmit);
|
||||
expect(submit).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders the footer in a trailing context block', () => {
|
||||
const contexts = byType(blocks, 'context');
|
||||
const footer = contexts.some((c) =>
|
||||
(c.elements as Array<{ text?: string }>).some((e) => e.text === 'via api.hanzo.ai'),
|
||||
);
|
||||
expect(footer).toBe(true);
|
||||
});
|
||||
|
||||
it('includes divider blocks for visual separation', () => {
|
||||
expect(byType(blocks, 'divider').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('produces JSON-serializable blocks', () => {
|
||||
expect(() => JSON.stringify(blocks)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSlackBlocks — signed out', () => {
|
||||
const blocks = toSlackBlocks(signedOutSpec);
|
||||
|
||||
it('shows the sign-in prompt and a SignIn button, and no widgets', () => {
|
||||
const section = byType(blocks, 'section').find(
|
||||
(s) => (s.text as { text?: string } | undefined)?.text === signedOutSpec.signInPrompt,
|
||||
);
|
||||
expect(section).toBeDefined();
|
||||
|
||||
const signIn = byType(blocks, 'actions')
|
||||
.flatMap((a) => a.elements as Array<{ action_id: string; style?: string }>)
|
||||
.find((e) => e.action_id === ActionId.SignIn);
|
||||
expect(signIn).toBeDefined();
|
||||
expect(signIn!.style).toBe('primary');
|
||||
|
||||
// No model picker / input in signed-out state.
|
||||
const hasSelect = byType(blocks, 'section').some(
|
||||
(s) => (s.accessory as { type?: string } | undefined)?.type === 'static_select',
|
||||
);
|
||||
expect(hasSelect).toBe(false);
|
||||
expect(byType(blocks, 'input')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSlackBlocks — image icon', () => {
|
||||
it('renders a URL icon as an image element in a context block', () => {
|
||||
const blocks = toSlackBlocks({ title: 'Hanzo', icon: 'https://hanzo.ai/logo.png', signedIn: true });
|
||||
const imageCtx = byType(blocks, 'context').find((c) =>
|
||||
(c.elements as Array<{ type: string }>).some((e) => e.type === 'image'),
|
||||
);
|
||||
expect(imageCtx).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ActionId, PROMPT_INPUT_ID, assistantPanel, type PanelSpec } from '../src';
|
||||
|
||||
describe('ActionId contract', () => {
|
||||
it('exposes stable string ids', () => {
|
||||
expect(ActionId.ModelSelect).toBe('hanzo_model_select');
|
||||
expect(ActionId.PromptSubmit).toBe('hanzo_prompt_submit');
|
||||
expect(ActionId.QuickAction).toBe('hanzo_quick_action');
|
||||
expect(ActionId.SignIn).toBe('hanzo_sign_in');
|
||||
expect(PROMPT_INPUT_ID).toBe('hanzo_prompt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantPanel', () => {
|
||||
it('builds a full signed-in panel from minimal input', () => {
|
||||
const spec = assistantPanel({
|
||||
models: ['zen-eco-3b', { id: 'zen-omni-30b', label: 'Zen Omni 30B' }],
|
||||
actions: [{ id: 'draft', label: 'Draft' }],
|
||||
output: '# Result\nhello',
|
||||
});
|
||||
|
||||
expect(spec.title).toBe('Hanzo Assistant');
|
||||
expect(spec.signedIn).toBe(true);
|
||||
expect(spec.modelPicker?.options).toHaveLength(2);
|
||||
// First model becomes the default selection.
|
||||
expect(spec.modelPicker?.value).toBe('zen-eco-3b');
|
||||
// String models normalize to ModelOption.
|
||||
expect(spec.modelPicker?.options[0]).toEqual({ id: 'zen-eco-3b' });
|
||||
expect(spec.modelPicker?.options[1]).toEqual({ id: 'zen-omni-30b', label: 'Zen Omni 30B' });
|
||||
expect(spec.quickActions).toEqual([{ id: 'draft', label: 'Draft' }]);
|
||||
expect(spec.output).toBe('# Result\nhello');
|
||||
expect(spec.input).toMatchObject({ submitLabel: 'Send', multiline: true });
|
||||
expect(spec.footer).toBe('via api.hanzo.ai');
|
||||
});
|
||||
|
||||
it('honors an explicit selected model', () => {
|
||||
const spec = assistantPanel({ models: ['a', 'b', 'c'], model: 'b' });
|
||||
expect(spec.modelPicker?.value).toBe('b');
|
||||
});
|
||||
|
||||
it('omits the model picker when no models given', () => {
|
||||
const spec = assistantPanel({ output: 'hi' });
|
||||
expect(spec.modelPicker).toBeUndefined();
|
||||
});
|
||||
|
||||
it('hides the input when input is false', () => {
|
||||
const spec = assistantPanel({ models: ['a'], input: false });
|
||||
expect(spec.input).toBeUndefined();
|
||||
});
|
||||
|
||||
it('merges custom input options over defaults', () => {
|
||||
const spec = assistantPanel({ models: ['a'], input: { placeholder: 'Type…', submitLabel: 'Go' } });
|
||||
expect(spec.input).toEqual({ placeholder: 'Type…', submitLabel: 'Go', multiline: true });
|
||||
});
|
||||
|
||||
it('produces a signed-out panel with a sign-in prompt', () => {
|
||||
const spec = assistantPanel({ signedIn: false });
|
||||
expect(spec.signedIn).toBe(false);
|
||||
expect(spec.modelPicker).toBeUndefined();
|
||||
expect(spec.quickActions).toBeUndefined();
|
||||
expect(spec.input).toBeUndefined();
|
||||
expect(spec.signInPrompt).toMatch(/sign in/i);
|
||||
expect(spec.signInLabel).toBe('Sign in');
|
||||
expect(spec.footer).toBe('via api.hanzo.ai');
|
||||
});
|
||||
|
||||
it('lets callers override title, footer, and sign-in prompt', () => {
|
||||
const signedOut = assistantPanel({ title: 'Zoo', footer: 'via api.zoo.ngo', signedIn: false, signInPrompt: 'Connect Zoo' });
|
||||
expect(signedOut.title).toBe('Zoo');
|
||||
expect(signedOut.footer).toBe('via api.zoo.ngo');
|
||||
expect(signedOut.signInPrompt).toBe('Connect Zoo');
|
||||
});
|
||||
});
|
||||
|
||||
/** A representative fully-populated spec reused across emitter tests. */
|
||||
export const richSpec: PanelSpec = {
|
||||
title: 'Hanzo Assistant',
|
||||
subtitle: 'Frontier AI, everywhere',
|
||||
icon: '🤖',
|
||||
modelPicker: {
|
||||
label: 'Model',
|
||||
value: 'zen-omni-30b',
|
||||
options: [
|
||||
{ id: 'zen-eco-3b', label: 'Zen Eco 3B' },
|
||||
{ id: 'zen-omni-30b', label: 'Zen Omni 30B' },
|
||||
],
|
||||
},
|
||||
quickActions: [
|
||||
{ id: 'draft', label: 'Draft', style: 'primary' },
|
||||
{ id: 'summarize', label: 'Summarize' },
|
||||
{ id: 'explain', label: 'Explain' },
|
||||
],
|
||||
output: '**Summary**\n- point one\n- point two',
|
||||
input: { placeholder: 'Ask Hanzo…', submitLabel: 'Send', multiline: true },
|
||||
footer: 'via api.hanzo.ai',
|
||||
signedIn: true,
|
||||
};
|
||||
|
||||
export const signedOutSpec: PanelSpec = {
|
||||
title: 'Hanzo Assistant',
|
||||
footer: 'via api.hanzo.ai',
|
||||
signedIn: false,
|
||||
signInPrompt: 'Sign in to Hanzo to use the assistant.',
|
||||
signInLabel: 'Sign in',
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ActionId,
|
||||
PROMPT_INPUT_ID,
|
||||
ADAPTIVE_CARD_SCHEMA,
|
||||
ADAPTIVE_CARD_VERSION,
|
||||
toAdaptiveCard,
|
||||
type AdaptiveElement,
|
||||
} from '../src';
|
||||
import { richSpec, signedOutSpec } from './spec.test';
|
||||
|
||||
function findByType(items: AdaptiveElement[], type: string): AdaptiveElement[] {
|
||||
return items.filter((i) => i.type === type);
|
||||
}
|
||||
|
||||
describe('toAdaptiveCard — envelope', () => {
|
||||
const card = toAdaptiveCard(richSpec);
|
||||
|
||||
it('is a valid AdaptiveCard envelope', () => {
|
||||
expect(card.$schema).toBe(ADAPTIVE_CARD_SCHEMA);
|
||||
expect(card.type).toBe('AdaptiveCard');
|
||||
expect(card.version).toBe(ADAPTIVE_CARD_VERSION);
|
||||
expect(Array.isArray(card.body)).toBe(true);
|
||||
expect(Array.isArray(card.actions)).toBe(true);
|
||||
});
|
||||
|
||||
it('is JSON-serializable', () => {
|
||||
expect(() => JSON.stringify(card)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAdaptiveCard — signed in', () => {
|
||||
const card = toAdaptiveCard(richSpec);
|
||||
|
||||
it('renders the title (with emoji icon inlined) as a bold TextBlock', () => {
|
||||
const title = findByType(card.body, 'TextBlock').find((t) =>
|
||||
String(t.text).includes('Hanzo Assistant'),
|
||||
);
|
||||
expect(title).toBeDefined();
|
||||
expect(title!.weight).toBe('Bolder');
|
||||
expect(String(title!.text)).toContain('🤖');
|
||||
});
|
||||
|
||||
it('renders the subtitle as a subtle TextBlock', () => {
|
||||
const subtitle = findByType(card.body, 'TextBlock').find((t) => t.text === 'Frontier AI, everywhere');
|
||||
expect(subtitle).toBeDefined();
|
||||
expect(subtitle!.isSubtle).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the model picker as an Input.ChoiceSet with the current value', () => {
|
||||
const choiceSet = findByType(card.body, 'Input.ChoiceSet')[0]!;
|
||||
expect(choiceSet).toBeDefined();
|
||||
expect(choiceSet.id).toBe(ActionId.ModelSelect);
|
||||
expect(choiceSet.value).toBe('zen-omni-30b');
|
||||
const choices = choiceSet.choices as Array<{ title: string; value: string }>;
|
||||
expect(choices.map((c) => c.value)).toEqual(['zen-eco-3b', 'zen-omni-30b']);
|
||||
expect(choices[0]!.title).toBe('Zen Eco 3B');
|
||||
});
|
||||
|
||||
it('renders quick actions as an ActionSet of Action.Submit', () => {
|
||||
const actionSet = findByType(card.body, 'ActionSet')[0]!;
|
||||
expect(actionSet).toBeDefined();
|
||||
const acts = actionSet.actions as Array<{ type: string; id: string; data: { action: string; chip: string }; style?: string }>;
|
||||
expect(acts).toHaveLength(3);
|
||||
expect(acts.every((a) => a.type === 'Action.Submit')).toBe(true);
|
||||
expect(acts[0]!.id).toBe(`${ActionId.QuickAction}:draft`);
|
||||
expect(acts[0]!.data.action).toBe(`${ActionId.QuickAction}:draft`);
|
||||
expect(acts[0]!.data.chip).toBe('draft');
|
||||
// Primary chip maps to positive style.
|
||||
expect(acts[0]!.style).toBe('positive');
|
||||
expect(acts[1]!.style).toBe('default');
|
||||
});
|
||||
|
||||
it('renders the output in a Container with a wrapped TextBlock', () => {
|
||||
const container = findByType(card.body, 'Container')[0]!;
|
||||
expect(container).toBeDefined();
|
||||
const items = container.items as AdaptiveElement[];
|
||||
const text = findByType(items, 'TextBlock')[0]!;
|
||||
expect(text.text).toBe(richSpec.output);
|
||||
expect(text.wrap).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the prompt as a multiline Input.Text', () => {
|
||||
const input = findByType(card.body, 'Input.Text')[0]!;
|
||||
expect(input).toBeDefined();
|
||||
expect(input.id).toBe(PROMPT_INPUT_ID);
|
||||
expect(input.isMultiline).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a card-level submit action wired to PromptSubmit', () => {
|
||||
const submit = card.actions.find((a) => (a as { id?: string }).id === ActionId.PromptSubmit);
|
||||
expect(submit).toBeDefined();
|
||||
expect((submit as unknown as { data: { action: string } }).data.action).toBe(ActionId.PromptSubmit);
|
||||
});
|
||||
|
||||
it('renders the footer as a subtle TextBlock', () => {
|
||||
const footer = findByType(card.body, 'TextBlock').find((t) => t.text === 'via api.hanzo.ai');
|
||||
expect(footer).toBeDefined();
|
||||
expect(footer!.isSubtle).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAdaptiveCard — signed out', () => {
|
||||
const card = toAdaptiveCard(signedOutSpec);
|
||||
|
||||
it('shows the sign-in prompt and a positive SignIn action, and no widgets', () => {
|
||||
const prompt = findByType(card.body, 'TextBlock').find((t) => t.text === signedOutSpec.signInPrompt);
|
||||
expect(prompt).toBeDefined();
|
||||
|
||||
const signIn = card.actions.find((a) => (a as { id?: string }).id === ActionId.SignIn);
|
||||
expect(signIn).toBeDefined();
|
||||
expect((signIn as unknown as { style: string }).style).toBe('positive');
|
||||
|
||||
// No interactive widgets in signed-out state.
|
||||
expect(findByType(card.body, 'Input.ChoiceSet')).toHaveLength(0);
|
||||
expect(findByType(card.body, 'Input.Text')).toHaveLength(0);
|
||||
expect(findByType(card.body, 'ActionSet')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAdaptiveCard — image icon', () => {
|
||||
it('renders a URL icon as an Image inside a ColumnSet', () => {
|
||||
const card = toAdaptiveCard({ title: 'Hanzo', icon: 'https://hanzo.ai/logo.png', signedIn: true });
|
||||
const columnSet = findByType(card.body, 'ColumnSet')[0]!;
|
||||
expect(columnSet).toBeDefined();
|
||||
const cols = columnSet.columns as Array<{ items: AdaptiveElement[] }>;
|
||||
const image = cols.flatMap((c) => c.items).find((i) => i.type === 'Image');
|
||||
expect(image).toBeDefined();
|
||||
expect(image!.url).toBe('https://hanzo.ai/logo.png');
|
||||
});
|
||||
});
|
||||
@@ -7,15 +7,16 @@
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"rootDir": ".",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: 'src/index.ts' },
|
||||
format: ['cjs', 'esm'],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['**/*.d.ts', '**/*.config.*'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
# Hanzo AI for Clio
|
||||
|
||||
Brings Hanzo AI into a lawyer's **Clio** matters. Pick a matter and Hanzo will:
|
||||
|
||||
- **Summarize the matter** — client, status, parties, key facts, open issues.
|
||||
- **Draft correspondence** — a send-ready letter/email from the matter context.
|
||||
- **Extract key dates & obligations** — a structured list with sources.
|
||||
- **Ask about this matter** — freeform Q&A over the matter's documents.
|
||||
|
||||
Every result can be **written back to Clio** — as a **Note** on the matter, or
|
||||
logged as an **Activity**. Model calls route through the same
|
||||
`api.hanzo.ai/v1` gateway as every other Hanzo surface (the browser extension,
|
||||
the Office add-in); Clio access is standard **OAuth2 + REST API v4**.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two credentials, kept apart by design:
|
||||
|
||||
- **Clio** — OAuth2 authorization-code grant. The `client_secret` lives **only**
|
||||
in the token-exchange service (`server.ts`), read from the environment. The
|
||||
browser SPA never sees it. The SPA holds a short-lived Clio **access token**
|
||||
in `sessionStorage` and calls Clio's REST API directly with it.
|
||||
- **Hanzo** — an `hk-` API key pasted by the user (kept in the browser), or
|
||||
empty for anonymous/limited models. Same contract as `@hanzo/office-addin`.
|
||||
|
||||
```
|
||||
browser SPA (app.ts) ──OAuth redirect──▶ app.clio.com/oauth/authorize
|
||||
│ code │
|
||||
├──POST /oauth/callback──▶ token service (server.ts, holds client_secret)
|
||||
│ └──POST app.clio.com/oauth/token──▶ tokens
|
||||
├──Clio REST v4 (Bearer access token)──▶ app.clio.com/api/v4/{matters,documents,notes,…}
|
||||
└──POST /v1/chat/completions (Bearer hk-)──▶ api.hanzo.ai
|
||||
```
|
||||
|
||||
The engine is small, pure and unit-tested. `clio-oauth.ts` (authorize URL,
|
||||
token exchange/refresh shaping), `clio-api.ts` (every REST wrapper — URL,
|
||||
headers, body, `fields`, pagination), `hanzo-chat.ts` (the gateway request
|
||||
shape), `actions.ts` (the four actions + matter-context assembly/truncation +
|
||||
write-back), `custom-action.ts` (Clio deep-link parsing). The DOM glue
|
||||
(`app.ts`) and the http service (`server.ts`) are thin over those.
|
||||
|
||||
## Build & test
|
||||
|
||||
```bash
|
||||
pnpm --filter @hanzo/clio test # vitest — 86 pure-logic tests
|
||||
pnpm --filter @hanzo/clio typecheck # tsc --noEmit
|
||||
pnpm --filter @hanzo/clio build # esbuild → dist/
|
||||
```
|
||||
|
||||
The build stamps the Clio **public** `client_id`, `redirect_uri` and `region`
|
||||
into the SPA (the secret is never bundled):
|
||||
|
||||
```bash
|
||||
CLIO_CLIENT_ID=<your-key> \
|
||||
CLIO_REDIRECT_URI=https://clio.hanzo.ai/oauth/callback \
|
||||
CLIO_REGION=us \
|
||||
HANZO_CLIO_BASE=https://clio.hanzo.ai \
|
||||
pnpm --filter @hanzo/clio build
|
||||
```
|
||||
|
||||
`dist/` then contains the static SPA (`index.html`, `app.js`, `styles.css`,
|
||||
`assets/`) and the token service (`server.js`).
|
||||
|
||||
## Register the Clio Developer App
|
||||
|
||||
1. Sign in to Clio and open **Settings → Developer Applications**
|
||||
(`https://app.clio.com/settings/developer_applications`) → **Add**.
|
||||
2. Fill in:
|
||||
- **Name**: `Hanzo AI`
|
||||
- **Redirect URI**: `https://clio.hanzo.ai/oauth/callback`
|
||||
(must match `CLIO_REDIRECT_URI` exactly, and be registered before use).
|
||||
- **Scopes / access**: request **read** and **write** — write is required for
|
||||
the write-back (create Note, log Activity, upload Document).
|
||||
3. Clio issues a **Client ID** (`CLIO_CLIENT_ID`, public) and a
|
||||
**Client Secret** (`CLIO_CLIENT_SECRET`, server-only). Store the secret in
|
||||
**KMS** (`kms.hanzo.ai`), synced to the service's environment. Never commit
|
||||
it; never ship it to the browser.
|
||||
|
||||
**OAuth scopes.** Clio's grant is coarse — the app's declared access (read /
|
||||
write) gates every REST call. This app asks for `read write`. If a firm scopes
|
||||
its app to read-only, the write-back actions will fail at Clio with a 403 and
|
||||
the UI surfaces Clio's message.
|
||||
|
||||
**Regions.** A firm's data lives in one Clio region. Set `CLIO_REGION` to
|
||||
`us` (default), `eu`, `au`, or `ca`; both the OAuth host and the API host derive
|
||||
from it (`app.clio.com`, `eu.app.clio.com`, `au.app.clio.com`,
|
||||
`ca.app.clio.com`).
|
||||
|
||||
## Custom Actions (a Hanzo button inside Clio)
|
||||
|
||||
Clio can render your app's buttons directly on records. In
|
||||
**Settings → Developer Applications → (your app) → Custom Actions**, add one:
|
||||
|
||||
- **Label**: `Open in Hanzo AI`
|
||||
- **UI reference / target**: **Matter** (Clio can also place actions on
|
||||
Contacts, Documents, and Bills).
|
||||
- **URL**: `https://clio.hanzo.ai/custom-action`
|
||||
|
||||
When a lawyer clicks it, Clio opens that URL with four query params:
|
||||
`custom_action_id`, `user_id`, `subject_url` (the URL-encoded record API path,
|
||||
e.g. `%2Fapi%2Fv4%2Fmatters%2F1234`), and `custom_action_nonce`.
|
||||
|
||||
The **nonce is a single-use code (60s expiry), not a locally-verifiable
|
||||
signature.** Per Clio's contract, the app validates the click by **replaying
|
||||
the nonce to the Clio API** as a `custom_action_nonce` query param on its next
|
||||
authenticated request; Clio validates it server-side (confirming the click's
|
||||
user matches the OAuth token) and returns `403` for a bad or stale one. So:
|
||||
|
||||
1. `server.ts` `/custom-action` parses the matter id from `subject_url`
|
||||
(`parseSubjectUrl`) and redirects to
|
||||
`index.html?matter_id=<id>&custom_action_nonce=<nonce>`.
|
||||
2. The SPA's first authenticated Clio call (`getMatter`) carries the nonce
|
||||
(`custom-action.ts`, `clio-api.ts`), so Clio performs the validation. The
|
||||
nonce is single-use — the app clears it after that one call.
|
||||
|
||||
## Hosting
|
||||
|
||||
- **SPA** — serve `dist/{index.html,app.js,styles.css,assets}` as static over
|
||||
`hanzoai/static` at `clio.hanzo.ai`.
|
||||
- **Token service** — run `dist/server.js` (a dependency-free Node http server)
|
||||
behind `hanzoai/ingress`, same origin, routing `/oauth/callback`,
|
||||
`/oauth/refresh`, `/custom-action`, `/healthz`. Required environment:
|
||||
|
||||
| var | example | notes |
|
||||
| -------------------- | ----------------------------------------- | ---------------- |
|
||||
| `CLIO_CLIENT_ID` | `abc…` | public |
|
||||
| `CLIO_CLIENT_SECRET` | *(from KMS)* | **server only** |
|
||||
| `CLIO_REDIRECT_URI` | `https://clio.hanzo.ai/oauth/callback` | matches the app |
|
||||
| `CLIO_REGION` | `us` \| `eu` \| `au` \| `ca` | default `us` |
|
||||
| `PORT` | `8787` | listen port |
|
||||
|
||||
The service **fails fast** if any of the three required values is unset.
|
||||
|
||||
## Clio App Directory listing
|
||||
|
||||
To list publicly in the **Clio App Directory**
|
||||
(`https://app.clio.com/settings/developer_applications` → submit for review):
|
||||
host at `clio.hanzo.ai` over HTTPS, register the redirect + Custom Actions
|
||||
above, then submit the app through Clio's developer portal for their review and
|
||||
Marketplace approval.
|
||||
|
||||
## Write-back flow
|
||||
|
||||
1. Pick a matter (or arrive via a Custom Action deep-link).
|
||||
2. Run an action — the app loads the matter + up to 10 of its documents,
|
||||
assembles a capped context (`assembleMatterContext`, 48k chars total, 12k per
|
||||
doc, truncation marked), and asks Hanzo.
|
||||
3. Review the output, choose **Save as matter Note** (default) or **Log as
|
||||
Activity**, and **Write back to Clio**:
|
||||
- Note → `POST /api/v4/notes` (`type: Matter`, pinned to the matter, subject
|
||||
`Hanzo AI: <action> — <matter>`, the full AI text as the detail).
|
||||
- Activity → `POST /api/v4/activities` (a `TimeEntry` logging the work
|
||||
product against the matter).
|
||||
|
||||
Nothing is persisted until the lawyer clicks write-back — Hanzo is drafting
|
||||
assistance, reviewed before it is used.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 956 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,100 @@
|
||||
// Build the Clio integration into dist/: bundle the SPA (app.ts, browser) and
|
||||
// the token-exchange service (server.ts, node), copy the static HTML/CSS/assets.
|
||||
//
|
||||
// node build.js → production (base https://clio.hanzo.ai)
|
||||
// HANZO_CLIO_BASE=http://localhost:8787 node build.js → local dev
|
||||
//
|
||||
// The Clio Developer App's PUBLIC client_id + redirect + region are stamped into
|
||||
// the SPA via esbuild `define` (the client_id is public; the SECRET is read from
|
||||
// the environment by the running server, never bundled). One place, one source.
|
||||
|
||||
import esbuild from 'esbuild';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const BASE = (process.env.HANZO_CLIO_BASE || 'https://clio.hanzo.ai').replace(/\/+$/, '');
|
||||
const CLIO_CLIENT_ID = process.env.CLIO_CLIENT_ID || '';
|
||||
const CLIO_REDIRECT_URI = process.env.CLIO_REDIRECT_URI || `${BASE}/oauth/callback`;
|
||||
const CLIO_REGION = process.env.CLIO_REGION || 'us';
|
||||
const watch = process.argv.includes('--watch');
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const src = path.join(root, 'src');
|
||||
const dist = path.join(root, 'dist');
|
||||
|
||||
async function build() {
|
||||
fs.rmSync(dist, { recursive: true, force: true });
|
||||
fs.mkdirSync(dist, { recursive: true });
|
||||
|
||||
// The browser SPA — client_id/redirect/region stamped via define. Secrets are
|
||||
// NOT define-able here; server.ts reads them from process.env at runtime.
|
||||
const spa = await esbuild.context({
|
||||
entryPoints: [path.join(src, 'app.ts')],
|
||||
outfile: path.join(dist, 'app.js'),
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'edge90', 'safari14', 'firefox90'],
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
define: {
|
||||
CLIO_CLIENT_ID: JSON.stringify(CLIO_CLIENT_ID),
|
||||
CLIO_REDIRECT_URI: JSON.stringify(CLIO_REDIRECT_URI),
|
||||
CLIO_REGION: JSON.stringify(CLIO_REGION),
|
||||
},
|
||||
logLevel: 'info',
|
||||
});
|
||||
await spa.rebuild();
|
||||
|
||||
// The token-exchange service — a Node ESM bundle. No secrets baked in; it
|
||||
// reads CLIO_CLIENT_SECRET etc. from the environment when it runs.
|
||||
const server = await esbuild.context({
|
||||
entryPoints: [path.join(src, 'server.ts')],
|
||||
outfile: path.join(dist, 'server.js'),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: ['node18'],
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
banner: { js: '// Hanzo Clio token-exchange service — run: node server.js' },
|
||||
logLevel: 'info',
|
||||
});
|
||||
await server.rebuild();
|
||||
|
||||
// Static files.
|
||||
for (const f of ['index.html', 'styles.css']) {
|
||||
fs.copyFileSync(path.join(src, f), path.join(dist, f));
|
||||
}
|
||||
|
||||
// assets/ (icons) — mirror office; create the dir even if empty.
|
||||
const assetsSrc = path.join(root, 'assets');
|
||||
const assetsDst = path.join(dist, 'assets');
|
||||
fs.mkdirSync(assetsDst, { recursive: true });
|
||||
if (fs.existsSync(assetsSrc)) {
|
||||
for (const f of fs.readdirSync(assetsSrc)) {
|
||||
fs.copyFileSync(path.join(assetsSrc, f), path.join(assetsDst, f));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Clio integration built → dist/ (base ${BASE}, region ${CLIO_REGION})`);
|
||||
console.log(' Serve dist/{index.html,app.js,styles.css,assets} as static; run dist/server.js for /oauth/*.');
|
||||
if (!CLIO_CLIENT_ID) {
|
||||
console.log(' ⚠ CLIO_CLIENT_ID not set — set it (and CLIO_CLIENT_SECRET on the server) before deploy.');
|
||||
}
|
||||
|
||||
if (watch) {
|
||||
await spa.watch();
|
||||
await server.watch();
|
||||
console.log('👀 watching…');
|
||||
} else {
|
||||
await spa.dispose();
|
||||
await server.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
build().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@hanzo/clio",
|
||||
"version": "0.1.0",
|
||||
"description": "Hanzo AI inside Clio — summarize matters, draft correspondence, extract key dates & obligations, and write the output back as a Clio note or activity. Model calls route through the api.hanzo.ai gateway; Clio access via OAuth2 + REST v4.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"watch": "node build.js --watch",
|
||||
"serve": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"esbuild": "^0.25.8",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// The four Hanzo AI actions over a Clio matter, plus the pure matter-context
|
||||
// assembly/truncation they share. An action = build a task prompt + a matter
|
||||
// context, ask Hanzo, and (optionally) WRITE the result back to Clio as a Note
|
||||
// or Activity. The context assembly and the per-action task strings are pure
|
||||
// and unit-tested; the Hanzo call and the Clio write are the two thin async
|
||||
// edges (hanzo-chat.ask, clio-api.createNote/createActivity).
|
||||
|
||||
import { ask, type ChatOptions } from './hanzo-chat.js';
|
||||
import {
|
||||
createNote,
|
||||
createActivity,
|
||||
clioFetch,
|
||||
type CreateNoteParams,
|
||||
type CreateActivityParams,
|
||||
} from './clio-api.js';
|
||||
|
||||
// ── Matter context assembly (pure) ────────────────────────────────────────
|
||||
|
||||
// A matter as this app consumes it — a subset of Clio's matter record plus the
|
||||
// pulled-in document texts. Shaped by app.ts from Clio responses.
|
||||
export interface MatterContext {
|
||||
displayNumber?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
clientName?: string;
|
||||
practiceArea?: string;
|
||||
responsibleAttorney?: string;
|
||||
openDate?: string;
|
||||
// Each document: its name and the text pulled via downloadDocument.
|
||||
documents: Array<{ name: string; text: string }>;
|
||||
}
|
||||
|
||||
// The model's context window is finite; a matter's documents can be huge. Cap
|
||||
// the total assembled context so a big matter never blows the request. Chosen
|
||||
// to fit comfortably in a modern context window alongside the response.
|
||||
export const MAX_CONTEXT_CHARS = 48_000;
|
||||
// Per-document cap so one giant document can't crowd out every other document.
|
||||
export const MAX_DOC_CHARS = 12_000;
|
||||
|
||||
// truncate cuts text to a max length, appending a visible marker so the model
|
||||
// (and a reviewing lawyer) know content was elided. Pure.
|
||||
export function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max) + `\n…[truncated ${text.length - max} chars]`;
|
||||
}
|
||||
|
||||
// isTextExtractable reports whether a Clio document's content_type yields useful
|
||||
// text when downloaded as-is. Legal matters are mostly PDF/DOCX (binary); those
|
||||
// come back as mojibake that wastes the context budget and teaches the model
|
||||
// nothing, so we skip their bytes and keep only the filename. Text, markdown,
|
||||
// JSON/XML, CSV, and HTML are worth reading. Matched on the MIME SUBTYPE (after
|
||||
// the `/`) so the DOCX type — application/vnd.openxmlformats-…, which contains
|
||||
// the substring "xml" in its VENDOR tree — is correctly treated as binary. Pure.
|
||||
const TEXT_SUBTYPES = new Set(['json', 'xml', 'csv', 'html', 'markdown', 'x-markdown', 'plain', 'rtf']);
|
||||
export function isTextExtractable(contentType: string | undefined): boolean {
|
||||
if (!contentType) return false;
|
||||
const ct = contentType.toLowerCase().split(';')[0].trim(); // drop any ;charset=
|
||||
if (ct.startsWith('text/')) return true;
|
||||
const subtype = ct.slice(ct.indexOf('/') + 1);
|
||||
return TEXT_SUBTYPES.has(subtype);
|
||||
}
|
||||
|
||||
// assembleMatterContext renders a MatterContext into the single fenced string
|
||||
// handed to Hanzo. Header lines carry the matter metadata; each document is
|
||||
// per-doc capped, then the whole thing is capped at MAX_CONTEXT_CHARS. Order is
|
||||
// metadata-first so the most important facts survive truncation.
|
||||
export function assembleMatterContext(m: MatterContext, maxChars = MAX_CONTEXT_CHARS): string {
|
||||
const lines: string[] = [];
|
||||
if (m.displayNumber) lines.push(`Matter: ${m.displayNumber}`);
|
||||
if (m.description) lines.push(`Description: ${m.description}`);
|
||||
if (m.clientName) lines.push(`Client: ${m.clientName}`);
|
||||
if (m.status) lines.push(`Status: ${m.status}`);
|
||||
if (m.practiceArea) lines.push(`Practice area: ${m.practiceArea}`);
|
||||
if (m.responsibleAttorney) lines.push(`Responsible attorney: ${m.responsibleAttorney}`);
|
||||
if (m.openDate) lines.push(`Opened: ${m.openDate}`);
|
||||
|
||||
for (const doc of m.documents) {
|
||||
const body = truncate(doc.text.trim(), MAX_DOC_CHARS);
|
||||
lines.push(`\n=== Document: ${doc.name} ===\n${body}`);
|
||||
}
|
||||
return truncate(lines.join('\n'), maxChars);
|
||||
}
|
||||
|
||||
// ── The four action definitions (pure task strings) ───────────────────────
|
||||
|
||||
export type ActionId = 'summarize' | 'draft' | 'dates' | 'chat';
|
||||
|
||||
// Each action's task instruction. `draft` and `chat` take a user-supplied
|
||||
// detail (what to draft / the question); `summarize` and `dates` are fixed.
|
||||
export function actionTask(id: ActionId, detail = ''): string {
|
||||
switch (id) {
|
||||
case 'summarize':
|
||||
return (
|
||||
'Summarize this matter for the responsible attorney. Cover the client, ' +
|
||||
'the nature of the matter, current status, key parties, and the most ' +
|
||||
'important facts and open issues from the documents. Be concise.'
|
||||
);
|
||||
case 'draft': {
|
||||
const what = detail.trim() || 'a professional letter to the client updating them on this matter';
|
||||
return (
|
||||
`Draft ${what}. Use the matter context for names, dates and facts. ` +
|
||||
'Produce a complete, send-ready draft with an appropriate salutation ' +
|
||||
'and sign-off. The attorney will review and edit before sending.'
|
||||
);
|
||||
}
|
||||
case 'dates':
|
||||
return (
|
||||
'Extract every key date, deadline, and obligation from this matter and ' +
|
||||
'its documents. Return a structured list; for each item give the date ' +
|
||||
'(or "no date stated"), what is due, who is obligated, and the source ' +
|
||||
'document. If none are present, say so plainly.'
|
||||
);
|
||||
case 'chat':
|
||||
return detail.trim() || 'What is the current status of this matter?';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Running an action ─────────────────────────────────────────────────────
|
||||
|
||||
export interface RunActionParams {
|
||||
id: ActionId;
|
||||
detail?: string; // draft instruction / chat question
|
||||
context: MatterContext;
|
||||
hanzoToken: string;
|
||||
chatOpts?: ChatOptions;
|
||||
}
|
||||
|
||||
// runAction assembles the context, forms the task, and asks Hanzo. Returns the
|
||||
// raw AI text — writing it back to Clio is a separate explicit step (writeBack),
|
||||
// so a lawyer reviews the output before it is persisted.
|
||||
export async function runAction(p: RunActionParams): Promise<string> {
|
||||
const context = assembleMatterContext(p.context);
|
||||
const task = actionTask(p.id, p.detail);
|
||||
return ask(task, context, p.hanzoToken, p.chatOpts);
|
||||
}
|
||||
|
||||
// noteSubject — a concise, action-labelled subject for the write-back note so a
|
||||
// lawyer scanning the matter's notes sees what Hanzo produced. Pure.
|
||||
export function noteSubject(id: ActionId, matterLabel = ''): string {
|
||||
const label: Record<ActionId, string> = {
|
||||
summarize: 'Matter summary',
|
||||
draft: 'Draft correspondence',
|
||||
dates: 'Key dates & obligations',
|
||||
chat: 'Q&A',
|
||||
};
|
||||
const suffix = matterLabel ? ` — ${matterLabel}` : '';
|
||||
return `Hanzo AI: ${label[id]}${suffix}`;
|
||||
}
|
||||
|
||||
export type WriteTarget = 'note' | 'activity';
|
||||
|
||||
export interface WriteBackParams {
|
||||
target: WriteTarget;
|
||||
token: string; // Clio bearer
|
||||
base: string; // regional Clio origin
|
||||
matterId: number;
|
||||
subject: string; // note subject / activity label
|
||||
body: string; // the AI output to persist
|
||||
}
|
||||
|
||||
// writeBack persists an AI result to Clio as either a matter Note (default —
|
||||
// visible on the matter, holds the full body) or an Activity (a time/audit log
|
||||
// entry). Returns the created record. One function, two targets, no branching
|
||||
// in the caller.
|
||||
export async function writeBack(p: WriteBackParams): Promise<any> {
|
||||
if (p.target === 'note') {
|
||||
const params: CreateNoteParams = {
|
||||
token: p.token,
|
||||
base: p.base,
|
||||
matterId: p.matterId,
|
||||
subject: p.subject,
|
||||
detail: p.body,
|
||||
};
|
||||
return clioFetch(createNote(params));
|
||||
}
|
||||
const params: CreateActivityParams = {
|
||||
token: p.token,
|
||||
base: p.base,
|
||||
matterId: p.matterId,
|
||||
note: `${p.subject}\n\n${p.body}`,
|
||||
};
|
||||
return clioFetch(createActivity(params));
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// The SPA glue. Wires the DOM to: Clio sign-in (redirect to Clio's authorize
|
||||
// URL via the token service), loading a matter + its documents, running the four
|
||||
// Hanzo actions, and writing the result back to Clio. The logic-heavy parts
|
||||
// (oauth URL, api request shaping, context assembly, chat, custom-action parse)
|
||||
// live in their tested modules; this is the thin binding.
|
||||
//
|
||||
// Credentials:
|
||||
// - Clio: the OAuth token is obtained server-side (token service) and returned
|
||||
// to the app; kept in sessionStorage for the tab's lifetime, refreshed via
|
||||
// the service. The client_secret is NEVER here.
|
||||
// - Hanzo: an `hk-` key pasted by the user (kept in localStorage), or empty
|
||||
// (anonymous/limited). Same contract as @hanzo/office-addin.
|
||||
|
||||
/* global window, document, sessionStorage, localStorage, crypto */
|
||||
|
||||
import { clioBase, DEFAULT_CLIO_REGION, DEFAULT_MODEL, type ClioRegion } from './config.js';
|
||||
import { oauthAuthorizeUrl, isExpired, type TokenSet } from './clio-oauth.js';
|
||||
import {
|
||||
listMatters,
|
||||
getMatter,
|
||||
listDocuments,
|
||||
downloadDocument,
|
||||
clioFetch,
|
||||
clioDownload,
|
||||
unwrapData,
|
||||
} from './clio-api.js';
|
||||
import { listModels } from './hanzo-chat.js';
|
||||
import {
|
||||
runAction,
|
||||
writeBack,
|
||||
noteSubject,
|
||||
isTextExtractable,
|
||||
type ActionId,
|
||||
type MatterContext,
|
||||
type WriteTarget,
|
||||
} from './actions.js';
|
||||
import { parseCustomAction } from './custom-action.js';
|
||||
|
||||
// The token service origin (this app's own backend). Same-origin in production
|
||||
// (clio.hanzo.ai serves both the static SPA and the service via ingress).
|
||||
const SERVICE = ''; // '' → same origin
|
||||
|
||||
// The Clio Developer App's public client_id and redirect. The client_id is
|
||||
// public (it identifies the app in the authorize URL); only the SECRET is
|
||||
// server-side. These are stamped at build time via esbuild `define`.
|
||||
declare const CLIO_CLIENT_ID: string;
|
||||
declare const CLIO_REDIRECT_URI: string;
|
||||
declare const CLIO_REGION: string;
|
||||
|
||||
const region: ClioRegion = (CLIO_REGION as ClioRegion) || DEFAULT_CLIO_REGION;
|
||||
const base = clioBase(region);
|
||||
|
||||
// ── credential storage ────────────────────────────────────────────────────
|
||||
const CLIO_TOKEN_KEY = 'hanzo.clio.tokens';
|
||||
const HANZO_KEY = 'hanzo.apiKey';
|
||||
const STATE_KEY = 'hanzo.clio.oauthState';
|
||||
|
||||
function clioTokens(): TokenSet | null {
|
||||
const raw = sessionStorage.getItem(CLIO_TOKEN_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}
|
||||
function hanzoKey(): string {
|
||||
return localStorage.getItem(HANZO_KEY) || '';
|
||||
}
|
||||
|
||||
// ── Clio calls through the app (bearer added here) ────────────────────────
|
||||
// The SPA holds the Clio access token in-tab and calls Clio directly with it
|
||||
// (the SECRET is never involved in a REST call, only in the token grant).
|
||||
// clioToken() is the current access token; ensureClioToken() refreshes it via
|
||||
// the token service (which holds the secret) when it is at/near expiry, so a
|
||||
// long matter session never fails mid-flight on a stale token.
|
||||
function clioToken(): string {
|
||||
return clioTokens()?.accessToken || '';
|
||||
}
|
||||
|
||||
async function ensureClioToken(): Promise<string> {
|
||||
const t = clioTokens();
|
||||
if (!t || !t.refreshToken || !isExpired(t)) return t?.accessToken || '';
|
||||
const resp = await fetch(`${SERVICE}/oauth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: t.refreshToken }),
|
||||
});
|
||||
if (!resp.ok) return t.accessToken; // let the call fail with Clio's own 401
|
||||
const next: TokenSet = await resp.json();
|
||||
sessionStorage.setItem(CLIO_TOKEN_KEY, JSON.stringify(next));
|
||||
return next.accessToken;
|
||||
}
|
||||
|
||||
async function loadMatterContext(matterId: number, nonce = ''): Promise<MatterContext> {
|
||||
const token = await ensureClioToken();
|
||||
// The first authenticated call carries the Custom Action nonce (if any) so
|
||||
// Clio validates the click server-side — a bad/stale nonce makes this 403.
|
||||
const matter = unwrapData<any>(await clioFetch(getMatter(token, base, matterId, undefined, nonce)));
|
||||
const docsResp = await clioFetch(listDocuments({ token, base, matterId, limit: 25 }));
|
||||
const docs = unwrapData<any[]>(docsResp) || [];
|
||||
|
||||
// Pull text only for text-extractable documents; a binary (PDF/DOCX) would
|
||||
// download as mojibake, so keep just its name so the model knows it exists.
|
||||
const documents: Array<{ name: string; text: string }> = [];
|
||||
for (const d of docs.slice(0, 10)) {
|
||||
const name = d.name || `document ${d.id}`;
|
||||
if (!isTextExtractable(d.content_type)) {
|
||||
documents.push({ name, text: `(binary document — ${d.content_type || 'unknown type'}, not extracted)` });
|
||||
continue;
|
||||
}
|
||||
let text = '';
|
||||
try {
|
||||
text = await clioDownload(downloadDocument(token, base, d.id));
|
||||
} catch {
|
||||
text = '(document content unavailable)';
|
||||
}
|
||||
documents.push({ name, text });
|
||||
}
|
||||
|
||||
return {
|
||||
displayNumber: matter.display_number,
|
||||
description: matter.description,
|
||||
status: matter.status,
|
||||
clientName: matter.client?.name,
|
||||
practiceArea: matter.practice_area?.name,
|
||||
responsibleAttorney: matter.responsible_attorney?.name,
|
||||
openDate: matter.open_date,
|
||||
documents,
|
||||
};
|
||||
}
|
||||
|
||||
// ── boot ──────────────────────────────────────────────────────────────────
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const $ = (id: string) => document.getElementById(id) as HTMLElement;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const statusEl = $('status');
|
||||
const outputEl = $('output') as HTMLTextAreaElement;
|
||||
const matterEl = $('matter') as HTMLSelectElement;
|
||||
const modelEl = $('model') as HTMLSelectElement;
|
||||
const detailEl = $('detail') as HTMLInputElement;
|
||||
const targetEl = $('target') as HTMLSelectElement;
|
||||
const apiKeyEl = $('apikey') as HTMLInputElement;
|
||||
const signInBtn = $('signin') as HTMLButtonElement;
|
||||
const writeBtn = $('writeback') as HTMLButtonElement;
|
||||
const actionBtns = Array.from(document.querySelectorAll<HTMLButtonElement>('.action'));
|
||||
|
||||
let currentMatterId = 0;
|
||||
let currentMatterLabel = '';
|
||||
let currentOutput = '';
|
||||
let lastAction: ActionId = 'summarize';
|
||||
// A Custom Action deep-link's single-use nonce — consumed on the first Clio
|
||||
// call (Clio validates it server-side, once), then cleared.
|
||||
let deepLinkNonce = params.get('custom_action_nonce') || '';
|
||||
|
||||
function setStatus(msg: string, kind: 'ok' | 'warn' | 'error' | '' = '') {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'status ' + kind;
|
||||
}
|
||||
|
||||
// OAuth callback landing: Clio redirected back with ?code=&state=. Exchange it
|
||||
// via the token service (which holds the secret), store the token set.
|
||||
async function maybeHandleOAuthCallback(): Promise<boolean> {
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
if (!code) return false;
|
||||
if (state !== sessionStorage.getItem(STATE_KEY)) {
|
||||
setStatus('OAuth state mismatch — sign in again.', 'error');
|
||||
return true;
|
||||
}
|
||||
setStatus('Completing Clio sign-in…');
|
||||
const resp = await fetch(`${SERVICE}/oauth/callback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const tokens = await resp.json();
|
||||
if (!resp.ok) {
|
||||
setStatus(tokens?.error || 'Clio sign-in failed.', 'error');
|
||||
return true;
|
||||
}
|
||||
sessionStorage.setItem(CLIO_TOKEN_KEY, JSON.stringify(tokens));
|
||||
// Drop the code/state from the URL so a refresh doesn't re-exchange.
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
setStatus('Signed in to Clio.', 'ok');
|
||||
return true;
|
||||
}
|
||||
|
||||
signInBtn.onclick = () => {
|
||||
const state = crypto.randomUUID();
|
||||
sessionStorage.setItem(STATE_KEY, state);
|
||||
window.location.href = oauthAuthorizeUrl({
|
||||
base,
|
||||
clientId: CLIO_CLIENT_ID,
|
||||
redirectUri: CLIO_REDIRECT_URI,
|
||||
state,
|
||||
});
|
||||
};
|
||||
|
||||
apiKeyEl.value = hanzoKey();
|
||||
apiKeyEl.onchange = () => {
|
||||
const k = apiKeyEl.value.trim();
|
||||
if (k) localStorage.setItem(HANZO_KEY, k);
|
||||
else localStorage.removeItem(HANZO_KEY);
|
||||
};
|
||||
|
||||
async function populateMatters() {
|
||||
if (!clioToken()) return;
|
||||
try {
|
||||
const resp = await clioFetch(listMatters({ token: clioToken(), base, status: 'open', limit: 50 }));
|
||||
const matters = unwrapData<any[]>(resp) || [];
|
||||
matterEl.innerHTML = '';
|
||||
for (const m of matters) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(m.id);
|
||||
opt.textContent = `${m.display_number || m.id} — ${m.description || ''}`.trim();
|
||||
matterEl.appendChild(opt);
|
||||
}
|
||||
if (currentMatterId) matterEl.value = String(currentMatterId);
|
||||
else if (matters[0]) selectMatter(matters[0].id, matterEl.options[0]?.textContent || '');
|
||||
} catch (e: any) {
|
||||
setStatus(e?.message || 'Could not load matters.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function selectMatter(id: number, label: string) {
|
||||
currentMatterId = id;
|
||||
currentMatterLabel = label;
|
||||
}
|
||||
matterEl.onchange = () => selectMatter(Number(matterEl.value), matterEl.selectedOptions[0]?.textContent || '');
|
||||
|
||||
async function populateModels() {
|
||||
try {
|
||||
const ids = (await listModels(hanzoKey())).sort();
|
||||
if (!ids.length) return;
|
||||
modelEl.innerHTML = '';
|
||||
for (const id of ids) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = id;
|
||||
modelEl.appendChild(opt);
|
||||
}
|
||||
modelEl.value = ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : ids[0];
|
||||
} catch {
|
||||
modelEl.innerHTML = `<option value="${DEFAULT_MODEL}">${DEFAULT_MODEL}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(id: ActionId) {
|
||||
if (!currentMatterId) {
|
||||
setStatus('Pick a matter first.', 'warn');
|
||||
return;
|
||||
}
|
||||
for (const b of actionBtns) b.disabled = true;
|
||||
writeBtn.disabled = true;
|
||||
outputEl.value = '';
|
||||
try {
|
||||
setStatus('Loading matter & documents from Clio…');
|
||||
const context = await loadMatterContext(currentMatterId, deepLinkNonce);
|
||||
deepLinkNonce = ''; // single-use — do not replay
|
||||
setStatus('Asking Hanzo…');
|
||||
currentOutput = await runAction({
|
||||
id,
|
||||
detail: detailEl.value,
|
||||
context,
|
||||
hanzoToken: hanzoKey(),
|
||||
chatOpts: { model: modelEl.value || DEFAULT_MODEL },
|
||||
});
|
||||
outputEl.value = currentOutput;
|
||||
writeBtn.disabled = !currentOutput;
|
||||
setStatus('Done — review, then write back to Clio.', 'ok');
|
||||
} catch (e: any) {
|
||||
setStatus(e?.message || String(e), 'error');
|
||||
} finally {
|
||||
for (const b of actionBtns) b.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Wire each action button once. Remember which action produced the current
|
||||
// output so the write-back note gets the right subject.
|
||||
for (const b of actionBtns) {
|
||||
b.onclick = () => {
|
||||
lastAction = b.dataset.action as ActionId;
|
||||
void run(lastAction);
|
||||
};
|
||||
}
|
||||
|
||||
writeBtn.onclick = async () => {
|
||||
const body = outputEl.value.trim();
|
||||
if (!body || !currentMatterId) return;
|
||||
try {
|
||||
setStatus('Writing back to Clio…');
|
||||
await writeBack({
|
||||
target: (targetEl.value as WriteTarget) || 'note',
|
||||
token: clioToken(),
|
||||
base,
|
||||
matterId: currentMatterId,
|
||||
subject: noteSubject(lastAction, currentMatterLabel),
|
||||
body,
|
||||
});
|
||||
setStatus(`Saved to Clio as a ${targetEl.value}.`, 'ok');
|
||||
} catch (e: any) {
|
||||
setStatus(e?.message || String(e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ── init ────────────────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
const handled = await maybeHandleOAuthCallback();
|
||||
// Custom Action deep-link: Clio (via the token service redirect) lands here
|
||||
// with ?matter_id=…, or with the raw subject_url params. Resolve the matter.
|
||||
const ca = parseCustomAction(params);
|
||||
const deepMatter = Number(params.get('matter_id')) || ca.matterId || 0;
|
||||
if (deepMatter) {
|
||||
selectMatter(deepMatter, '');
|
||||
if (clioToken()) {
|
||||
try {
|
||||
// First authenticated call — replay the single-use nonce for Clio's
|
||||
// server-side validation of the Custom Action click, then clear it.
|
||||
const token = await ensureClioToken();
|
||||
const m = unwrapData<any>(await clioFetch(getMatter(token, base, deepMatter, undefined, deepLinkNonce)));
|
||||
deepLinkNonce = '';
|
||||
currentMatterLabel = `${m.display_number || deepMatter}`;
|
||||
} catch (e: any) {
|
||||
setStatus(e?.message || 'Could not open the matter from Clio.', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
await populateModels();
|
||||
if (!handled && !clioToken()) {
|
||||
setStatus('Sign in to Clio to load your matters.', 'warn');
|
||||
} else {
|
||||
await populateMatters();
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
// Clio REST API v4 client. Every wrapper is PURE request-shaping — it returns
|
||||
// the { url, method, headers, body } a caller will fetch, so the wire contract
|
||||
// (path, fields selection, pagination, JSON envelope) is unit-testable without
|
||||
// a network. `clioFetch` at the bottom is the one thin place that actually
|
||||
// hits the network and unwraps Clio's `{ data }` / `{ data, meta }` envelope.
|
||||
//
|
||||
// Clio conventions this encodes:
|
||||
// - Bearer token auth; X-API-Version pins the response shape.
|
||||
// - `fields` query param selects returned attributes (Clio returns only id by
|
||||
// default) — always request the fields an action needs, no over-fetching.
|
||||
// - List responses paginate via `?limit=&page_token=`; the next token is in
|
||||
// `meta.paging.next` (a full URL). extractNextPageToken pulls it out.
|
||||
// - Writes (POST) wrap the record under a top-level `data` key.
|
||||
|
||||
import { clioApiRoot, CLIO_API_VERSION } from './config.js';
|
||||
|
||||
export interface ClioRequest {
|
||||
url: string;
|
||||
method: 'GET' | 'POST';
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
// baseHeaders — auth + version on every call. JSON content-type is added only
|
||||
// for writes (a GET has no body).
|
||||
function baseHeaders(token: string, json = false): Record<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
'X-API-Version': CLIO_API_VERSION,
|
||||
};
|
||||
if (json) h['Content-Type'] = 'application/json';
|
||||
return h;
|
||||
}
|
||||
|
||||
// buildQuery renders a query string from a params object, dropping undefined /
|
||||
// empty values and joining array-valued params (e.g. fields) with commas — the
|
||||
// form Clio expects for `fields`. Deterministic key order for stable tests.
|
||||
function buildQuery(params: Record<string, string | number | string[] | undefined>): string {
|
||||
const q = new URLSearchParams();
|
||||
for (const key of Object.keys(params)) {
|
||||
const v = params[key];
|
||||
if (v === undefined || v === '') continue;
|
||||
q.set(key, Array.isArray(v) ? v.join(',') : String(v));
|
||||
}
|
||||
const s = q.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
// ── Matters ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ListMattersParams {
|
||||
token: string;
|
||||
base: string; // regional Clio origin
|
||||
// Clio filters: status (open/closed/pending), a text query, the owning
|
||||
// client contact, and a page size / cursor for pagination.
|
||||
query?: string;
|
||||
status?: 'open' | 'closed' | 'pending';
|
||||
clientId?: number;
|
||||
limit?: number;
|
||||
pageToken?: string;
|
||||
// Fields to return; sensible default covers what the matter list UI shows.
|
||||
fields?: string[];
|
||||
}
|
||||
|
||||
const MATTER_LIST_FIELDS = ['id', 'display_number', 'description', 'status', 'client{id,name}'];
|
||||
const MATTER_FIELDS = [
|
||||
'id',
|
||||
'display_number',
|
||||
'description',
|
||||
'status',
|
||||
'open_date',
|
||||
'client{id,name}',
|
||||
'practice_area{id,name}',
|
||||
'responsible_attorney{id,name}',
|
||||
];
|
||||
|
||||
export function listMatters(p: ListMattersParams): ClioRequest {
|
||||
const query = buildQuery({
|
||||
query: p.query,
|
||||
status: p.status,
|
||||
client_id: p.clientId,
|
||||
limit: p.limit,
|
||||
page_token: p.pageToken,
|
||||
fields: p.fields ?? MATTER_LIST_FIELDS,
|
||||
});
|
||||
return {
|
||||
url: `${clioApiRoot(p.base)}/matters${query}`,
|
||||
method: 'GET',
|
||||
headers: baseHeaders(p.token),
|
||||
};
|
||||
}
|
||||
|
||||
// getMatter fetches one matter. `nonce` (optional) is a Clio Custom Action
|
||||
// single-use code: when present it is replayed as `custom_action_nonce` so this
|
||||
// authenticated call doubles as Clio's server-side validation of the click
|
||||
// (Clio 403s a bad/stale nonce). This is Clio's documented validation path — a
|
||||
// nonce is NOT verified locally.
|
||||
export function getMatter(
|
||||
token: string,
|
||||
base: string,
|
||||
id: number,
|
||||
fields: string[] = MATTER_FIELDS,
|
||||
nonce = '',
|
||||
): ClioRequest {
|
||||
const query = buildQuery({ fields, custom_action_nonce: nonce });
|
||||
return {
|
||||
url: `${clioApiRoot(base)}/matters/${id}${query}`,
|
||||
method: 'GET',
|
||||
headers: baseHeaders(token),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Documents ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ListDocumentsParams {
|
||||
token: string;
|
||||
base: string;
|
||||
matterId?: number; // scope to one matter's documents
|
||||
limit?: number;
|
||||
pageToken?: string;
|
||||
fields?: string[];
|
||||
}
|
||||
|
||||
const DOCUMENT_FIELDS = ['id', 'name', 'content_type', 'size', 'created_at', 'document_category{id,name}'];
|
||||
|
||||
export function listDocuments(p: ListDocumentsParams): ClioRequest {
|
||||
const query = buildQuery({
|
||||
matter_id: p.matterId,
|
||||
limit: p.limit,
|
||||
page_token: p.pageToken,
|
||||
fields: p.fields ?? DOCUMENT_FIELDS,
|
||||
});
|
||||
return {
|
||||
url: `${clioApiRoot(p.base)}/documents${query}`,
|
||||
method: 'GET',
|
||||
headers: baseHeaders(p.token),
|
||||
};
|
||||
}
|
||||
|
||||
// downloadDocument hits the binary download sub-resource. No JSON Accept — the
|
||||
// response is the file bytes, so the caller reads it as text/blob, not `.data`.
|
||||
export function downloadDocument(token: string, base: string, id: number): ClioRequest {
|
||||
return {
|
||||
url: `${clioApiRoot(base)}/documents/${id}/download`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-API-Version': CLIO_API_VERSION,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Contacts ──────────────────────────────────────────────────────────────
|
||||
|
||||
const CONTACT_FIELDS = ['id', 'name', 'type', 'primary_email_address', 'primary_phone_number'];
|
||||
|
||||
export function listContacts(
|
||||
token: string,
|
||||
base: string,
|
||||
opts: { query?: string; limit?: number; pageToken?: string; fields?: string[] } = {},
|
||||
): ClioRequest {
|
||||
const query = buildQuery({
|
||||
query: opts.query,
|
||||
limit: opts.limit,
|
||||
page_token: opts.pageToken,
|
||||
fields: opts.fields ?? CONTACT_FIELDS,
|
||||
});
|
||||
return {
|
||||
url: `${clioApiRoot(base)}/contacts${query}`,
|
||||
method: 'GET',
|
||||
headers: baseHeaders(token),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Write-back: Notes & Activities ────────────────────────────────────────
|
||||
|
||||
export interface CreateNoteParams {
|
||||
token: string;
|
||||
base: string;
|
||||
matterId: number;
|
||||
subject: string;
|
||||
detail: string; // the AI output body
|
||||
}
|
||||
|
||||
// createNote posts a Note pinned to a matter. Clio wraps the record under a
|
||||
// top-level `data` key; a matter note sets type='Matter' and the matter ref.
|
||||
export function createNote(p: CreateNoteParams): ClioRequest {
|
||||
const data = {
|
||||
subject: p.subject,
|
||||
detail: p.detail,
|
||||
type: 'Matter',
|
||||
matter: { id: p.matterId },
|
||||
};
|
||||
return {
|
||||
url: `${clioApiRoot(p.base)}/notes`,
|
||||
method: 'POST',
|
||||
headers: baseHeaders(p.token, true),
|
||||
body: JSON.stringify({ data }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateActivityParams {
|
||||
token: string;
|
||||
base: string;
|
||||
matterId: number;
|
||||
note: string; // description logged on the activity
|
||||
// Clio activities are time or expense entries; a note-style log uses a
|
||||
// TimeEntry with a quantity (seconds). Default 0 → a non-billable log entry.
|
||||
quantitySeconds?: number;
|
||||
date?: string; // ISO date; defaults to Clio's server date when omitted
|
||||
}
|
||||
|
||||
// createActivity logs an Activity (TimeEntry) against a matter — the audit-trail
|
||||
// way to record that Hanzo produced work product on the matter. quantity is in
|
||||
// seconds per Clio's schema.
|
||||
export function createActivity(p: CreateActivityParams): ClioRequest {
|
||||
const data: Record<string, unknown> = {
|
||||
type: 'TimeEntry',
|
||||
matter: { id: p.matterId },
|
||||
note: p.note,
|
||||
quantity: p.quantitySeconds ?? 0,
|
||||
};
|
||||
if (p.date) data.date = p.date;
|
||||
return {
|
||||
url: `${clioApiRoot(p.base)}/activities`,
|
||||
method: 'POST',
|
||||
headers: baseHeaders(p.token, true),
|
||||
body: JSON.stringify({ data }),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Response unwrapping (pure) ────────────────────────────────────────────
|
||||
|
||||
// unwrapData pulls the record(s) out of Clio's `{ data }` envelope and throws
|
||||
// on Clio's `{ error }` / `{ errors }` shapes so callers surface the real message.
|
||||
export function unwrapData<T = any>(payload: any): T {
|
||||
if (payload && (payload.error || payload.errors)) {
|
||||
const err = payload.error?.message || payload.error || JSON.stringify(payload.errors);
|
||||
throw new Error(`Clio API error: ${err}`);
|
||||
}
|
||||
if (payload && 'data' in payload) return payload.data as T;
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
// extractNextPageToken reads Clio's cursor from meta.paging.next (a full URL)
|
||||
// and returns just the page_token query value, or '' when there is no next page.
|
||||
export function extractNextPageToken(payload: any): string {
|
||||
const next: unknown = payload?.meta?.paging?.next;
|
||||
if (typeof next !== 'string' || !next) return '';
|
||||
try {
|
||||
return new URL(next).searchParams.get('page_token') ?? '';
|
||||
} catch {
|
||||
// Clio gave a token, not a full URL — take it verbatim.
|
||||
const m = /[?&]page_token=([^&]+)/.exec(next);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── The one thin network place ────────────────────────────────────────────
|
||||
|
||||
// clioFetch executes a shaped ClioRequest and returns the parsed JSON envelope
|
||||
// (NOT unwrapped — the caller chooses unwrapData vs. reading meta for paging).
|
||||
// Errors carry Clio's status + body so failures are diagnosable.
|
||||
export async function clioFetch(req: ClioRequest): Promise<any> {
|
||||
const resp = await fetch(req.url, { method: req.method, headers: req.headers, body: req.body });
|
||||
const text = await resp.text();
|
||||
let data: any;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
throw new Error(`Clio API ${resp.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const msg = data?.error?.message || data?.error || JSON.stringify(data?.errors) || `HTTP ${resp.status}`;
|
||||
throw new Error(`Clio API ${resp.status}: ${msg}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// clioDownload executes a download request and returns the raw text of the file
|
||||
// (Clio serves the document body). Kept separate from clioFetch because the
|
||||
// response is NOT the JSON envelope.
|
||||
export async function clioDownload(req: ClioRequest): Promise<string> {
|
||||
const resp = await fetch(req.url, { method: req.method, headers: req.headers });
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text().catch(() => '');
|
||||
throw new Error(`Clio download ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Clio OAuth2 (authorization code grant). PURE request shaping only — the
|
||||
// authorize URL a browser is redirected to, and the token-exchange / refresh
|
||||
// request shape (URL + form body). The client_secret is a caller-supplied
|
||||
// value that ONLY server.ts holds (from the environment); this module never
|
||||
// sources it, so it stays trivially unit-testable and free of secrets.
|
||||
//
|
||||
// Clio's endpoints: authorize `${base}/oauth/authorize`, token `${base}/oauth/token`.
|
||||
// Token exchange and refresh are both application/x-www-form-urlencoded POSTs.
|
||||
|
||||
import { clioAuthorizeURL, clioTokenURL, CLIO_SCOPES } from './config.js';
|
||||
|
||||
export interface AuthorizeParams {
|
||||
base: string; // regional Clio origin (from clioBase)
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
// CSRF state the caller mints and later verifies on the callback.
|
||||
state: string;
|
||||
// Override the default scopes if a firm's app is scoped narrower.
|
||||
scope?: string;
|
||||
// Clio can redirect back even when the user DECLINES consent (with an
|
||||
// `error` param) instead of showing Clio's own decline page — set this so the
|
||||
// app can handle a declined grant gracefully. Off by default.
|
||||
redirectOnDecline?: boolean;
|
||||
}
|
||||
|
||||
// oauthAuthorizeUrl builds the exact URL to redirect the user's browser to.
|
||||
// Clio's authorization endpoint takes response_type=code, client_id,
|
||||
// redirect_uri, state, and optionally redirect_on_decline. Clio returns a
|
||||
// refresh_token on the authorization_code grant BY DEFAULT — there is no
|
||||
// Google-style `access_type=offline` (Clio would ignore it).
|
||||
export function oauthAuthorizeUrl(p: AuthorizeParams): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: p.clientId,
|
||||
redirect_uri: p.redirectUri,
|
||||
state: p.state,
|
||||
scope: p.scope ?? CLIO_SCOPES,
|
||||
});
|
||||
if (p.redirectOnDecline) params.set('redirect_on_decline', 'true');
|
||||
return `${clioAuthorizeURL(p.base)}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// The shape of a request server.ts will POST — separated from fetch so a test
|
||||
// asserts the URL, headers, and form body without a network call.
|
||||
export interface TokenRequest {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: string; // urlencoded form
|
||||
}
|
||||
|
||||
export interface ExchangeParams {
|
||||
base: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
code: string; // the authorization code from the callback
|
||||
}
|
||||
|
||||
// tokenExchange shapes the code→token POST. Clio: grant_type=authorization_code,
|
||||
// with client_id, client_secret, code, redirect_uri as a urlencoded body.
|
||||
export function tokenExchange(p: ExchangeParams): TokenRequest {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: p.code,
|
||||
client_id: p.clientId,
|
||||
client_secret: p.clientSecret,
|
||||
redirect_uri: p.redirectUri,
|
||||
});
|
||||
return {
|
||||
url: clioTokenURL(p.base),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface RefreshParams {
|
||||
base: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// tokenRefresh shapes the refresh POST. Clio: grant_type=refresh_token with the
|
||||
// stored refresh_token. Same endpoint, same content type as exchange.
|
||||
export function tokenRefresh(p: RefreshParams): TokenRequest {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: p.refreshToken,
|
||||
client_id: p.clientId,
|
||||
client_secret: p.clientSecret,
|
||||
});
|
||||
return {
|
||||
url: clioTokenURL(p.base),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TokenSet {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenType: string;
|
||||
// Absolute expiry (epoch ms) computed from Clio's expires_in at parse time,
|
||||
// so callers compare against Date.now() without re-deriving.
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// parseTokenResponse turns Clio's JSON token payload into a TokenSet, computing
|
||||
// the absolute expiry. `now` is injectable so the derivation is unit-testable.
|
||||
// Throws on an OAuth error payload so the caller surfaces Clio's real reason.
|
||||
export function parseTokenResponse(data: any, now: number = Date.now()): TokenSet {
|
||||
if (data && data.error) {
|
||||
const desc = data.error_description || data.error;
|
||||
throw new Error(`Clio OAuth error: ${desc}`);
|
||||
}
|
||||
const accessToken = data?.access_token;
|
||||
if (typeof accessToken !== 'string' || !accessToken) {
|
||||
throw new Error('Clio OAuth: no access_token in response');
|
||||
}
|
||||
const expiresIn = Number(data?.expires_in ?? 0);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: typeof data?.refresh_token === 'string' ? data.refresh_token : '',
|
||||
tokenType: typeof data?.token_type === 'string' ? data.token_type : 'bearer',
|
||||
expiresAt: now + expiresIn * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// isExpired reports whether a token set is at/near expiry. A skew (default 60s)
|
||||
// refreshes slightly early so an in-flight request never races the boundary.
|
||||
export function isExpired(tokens: TokenSet, now: number = Date.now(), skewMs = 60_000): boolean {
|
||||
return now >= tokens.expiresAt - skewMs;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Clio integration config — the two backends this app talks to and nothing
|
||||
// else. Hanzo (the model gateway, api.hanzo.ai/v1, mirrored from @hanzo/office-addin)
|
||||
// and Clio (OAuth + REST v4, app.clio.com by default, regional bases configurable).
|
||||
//
|
||||
// Rule: no `/api/` prefix on Hanzo (it is api.hanzo.ai already); Clio uses its
|
||||
// literal `/api/v4` + `/oauth/*` paths. Secrets never live here — the Clio
|
||||
// client_secret is read from the environment by server.ts, never bundled.
|
||||
|
||||
// ── Hanzo model gateway ───────────────────────────────────────────────────
|
||||
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
|
||||
|
||||
// Default model. Overridable per-request; the gateway routes it.
|
||||
export const DEFAULT_MODEL = 'zen-1';
|
||||
|
||||
export function chatCompletionsURL(): string {
|
||||
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
|
||||
}
|
||||
export function modelsURL(): string {
|
||||
return `${HANZO_API_BASE_URL}/v1/models`;
|
||||
}
|
||||
|
||||
// ── Clio regions ──────────────────────────────────────────────────────────
|
||||
// Clio hosts one deployment per data-residency region; the OAuth host and the
|
||||
// API host share the SAME origin per region (there is no separate auth host).
|
||||
// A firm's tokens and data live in exactly one region, so the base is a single
|
||||
// configurable value, not a fan-out.
|
||||
export type ClioRegion = 'us' | 'eu' | 'au' | 'ca';
|
||||
|
||||
export const CLIO_REGION_BASES: Record<ClioRegion, string> = {
|
||||
us: 'https://app.clio.com',
|
||||
eu: 'https://eu.app.clio.com',
|
||||
au: 'https://au.app.clio.com',
|
||||
ca: 'https://ca.app.clio.com',
|
||||
};
|
||||
|
||||
export const DEFAULT_CLIO_REGION: ClioRegion = 'us';
|
||||
|
||||
// The Clio REST API version pin. Sent as X-API-Version on every call so Clio
|
||||
// serves a stable response shape regardless of what it rolls forward to.
|
||||
export const CLIO_API_VERSION = '4.0.11';
|
||||
|
||||
// clioBase resolves a region (or an explicit base override) to the origin used
|
||||
// for BOTH /oauth/* and /api/v4. An explicit base wins — a firm on a private
|
||||
// Clio host, or a test, can pass one directly. Otherwise the region table.
|
||||
// Trailing slashes are stripped so callers can always append `/oauth/...`.
|
||||
export function clioBase(region: ClioRegion = DEFAULT_CLIO_REGION, override?: string): string {
|
||||
const raw = override || CLIO_REGION_BASES[region] || CLIO_REGION_BASES[DEFAULT_CLIO_REGION];
|
||||
return raw.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// The Clio REST v4 root and the two OAuth endpoints, derived from the base.
|
||||
export function clioApiRoot(base: string): string {
|
||||
return `${base}/api/v4`;
|
||||
}
|
||||
export function clioAuthorizeURL(base: string): string {
|
||||
return `${base}/oauth/authorize`;
|
||||
}
|
||||
export function clioTokenURL(base: string): string {
|
||||
return `${base}/oauth/token`;
|
||||
}
|
||||
|
||||
// OAuth scopes Clio grants. Clio scopes are coarse (all/read/write per the
|
||||
// app's declared access); we request read+write because the write-back actions
|
||||
// (notes, activities, document upload) need write. Documented in README.
|
||||
export const CLIO_SCOPES = 'read write';
|
||||
@@ -0,0 +1,69 @@
|
||||
// Clio Custom Actions land here. When a lawyer clicks a Hanzo button Clio has
|
||||
// rendered on a matter, Clio opens this app's deep-link URL with four query
|
||||
// params: `custom_action_id`, `user_id`, `subject_url` (URL-encoded API URL of
|
||||
// the record, e.g. /api/v4/matters/1234), and `custom_action_nonce`.
|
||||
//
|
||||
// IMPORTANT (Clio's real contract): the nonce is NOT a locally-verifiable HMAC.
|
||||
// It is a SINGLE-USE code (60s expiry) that the app passes BACK to the Clio API
|
||||
// as a query param on its next authenticated request; Clio validates it
|
||||
// server-side, confirming the click's user matches the OAuth token — a bad or
|
||||
// stale nonce makes that request 403. So this module is PURE parsing only; the
|
||||
// validation is "make an authenticated Clio call carrying the nonce" (the app
|
||||
// does this when it loads the matter), not a signature check we perform.
|
||||
|
||||
export interface CustomActionContext {
|
||||
// The record the action fired on, as a (possibly URL-encoded) Clio API URL,
|
||||
// e.g. /api/v4/matters/1234 or https://app.clio.com/api/v4/matters/1234.
|
||||
subjectUrl: string;
|
||||
customActionId: string;
|
||||
userId: string;
|
||||
// Single-use code to replay to the Clio API for server-side validation.
|
||||
nonce: string;
|
||||
// The parsed record type + id from subject_url, when it is a matter.
|
||||
matterId?: number;
|
||||
subjectType?: string; // 'matters' | 'contacts' | 'documents' | 'bills'
|
||||
}
|
||||
|
||||
// parseSubjectUrl extracts the record type and numeric id from a Clio
|
||||
// subject_url. Clio's REST URLs are `.../api/v4/{type}/{id}(.json)?`; the value
|
||||
// may be URL-encoded (%2Fapi%2Fv4%2F…) and may be a bare path or a full URL.
|
||||
// Returns empty when it doesn't match (defensive — this is external input). Pure.
|
||||
export function parseSubjectUrl(subjectUrl: string): { subjectType?: string; recordId?: number } {
|
||||
if (!subjectUrl) return {};
|
||||
// Clio URL-encodes subject_url; decode first (safe on an already-decoded value).
|
||||
let decoded = subjectUrl;
|
||||
try {
|
||||
decoded = decodeURIComponent(subjectUrl);
|
||||
} catch {
|
||||
/* not encoded — use as-is */
|
||||
}
|
||||
let path: string;
|
||||
try {
|
||||
path = new URL(decoded).pathname;
|
||||
} catch {
|
||||
path = decoded; // tolerate a bare path
|
||||
}
|
||||
const m = /\/api\/v4\/([a-z_]+)\/(\d+)(?:\.json)?$/.exec(path);
|
||||
if (!m) return {};
|
||||
return { subjectType: m[1], recordId: Number(m[2]) };
|
||||
}
|
||||
|
||||
// parseCustomAction turns the deep-link query params (a URLSearchParams or a
|
||||
// plain record) into a CustomActionContext, resolving the matter id when the
|
||||
// action fired on a matter. Missing params become empty strings so the shape is
|
||||
// always complete. Pure — the caller reads window.location in app.ts.
|
||||
export function parseCustomAction(params: URLSearchParams | Record<string, string>): CustomActionContext {
|
||||
const get = (k: string): string =>
|
||||
params instanceof URLSearchParams ? params.get(k) ?? '' : params[k] ?? '';
|
||||
|
||||
const subjectUrl = get('subject_url');
|
||||
const { subjectType, recordId } = parseSubjectUrl(subjectUrl);
|
||||
return {
|
||||
subjectUrl,
|
||||
customActionId: get('custom_action_id'),
|
||||
userId: get('user_id'),
|
||||
nonce: get('custom_action_nonce'),
|
||||
subjectType,
|
||||
matterId: subjectType === 'matters' ? recordId : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// The Hanzo chat call and its request/response shaping — pure, host-agnostic,
|
||||
// unit-testable (no DOM, no Clio). Mirrors @hanzo/office-addin's chat.ts wire
|
||||
// contract exactly (same api.hanzo.ai/v1 gateway, same OpenAI-compatible
|
||||
// shapes) so there is ONE chat contract across every Hanzo surface. The only
|
||||
// difference is the system prompt: here Hanzo is a legal assistant working over
|
||||
// a Clio matter, not an in-document editor.
|
||||
|
||||
import { chatCompletionsURL, modelsURL, DEFAULT_MODEL } from './config.js';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// The system prompt that frames Hanzo as a lawyer's assistant over a matter.
|
||||
// Kept as a constant so actions.ts composes it with a task + matter context.
|
||||
export const LEGAL_SYSTEM_PROMPT =
|
||||
'You are Hanzo AI assisting a lawyer inside their Clio practice-management ' +
|
||||
'system. You are given a matter and, where relevant, excerpts of its ' +
|
||||
'documents. Be precise, cite the parties, dates, defined terms and ' +
|
||||
'obligations from the provided context, and never invent facts not present ' +
|
||||
'in it. Return clean prose ready to save as a note or send — no markdown ' +
|
||||
'fences, no "Here is", no preamble. This is drafting assistance, not legal ' +
|
||||
'advice, and the lawyer reviews everything before it is used.';
|
||||
|
||||
// buildMessages composes the system frame, the matter context (as fenced data),
|
||||
// and the task instruction. context is fenced so the model treats a matter's
|
||||
// documents as data, never as instructions (prompt-injection boundary).
|
||||
export function buildMessages(task: string, context: string, systemPrompt = LEGAL_SYSTEM_PROMPT): ChatMessage[] {
|
||||
const system: ChatMessage = { role: 'system', content: systemPrompt };
|
||||
const trimmed = context.trim();
|
||||
const user: ChatMessage = {
|
||||
role: 'user',
|
||||
content: trimmed ? `${task}\n\n---- matter context ----\n${trimmed}` : task,
|
||||
};
|
||||
return [system, user];
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
stream?: boolean;
|
||||
signal?: AbortSignal;
|
||||
system?: string;
|
||||
}
|
||||
|
||||
// requestBody is the exact JSON the gateway receives. Separated so a test can
|
||||
// assert the wire shape without a network call. `stream` defaults to false.
|
||||
export function requestBody(messages: ChatMessage[], opts: ChatOptions = {}): Record<string, unknown> {
|
||||
return {
|
||||
model: opts.model || DEFAULT_MODEL,
|
||||
messages,
|
||||
stream: opts.stream === true,
|
||||
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// extractContent pulls the assistant text out of an OpenAI-compatible response,
|
||||
// tolerating the shapes the gateway returns. Throws on an error payload.
|
||||
export function extractContent(data: any): string {
|
||||
if (data && data.error) {
|
||||
const msg = typeof data.error === 'string' ? data.error : data.error.message || 'unknown error';
|
||||
throw new Error(`Hanzo API error: ${msg}`);
|
||||
}
|
||||
const choice = data?.choices?.[0];
|
||||
const content = choice?.message?.content ?? choice?.text ?? data?.content ?? '';
|
||||
if (typeof content !== 'string' || content === '') {
|
||||
throw new Error('Hanzo API returned no content');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ask runs one non-streaming completion and returns the text. token may be
|
||||
// empty (anonymous/limited); when present it is the Hanzo bearer (hk- key or
|
||||
// IAM OAuth token).
|
||||
export async function ask(task: string, context: string, token: string, opts: ChatOptions = {}): Promise<string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(chatCompletionsURL(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody(buildMessages(task, context, opts.system), opts)),
|
||||
signal: opts.signal,
|
||||
});
|
||||
const text = await resp.text();
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Hanzo API ${resp.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
try {
|
||||
return extractContent(data);
|
||||
} catch {
|
||||
const m = data?.error?.message || data?.error || `HTTP ${resp.status}`;
|
||||
throw new Error(`Hanzo API ${resp.status}: ${m}`);
|
||||
}
|
||||
}
|
||||
return extractContent(data);
|
||||
}
|
||||
|
||||
// streamDelta pulls the incremental text out of one OpenAI-compatible SSE frame.
|
||||
export function streamDelta(payload: string): string {
|
||||
const json = JSON.parse(payload);
|
||||
return json?.choices?.[0]?.delta?.content ?? '';
|
||||
}
|
||||
|
||||
// askStream streams a completion, invoking onDelta per chunk, resolving with the
|
||||
// full text. Same inputs as `ask`; used for the live-typing pane.
|
||||
export async function askStream(
|
||||
task: string,
|
||||
context: string,
|
||||
token: string,
|
||||
onDelta: (text: string) => void,
|
||||
opts: ChatOptions = {},
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(chatCompletionsURL(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody(buildMessages(task, context, opts.system), { ...opts, stream: true })),
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (!resp.ok || !resp.body) {
|
||||
const detail = await resp.text().catch(() => '');
|
||||
throw new Error(`Hanzo API ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let full = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
const payload = trimmed.slice(5).trim();
|
||||
if (payload === '[DONE]') return full;
|
||||
try {
|
||||
const delta = streamDelta(payload);
|
||||
if (delta) {
|
||||
full += delta;
|
||||
onDelta(delta);
|
||||
}
|
||||
} catch {
|
||||
// partial SSE frame — wait for the next chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
// listModels returns the model ids the caller may route to, from /v1/models.
|
||||
export async function listModels(token: string): Promise<string[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const resp = await fetch(modelsURL(), { headers });
|
||||
if (!resp.ok) throw new Error(`Hanzo API ${resp.status}: model list failed`);
|
||||
const data: any = await resp.json();
|
||||
const items = data?.data ?? data?.models ?? (Array.isArray(data) ? data : []);
|
||||
return items
|
||||
.map((m: any) => (typeof m === 'string' ? m : m?.id))
|
||||
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hanzo AI for Clio</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="assets/icon-32.png" alt="Hanzo" />
|
||||
<span class="title">Hanzo AI for Clio</span>
|
||||
<select id="model" aria-label="Model"></select>
|
||||
<button id="signin" class="secondary">Sign in to Clio</button>
|
||||
</header>
|
||||
|
||||
<label for="matter">Matter</label>
|
||||
<select id="matter" aria-label="Matter"></select>
|
||||
|
||||
<!-- The four AI actions over the selected matter. -->
|
||||
<div class="actions">
|
||||
<button class="action" data-action="summarize">Summarize matter</button>
|
||||
<button class="action" data-action="draft">Draft correspondence</button>
|
||||
<button class="action" data-action="dates">Key dates & obligations</button>
|
||||
<button class="action" data-action="chat">Ask about this matter</button>
|
||||
</div>
|
||||
|
||||
<label for="detail">Instruction (for Draft / Ask) — optional</label>
|
||||
<input id="detail" type="text"
|
||||
placeholder="e.g. a demand letter to opposing counsel · What is the filing deadline?" />
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<label for="output">Result</label>
|
||||
<textarea id="output" placeholder="Hanzo's output appears here — review before writing back to Clio."></textarea>
|
||||
|
||||
<div class="row">
|
||||
<select id="target" aria-label="Write-back target">
|
||||
<option value="note">Save as matter Note</option>
|
||||
<option value="activity">Log as Activity</option>
|
||||
</select>
|
||||
<button id="writeback" disabled>Write back to Clio</button>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Hanzo API key (optional — for your models)</summary>
|
||||
<div class="row">
|
||||
<input id="apikey" type="password" placeholder="hk-…" />
|
||||
</div>
|
||||
<p class="hint">Pasted keys stay in your browser. Clio sign-in is handled server-side; the Clio secret never reaches this page.</p>
|
||||
</details>
|
||||
|
||||
<footer>Model calls route through api.hanzo.ai · Clio data via app.clio.com/api/v4 · nothing stored server-side.</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
// The token-exchange + Clio-proxy service. This is the ONLY place the Clio
|
||||
// client_secret exists — it is read from the environment (never bundled, never
|
||||
// sent to the browser). The client app (app.ts) talks to THIS service for the
|
||||
// OAuth code→token exchange, token refresh, the Custom Action nonce check, and
|
||||
// (optionally) as a proxy for Clio API calls so a Clio token never has to be
|
||||
// handed to the browser either.
|
||||
//
|
||||
// It is a dependency-free Node http handler built on the pure modules
|
||||
// (clio-oauth, clio-api, custom-action) so it is deployable behind
|
||||
// hanzoai/ingress as a small service at clio.hanzo.ai. Documented here; the
|
||||
// pure logic it wraps is what the tests cover (an http server is an integration
|
||||
// concern, not a unit).
|
||||
//
|
||||
// node dist/server.js (after build.js bundles it)
|
||||
//
|
||||
// Required environment:
|
||||
// CLIO_CLIENT_ID — the Developer App's key
|
||||
// CLIO_CLIENT_SECRET — the Developer App's secret (SERVER ONLY)
|
||||
// CLIO_REDIRECT_URI — e.g. https://clio.hanzo.ai/oauth/callback
|
||||
// CLIO_REGION — us | eu | au | ca (default us)
|
||||
// PORT — listen port (default 8787)
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { clioBase, type ClioRegion } from './config.js';
|
||||
import { tokenExchange, tokenRefresh, parseTokenResponse } from './clio-oauth.js';
|
||||
import { parseCustomAction } from './custom-action.js';
|
||||
|
||||
interface Env {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
region: ClioRegion;
|
||||
port: number;
|
||||
}
|
||||
|
||||
// readEnv fails fast (throws) if a required secret is missing — a server that
|
||||
// can't exchange tokens must not pretend to start. No default for the secret.
|
||||
export function readEnv(env: NodeJS.ProcessEnv = process.env): Env {
|
||||
const clientId = env.CLIO_CLIENT_ID;
|
||||
const clientSecret = env.CLIO_CLIENT_SECRET;
|
||||
const redirectUri = env.CLIO_REDIRECT_URI;
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
throw new Error('CLIO_CLIENT_ID, CLIO_CLIENT_SECRET and CLIO_REDIRECT_URI are required');
|
||||
}
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
region: (env.CLIO_REGION as ClioRegion) || 'us',
|
||||
port: Number(env.PORT) || 8787,
|
||||
};
|
||||
}
|
||||
|
||||
// customActionRedirect builds the SPA landing URL for a verified Custom Action
|
||||
// deep-link. Clio's nonce is NOT locally verifiable — it is a single-use code
|
||||
// the SPA must replay to the Clio API (Clio validates server-side, 403 on a bad
|
||||
// one). So the server just parses the deep-link and forwards the matter id AND
|
||||
// the nonce into the SPA, which carries the nonce on its first authenticated
|
||||
// Clio call. Pure (given the query), so it is unit-tested. Returns '' when the
|
||||
// deep-link is not a matter action (nothing to open).
|
||||
export function customActionRedirect(search: URLSearchParams): string {
|
||||
const ctx = parseCustomAction(search);
|
||||
if (!ctx.matterId) return '';
|
||||
const q = new URLSearchParams({ matter_id: String(ctx.matterId) });
|
||||
if (ctx.nonce) q.set('custom_action_nonce', ctx.nonce);
|
||||
return `/index.html?${q.toString()}`;
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of req) chunks.push(c as Buffer);
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, status: number, body: unknown): void {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
// POST /oauth/callback { code } → exchanges for tokens (secret stays here) and
|
||||
// returns the TokenSet to the app. The app never sees the client_secret.
|
||||
async function handleCallback(env: Env, base: string, res: ServerResponse, body: string): Promise<void> {
|
||||
const { code } = JSON.parse(body || '{}');
|
||||
if (!code) return json(res, 400, { error: 'missing code' });
|
||||
const reqShape = tokenExchange({
|
||||
base,
|
||||
clientId: env.clientId,
|
||||
clientSecret: env.clientSecret,
|
||||
redirectUri: env.redirectUri,
|
||||
code,
|
||||
});
|
||||
const clioResp = await fetch(reqShape.url, { method: 'POST', headers: reqShape.headers, body: reqShape.body });
|
||||
const data = await clioResp.json().catch(() => ({}));
|
||||
try {
|
||||
return json(res, 200, parseTokenResponse(data));
|
||||
} catch (e: any) {
|
||||
return json(res, 400, { error: e?.message || 'token exchange failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /oauth/refresh { refresh_token } → refreshes; same secret-side pattern.
|
||||
async function handleRefresh(env: Env, base: string, res: ServerResponse, body: string): Promise<void> {
|
||||
const { refresh_token } = JSON.parse(body || '{}');
|
||||
if (!refresh_token) return json(res, 400, { error: 'missing refresh_token' });
|
||||
const reqShape = tokenRefresh({
|
||||
base,
|
||||
clientId: env.clientId,
|
||||
clientSecret: env.clientSecret,
|
||||
refreshToken: refresh_token,
|
||||
});
|
||||
const clioResp = await fetch(reqShape.url, { method: 'POST', headers: reqShape.headers, body: reqShape.body });
|
||||
const data = await clioResp.json().catch(() => ({}));
|
||||
try {
|
||||
return json(res, 200, parseTokenResponse(data));
|
||||
} catch (e: any) {
|
||||
return json(res, 400, { error: e?.message || 'refresh failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /custom-action?… → redirect the browser to the SPA landing (index.html)
|
||||
// with the matter id + the single-use nonce, so the app opens on the right
|
||||
// matter and replays the nonce to Clio for server-side validation. A non-matter
|
||||
// (or malformed) deep-link falls back to the SPA root.
|
||||
function handleCustomAction(url: URL, res: ServerResponse): void {
|
||||
const dest = customActionRedirect(url.searchParams) || '/index.html';
|
||||
res.writeHead(302, { Location: dest });
|
||||
res.end();
|
||||
}
|
||||
|
||||
// The request router. Everything is a small handler over the pure modules.
|
||||
export function createHandler(env: Env) {
|
||||
const base = clioBase(env.region);
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
const url = new URL(req.url || '/', `http://localhost:${env.port}`);
|
||||
try {
|
||||
if (req.method === 'POST' && url.pathname === '/oauth/callback') {
|
||||
return await handleCallback(env, base, res, await readBody(req));
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/oauth/refresh') {
|
||||
return await handleRefresh(env, base, res, await readBody(req));
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/custom-action') {
|
||||
return handleCustomAction(url, res);
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/healthz') {
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
return json(res, 404, { error: 'not found' });
|
||||
} catch (e: any) {
|
||||
return json(res, 500, { error: e?.message || 'internal error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// main boots the http server when run directly. Import-safe: only the direct
|
||||
// entry starts listening, so tests can import readEnv/customActionRedirect
|
||||
// without opening a port.
|
||||
export function main(): void {
|
||||
const env = readEnv();
|
||||
const server = createServer(createHandler(env));
|
||||
server.listen(env.port, () => {
|
||||
// Structured, single-line startup log.
|
||||
console.log(JSON.stringify({ msg: 'clio token-exchange service up', port: env.port, region: env.region }));
|
||||
});
|
||||
}
|
||||
|
||||
// ESM entry check: run main() only when this file is the process entry.
|
||||
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* Hanzo AI for Clio — one small stylesheet, mirroring the office add-in look:
|
||||
system font, light/dark auto, black-on-white accent. No framework. */
|
||||
:root { color-scheme: light dark; --fg:#1a1a1a; --muted:#666; --bg:#fff; --line:#e3e3e3; --accent:#111; }
|
||||
@media (prefers-color-scheme: dark) { :root { --fg:#eaeaea; --muted:#9a9a9a; --bg:#1e1e1e; --line:#333; --accent:#fff; } }
|
||||
* { box-sizing: border-box; }
|
||||
body { font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 16px; max-width: 720px; margin-inline: auto; }
|
||||
header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
header img { width: 24px; height: 24px; }
|
||||
header .title { font-weight: 600; font-size: 16px; }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
|
||||
textarea, select, input[type="text"], input[type="password"] {
|
||||
width: 100%; font: inherit; color: var(--fg); background: var(--bg);
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 8px;
|
||||
}
|
||||
textarea#output { min-height: 180px; resize: vertical; }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
button { font: inherit; border: 1px solid var(--line); background: var(--accent); color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer; white-space: nowrap; }
|
||||
button.secondary { background: transparent; color: var(--fg); margin-left: auto; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.actions .action { background: transparent; color: var(--fg); border: 1px solid var(--line); border-radius: 999px; padding: 6px 12px; }
|
||||
.actions .action:hover:not(:disabled) { border-color: var(--accent); }
|
||||
.status { min-height: 18px; font-size: 12px; margin-top: 10px; color: var(--muted); }
|
||||
.status.ok { color: #1a7f37; } .status.warn { color: #9a6700; } .status.error { color: #cf222e; }
|
||||
select#model { width: auto; max-width: 140px; margin-left: auto; padding: 4px 8px; font-size: 12px; }
|
||||
select#target { width: auto; }
|
||||
details { margin-top: 12px; }
|
||||
details summary { cursor: pointer; font-size: 12px; color: var(--muted); }
|
||||
.hint { font-size: 11px; color: var(--muted); margin: 6px 0 0; }
|
||||
footer { margin-top: 16px; font-size: 11px; color: var(--muted); }
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
truncate,
|
||||
assembleMatterContext,
|
||||
actionTask,
|
||||
noteSubject,
|
||||
writeBack,
|
||||
runAction,
|
||||
isTextExtractable,
|
||||
MAX_DOC_CHARS,
|
||||
MAX_CONTEXT_CHARS,
|
||||
type MatterContext,
|
||||
} from '../src/actions';
|
||||
import { clioBase } from '../src/config';
|
||||
|
||||
const base = clioBase('us');
|
||||
|
||||
describe('truncate', () => {
|
||||
it('leaves short text intact', () => {
|
||||
expect(truncate('hello', 10)).toBe('hello');
|
||||
});
|
||||
it('cuts to max and marks the elision with the dropped count', () => {
|
||||
const out = truncate('abcdefghij', 4);
|
||||
expect(out.startsWith('abcd')).toBe(true);
|
||||
expect(out).toContain('truncated 6 chars');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleMatterContext', () => {
|
||||
const matter: MatterContext = {
|
||||
displayNumber: '00123-Smith',
|
||||
description: 'Estate administration',
|
||||
clientName: 'Jane Smith',
|
||||
status: 'open',
|
||||
practiceArea: 'Estates',
|
||||
responsibleAttorney: 'A. Attorney',
|
||||
openDate: '2026-01-01',
|
||||
documents: [
|
||||
{ name: 'will.pdf', text: 'Last will and testament...' },
|
||||
{ name: 'inventory.docx', text: 'Asset inventory...' },
|
||||
],
|
||||
};
|
||||
|
||||
it('puts metadata first, then each fenced document', () => {
|
||||
const ctx = assembleMatterContext(matter);
|
||||
const willIdx = ctx.indexOf('=== Document: will.pdf ===');
|
||||
expect(ctx.indexOf('Matter: 00123-Smith')).toBeLessThan(willIdx);
|
||||
expect(ctx).toContain('Client: Jane Smith');
|
||||
expect(ctx).toContain('Responsible attorney: A. Attorney');
|
||||
expect(ctx).toContain('=== Document: inventory.docx ===');
|
||||
});
|
||||
|
||||
it('omits absent metadata lines', () => {
|
||||
const ctx = assembleMatterContext({ displayNumber: 'X', documents: [] });
|
||||
expect(ctx).toBe('Matter: X');
|
||||
expect(ctx).not.toContain('Client:');
|
||||
});
|
||||
|
||||
it('caps each document at MAX_DOC_CHARS', () => {
|
||||
const big = 'z'.repeat(MAX_DOC_CHARS + 5000);
|
||||
const ctx = assembleMatterContext({ displayNumber: 'X', documents: [{ name: 'big.txt', text: big }] });
|
||||
expect(ctx).toContain('truncated');
|
||||
// the raw doc body length in context is bounded by the per-doc cap (+marker)
|
||||
const bodyLen = ctx.split('=== Document: big.txt ===\n')[1].length;
|
||||
expect(bodyLen).toBeLessThan(MAX_DOC_CHARS + 100);
|
||||
});
|
||||
|
||||
it('caps the whole context at MAX_CONTEXT_CHARS', () => {
|
||||
const docs = Array.from({ length: 20 }, (_, i) => ({ name: `d${i}.txt`, text: 'y'.repeat(MAX_DOC_CHARS) }));
|
||||
const ctx = assembleMatterContext({ displayNumber: 'X', documents: docs });
|
||||
expect(ctx.length).toBeLessThanOrEqual(MAX_CONTEXT_CHARS + 40); // + the truncation marker
|
||||
expect(ctx).toContain('truncated');
|
||||
});
|
||||
|
||||
it('respects a custom overall cap', () => {
|
||||
const ctx = assembleMatterContext({ displayNumber: 'ABCDEFGHIJ', documents: [] }, 5);
|
||||
expect(ctx.startsWith('Matte')).toBe(true);
|
||||
expect(ctx).toContain('truncated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTextExtractable', () => {
|
||||
it('accepts text-ish content types', () => {
|
||||
expect(isTextExtractable('text/plain')).toBe(true);
|
||||
expect(isTextExtractable('text/html')).toBe(true);
|
||||
expect(isTextExtractable('application/json')).toBe(true);
|
||||
expect(isTextExtractable('application/xml')).toBe(true);
|
||||
expect(isTextExtractable('text/markdown')).toBe(true);
|
||||
expect(isTextExtractable('text/csv')).toBe(true);
|
||||
expect(isTextExtractable('application/rtf')).toBe(true);
|
||||
expect(isTextExtractable('text/plain; charset=utf-8')).toBe(true); // strips params
|
||||
});
|
||||
it('rejects binary legal-document types (PDF/DOCX) and missing types', () => {
|
||||
expect(isTextExtractable('application/pdf')).toBe(false);
|
||||
expect(isTextExtractable('application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe(false);
|
||||
expect(isTextExtractable('image/png')).toBe(false);
|
||||
expect(isTextExtractable(undefined)).toBe(false);
|
||||
expect(isTextExtractable('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionTask', () => {
|
||||
it('summarize is a fixed instruction', () => {
|
||||
expect(actionTask('summarize')).toContain('Summarize this matter');
|
||||
});
|
||||
it('dates asks for a structured list with sources', () => {
|
||||
const t = actionTask('dates');
|
||||
expect(t).toContain('key date');
|
||||
expect(t).toContain('source document');
|
||||
});
|
||||
it('draft embeds the user instruction, with a sensible default', () => {
|
||||
expect(actionTask('draft', 'a demand letter')).toContain('Draft a demand letter');
|
||||
expect(actionTask('draft')).toContain('letter to the client');
|
||||
});
|
||||
it('chat returns the question, with a default', () => {
|
||||
expect(actionTask('chat', 'What is the deadline?')).toBe('What is the deadline?');
|
||||
expect(actionTask('chat')).toContain('current status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('noteSubject', () => {
|
||||
it('labels each action and appends the matter label', () => {
|
||||
expect(noteSubject('summarize', '00123')).toBe('Hanzo AI: Matter summary — 00123');
|
||||
expect(noteSubject('draft')).toBe('Hanzo AI: Draft correspondence');
|
||||
expect(noteSubject('dates', 'M-1')).toBe('Hanzo AI: Key dates & obligations — M-1');
|
||||
expect(noteSubject('chat')).toBe('Hanzo AI: Q&A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAction', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('assembles context, forms the task, and posts to the Hanzo gateway', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
const body = JSON.parse(init.body);
|
||||
// matter context is fenced in the user message
|
||||
expect(body.messages[1].content).toContain('Matter: 00123-Smith');
|
||||
expect(body.messages[1].content).toContain('Summarize this matter');
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ content: 'SUMMARY' }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const out = await runAction({
|
||||
id: 'summarize',
|
||||
context: { displayNumber: '00123-Smith', documents: [] },
|
||||
hanzoToken: 'hk-x',
|
||||
});
|
||||
expect(out).toBe('SUMMARY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeBack', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('note target POSTs to /notes with the subject + body', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://app.clio.com/api/v4/notes');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.data.subject).toBe('Hanzo AI: Matter summary');
|
||||
expect(body.data.detail).toBe('the output');
|
||||
expect(body.data.matter).toEqual({ id: 42 });
|
||||
return { ok: true, status: 201, text: async () => JSON.stringify({ data: { id: 999 } }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const created = await writeBack({
|
||||
target: 'note',
|
||||
token: 'CT',
|
||||
base,
|
||||
matterId: 42,
|
||||
subject: 'Hanzo AI: Matter summary',
|
||||
body: 'the output',
|
||||
});
|
||||
expect(created.data.id).toBe(999);
|
||||
});
|
||||
|
||||
it('activity target POSTs to /activities as a TimeEntry combining subject + body', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://app.clio.com/api/v4/activities');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.data.type).toBe('TimeEntry');
|
||||
expect(body.data.note).toContain('SUBJ');
|
||||
expect(body.data.note).toContain('BODY');
|
||||
expect(body.data.matter).toEqual({ id: 7 });
|
||||
return { ok: true, status: 201, text: async () => JSON.stringify({ data: { id: 1 } }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await writeBack({ target: 'activity', token: 'CT', base, matterId: 7, subject: 'SUBJ', body: 'BODY' });
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
listMatters,
|
||||
getMatter,
|
||||
listDocuments,
|
||||
downloadDocument,
|
||||
listContacts,
|
||||
createNote,
|
||||
createActivity,
|
||||
unwrapData,
|
||||
extractNextPageToken,
|
||||
} from '../src/clio-api';
|
||||
import { clioBase, CLIO_API_VERSION } from '../src/config';
|
||||
|
||||
const token = 'TOK';
|
||||
const base = clioBase('us');
|
||||
|
||||
function q(url: string): URLSearchParams {
|
||||
return new URL(url).searchParams;
|
||||
}
|
||||
|
||||
describe('baseHeaders (via listMatters)', () => {
|
||||
it('sends Bearer auth, Accept json, and the pinned X-API-Version', () => {
|
||||
const r = listMatters({ token, base });
|
||||
expect(r.headers.Authorization).toBe('Bearer TOK');
|
||||
expect(r.headers.Accept).toBe('application/json');
|
||||
expect(r.headers['X-API-Version']).toBe(CLIO_API_VERSION);
|
||||
// GET → no content-type
|
||||
expect(r.headers['Content-Type']).toBeUndefined();
|
||||
expect(r.method).toBe('GET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listMatters', () => {
|
||||
it('hits /matters with default fields joined by commas', () => {
|
||||
const r = listMatters({ token, base });
|
||||
expect(r.url.startsWith('https://app.clio.com/api/v4/matters?')).toBe(true);
|
||||
expect(q(r.url).get('fields')).toContain('display_number');
|
||||
expect(q(r.url).get('fields')).toContain('client{id,name}');
|
||||
});
|
||||
|
||||
it('passes status, query, client_id, limit and page_token', () => {
|
||||
const r = listMatters({ token, base, status: 'open', query: 'smith', clientId: 42, limit: 25, pageToken: 'PT' });
|
||||
const p = q(r.url);
|
||||
expect(p.get('status')).toBe('open');
|
||||
expect(p.get('query')).toBe('smith');
|
||||
expect(p.get('client_id')).toBe('42');
|
||||
expect(p.get('limit')).toBe('25');
|
||||
expect(p.get('page_token')).toBe('PT');
|
||||
});
|
||||
|
||||
it('drops undefined/empty params', () => {
|
||||
const r = listMatters({ token, base, query: '' });
|
||||
expect(q(r.url).has('query')).toBe(false);
|
||||
expect(q(r.url).has('status')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an explicit fields override', () => {
|
||||
const r = listMatters({ token, base, fields: ['id', 'description'] });
|
||||
expect(q(r.url).get('fields')).toBe('id,description');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMatter', () => {
|
||||
it('hits /matters/{id} with the rich default field set', () => {
|
||||
const r = getMatter(token, base, 123);
|
||||
expect(r.url.startsWith('https://app.clio.com/api/v4/matters/123?')).toBe(true);
|
||||
const fields = q(r.url).get('fields')!;
|
||||
expect(fields).toContain('responsible_attorney{id,name}');
|
||||
expect(fields).toContain('practice_area{id,name}');
|
||||
expect(fields).toContain('open_date');
|
||||
});
|
||||
it('honors a fields override', () => {
|
||||
const r = getMatter(token, base, 1, ['id', 'status']);
|
||||
expect(q(r.url).get('fields')).toBe('id,status');
|
||||
});
|
||||
it('replays a Custom Action nonce when given, omits it otherwise', () => {
|
||||
expect(q(getMatter(token, base, 1, undefined, 'NONCE').url).get('custom_action_nonce')).toBe('NONCE');
|
||||
expect(q(getMatter(token, base, 1).url).has('custom_action_nonce')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listDocuments', () => {
|
||||
it('scopes to a matter and paginates', () => {
|
||||
const r = listDocuments({ token, base, matterId: 9, limit: 25, pageToken: 'N' });
|
||||
const p = q(r.url);
|
||||
expect(r.url.startsWith('https://app.clio.com/api/v4/documents?')).toBe(true);
|
||||
expect(p.get('matter_id')).toBe('9');
|
||||
expect(p.get('limit')).toBe('25');
|
||||
expect(p.get('page_token')).toBe('N');
|
||||
expect(p.get('fields')).toContain('content_type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadDocument', () => {
|
||||
it('hits the download sub-resource with auth + version but no JSON Accept', () => {
|
||||
const r = downloadDocument(token, base, 55);
|
||||
expect(r.url).toBe('https://app.clio.com/api/v4/documents/55/download');
|
||||
expect(r.headers.Authorization).toBe('Bearer TOK');
|
||||
expect(r.headers['X-API-Version']).toBe(CLIO_API_VERSION);
|
||||
expect(r.headers.Accept).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listContacts', () => {
|
||||
it('hits /contacts with default fields and optional query', () => {
|
||||
const r = listContacts(token, base, { query: 'acme', limit: 10 });
|
||||
const p = q(r.url);
|
||||
expect(r.url.startsWith('https://app.clio.com/api/v4/contacts?')).toBe(true);
|
||||
expect(p.get('query')).toBe('acme');
|
||||
expect(p.get('limit')).toBe('10');
|
||||
expect(p.get('fields')).toContain('primary_email_address');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNote', () => {
|
||||
it('POSTs a matter-scoped note wrapped under data with JSON content-type', () => {
|
||||
const r = createNote({ token, base, matterId: 7, subject: 'S', detail: 'BODY' });
|
||||
expect(r.method).toBe('POST');
|
||||
expect(r.url).toBe('https://app.clio.com/api/v4/notes');
|
||||
expect(r.headers['Content-Type']).toBe('application/json');
|
||||
const body = JSON.parse(r.body!);
|
||||
expect(body.data.subject).toBe('S');
|
||||
expect(body.data.detail).toBe('BODY');
|
||||
expect(body.data.type).toBe('Matter');
|
||||
expect(body.data.matter).toEqual({ id: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createActivity', () => {
|
||||
it('POSTs a TimeEntry against a matter, quantity default 0', () => {
|
||||
const r = createActivity({ token, base, matterId: 3, note: 'logged by Hanzo' });
|
||||
expect(r.url).toBe('https://app.clio.com/api/v4/activities');
|
||||
const body = JSON.parse(r.body!);
|
||||
expect(body.data.type).toBe('TimeEntry');
|
||||
expect(body.data.matter).toEqual({ id: 3 });
|
||||
expect(body.data.note).toBe('logged by Hanzo');
|
||||
expect(body.data.quantity).toBe(0);
|
||||
expect(body.data.date).toBeUndefined();
|
||||
});
|
||||
it('passes an explicit quantity and date', () => {
|
||||
const r = createActivity({ token, base, matterId: 3, note: 'n', quantitySeconds: 900, date: '2026-01-02' });
|
||||
const body = JSON.parse(r.body!);
|
||||
expect(body.data.quantity).toBe(900);
|
||||
expect(body.data.date).toBe('2026-01-02');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrapData', () => {
|
||||
it('returns the data field of a Clio envelope', () => {
|
||||
expect(unwrapData({ data: { id: 1 } })).toEqual({ id: 1 });
|
||||
expect(unwrapData({ data: [{ id: 1 }, { id: 2 }] })).toHaveLength(2);
|
||||
});
|
||||
it('passes through a payload with no envelope', () => {
|
||||
expect(unwrapData({ id: 9 })).toEqual({ id: 9 });
|
||||
});
|
||||
it('throws on a Clio error / errors payload', () => {
|
||||
expect(() => unwrapData({ error: { message: 'nope' } })).toThrow(/nope/);
|
||||
expect(() => unwrapData({ errors: [{ type: 'x' }] })).toThrow(/Clio API error/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNextPageToken', () => {
|
||||
it('pulls page_token from a full next URL', () => {
|
||||
const payload = { meta: { paging: { next: 'https://app.clio.com/api/v4/matters?limit=25&page_token=ABC123' } } };
|
||||
expect(extractNextPageToken(payload)).toBe('ABC123');
|
||||
});
|
||||
it('returns empty when there is no next page', () => {
|
||||
expect(extractNextPageToken({ meta: { paging: {} } })).toBe('');
|
||||
expect(extractNextPageToken({})).toBe('');
|
||||
});
|
||||
it('tolerates a bare token string', () => {
|
||||
expect(extractNextPageToken({ meta: { paging: { next: '?page_token=RAW' } } })).toBe('RAW');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
oauthAuthorizeUrl,
|
||||
tokenExchange,
|
||||
tokenRefresh,
|
||||
parseTokenResponse,
|
||||
isExpired,
|
||||
} from '../src/clio-oauth';
|
||||
import { clioBase } from '../src/config';
|
||||
|
||||
const base = clioBase('us');
|
||||
|
||||
describe('oauthAuthorizeUrl', () => {
|
||||
it('builds the authorize URL with all required params', () => {
|
||||
const url = oauthAuthorizeUrl({
|
||||
base,
|
||||
clientId: 'CID',
|
||||
redirectUri: 'https://clio.hanzo.ai/oauth/callback',
|
||||
state: 'st4te',
|
||||
});
|
||||
const u = new URL(url);
|
||||
expect(u.origin + u.pathname).toBe('https://app.clio.com/oauth/authorize');
|
||||
expect(u.searchParams.get('response_type')).toBe('code');
|
||||
expect(u.searchParams.get('client_id')).toBe('CID');
|
||||
expect(u.searchParams.get('redirect_uri')).toBe('https://clio.hanzo.ai/oauth/callback');
|
||||
expect(u.searchParams.get('state')).toBe('st4te');
|
||||
expect(u.searchParams.get('scope')).toBe('read write');
|
||||
// Clio returns a refresh_token by default — NO Google-style access_type.
|
||||
expect(u.searchParams.has('access_type')).toBe(false);
|
||||
// redirect_on_decline is off unless requested.
|
||||
expect(u.searchParams.has('redirect_on_decline')).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a custom scope and can opt into redirect_on_decline', () => {
|
||||
const url = oauthAuthorizeUrl({
|
||||
base,
|
||||
clientId: 'CID',
|
||||
redirectUri: 'https://x/cb',
|
||||
state: 's',
|
||||
scope: 'read',
|
||||
redirectOnDecline: true,
|
||||
});
|
||||
const u = new URL(url);
|
||||
expect(u.searchParams.get('scope')).toBe('read');
|
||||
expect(u.searchParams.get('redirect_on_decline')).toBe('true');
|
||||
expect(u.searchParams.has('access_type')).toBe(false);
|
||||
});
|
||||
|
||||
it('targets the eu authorize host when built from the eu base', () => {
|
||||
const url = oauthAuthorizeUrl({ base: clioBase('eu'), clientId: 'C', redirectUri: 'https://x/cb', state: 's' });
|
||||
expect(url.startsWith('https://eu.app.clio.com/oauth/authorize?')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenExchange', () => {
|
||||
it('shapes an authorization_code POST as urlencoded form', () => {
|
||||
const req = tokenExchange({
|
||||
base,
|
||||
clientId: 'CID',
|
||||
clientSecret: 'SECRET',
|
||||
redirectUri: 'https://x/cb',
|
||||
code: 'auth-code',
|
||||
});
|
||||
expect(req.url).toBe('https://app.clio.com/oauth/token');
|
||||
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
expect(req.headers.Accept).toBe('application/json');
|
||||
const form = new URLSearchParams(req.body);
|
||||
expect(form.get('grant_type')).toBe('authorization_code');
|
||||
expect(form.get('code')).toBe('auth-code');
|
||||
expect(form.get('client_id')).toBe('CID');
|
||||
expect(form.get('client_secret')).toBe('SECRET');
|
||||
expect(form.get('redirect_uri')).toBe('https://x/cb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenRefresh', () => {
|
||||
it('shapes a refresh_token POST', () => {
|
||||
const req = tokenRefresh({ base, clientId: 'CID', clientSecret: 'SECRET', refreshToken: 'r3fresh' });
|
||||
expect(req.url).toBe('https://app.clio.com/oauth/token');
|
||||
const form = new URLSearchParams(req.body);
|
||||
expect(form.get('grant_type')).toBe('refresh_token');
|
||||
expect(form.get('refresh_token')).toBe('r3fresh');
|
||||
expect(form.get('client_id')).toBe('CID');
|
||||
expect(form.get('client_secret')).toBe('SECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTokenResponse', () => {
|
||||
it('computes an absolute expiry from expires_in against injected now', () => {
|
||||
const now = 1_000_000;
|
||||
const t = parseTokenResponse(
|
||||
{ access_token: 'AT', refresh_token: 'RT', token_type: 'bearer', expires_in: 3600 },
|
||||
now,
|
||||
);
|
||||
expect(t.accessToken).toBe('AT');
|
||||
expect(t.refreshToken).toBe('RT');
|
||||
expect(t.tokenType).toBe('bearer');
|
||||
expect(t.expiresAt).toBe(now + 3600 * 1000);
|
||||
});
|
||||
|
||||
it('defaults token_type and tolerates a missing refresh_token', () => {
|
||||
const t = parseTokenResponse({ access_token: 'AT', expires_in: 60 }, 0);
|
||||
expect(t.tokenType).toBe('bearer');
|
||||
expect(t.refreshToken).toBe('');
|
||||
});
|
||||
|
||||
it('throws on an OAuth error payload with the description', () => {
|
||||
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'bad code' })).toThrow(/bad code/);
|
||||
});
|
||||
|
||||
it('throws when access_token is absent', () => {
|
||||
expect(() => parseTokenResponse({ token_type: 'bearer' })).toThrow(/no access_token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpired', () => {
|
||||
const t = parseTokenResponse({ access_token: 'AT', expires_in: 3600 }, 0);
|
||||
it('is false well before expiry', () => {
|
||||
expect(isExpired(t, 1000)).toBe(false);
|
||||
});
|
||||
it('is true past expiry', () => {
|
||||
expect(isExpired(t, 3600 * 1000 + 1)).toBe(true);
|
||||
});
|
||||
it('refreshes early within the skew window', () => {
|
||||
// 30s before the 3600s expiry, default 60s skew → treated as expired
|
||||
expect(isExpired(t, 3600 * 1000 - 30_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
HANZO_API_BASE_URL,
|
||||
chatCompletionsURL,
|
||||
modelsURL,
|
||||
clioBase,
|
||||
clioApiRoot,
|
||||
clioAuthorizeURL,
|
||||
clioTokenURL,
|
||||
CLIO_REGION_BASES,
|
||||
CLIO_API_VERSION,
|
||||
CLIO_SCOPES,
|
||||
DEFAULT_MODEL,
|
||||
} from '../src/config';
|
||||
|
||||
describe('Hanzo gateway endpoints', () => {
|
||||
it('chat + models go through api.hanzo.ai/v1 with no /api/ prefix', () => {
|
||||
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
|
||||
expect(HANZO_API_BASE_URL).toBe('https://api.hanzo.ai');
|
||||
expect(chatCompletionsURL()).not.toContain('/api/');
|
||||
expect(DEFAULT_MODEL).toBe('zen-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clio regional bases', () => {
|
||||
it('maps every region to its documented host', () => {
|
||||
expect(CLIO_REGION_BASES.us).toBe('https://app.clio.com');
|
||||
expect(CLIO_REGION_BASES.eu).toBe('https://eu.app.clio.com');
|
||||
expect(CLIO_REGION_BASES.au).toBe('https://au.app.clio.com');
|
||||
expect(CLIO_REGION_BASES.ca).toBe('https://ca.app.clio.com');
|
||||
});
|
||||
|
||||
it('clioBase defaults to US and resolves each region', () => {
|
||||
expect(clioBase()).toBe('https://app.clio.com');
|
||||
expect(clioBase('eu')).toBe('https://eu.app.clio.com');
|
||||
expect(clioBase('au')).toBe('https://au.app.clio.com');
|
||||
expect(clioBase('ca')).toBe('https://ca.app.clio.com');
|
||||
});
|
||||
|
||||
it('clioBase honors an explicit override and strips trailing slashes', () => {
|
||||
expect(clioBase('us', 'https://firm.clio.example///')).toBe('https://firm.clio.example');
|
||||
});
|
||||
|
||||
it('falls back to US for an unknown region', () => {
|
||||
// @ts-expect-error — exercising the defensive default
|
||||
expect(clioBase('xx')).toBe('https://app.clio.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clio derived endpoints', () => {
|
||||
const base = clioBase('us');
|
||||
it('builds the REST v4 root and both OAuth endpoints', () => {
|
||||
expect(clioApiRoot(base)).toBe('https://app.clio.com/api/v4');
|
||||
expect(clioAuthorizeURL(base)).toBe('https://app.clio.com/oauth/authorize');
|
||||
expect(clioTokenURL(base)).toBe('https://app.clio.com/oauth/token');
|
||||
});
|
||||
it('uses eu host when built from the eu base', () => {
|
||||
const eu = clioBase('eu');
|
||||
expect(clioApiRoot(eu)).toBe('https://eu.app.clio.com/api/v4');
|
||||
expect(clioTokenURL(eu)).toBe('https://eu.app.clio.com/oauth/token');
|
||||
});
|
||||
it('pins the API version and requests read+write scopes', () => {
|
||||
expect(CLIO_API_VERSION).toBe('4.0.11');
|
||||
expect(CLIO_SCOPES).toBe('read write');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSubjectUrl, parseCustomAction } from '../src/custom-action';
|
||||
|
||||
describe('parseSubjectUrl', () => {
|
||||
it('extracts the type + numeric id from a Clio matter URL', () => {
|
||||
expect(parseSubjectUrl('https://app.clio.com/api/v4/matters/1234.json')).toEqual({
|
||||
subjectType: 'matters',
|
||||
recordId: 1234,
|
||||
});
|
||||
});
|
||||
it('works without the .json suffix and for other record types', () => {
|
||||
expect(parseSubjectUrl('https://app.clio.com/api/v4/contacts/9')).toEqual({
|
||||
subjectType: 'contacts',
|
||||
recordId: 9,
|
||||
});
|
||||
expect(parseSubjectUrl('https://eu.app.clio.com/api/v4/documents/55.json')).toEqual({
|
||||
subjectType: 'documents',
|
||||
recordId: 55,
|
||||
});
|
||||
});
|
||||
it('tolerates a bare path', () => {
|
||||
expect(parseSubjectUrl('/api/v4/matters/77')).toEqual({ subjectType: 'matters', recordId: 77 });
|
||||
});
|
||||
it('decodes a URL-encoded subject_url (how Clio sends it)', () => {
|
||||
expect(parseSubjectUrl(encodeURIComponent('/api/v4/matters/1234'))).toEqual({
|
||||
subjectType: 'matters',
|
||||
recordId: 1234,
|
||||
});
|
||||
});
|
||||
it('returns empty for a non-matching URL or empty input', () => {
|
||||
expect(parseSubjectUrl('https://app.clio.com/nope')).toEqual({});
|
||||
expect(parseSubjectUrl('')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCustomAction', () => {
|
||||
it('parses the four Clio deep-link params and resolves matter_id', () => {
|
||||
const params = new URLSearchParams({
|
||||
custom_action_id: '7',
|
||||
user_id: '42',
|
||||
subject_url: encodeURIComponent('/api/v4/matters/321'),
|
||||
custom_action_nonce: 'abc123',
|
||||
});
|
||||
const ctx = parseCustomAction(params);
|
||||
expect(ctx.customActionId).toBe('7');
|
||||
expect(ctx.userId).toBe('42');
|
||||
expect(ctx.nonce).toBe('abc123');
|
||||
expect(ctx.subjectType).toBe('matters');
|
||||
expect(ctx.matterId).toBe(321);
|
||||
});
|
||||
|
||||
it('leaves matterId undefined when the action fired on a non-matter record', () => {
|
||||
const ctx = parseCustomAction(
|
||||
new URLSearchParams({ subject_url: '/api/v4/contacts/9', user_id: '1', custom_action_nonce: 'n' }),
|
||||
);
|
||||
expect(ctx.subjectType).toBe('contacts');
|
||||
expect(ctx.matterId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a plain record and fills missing params with empty strings', () => {
|
||||
const ctx = parseCustomAction({ user_id: '5' });
|
||||
expect(ctx.userId).toBe('5');
|
||||
expect(ctx.subjectUrl).toBe('');
|
||||
expect(ctx.nonce).toBe('');
|
||||
expect(ctx.customActionId).toBe('');
|
||||
expect(ctx.matterId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
buildMessages,
|
||||
requestBody,
|
||||
extractContent,
|
||||
ask,
|
||||
streamDelta,
|
||||
listModels,
|
||||
LEGAL_SYSTEM_PROMPT,
|
||||
} from '../src/hanzo-chat';
|
||||
import { DEFAULT_MODEL } from '../src/config';
|
||||
|
||||
describe('buildMessages', () => {
|
||||
it('uses the legal system prompt and passes a bare task with no context', () => {
|
||||
const m = buildMessages('summarize this matter', '');
|
||||
expect(m[0].role).toBe('system');
|
||||
expect(m[0].content).toBe(LEGAL_SYSTEM_PROMPT);
|
||||
expect(m[1].role).toBe('user');
|
||||
expect(m[1].content).toBe('summarize this matter');
|
||||
});
|
||||
|
||||
it('fences the matter context as data when present', () => {
|
||||
const m = buildMessages('extract dates', 'Matter: 0001\nDescription: Estate of X');
|
||||
expect(m[1].content).toContain('extract dates');
|
||||
expect(m[1].content).toContain('---- matter context ----');
|
||||
expect(m[1].content).toContain('Estate of X');
|
||||
});
|
||||
|
||||
it('accepts a custom system prompt override', () => {
|
||||
const m = buildMessages('x', '', 'CUSTOM');
|
||||
expect(m[0].content).toBe('CUSTOM');
|
||||
});
|
||||
|
||||
it('legal system prompt forbids markdown fences and hallucination', () => {
|
||||
expect(LEGAL_SYSTEM_PROMPT.toLowerCase()).toContain('no markdown');
|
||||
expect(LEGAL_SYSTEM_PROMPT.toLowerCase()).toContain('never invent facts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestBody', () => {
|
||||
it('defaults the model and disables streaming', () => {
|
||||
const b = requestBody(buildMessages('x', ''));
|
||||
expect(b.model).toBe(DEFAULT_MODEL);
|
||||
expect(b.stream).toBe(false);
|
||||
expect(Array.isArray(b.messages)).toBe(true);
|
||||
});
|
||||
it('passes explicit model + temperature through', () => {
|
||||
const b = requestBody(buildMessages('x', ''), { model: 'zen-pro', temperature: 0.2 });
|
||||
expect(b.model).toBe('zen-pro');
|
||||
expect(b.temperature).toBe(0.2);
|
||||
});
|
||||
it('enables streaming only when opts.stream is true', () => {
|
||||
expect(requestBody(buildMessages('x', ''), { stream: true }).stream).toBe(true);
|
||||
expect(requestBody(buildMessages('x', ''), { stream: false }).stream).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamDelta', () => {
|
||||
it('reads choices[0].delta.content from an SSE frame', () => {
|
||||
expect(streamDelta(JSON.stringify({ choices: [{ delta: { content: 'Hel' } }] }))).toBe('Hel');
|
||||
});
|
||||
it('returns empty for a role-only / empty delta', () => {
|
||||
expect(streamDelta(JSON.stringify({ choices: [{ delta: { role: 'assistant' } }] }))).toBe('');
|
||||
expect(streamDelta(JSON.stringify({ choices: [{}] }))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractContent', () => {
|
||||
it('reads OpenAI choices[0].message.content', () => {
|
||||
expect(extractContent({ choices: [{ message: { content: 'hello' } }] })).toBe('hello');
|
||||
});
|
||||
it('tolerates choices[0].text and bare content', () => {
|
||||
expect(extractContent({ choices: [{ text: 'a' }] })).toBe('a');
|
||||
expect(extractContent({ content: 'b' })).toBe('b');
|
||||
});
|
||||
it('throws on an error payload', () => {
|
||||
expect(() => extractContent({ error: { message: 'rate limited' } })).toThrow(/rate limited/);
|
||||
});
|
||||
it('throws when there is no content', () => {
|
||||
expect(() => extractContent({ choices: [{}] })).toThrow(/no content/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ask', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('POSTs to the gateway with the bearer and returns the content', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init: any) => {
|
||||
const body = JSON.parse(init.body);
|
||||
expect(init.headers.Authorization).toBe('Bearer hk-abc');
|
||||
expect(body.messages[0].content).toBe(LEGAL_SYSTEM_PROMPT);
|
||||
expect(body.messages[1].content).toContain('summarize');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ choices: [{ message: { content: 'a summary' } }] }),
|
||||
} as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const out = await ask('summarize', 'Matter: 1', 'hk-abc');
|
||||
expect(out).toBe('a summary');
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
});
|
||||
|
||||
it('omits Authorization when token is empty', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init: any) => {
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ content: 'ok' }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await ask('hi', '', '')).toBe('ok');
|
||||
});
|
||||
|
||||
it('surfaces a structured gateway error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: async () => JSON.stringify({ error: { message: 'too many requests' } }),
|
||||
} as any)),
|
||||
);
|
||||
await expect(ask('x', '', 't')).rejects.toThrow(/too many requests/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listModels', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns ids from the /v1/models data array with the bearer', async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init: any) => {
|
||||
expect(url).toBe('https://api.hanzo.ai/v1/models');
|
||||
expect(init.headers.Authorization).toBe('Bearer t');
|
||||
return { ok: true, json: async () => ({ data: [{ id: 'zen-1' }, { id: 'zen-pro' }] }) } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await listModels('t')).toEqual(['zen-1', 'zen-pro']);
|
||||
});
|
||||
|
||||
it('tolerates a bare string array and omits the bearer when unauthenticated', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init: any) => {
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
return { ok: true, json: async () => ['a', 'b'] } as any;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await listModels('')).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readEnv, customActionRedirect } from '../src/server';
|
||||
|
||||
describe('readEnv', () => {
|
||||
const good = {
|
||||
CLIO_CLIENT_ID: 'CID',
|
||||
CLIO_CLIENT_SECRET: 'SECRET',
|
||||
CLIO_REDIRECT_URI: 'https://clio.hanzo.ai/oauth/callback',
|
||||
};
|
||||
|
||||
it('reads a complete environment with region + port defaults', () => {
|
||||
const env = readEnv(good as any);
|
||||
expect(env.clientId).toBe('CID');
|
||||
expect(env.clientSecret).toBe('SECRET');
|
||||
expect(env.redirectUri).toBe('https://clio.hanzo.ai/oauth/callback');
|
||||
expect(env.region).toBe('us');
|
||||
expect(env.port).toBe(8787);
|
||||
});
|
||||
|
||||
it('honors CLIO_REGION and PORT overrides', () => {
|
||||
const env = readEnv({ ...good, CLIO_REGION: 'eu', PORT: '9000' } as any);
|
||||
expect(env.region).toBe('eu');
|
||||
expect(env.port).toBe(9000);
|
||||
});
|
||||
|
||||
it('throws fast when a required secret is missing (no silent default)', () => {
|
||||
expect(() => readEnv({ CLIO_CLIENT_ID: 'x' } as any)).toThrow(/required/);
|
||||
expect(() => readEnv({ CLIO_CLIENT_ID: 'x', CLIO_CLIENT_SECRET: 'y' } as any)).toThrow(/required/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('customActionRedirect', () => {
|
||||
it('redirects a matter deep-link to the SPA carrying matter_id + nonce', () => {
|
||||
const search = new URLSearchParams({
|
||||
custom_action_id: '7',
|
||||
user_id: '42',
|
||||
subject_url: encodeURIComponent('/api/v4/matters/321'),
|
||||
custom_action_nonce: 'NONCE1',
|
||||
});
|
||||
const dest = customActionRedirect(search);
|
||||
const u = new URL(dest, 'https://clio.hanzo.ai');
|
||||
expect(u.pathname).toBe('/index.html');
|
||||
expect(u.searchParams.get('matter_id')).toBe('321');
|
||||
expect(u.searchParams.get('custom_action_nonce')).toBe('NONCE1');
|
||||
});
|
||||
|
||||
it('omits the nonce param when Clio sent none', () => {
|
||||
const search = new URLSearchParams({ subject_url: '/api/v4/matters/9' });
|
||||
const u = new URL(customActionRedirect(search), 'https://clio.hanzo.ai');
|
||||
expect(u.searchParams.get('matter_id')).toBe('9');
|
||||
expect(u.searchParams.has('custom_action_nonce')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty for a non-matter deep-link (nothing to open)', () => {
|
||||
expect(customActionRedirect(new URLSearchParams({ subject_url: '/api/v4/contacts/9' }))).toBe('');
|
||||
expect(customActionRedirect(new URLSearchParams())).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user