Compare commits
81
Commits
v1.9.29
...
usage-panel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
967297f2dc | ||
|
|
300337a57b | ||
|
|
8e722f22fe | ||
|
|
5a55ce029f | ||
|
|
abb2ad56cb | ||
|
|
4193d4889e | ||
|
|
8de63fa05f | ||
|
|
02b447d027 | ||
|
|
96807b05cf | ||
|
|
05a35ac763 | ||
|
|
a164395d4a | ||
|
|
4670a9ee91 | ||
|
|
a2c014b777 | ||
|
|
fc6d9bb805 | ||
|
|
ad6af1fd72 | ||
|
|
73370ce288 | ||
|
|
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 | ||
|
|
1c7679c41e | ||
|
|
3105d4a099 | ||
|
|
5f14961fbb | ||
|
|
36de7695af | ||
|
|
0ae9186728 |
@@ -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
|
||||
@@ -36,6 +39,90 @@ jobs:
|
||||
- name: Test Office add-in
|
||||
run: pnpm --filter @hanzo/office-addin test
|
||||
|
||||
- name: Test Outlook add-in
|
||||
run: pnpm --filter @hanzo/outlook-addin test
|
||||
|
||||
- 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 Epic (SMART-on-FHIR)
|
||||
run: pnpm --filter @hanzo/epic test
|
||||
|
||||
- name: Test Canvas LMS (education)
|
||||
run: pnpm --filter @hanzo/canvas-lms test
|
||||
|
||||
- name: Test Workday (HR)
|
||||
run: pnpm --filter @hanzo/workday test
|
||||
|
||||
- name: Test iManage (legal DMS)
|
||||
run: pnpm --filter @hanzo/imanage 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
|
||||
|
||||
|
||||
+372
-13
@@ -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
|
||||
@@ -238,7 +240,7 @@ jobs:
|
||||
run: ovsx publish *.vsix -p ${{ secrets.OVSX_PAT }}
|
||||
continue-on-error: true
|
||||
|
||||
# ─── npm (@hanzo/mcp) ───
|
||||
# ─── npm (all @hanzo/* workspace packages) ───
|
||||
npm:
|
||||
name: npm
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
@@ -253,13 +255,37 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @hanzo/mcp run build
|
||||
- name: Publish @hanzo/mcp
|
||||
working-directory: packages/mcp
|
||||
run: npm publish --access public
|
||||
|
||||
# Shared libraries first: their consumers (slack, teams) import the built
|
||||
# dist at publish time, so @hanzo/cards must exist before the rest build.
|
||||
- name: Build shared libraries
|
||||
run: pnpm --filter @hanzo/cards build
|
||||
|
||||
# Build everything with a build script. Guarded with `|| true` so a package
|
||||
# whose toolchain is absent on the runner (jupyter→jupyterlab, desktop→cargo)
|
||||
# cannot abort the publish of the rest.
|
||||
- name: Build all packages
|
||||
run: pnpm -r --if-present run build || true
|
||||
|
||||
# Recursive publish: skips private (root, apps/site), skips versions already
|
||||
# on npm (e.g. @hanzo/mcp), rewrites workspace:* → the real version, and
|
||||
# publishes in topological order so @hanzo/cards lands before its consumers.
|
||||
# --ignore-scripts is required: raycast (hanzo-ai) and vscode (hanzo-ide)
|
||||
# define a `publish` lifecycle script targeting their own stores (Raycast /
|
||||
# VS Marketplace); without this flag those scripts run mid-publish, fail, and
|
||||
# abort every package after them. Builds already ran above, so no publish-time
|
||||
# script is needed. `|| true` keeps one bad package from blocking the rest.
|
||||
- name: Publish all
|
||||
run: |
|
||||
# setup-node writes a ${NODE_AUTH_TOKEN}-templated .npmrc that `npm` expands
|
||||
# but `pnpm publish` does not reliably read — write the token literally so
|
||||
# pnpm authenticates (a 404 on PUT is npm's obscured "unauthenticated").
|
||||
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> .npmrc
|
||||
echo "@hanzo:registry=https://registry.npmjs.org/" >> .npmrc
|
||||
npm whoami || echo "::warning::npm whoami failed — NPM_TOKEN invalid or lacks @hanzo publish rights"
|
||||
pnpm -r publish --access public --no-git-checks --ignore-scripts --report-summary || true
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
# ─── JetBrains Marketplace ───
|
||||
jetbrains:
|
||||
@@ -326,11 +352,298 @@ jobs:
|
||||
name: hanzo-ai-office
|
||||
path: /tmp/hanzo-ai-office-v*.zip
|
||||
|
||||
# ─── Microsoft Outlook mail add-in ───
|
||||
outlook:
|
||||
name: Outlook Add-in
|
||||
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/outlook-addin build
|
||||
cd packages/outlook/dist && npx --yes bestzip "/tmp/hanzo-ai-outlook-v${VERSION}.zip" .
|
||||
|
||||
- name: Upload Outlook add-in
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
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]
|
||||
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
|
||||
@@ -364,6 +677,7 @@ jobs:
|
||||
| App | Platform | Download |
|
||||
|-----|----------|----------|
|
||||
| **Word · Excel · PowerPoint** | Windows / macOS / Web / iPad | [`hanzo-ai-office-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-office-v${{ steps.version.outputs.version }}.zip) |
|
||||
| **Outlook** | Windows / macOS / Web | [`hanzo-ai-outlook-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-outlook-v${{ steps.version.outputs.version }}.zip) |
|
||||
|
||||
> Office: unzip and sideload `manifest.xml` via the M365 admin center, or Insert → Add-ins → Upload My Add-in.
|
||||
|
||||
@@ -379,6 +693,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: |
|
||||
@@ -386,4 +738,11 @@ jobs:
|
||||
artifacts/hanzo-ai-ide/*
|
||||
artifacts/hanzo-ai-jetbrains/*
|
||||
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/*
|
||||
|
||||
@@ -65,3 +65,4 @@ build/
|
||||
.next/
|
||||
*.log
|
||||
tmp/
|
||||
.npmrc
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.29",
|
||||
"version": "1.9.32",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -47,5 +47,12 @@
|
||||
"homepage": "https://hanzo.ai",
|
||||
"bugs": {
|
||||
"url": "https://github.com/hanzoai/aci/issues"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -16,5 +16,11 @@
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.9.29",
|
||||
"version": "1.9.32",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
@@ -36,5 +36,14 @@
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"images",
|
||||
"manifest.json"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type ZapManager,
|
||||
} from './shared/zap.js';
|
||||
import { connectNativeZap, type NativeZapState } from './shared/native-zap.js';
|
||||
import { fetchAllUsage } from './shared/usage.js';
|
||||
import { pickEvaluable, wrapEvaluable } from './shared/evaluable.js';
|
||||
import {
|
||||
getRagConfig,
|
||||
@@ -3000,6 +3001,17 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
sendResponse({ success: false, error: 'Tab filesystem requires CDP bridge.' });
|
||||
break;
|
||||
|
||||
// AI provider usage (Claude + Codex, via @hanzo/usage)
|
||||
case 'usage.fetch': {
|
||||
try {
|
||||
const providers = await fetchAllUsage();
|
||||
sendResponse({ success: true, providers });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sendResponse({ success: false, error: `Unknown action: ${request.action}` });
|
||||
break;
|
||||
|
||||
@@ -38,6 +38,7 @@ import { BrowserControl } from './browser-control';
|
||||
import { WebGPUAI } from './webgpu-ai';
|
||||
import { getCDPBridge, CDPBridge } from './browser-dispatch';
|
||||
import * as auth from './auth';
|
||||
import { fetchAllUsage } from './shared/usage.js';
|
||||
import { listModels, chatCompletion, ChatMessage } from './chat-client';
|
||||
import { PageMonitor } from './page-monitor';
|
||||
import {
|
||||
@@ -1787,6 +1788,19 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
break;
|
||||
}
|
||||
|
||||
// --- AI provider usage (Claude + Codex, via @hanzo/usage) ---
|
||||
case 'usage.fetch': {
|
||||
(async () => {
|
||||
try {
|
||||
const providers = await fetchAllUsage();
|
||||
sendResponse({ success: true, providers });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
})();
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ async function build() {
|
||||
const hanzoGuiShim = path.join(__dirname, 'gui-primitives.tsx');
|
||||
console.log('Using @hanzo/gui local primitives shim:', hanzoGuiShim);
|
||||
|
||||
// The Usage panel runs the headless @hanzo/usage engine in the background
|
||||
// context. The package lives in the sibling repo (hanzoai/usage); alias its
|
||||
// built ESM entry so esbuild bundles it into the background scripts. Same
|
||||
// pattern as the @hanzo/gui shim above — one specifier, one source of truth.
|
||||
const hanzoUsage = path.join(__dirname, '..', '..', '..', '..', 'usage', 'packages', 'core', 'dist', 'index.js');
|
||||
const backgroundAliases = { '@hanzo/usage': hanzoUsage };
|
||||
console.log('Using @hanzo/usage engine:', hanzoUsage);
|
||||
|
||||
// Ensure dist directories exist
|
||||
fs.mkdirSync('dist/browser-extension', { recursive: true });
|
||||
fs.mkdirSync('dist/browser-extension/chrome', { recursive: true });
|
||||
@@ -40,7 +48,8 @@ async function build() {
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'safari14'],
|
||||
format: 'esm',
|
||||
external: ['chrome', 'browser']
|
||||
external: ['chrome', 'browser'],
|
||||
alias: backgroundAliases
|
||||
});
|
||||
|
||||
// Build Firefox-specific background script (no ESM export, uses browser.* APIs)
|
||||
@@ -51,7 +60,8 @@ async function build() {
|
||||
platform: 'browser',
|
||||
target: ['firefox91'],
|
||||
format: 'iife', // Immediately-invoked function expression (no exports)
|
||||
external: ['browser']
|
||||
external: ['browser'],
|
||||
alias: backgroundAliases
|
||||
});
|
||||
|
||||
// Build WebGPU AI module
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"host_permissions": [
|
||||
"http://localhost/*",
|
||||
"https://localhost/*",
|
||||
"https://claude.ai/*",
|
||||
"https://chatgpt.com/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_security_policy": {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"host_permissions": [
|
||||
"http://localhost/*",
|
||||
"https://localhost/*",
|
||||
"https://claude.ai/*",
|
||||
"https://chatgpt.com/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_security_policy": {
|
||||
|
||||
@@ -96,6 +96,22 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- AI Usage (Claude + Codex, from the current browser session) -->
|
||||
<div class="usage-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<h3>AI Usage</h3>
|
||||
<button id="usage-refresh" class="btn-icon" title="Refresh usage">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M23 4v6h-6M1 20v-6h6"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="usage-list" class="usage-list" style="display:flex;flex-direction:column;gap:8px;"></div>
|
||||
<a href="https://console.hanzo.ai/ai-accounts" target="_blank"
|
||||
style="display:inline-block;margin-top:8px;font-size:11px;color:var(--text-muted,#888);">Manage AI accounts →</a>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Tools -->
|
||||
<h3>Tools</h3>
|
||||
<div class="tools-grid">
|
||||
|
||||
@@ -693,6 +693,90 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- AI Usage (Claude + Codex) — data fetched by the background context
|
||||
// where host permissions attach the live claude.ai / chatgpt.com cookies. ---
|
||||
const usageList = document.getElementById('usage-list');
|
||||
const usageRefresh = document.getElementById('usage-refresh');
|
||||
|
||||
interface UsageWindow { usedPercent: number; resetsAt?: string }
|
||||
interface UsageSnapshotLite {
|
||||
primary?: UsageWindow;
|
||||
secondary?: UsageWindow;
|
||||
identity?: { plan?: string };
|
||||
}
|
||||
interface ProviderUsageLite {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
color?: string;
|
||||
snapshot?: UsageSnapshotLite;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/[&<>"]/g, (c) => (({ '&': '&', '<': '<', '>': '>', '"': '"' }) as Record<string, string>)[c]);
|
||||
|
||||
function resetLabel(iso?: string): string {
|
||||
if (!iso) return '';
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
if (!Number.isFinite(ms)) return '';
|
||||
if (ms <= 0) return 'resets now';
|
||||
const m = Math.round(ms / 60000);
|
||||
if (m < 60) return `resets in ${m}m`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `resets in ${h}h`;
|
||||
return `resets in ${Math.round(h / 24)}d`;
|
||||
}
|
||||
|
||||
function lane(label: string, w: UsageWindow | undefined, color?: string): string {
|
||||
if (!w || typeof w.usedPercent !== 'number') return '';
|
||||
const pct = Math.max(0, Math.min(100, Math.round(w.usedPercent)));
|
||||
return `
|
||||
<div class="usage-lane" style="display:flex;align-items:center;gap:8px;font-size:11px;margin-top:4px;">
|
||||
<span style="width:52px;color:var(--text-muted,#888);">${label}</span>
|
||||
<span style="flex:1;height:6px;border-radius:3px;background:var(--surface-2,#222);overflow:hidden;">
|
||||
<span style="display:block;height:100%;width:${pct}%;background:${color || 'var(--accent,#d97757)'};"></span>
|
||||
</span>
|
||||
<span style="width:34px;text-align:right;">${pct}%</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderUsage(providers: ProviderUsageLite[]) {
|
||||
if (!usageList) return;
|
||||
usageList.innerHTML = providers
|
||||
.map((p) => {
|
||||
const body = p.error
|
||||
? `<div style="font-size:11px;color:var(--text-muted,#888);margin-top:4px;">${esc(p.error)}</div>`
|
||||
: `${lane('Session', p.snapshot?.primary, p.color)}${lane('Weekly', p.snapshot?.secondary, p.color)}
|
||||
<div style="font-size:10px;color:var(--text-muted,#888);margin-top:3px;">${esc(resetLabel(p.snapshot?.primary?.resetsAt))}</div>`;
|
||||
const plan = p.snapshot?.identity?.plan;
|
||||
return `
|
||||
<div class="usage-row" style="padding:8px;border:1px solid var(--border,#2a2a2a);border-radius:8px;">
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${p.color || '#888'};"></span>
|
||||
<strong style="font-size:12px;">${esc(p.displayName)}</strong>
|
||||
${plan ? `<span style="font-size:10px;color:var(--text-muted,#888);">${esc(plan)}</span>` : ''}
|
||||
</div>
|
||||
${body}
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function loadUsage() {
|
||||
if (!usageList) return;
|
||||
usageList.innerHTML = '<div style="font-size:11px;color:var(--text-muted,#888);">Loading…</div>';
|
||||
chrome.runtime.sendMessage({ action: 'usage.fetch' }, (resp) => {
|
||||
if (chrome.runtime.lastError || !resp?.success) {
|
||||
usageList.innerHTML = '<div style="font-size:11px;color:var(--text-muted,#888);">Usage unavailable</div>';
|
||||
return;
|
||||
}
|
||||
renderUsage(resp.providers as ProviderUsageLite[]);
|
||||
});
|
||||
}
|
||||
|
||||
usageRefresh?.addEventListener('click', loadUsage);
|
||||
loadUsage();
|
||||
|
||||
// Init
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Usage engine bridge — runs the @hanzo/usage providers inside the extension
|
||||
// background context, where host permissions let fetch() attach the user's
|
||||
// claude.ai / chatgpt.com session cookies (credentials: 'include') and bypass
|
||||
// CORS. There is no filesystem in a browser, so file ops are no-ops and the
|
||||
// OAuth/CLI strategies simply report "unavailable": the web strategy carries
|
||||
// Claude, and a small local fetch carries Codex (whose provider only ships an
|
||||
// auth.json OAuth lane, unusable without disk access).
|
||||
|
||||
import {
|
||||
runPipeline,
|
||||
claudeProvider,
|
||||
codexProvider,
|
||||
type UsageHost,
|
||||
type HttpRequest,
|
||||
type HttpResponse,
|
||||
type UsageSnapshot,
|
||||
type RateWindow,
|
||||
type ProviderFetchOutcome,
|
||||
} from '@hanzo/usage';
|
||||
|
||||
/** Serialisable per-provider result sent to the popup. */
|
||||
export interface ProviderUsage {
|
||||
providerId: string;
|
||||
displayName: string;
|
||||
color?: string;
|
||||
dashboardUrl?: string;
|
||||
snapshot?: UsageSnapshot;
|
||||
/** User-facing failure reason (e.g. "Open chatgpt.com to sign in"). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const http = async (req: HttpRequest): Promise<HttpResponse> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = req.timeoutMs
|
||||
? setTimeout(() => controller.abort(), req.timeoutMs)
|
||||
: undefined;
|
||||
try {
|
||||
const res = await fetch(req.url, {
|
||||
method: req.method ?? 'GET',
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
});
|
||||
const headers: Record<string, string> = {};
|
||||
res.headers.forEach((v, k) => {
|
||||
headers[k] = v;
|
||||
});
|
||||
return { status: res.status, headers, text: await res.text() };
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
/** Browser host: network only; no fs, env, or home directory in an extension. */
|
||||
const browserHost: UsageHost = {
|
||||
readTextFile: async () => undefined,
|
||||
listDir: async () => [],
|
||||
writeTextFile: async () => {},
|
||||
http,
|
||||
env: () => undefined,
|
||||
homeDir: () => '',
|
||||
now: () => new Date(),
|
||||
};
|
||||
|
||||
const isAuthError = (text: string): boolean => /\b(401|403)\b/.test(text);
|
||||
|
||||
const outcomeError = (providerHost: string, outcome: ProviderFetchOutcome): string => {
|
||||
const attempt = [...outcome.attempts].reverse().find((a) => a.error);
|
||||
const detail = attempt?.error ?? 'no data';
|
||||
return isAuthError(detail) ? `Open ${providerHost} to sign in` : detail;
|
||||
};
|
||||
|
||||
const fetchClaude = async (): Promise<ProviderUsage> => {
|
||||
const { displayName, color, dashboardUrl } = claudeProvider.metadata;
|
||||
const base: ProviderUsage = { providerId: 'claude', displayName, color, dashboardUrl };
|
||||
try {
|
||||
// Empty cookieHeader passes the web strategy's `typeof === 'string'` gate;
|
||||
// the real session cookie is attached by credentials: 'include'.
|
||||
const outcome = await runPipeline(claudeProvider, {
|
||||
host: browserHost,
|
||||
sourceMode: 'web',
|
||||
settings: { cookieHeader: '' },
|
||||
});
|
||||
if (outcome.result) return { ...base, snapshot: outcome.result.usage };
|
||||
return { ...base, error: outcomeError('claude.ai', outcome) };
|
||||
} catch (e) {
|
||||
return { ...base, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
};
|
||||
|
||||
// Codex wire shape (chatgpt.com/backend-api/wham/usage) — mirrors the private
|
||||
// interface in @hanzo/usage's codex provider, mapped onto the package's public
|
||||
// UsageSnapshot / RateWindow types.
|
||||
interface CodexWindow {
|
||||
used_percent?: number;
|
||||
reset_at?: number;
|
||||
limit_window_seconds?: number;
|
||||
}
|
||||
interface CodexUsageResponse {
|
||||
plan_type?: string;
|
||||
rate_limit?: { primary_window?: CodexWindow; secondary_window?: CodexWindow };
|
||||
}
|
||||
|
||||
const toWindow = (w: CodexWindow | undefined): RateWindow | undefined => {
|
||||
if (!w || typeof w.used_percent !== 'number') return undefined;
|
||||
return {
|
||||
usedPercent: w.used_percent,
|
||||
windowMinutes: w.limit_window_seconds ? Math.round(w.limit_window_seconds / 60) : undefined,
|
||||
resetsAt: w.reset_at ? new Date(w.reset_at * 1000).toISOString() : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const fetchCodex = async (): Promise<ProviderUsage> => {
|
||||
const { displayName, color, dashboardUrl } = codexProvider.metadata;
|
||||
const base: ProviderUsage = { providerId: 'codex', displayName, color, dashboardUrl };
|
||||
try {
|
||||
const res = await http({
|
||||
url: 'https://chatgpt.com/backend-api/wham/usage',
|
||||
headers: { Accept: 'application/json' },
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
return {
|
||||
...base,
|
||||
error: isAuthError(String(res.status))
|
||||
? 'Open chatgpt.com to sign in'
|
||||
: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
const body = JSON.parse(res.text) as CodexUsageResponse;
|
||||
const snapshot: UsageSnapshot = {
|
||||
providerId: 'codex',
|
||||
primary: toWindow(body.rate_limit?.primary_window),
|
||||
secondary: toWindow(body.rate_limit?.secondary_window),
|
||||
identity: { providerId: 'codex', plan: body.plan_type, loginMethod: 'web' },
|
||||
dataConfidence: 'percentOnly',
|
||||
updatedAt: browserHost.now().toISOString(),
|
||||
};
|
||||
return { ...base, snapshot };
|
||||
} catch (e) {
|
||||
return { ...base, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
};
|
||||
|
||||
/** Fetch Claude + Codex usage in parallel from the current browser session. */
|
||||
export const fetchAllUsage = async (): Promise<ProviderUsage[]> =>
|
||||
Promise.all([fetchClaude(), fetchCodex()]);
|
||||
@@ -9,6 +9,10 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@hanzo/usage": ["../../../../usage/packages/core/dist/index.d.ts"]
|
||||
},
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
|
||||
@@ -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,64 @@
|
||||
{
|
||||
"name": "@hanzo/canva",
|
||||
"version": "1.0.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.",
|
||||
"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"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"main": "dist/app.js"
|
||||
}
|
||||
@@ -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,236 @@
|
||||
# Hanzo AI for Canvas LMS
|
||||
|
||||
AI over your Canvas course — **summarize a course**, **draft assignment feedback**,
|
||||
**generate quiz/discussion questions**, **summarize student submissions**, and
|
||||
**draft an announcement**, plus a freeform "ask about the course" box. An
|
||||
embedded-app **panel** you host at `canvas.hanzo.ai`, backed by a small **OAuth +
|
||||
API-proxy service** that keeps the Canvas client secret and access tokens
|
||||
server-side.
|
||||
|
||||
Built on the published Hanzo SDK:
|
||||
|
||||
- **[`@hanzo/ai`](https://www.npmjs.com/package/@hanzo/ai)** — the headless client.
|
||||
`createAiClient({ baseUrl, token }).chat.completions.create({ model, messages })`
|
||||
and `.models.list()`. All model calls go to `https://api.hanzo.ai` on `/v1/...`
|
||||
(never an `/api/` prefix). We import it; we do not reimplement the transport.
|
||||
- **[`@hanzo/iam`](https://www.npmjs.com/package/@hanzo/iam)** — Hanzo identity /
|
||||
the `hk-…` API key the panel uses as its gateway bearer.
|
||||
|
||||
Canvas is the dominant higher-ed / K12 LMS: courses, syllabi, modules, assignments,
|
||||
pages, discussions, and student submissions. This app reads those records through
|
||||
the Canvas REST API and grounds every AI draft in them. Everything it produces —
|
||||
feedback, questions, announcements, summaries — is a **draft for an educator to
|
||||
review**, not an authoritative grade or decision.
|
||||
|
||||
> **Package name:** `@hanzo/canvas-lms` (directory `packages/canvas-lms`) — named to
|
||||
> avoid colliding with the design-tool package `packages/canva`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser panel (dist/index.html, app.js) Server service (dist/server.js)
|
||||
───────────────────────────────────── ──────────────────────────────
|
||||
reads Canvas via ──► /proxy/* ──────────► injects OAuth Bearer + refresh,
|
||||
same-origin proxy (cookie) forwards to {canvas-host}/api/v1
|
||||
(never sees a Canvas token) with the user's access token
|
||||
│
|
||||
└── calls api.hanzo.ai /v1 directly with the pasted hk-… Hanzo key
|
||||
(@hanzo/ai headless client)
|
||||
```
|
||||
|
||||
- The **panel** never holds a Canvas token or the client secret. It reaches Canvas
|
||||
only through the same-origin `/proxy/*` endpoint (with an HttpOnly session
|
||||
cookie). It talks to `api.hanzo.ai` directly with the user's pasted Hanzo key.
|
||||
- The **service** is the only place `CANVAS_CLIENT_SECRET` and the OAuth tokens
|
||||
exist. It runs the install flow, holds the session, refreshes the token
|
||||
transparently, and proxies REST calls.
|
||||
|
||||
### Source layout (all pure logic is unit-tested)
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `src/config.ts` | Canvas host normalization, REST version, gateway URL, server-secret config (`readServerConfig` fails fast). |
|
||||
| `src/canvas-oauth.ts` | OAuth2 Authorization-Code shaping: `authorizeUrl`, `tokenExchange`, `refreshExchange`, token parsing, non-rotating-refresh carry + expiry math. Pure. |
|
||||
| `src/canvas-api.ts` | REST v1 request wrappers (`listCourses`, `getCourse`, `listAssignments`, `listSubmissions`, `postSubmissionComment`, `createAnnouncement`, …) with pagination + `Link`-header parsing, and response parsers. Pure. |
|
||||
| `src/course.ts` | Course/assignment/submission/page/module JSON → windowed context text, HTML→text, honest truncation. Pure. |
|
||||
| `src/hanzo.ts` | Thin over `@hanzo/ai`: prompt assembly + the single `ask` path + `listModels`. |
|
||||
| `src/actions.ts` | The five AI actions (id → prompt) over `ask`. One code path to the model. |
|
||||
| `src/panel.ts` | Browser proxy-client request shaping + launch-context parsing. Pure. |
|
||||
| `src/app.ts` | The DOM glue for the panel (the one impure browser entry). |
|
||||
| `src/server.ts` | Node http service: OAuth install/callback + `/proxy/*`. |
|
||||
|
||||
---
|
||||
|
||||
## 1. Create a Canvas Developer Key
|
||||
|
||||
Canvas OAuth apps authenticate with a **Developer Key** (an OAuth2
|
||||
client-id/secret). An account admin creates it in **Admin → Developer Keys → +
|
||||
Developer Key → API Key**:
|
||||
|
||||
1. **Key Name**: `Hanzo AI for Canvas` (any label).
|
||||
2. **Redirect URIs** — exactly the URL your service serves the callback at:
|
||||
|
||||
```
|
||||
https://canvas.hanzo.ai/oauth/callback
|
||||
```
|
||||
|
||||
(For local development, add `http://localhost:8791/oauth/callback` too.)
|
||||
3. **Scopes** — leave **Enforce Scopes** *off* for the simplest setup (permissions
|
||||
then follow the authorizing user's Canvas role). If you enable enforcement, add
|
||||
the granular URL-scopes exported as `RECOMMENDED_SCOPES` in `src/config.ts`
|
||||
(the read surface plus `PUT …/submissions/:user_id` and
|
||||
`POST …/discussion_topics` for the two write-backs).
|
||||
4. **Save**, then **ON** the key's state, and copy the **Client ID** (the numeric
|
||||
key id) and **Client Secret** (the "Key" / secret value).
|
||||
5. Note your institution's **Canvas host** (e.g. `school.instructure.com` or your
|
||||
custom Canvas domain) — the app builds every OAuth + API URL from it.
|
||||
|
||||
Because Canvas is self-hosted per institution, the host is a required deployment
|
||||
value (`CANVAS_HOST`); there is no fixed host list.
|
||||
|
||||
---
|
||||
|
||||
## 2. OAuth flow
|
||||
|
||||
Standard **Authorization Code** grant (server-side secret):
|
||||
|
||||
1. `GET /oauth/install` → 302 to
|
||||
`https://{canvas-host}/login/oauth2/auth?client_id=…&response_type=code&redirect_uri=…&state=…`.
|
||||
2. The user consents; Canvas redirects back to `GET /oauth/callback?code=…&state=…`.
|
||||
3. The service verifies `state`, then POSTs to `/login/oauth2/token` with
|
||||
`grant_type=authorization_code`, the `code`, the `client_id` + `client_secret`
|
||||
(**in the body, server-side only**), and the same `redirect_uri`.
|
||||
4. It opens a session, sets an HttpOnly cookie, and the panel is authenticated.
|
||||
5. Access tokens are short-lived (~1h). The service refreshes with
|
||||
`grant_type=refresh_token` **before** each proxied call when the token has
|
||||
expired. Canvas does **not** rotate the refresh token (the refresh response omits
|
||||
it), so the original refresh token is carried forward.
|
||||
|
||||
> In-memory sessions here are for a single instance. For a multi-instance
|
||||
> deployment behind `hanzoai/ingress`, persist sessions + the OAuth `state` set to
|
||||
> `hanzoai/kv` (Valkey), and read `CANVAS_CLIENT_SECRET` from KMS (`kms.hanzo.ai`) —
|
||||
> never from a committed env file.
|
||||
|
||||
---
|
||||
|
||||
## 3. Canvas REST API
|
||||
|
||||
All reads/writes go through `https://{canvas-host}/api/v1/...` with the user's
|
||||
`Authorization: Bearer {token}`. Canvas's own path is `/api/v1` — this is unrelated
|
||||
to the Hanzo gateway (which is always `api.hanzo.ai/v1/...`, never `/api/`).
|
||||
|
||||
| Resource | Endpoint (relative to `/api/v1`) |
|
||||
| --- | --- |
|
||||
| Courses | `GET /courses`, `GET /courses/{id}?include[]=syllabus_body` |
|
||||
| Assignments | `GET /courses/{id}/assignments`, `GET /courses/{id}/assignments/{id}` |
|
||||
| Submissions | `GET /courses/{id}/assignments/{aid}/submissions`, `GET …/submissions/{user_id}` |
|
||||
| **Submission comment** (write) | `PUT /courses/{id}/assignments/{aid}/submissions/{user_id}` — body `{ "comment": { "text_comment": "…" } }` |
|
||||
| Discussions | `GET /courses/{id}/discussion_topics` |
|
||||
| **Announcement** (write) | `POST /courses/{id}/discussion_topics` — body `{ "title", "message", "is_announcement": true }` |
|
||||
| Pages | `GET /courses/{id}/pages`, `GET /courses/{id}/pages/{url_or_id}` |
|
||||
| Modules | `GET /courses/{id}/modules?include[]=items` |
|
||||
| Enrollments | `GET /courses/{id}/enrollments` |
|
||||
|
||||
> Canvas models a submission comment as an **update to the submission** (a `PUT`
|
||||
> with `comment[text_comment]`), not a separate comments collection — there is no
|
||||
> `POST …/submissions/{id}/comments`. This app uses the correct `PUT` shape.
|
||||
|
||||
List endpoints paginate with `page` (1-based) + `per_page` (max 100) and return the
|
||||
page relations in the **RFC 5988 `Link`** response header
|
||||
(`<…?page=2…>; rel="next"`), parsed by `parseLinkHeader` / `nextPage`.
|
||||
|
||||
---
|
||||
|
||||
## 4. The summarize-course / draft-feedback flow
|
||||
|
||||
1. Connect the app (`/oauth/install`) so the service holds a Canvas token.
|
||||
2. Open the panel and **Load** a course (the picker lists the courses the connected
|
||||
user can see). The panel pulls the course's syllabus, modules, assignments,
|
||||
pages, and discussions through the proxy and assembles a windowed,
|
||||
truncation-honest context (Canvas HTML is stripped to plain text).
|
||||
3. **Summarize course** — the model writes a course overview from the syllabus and
|
||||
module structure, grounded only in the records.
|
||||
4. **Draft feedback** — pick an assignment and **Load submissions**; the model
|
||||
drafts specific, constructive feedback for a student submission, grounded in what
|
||||
the student actually wrote and the assignment's requirements. The result lands in
|
||||
the Result box.
|
||||
5. Optional write-backs (gated, behind a confirm):
|
||||
- **Post as submission comment** posts the Result back via
|
||||
`PUT …/submissions/{user_id}` with `comment[text_comment]`, targeting the first
|
||||
submitted student on the loaded assignment.
|
||||
- **Post as announcement** posts the Result as a course announcement via
|
||||
`POST …/discussion_topics` (`is_announcement: true`), first line as the subject.
|
||||
|
||||
The other actions — **Generate questions** (quiz + discussion prompts from a
|
||||
page/module), **Summarize submissions** (submitted/missing/late, score spread,
|
||||
common strengths and problems), and **Draft announcement** — run the same way.
|
||||
|
||||
---
|
||||
|
||||
## Build & run
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter @hanzo/canvas-lms build # → dist/ (panel + server)
|
||||
pnpm --filter @hanzo/canvas-lms test # vitest
|
||||
pnpm --filter @hanzo/canvas-lms typecheck # tsc --noEmit
|
||||
|
||||
# run the service
|
||||
CANVAS_HOST=school.instructure.com \
|
||||
CANVAS_CLIENT_ID=… \
|
||||
CANVAS_CLIENT_SECRET=… \
|
||||
CANVAS_REDIRECT_URI=https://canvas.hanzo.ai/oauth/callback \
|
||||
node packages/canvas-lms/dist/server.js
|
||||
```
|
||||
|
||||
### Required environment (service)
|
||||
|
||||
| Var | Notes |
|
||||
| --- | --- |
|
||||
| `CANVAS_HOST` | Institution Canvas host, e.g. `school.instructure.com`. Normalized (scheme/path stripped). |
|
||||
| `CANVAS_CLIENT_ID` | Developer Key client id (public). |
|
||||
| `CANVAS_CLIENT_SECRET` | **Server only.** From KMS in production. |
|
||||
| `CANVAS_REDIRECT_URI` | Must match the key's registered redirect. |
|
||||
| `PORT` | Listen port (default `8791`). |
|
||||
|
||||
`readServerConfig` throws on a missing value — the service refuses to start rather
|
||||
than pretend it can complete an OAuth exchange or reach an unknown Canvas host.
|
||||
|
||||
---
|
||||
|
||||
## Deploy over hanzoai/ingress
|
||||
|
||||
Host the static panel (`dist/index.html`, `app.js`, `styles.css`) with the
|
||||
**hanzoai/static** plugin and run `dist/server.js` as a small service, both behind
|
||||
**hanzoai/ingress** at `canvas.hanzo.ai` (no nginx, no caddy):
|
||||
|
||||
- `/` and the static assets → the panel.
|
||||
- `/oauth/*`, `/proxy/*`, `/healthz` → the service.
|
||||
|
||||
Secrets come from **KMS** as `KMSSecret`-synced env; sessions from **Valkey**
|
||||
(`hanzoai/kv`) for multi-instance. Image published to
|
||||
`ghcr.io/hanzoai/canvas-lms:<tag>` by CI/CD (platform.hanzo.ai / Tekton) — never
|
||||
built locally.
|
||||
|
||||
---
|
||||
|
||||
## Alternative embed path: LTI 1.3
|
||||
|
||||
This package ships the **OAuth2 + REST API** app (an external app the instructor
|
||||
connects, ideal for a standalone panel at `canvas.hanzo.ai`). Canvas also supports
|
||||
**LTI 1.3 / Advantage** as an in-Canvas launch: Canvas issues a signed OIDC launch
|
||||
JWT and the tool calls the same REST API (or LTI Advantage services — Names & Roles,
|
||||
Assignment & Grade Services). LTI is the better fit for a deep in-course placement
|
||||
(course navigation, assignment menu) and passes the course context (`custom_canvas_course_id`)
|
||||
in the launch — which `parseLaunchContext` already reads. The AI + context modules
|
||||
(`course.ts`, `hanzo.ts`, `actions.ts`, `canvas-api.ts`) are transport-agnostic and
|
||||
would be reused unchanged behind an LTI launch; only the auth front-door differs.
|
||||
This build ships the OAuth+API app.
|
||||
|
||||
---
|
||||
|
||||
*Routed through `api.hanzo.ai`. Answers are grounded in your Canvas course records —
|
||||
a draft for an educator to review, not an authoritative grade, decision, or the
|
||||
final word to a student.*
|
||||
@@ -0,0 +1,86 @@
|
||||
// Build @hanzo/canvas-lms into dist/: bundle the web panel (app.ts) as ESM for the
|
||||
// browser, copy index.html (with its entry script stamped) + styles.css, and bundle
|
||||
// the OAuth + API-proxy server for Node. No framework — esbuild + Node stdlib only.
|
||||
// @hanzo/ai is bundled into both outputs (it is the headless client the panel and
|
||||
// server both call).
|
||||
//
|
||||
// node build.js → production build
|
||||
// node build.js --watch → rebuild on change
|
||||
// HANZO_CANVAS_BASE=https://localhost:8443 node build.js → dev base (informational)
|
||||
//
|
||||
// Output layout:
|
||||
// dist/index.html dist/app.js dist/styles.css (panel)
|
||||
// dist/server.js (service)
|
||||
//
|
||||
// The panel is HOSTED over hanzoai/static behind hanzoai/ingress at canvas.hanzo.ai;
|
||||
// the server runs as a small service (OAuth callback + API proxy). All model calls go
|
||||
// to api.hanzo.ai regardless of the host base.
|
||||
|
||||
import esbuild from 'esbuild';
|
||||
import { rmSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BASE = (process.env.HANZO_CANVAS_BASE || 'https://canvas.hanzo.ai').replace(/\/+$/, '');
|
||||
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.
|
||||
const panelCtx = await esbuild.context({
|
||||
entryPoints: { app: join(src, 'app.ts') },
|
||||
outdir: dist,
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
logLevel: 'info',
|
||||
});
|
||||
await panelCtx.rebuild();
|
||||
|
||||
// Copy index.html (stamp __ENTRY__ + base) and styles.css.
|
||||
const html = readFileSync(join(src, 'index.html'), 'utf8')
|
||||
.split('__ENTRY__').join('app.js')
|
||||
.replace('</head>', ` <meta name="hanzo:base" content="${BASE}" />\n</head>`);
|
||||
writeFileSync(join(dist, 'index.html'), html);
|
||||
copyFileSync(join(src, 'styles.css'), join(dist, 'styles.css'));
|
||||
|
||||
// Bundle the server as a Node ESM binary — our pure stdlib code + @hanzo/ai.
|
||||
const serverCtx = await esbuild.context({
|
||||
entryPoints: [join(src, 'server.ts')],
|
||||
outfile: join(dist, 'server.js'),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: ['node18'],
|
||||
sourcemap: true,
|
||||
minify: !watch,
|
||||
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
|
||||
logLevel: 'info',
|
||||
});
|
||||
await serverCtx.rebuild();
|
||||
|
||||
console.log(`Hanzo AI for Canvas built -> dist/ (base ${BASE})`);
|
||||
console.log(' Panel: dist/index.html · Service: dist/server.js');
|
||||
|
||||
if (watch) {
|
||||
await panelCtx.watch();
|
||||
await serverCtx.watch();
|
||||
console.log('watching...');
|
||||
} else {
|
||||
await panelCtx.dispose();
|
||||
await serverCtx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
build().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@hanzo/canvas-lms",
|
||||
"version": "1.0.0",
|
||||
"description": "Hanzo AI for Canvas LMS — AI over your course: summarize a course, draft assignment feedback, generate quiz/discussion questions, summarize student submissions, and draft an announcement. An embedded-app panel plus an OAuth + API-proxy service, built on @hanzo/ai and @hanzo/iam over the api.hanzo.ai gateway.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"watch": "node build.js --watch",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/ai": "^0.2.0",
|
||||
"@hanzo/iam": "^0.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"esbuild": "^0.25.8",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"hanzo",
|
||||
"canvas",
|
||||
"lms",
|
||||
"education",
|
||||
"instructure",
|
||||
"ai",
|
||||
"oauth"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/extension.git",
|
||||
"directory": "packages/canvas-lms"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"main": "dist/server.js"
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// The AI actions over a Canvas course — summarize the course, draft assignment
|
||||
// feedback, generate quiz/discussion questions, summarize student submissions, and
|
||||
// draft an announcement. Each is a prompt template applied to the windowed course
|
||||
// context via the single `ask` primitive in hanzo.ts. There is exactly ONE code path
|
||||
// to the model: an action is (id → prompt), and the panel and any server-side caller
|
||||
// resolve an id here and call `ask`. No action speaks to the gateway directly. (A
|
||||
// freeform "ask about the course" is `ask` directly, not an action — it has no fixed
|
||||
// prompt.)
|
||||
|
||||
import { ask, type AskOptions, type CourseMeta } from './hanzo.js';
|
||||
import type { CourseContext } from './course.js';
|
||||
|
||||
// The action catalog. `id` is the stable key the panel's chips use; `label` is the
|
||||
// button text; `prompt` is the instruction handed to the model as the task, over the
|
||||
// fenced course records. Prompts are specific and output-shaped (bullets, structured
|
||||
// lists) so results are consistent and parseable.
|
||||
export const ACTIONS = {
|
||||
summarizeCourse: {
|
||||
label: 'Summarize course',
|
||||
prompt:
|
||||
'Write a clear overview of this course for a student or a colleague, grounded ' +
|
||||
'in the syllabus and module structure in these records. Cover: what the course ' +
|
||||
'is about, how it is organised (the modules and their sequence), the major ' +
|
||||
'assignments and how grading works if the records say, and any key policies. ' +
|
||||
'Use a short headline followed by grouped bullets. Base every statement on the ' +
|
||||
'records; if the syllabus or modules look incomplete, say so plainly.',
|
||||
},
|
||||
draftFeedback: {
|
||||
label: 'Draft feedback',
|
||||
prompt:
|
||||
'Draft constructive, specific feedback for the student submission in these ' +
|
||||
'records, addressed to the student. Ground every point in what the submission ' +
|
||||
'actually contains and in the assignment’s stated requirements; do not invent ' +
|
||||
'content the student did not write or a grade the records do not show. Lead with ' +
|
||||
'what was done well, then give concrete, actionable suggestions for improvement, ' +
|
||||
'and close with an encouraging next step. Keep the tone supportive and ' +
|
||||
'professional. Return only the feedback text, ready to paste as a submission ' +
|
||||
'comment for the instructor to review.',
|
||||
},
|
||||
generateQuestions: {
|
||||
label: 'Generate questions',
|
||||
prompt:
|
||||
'From the course content in these records (a page, module, or assignment), ' +
|
||||
'generate assessment questions. Produce two groups. First, "Quiz questions": 5 ' +
|
||||
'to 8 questions that check understanding of the material, a mix of ' +
|
||||
'multiple-choice (with options and the correct answer marked) and short-answer, ' +
|
||||
'each tied to a specific idea in the records. Second, "Discussion prompts": 3 ' +
|
||||
'open-ended prompts that invite analysis or application. Base every question ' +
|
||||
'strictly on the provided content; do not test material that is not present.',
|
||||
},
|
||||
summarizeSubmissions: {
|
||||
label: 'Summarize submissions',
|
||||
prompt:
|
||||
'Summarize the student submissions in these records for the instructor. Cover: ' +
|
||||
'how many submitted vs are missing or late, the range of scores or grades if the ' +
|
||||
'records show them, common strengths and common problems across the responses, ' +
|
||||
'and any submissions that stand out (exemplary or struggling) — referenced by ' +
|
||||
'user id, not invented names. Structure as a one-line headline followed by ' +
|
||||
'grouped bullets. Do not fabricate scores or content the records do not contain.',
|
||||
},
|
||||
draftAnnouncement: {
|
||||
label: 'Draft announcement',
|
||||
prompt:
|
||||
'Draft a course announcement for the students, grounded in these course records ' +
|
||||
'(upcoming assignments, due dates, module content). Write a short, clear ' +
|
||||
'announcement with a specific subject line on the first line, then the body: what ' +
|
||||
'is happening, what students need to do, and any relevant due date drawn from the ' +
|
||||
'records. Keep it warm and concise. Do not invent dates or requirements that are ' +
|
||||
'not in the records. Return the subject line and body, ready for the instructor ' +
|
||||
'to review and post.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// An action id from the catalog.
|
||||
export type ActionId = keyof typeof ACTIONS;
|
||||
|
||||
// isActionId narrows an arbitrary string to a known action id. Boundary guard — the
|
||||
// panel and the server validate an inbound id here before running it.
|
||||
export function isActionId(id: string): id is ActionId {
|
||||
return Object.prototype.hasOwnProperty.call(ACTIONS, id);
|
||||
}
|
||||
|
||||
// actionPrompt resolves an action id to its prompt template. Throws on an unknown id
|
||||
// (a boundary error, surfaced to the caller) rather than silently running a default.
|
||||
// Pure.
|
||||
export function actionPrompt(id: string): string {
|
||||
if (!isActionId(id)) throw new Error(`Unknown action: ${id}`);
|
||||
return ACTIONS[id].prompt;
|
||||
}
|
||||
|
||||
// actionList is the ordered catalog for building the UI (chips) — id + label,
|
||||
// derived from ACTIONS so the panel and the catalog can never drift. Pure.
|
||||
export function actionList(): Array<{ id: ActionId; label: string }> {
|
||||
return (Object.keys(ACTIONS) as ActionId[]).map((id) => ({ id, label: ACTIONS[id].label }));
|
||||
}
|
||||
|
||||
// runAction is the single entry point every surface calls: resolve the action's
|
||||
// prompt and run it over the windowed course context via `ask`. This is the one code
|
||||
// path from an action id to the model. Async so an unknown id surfaces as a rejected
|
||||
// promise (not a synchronous throw), giving callers ONE way to handle failure:
|
||||
// `await`/`.catch`. A gateway error rejects via `ask`.
|
||||
export async function runAction(
|
||||
id: string,
|
||||
ctx: CourseContext,
|
||||
meta?: CourseMeta,
|
||||
opts: AskOptions = {},
|
||||
): Promise<string> {
|
||||
return ask(actionPrompt(id), ctx, meta, opts);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// The Canvas panel glue: the embedded/linked-app page that opens with a course in
|
||||
// context and drives the AI actions over the course's live records. All the
|
||||
// logic-heavy work (action prompts, context windowing, chat shaping, proxy request
|
||||
// shaping, auth) lives in its own tested modules; this file binds them to the DOM. It
|
||||
// is the one impure, browser-only entry point.
|
||||
//
|
||||
// Data flow: the panel reads Canvas ONLY through the same-origin server proxy
|
||||
// (createProxyClient), which holds the OAuth token server-side. It pulls the course's
|
||||
// syllabus / modules / assignments / pages / discussions, assembles a windowed context
|
||||
// (buildCourseContext), and runs an action or a freeform question against api.hanzo.ai
|
||||
// with the pasted Hanzo key. The optional write-backs (submission comment, course
|
||||
// announcement) post back through the same proxy, behind explicit buttons.
|
||||
|
||||
import { actionList, isActionId, runAction } from './actions.js';
|
||||
import { ask as askHanzo, listModels, type CourseMeta } from './hanzo.js';
|
||||
import {
|
||||
buildCourseContext,
|
||||
contextNote,
|
||||
courseSection,
|
||||
moduleSection,
|
||||
assignmentSection,
|
||||
pageSection,
|
||||
discussionSection,
|
||||
submissionSection,
|
||||
type CourseContext,
|
||||
type Section,
|
||||
} from './course.js';
|
||||
import { DEFAULT_MODEL } from './config.js';
|
||||
import { getApiKey, setApiKey, hasApiKey, bearer, validateKey } from './auth.js';
|
||||
import { createProxyClient, parseLaunchContext, type ProxyClient } from './panel.js';
|
||||
import type { Course, Assignment, Submission } from './canvas-api.js';
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const launch = parseLaunchContext(window.location.search);
|
||||
|
||||
const courseEl = $<HTMLSelectElement>('course');
|
||||
const assignmentEl = $<HTMLSelectElement>('assignment');
|
||||
const loadBtn = $<HTMLButtonElement>('load');
|
||||
const loadSubsBtn = $<HTMLButtonElement>('loadsubs');
|
||||
const recordsEl = $('records');
|
||||
const outputEl = $<HTMLTextAreaElement>('output');
|
||||
const statusEl = $('status');
|
||||
const modelEl = $<HTMLSelectElement>('model');
|
||||
const chipRow = $('chips');
|
||||
const runBtn = $<HTMLButtonElement>('run');
|
||||
const stopBtn = $<HTMLButtonElement>('stop');
|
||||
const promptEl = $<HTMLTextAreaElement>('prompt');
|
||||
const apiKeyEl = $<HTMLInputElement>('apikey');
|
||||
const saveKeyBtn = $<HTMLButtonElement>('savekey');
|
||||
const authHint = $('authhint');
|
||||
const commentBtn = $<HTMLButtonElement>('comment');
|
||||
const announceBtn = $<HTMLButtonElement>('announce');
|
||||
|
||||
apiKeyEl.value = getApiKey();
|
||||
reflectAuth();
|
||||
void populateModels();
|
||||
void populateCourses();
|
||||
|
||||
const client: ProxyClient = createProxyClient();
|
||||
|
||||
// Loaded state: the course-overview sections, the loaded submissions (a separate
|
||||
// section appended on demand), and the ids the write-backs target.
|
||||
let overview: Section[] = [];
|
||||
let submissions: Submission[] = [];
|
||||
let ctx: CourseContext | null = null;
|
||||
let meta: CourseMeta = {};
|
||||
let currentCourseId = '';
|
||||
let currentAssignmentId = '';
|
||||
let assignments: Assignment[] = [];
|
||||
|
||||
// Action chips — derived from the catalog so UI and logic never drift.
|
||||
for (const a of actionList()) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'chip';
|
||||
b.textContent = a.label;
|
||||
b.dataset.action = a.id;
|
||||
b.onclick = () => void run(a.id);
|
||||
chipRow.appendChild(b);
|
||||
}
|
||||
|
||||
loadBtn.onclick = () => void loadCourse();
|
||||
loadSubsBtn.onclick = () => void loadSubmissions();
|
||||
courseEl.onchange = () => {
|
||||
currentCourseId = courseEl.value;
|
||||
};
|
||||
assignmentEl.onchange = () => {
|
||||
currentAssignmentId = assignmentEl.value;
|
||||
};
|
||||
runBtn.onclick = () => {
|
||||
const prompt = promptEl.value.trim();
|
||||
if (prompt) void ask(prompt);
|
||||
};
|
||||
stopBtn.onclick = () => controller?.abort();
|
||||
commentBtn.onclick = () => void postComment();
|
||||
announceBtn.onclick = () => void postAnnouncement();
|
||||
saveKeyBtn.onclick = async () => {
|
||||
const key = apiKeyEl.value.trim();
|
||||
setStatus('Checking key…');
|
||||
try {
|
||||
const models = await validateKey(key);
|
||||
setApiKey(key);
|
||||
fillModels(models);
|
||||
reflectAuth();
|
||||
setStatus('Key saved.', 'ok');
|
||||
} catch (e: any) {
|
||||
setStatus(e?.message || 'Key rejected.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// If we launched already scoped to a course, load it straight away.
|
||||
if (launch.courseId) {
|
||||
void populateCourses().then(() => {
|
||||
courseEl.value = launch.courseId;
|
||||
currentCourseId = launch.courseId;
|
||||
void loadCourse();
|
||||
});
|
||||
}
|
||||
|
||||
// populateCourses fills the course picker from the proxy. An auth failure leaves the
|
||||
// picker empty with a status hint pointing at the connect flow.
|
||||
async function populateCourses(): Promise<void> {
|
||||
setStatus('Loading courses…');
|
||||
try {
|
||||
const { items } = await client.listCourses({ perPage: 100 });
|
||||
fillCourses(items);
|
||||
setStatus(
|
||||
items.length ? `Loaded ${items.length} courses.` : 'No courses visible.',
|
||||
items.length ? 'ok' : 'warn',
|
||||
);
|
||||
} catch (e: any) {
|
||||
setStatus(
|
||||
e?.message || 'Could not load courses — is the app connected? (Connect on canvas.hanzo.ai)',
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// loadCourse pulls the scoped course's records and assembles the overview context
|
||||
// the actions run over. The course/syllabus, modules, assignments, pages, and
|
||||
// discussions are fetched in parallel; each failure is tolerated so a course missing
|
||||
// one area still yields a usable context.
|
||||
async function loadCourse(): Promise<void> {
|
||||
const courseId = courseEl.value || currentCourseId;
|
||||
if (!courseId) {
|
||||
setStatus('Pick a course first.', 'warn');
|
||||
return;
|
||||
}
|
||||
currentCourseId = courseId;
|
||||
setStatus('Loading course records…');
|
||||
const [course, modules, assigns, pages, discussions] = await Promise.all([
|
||||
client.getCourse(courseId).catch(() => null),
|
||||
client.listModules(courseId, { perPage: 100 }).then((r) => r.items).catch(() => []),
|
||||
client.listAssignments(courseId, { perPage: 100 }).then((r) => r.items).catch(() => [] as Assignment[]),
|
||||
client.listPages(courseId, { perPage: 100 }).then((r) => r.items).catch(() => []),
|
||||
client.listDiscussions(courseId, { perPage: 100 }).then((r) => r.items).catch(() => []),
|
||||
]);
|
||||
|
||||
assignments = assigns;
|
||||
submissions = [];
|
||||
fillAssignments(assigns);
|
||||
|
||||
overview = [
|
||||
...(course ? [courseSection(course)] : []),
|
||||
moduleSection(modules),
|
||||
assignmentSection(assigns),
|
||||
pageSection(pages),
|
||||
discussionSection(discussions),
|
||||
];
|
||||
meta = course ? courseMetaOf(course) : {};
|
||||
rebuildContext();
|
||||
|
||||
recordsEl.textContent =
|
||||
`${modules.length} modules · ${assigns.length} assignments · ${pages.length} pages · ${discussions.length} discussions`;
|
||||
announceBtn.disabled = false;
|
||||
announceBtn.title = 'Post the result as a course announcement';
|
||||
loadSubsBtn.disabled = assigns.length === 0;
|
||||
setStatus(contextNote(ctx!));
|
||||
}
|
||||
|
||||
// loadSubmissions pulls the selected assignment's submissions and appends them to the
|
||||
// context so the feedback / summarize-submissions actions have student work. Enables
|
||||
// the submission-comment write-back against the first submission.
|
||||
async function loadSubmissions(): Promise<void> {
|
||||
const assignmentId = assignmentEl.value || currentAssignmentId;
|
||||
if (!currentCourseId || !assignmentId) {
|
||||
setStatus('Load a course and pick an assignment first.', 'warn');
|
||||
return;
|
||||
}
|
||||
currentAssignmentId = assignmentId;
|
||||
setBusy(true);
|
||||
setStatus('Loading submissions…');
|
||||
try {
|
||||
const { items } = await client.listSubmissions(currentCourseId, assignmentId, { perPage: 100 });
|
||||
submissions = items;
|
||||
rebuildContext();
|
||||
const withWork = submissions.filter((s) => s.workflowState && s.workflowState !== 'unsubmitted');
|
||||
commentBtn.disabled = withWork.length === 0;
|
||||
commentBtn.title = withWork.length
|
||||
? `Post the result as a comment on user ${withWork[0].userId}'s submission`
|
||||
: 'No submitted work to comment on';
|
||||
recordsEl.textContent += ` · ${submissions.length} submissions`;
|
||||
setStatus(contextNote(ctx!));
|
||||
} catch (e: any) {
|
||||
setStatus(e?.message || 'Could not load submissions.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildContext re-windows the overview + any loaded submissions into one context.
|
||||
function rebuildContext(): void {
|
||||
ctx = buildCourseContext([...overview, ...(submissions.length ? [submissionSection(submissions)] : [])]);
|
||||
}
|
||||
|
||||
// run executes one of the named actions over the loaded course context.
|
||||
async function run(actionId: string): Promise<void> {
|
||||
if (!isActionId(actionId)) return;
|
||||
await execute((c, m, opts) => runAction(actionId, c, m, opts));
|
||||
}
|
||||
|
||||
// ask executes a freeform question over the loaded course context.
|
||||
async function ask(prompt: string): Promise<void> {
|
||||
await execute((c, m, opts) => askHanzo(prompt, c, m, opts));
|
||||
}
|
||||
|
||||
// execute is the shared runner: require a loaded context, call the model, show the
|
||||
// result. Both the chips and freeform ask funnel through here (one path).
|
||||
async function execute(
|
||||
call: (
|
||||
c: CourseContext,
|
||||
m: CourseMeta,
|
||||
opts: { token: string; model: string; signal: AbortSignal },
|
||||
) => Promise<string>,
|
||||
): Promise<void> {
|
||||
if (!ctx || ctx.totalBlocks === 0) {
|
||||
setStatus('Load a course first (pick one and press Load).', 'warn');
|
||||
return;
|
||||
}
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
setBusy(true);
|
||||
setStatus(contextNote(ctx));
|
||||
outputEl.value = '';
|
||||
try {
|
||||
const text = await call(ctx, meta, {
|
||||
token: bearer(),
|
||||
model: modelEl.value || DEFAULT_MODEL,
|
||||
signal: controller.signal,
|
||||
});
|
||||
outputEl.value = text;
|
||||
setStatus('Done.', 'ok');
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') setStatus('Stopped.');
|
||||
else setStatus(e?.message || 'Request failed.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// postComment writes the current output back to Canvas as a comment on the first
|
||||
// submitted student's submission — an explicit, documented write-back. Guarded: it
|
||||
// never posts an empty body and always confirms who it targeted.
|
||||
async function postComment(): Promise<void> {
|
||||
const body = outputEl.value.trim();
|
||||
const target = submissions.find((s) => s.workflowState && s.workflowState !== 'unsubmitted');
|
||||
if (!target) return setStatus('No submitted work to comment on.', 'warn');
|
||||
if (!body) return setStatus('Nothing to post — run an action or write feedback first.', 'warn');
|
||||
if (!confirm(`Post this as a comment on user ${target.userId}'s submission?`)) return;
|
||||
setBusy(true);
|
||||
setStatus('Posting comment to Canvas…');
|
||||
try {
|
||||
await client.postSubmissionComment(currentCourseId, currentAssignmentId, target.userId, body);
|
||||
setStatus(`Comment posted on user ${target.userId}'s submission.`, 'ok');
|
||||
} catch (e: any) {
|
||||
setStatus(e?.message || 'Comment failed.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// postAnnouncement writes the current output back as a course announcement. Canvas
|
||||
// needs a title + body; we take the first line of the result as the title and the
|
||||
// rest as the body, so a "subject line then body" draft posts cleanly.
|
||||
async function postAnnouncement(): Promise<void> {
|
||||
const text = outputEl.value.trim();
|
||||
if (!currentCourseId) return setStatus('Load a course first.', 'warn');
|
||||
if (!text) return setStatus('Nothing to post — draft an announcement first.', 'warn');
|
||||
const nl = text.indexOf('\n');
|
||||
const title = (nl === -1 ? text : text.slice(0, nl)).trim().slice(0, 200);
|
||||
const message = (nl === -1 ? text : text.slice(nl + 1)).trim() || text;
|
||||
if (!confirm(`Post a course announcement titled “${title}”?`)) return;
|
||||
setBusy(true);
|
||||
setStatus('Posting announcement to Canvas…');
|
||||
try {
|
||||
await client.createAnnouncement(currentCourseId, title, message);
|
||||
setStatus('Announcement posted.', 'ok');
|
||||
} catch (e: any) {
|
||||
setStatus(e?.message || 'Announcement failed.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- small DOM helpers ----------------------------------------------------
|
||||
|
||||
let courses: Course[] = [];
|
||||
function courseMetaOf(course: Course): CourseMeta {
|
||||
return { name: course.name || undefined, courseCode: course.courseCode || undefined, term: course.termName || undefined };
|
||||
}
|
||||
function fillCourses(items: Course[]): void {
|
||||
courses = items;
|
||||
courseEl.innerHTML = '';
|
||||
for (const c of items) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(c.id);
|
||||
opt.textContent = c.name || c.courseCode || `Course ${c.id}`;
|
||||
courseEl.appendChild(opt);
|
||||
}
|
||||
if (items.length && !currentCourseId) currentCourseId = String(items[0].id);
|
||||
}
|
||||
function fillAssignments(items: Assignment[]): void {
|
||||
assignmentEl.innerHTML = '';
|
||||
for (const a of items) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(a.id);
|
||||
opt.textContent = a.name || `Assignment ${a.id}`;
|
||||
assignmentEl.appendChild(opt);
|
||||
}
|
||||
currentAssignmentId = items.length ? String(items[0].id) : '';
|
||||
}
|
||||
|
||||
async function populateModels(): Promise<void> {
|
||||
try {
|
||||
fillModels(await listModels({ token: bearer() }));
|
||||
} catch {
|
||||
fillModels([DEFAULT_MODEL]);
|
||||
}
|
||||
}
|
||||
function fillModels(ids: string[]): void {
|
||||
const list = ids.length ? ids : [DEFAULT_MODEL];
|
||||
modelEl.innerHTML = '';
|
||||
for (const id of list) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = id;
|
||||
modelEl.appendChild(opt);
|
||||
}
|
||||
if (list.includes(DEFAULT_MODEL)) modelEl.value = DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function reflectAuth(): void {
|
||||
authHint.textContent = hasApiKey()
|
||||
? 'Using your saved Hanzo key.'
|
||||
: 'No key saved — using public models. Paste an hk-… key for your org models.';
|
||||
}
|
||||
|
||||
function setBusy(b: boolean): void {
|
||||
runBtn.disabled = b;
|
||||
stopBtn.disabled = !b;
|
||||
loadBtn.disabled = b;
|
||||
loadSubsBtn.disabled = b || assignments.length === 0;
|
||||
for (const c of Array.from(chipRow.querySelectorAll('button'))) (c as HTMLButtonElement).disabled = b;
|
||||
announceBtn.disabled = b || !currentCourseId;
|
||||
commentBtn.disabled = b || submissions.length === 0;
|
||||
}
|
||||
|
||||
function setStatus(msg: string, kind: '' | 'ok' | 'warn' | 'error' = ''): void {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = `status${kind ? ' ' + kind : ''}`;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// Auth for the web panel: the zero-setup pasted-key path for the Hanzo gateway. The
|
||||
// Canvas panel is a static web page (iframe), so the Hanzo credential lives in
|
||||
// localStorage and is validated by a real /v1/models call — a key that can't list
|
||||
// models is rejected before it's saved, so the user learns at paste time, not at
|
||||
// first action. Mirrors @hanzo/procore exactly (one way to hold a key).
|
||||
//
|
||||
// The Canvas access token (for reading courses/assignments/submissions) is a SEPARATE
|
||||
// credential minted server-side by the OAuth flow and held by server.ts; the panel
|
||||
// reaches Canvas only through the server proxy (with its session cookie), so it never
|
||||
// holds the Canvas token or secret. This module is only the Hanzo-gateway bearer.
|
||||
|
||||
import { APIKEY_STORAGE_KEY, pickBearer } from './config.js';
|
||||
import { listModels } from './hanzo.js';
|
||||
|
||||
export function getApiKey(): string {
|
||||
try {
|
||||
return localStorage.getItem(APIKEY_STORAGE_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function setApiKey(key: string): void {
|
||||
try {
|
||||
const k = key.trim();
|
||||
if (k) localStorage.setItem(APIKEY_STORAGE_KEY, k);
|
||||
else localStorage.removeItem(APIKEY_STORAGE_KEY);
|
||||
} catch {
|
||||
/* private-mode / storage disabled — the in-memory key still works this session */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearApiKey(): void {
|
||||
setApiKey('');
|
||||
}
|
||||
|
||||
export function hasApiKey(): boolean {
|
||||
return !!getApiKey();
|
||||
}
|
||||
|
||||
// bearer is the credential the chat call sends: the pasted key (or empty for
|
||||
// anonymous public models). When Hanzo OAuth lands it slots in as the second argument
|
||||
// to pickBearer with no change to callers.
|
||||
export function bearer(): string {
|
||||
return pickBearer(getApiKey(), '');
|
||||
}
|
||||
|
||||
// validateKey confirms a pasted key actually works by listing models with it. Returns
|
||||
// the models on success so the caller populates the picker in one round-trip; throws
|
||||
// with the gateway's message on failure.
|
||||
export async function validateKey(key: string): Promise<string[]> {
|
||||
const k = key.trim();
|
||||
if (!k) throw new Error('Enter a Hanzo API key (hk-…).');
|
||||
return listModels({ token: k });
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// Canvas REST API v1 — pure request shaping + response parsing. Every function
|
||||
// returns a PreparedRequest (url + headers [+ body]) or parses a response body;
|
||||
// none opens a socket, so the whole API surface is unit-testable without a
|
||||
// network. server.ts is the thin glue that fetches these shapes with the OAuth
|
||||
// Bearer access token.
|
||||
//
|
||||
// The REST root is `https://{host}/api/v1` (see config.apiBaseUrl). Canvas scopes
|
||||
// reads/writes by the authorizing user's Bearer token; course-scoped resources
|
||||
// take the course id in the path (…/courses/{id}/…). Docs:
|
||||
// canvas.instructure.com/doc/api/courses.html, assignments.html, submissions.html,
|
||||
// discussion_topics.html, pages.html, modules.html, enrollments.html.
|
||||
|
||||
// A prepared GET: url + headers a single fetch needs (Bearer only).
|
||||
export interface PreparedGet {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
// A prepared write (POST/PUT): adds the JSON body and Content-Type. Canvas accepts
|
||||
// a JSON body (Content-Type: application/json) for these endpoints; we use it so
|
||||
// nested params stay structured rather than form-bracket-encoded.
|
||||
export interface PreparedWrite extends PreparedGet {
|
||||
method: 'POST' | 'PUT';
|
||||
body: string;
|
||||
}
|
||||
|
||||
// headers builds the auth header set common to every request.
|
||||
function headers(accessToken: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${accessToken}` };
|
||||
}
|
||||
|
||||
// jsonHeaders adds Content-Type for a write body on top of the auth header.
|
||||
function jsonHeaders(accessToken: string): Record<string, string> {
|
||||
return { ...headers(accessToken), 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
// The scope every request carries: which REST root and which bearer. Grouping them
|
||||
// keeps every wrapper's signature small.
|
||||
export interface Scope {
|
||||
apiBase: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
// Pagination controls. Canvas paginates with page (1-based) + per_page and returns
|
||||
// the page relations (next/prev/first/last) in the RFC 5988 `Link` response header
|
||||
// (parsed by the caller with parseLinkHeader). We attach these as query params on
|
||||
// list endpoints.
|
||||
export interface Page {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
// DEFAULT_PER_PAGE is Canvas's documented maximum page size; 100 minimises
|
||||
// round-trips (Canvas's own default is only 10).
|
||||
export const DEFAULT_PER_PAGE = 100;
|
||||
|
||||
// pageParams renders pagination into query pairs, clamping per_page to the
|
||||
// documented maximum. Only emits params that are set, so an unpaginated call stays
|
||||
// clean. Pure — unit-tested.
|
||||
export function pageParams(page: Page | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!page) return out;
|
||||
if (typeof page.page === 'number' && page.page > 0) out.page = String(Math.floor(page.page));
|
||||
if (typeof page.perPage === 'number' && page.perPage > 0) {
|
||||
out.per_page = String(Math.min(Math.floor(page.perPage), DEFAULT_PER_PAGE));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A repeated query param, e.g. Canvas's `include[]=syllabus_body`. buildUrl expands
|
||||
// an array value into one pair per element with a trailing `[]` on the key.
|
||||
type Query = Record<string, string | string[] | undefined>;
|
||||
|
||||
// buildUrl joins the REST root + path and appends a query string, dropping
|
||||
// undefined/empty values and expanding arrays into Canvas's `key[]=v` repetition.
|
||||
// One place builds every URL so scoping/pagination params are applied
|
||||
// consistently. Pure.
|
||||
function buildUrl(apiBase: string, path: string, query: Query = {}): string {
|
||||
const base = `${apiBase.replace(/\/+$/, '')}${path}`;
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) if (item !== undefined && item !== '') q.append(`${k}[]`, item);
|
||||
} else if (v !== undefined && v !== '') {
|
||||
q.set(k, v);
|
||||
}
|
||||
}
|
||||
const qs = q.toString();
|
||||
return qs ? `${base}?${qs}` : base;
|
||||
}
|
||||
|
||||
// enc encodes a single path segment (ids are integers and page urls are slugs, but
|
||||
// we encode defensively so a stray value can never break — or traverse — the path).
|
||||
function enc(segment: number | string): string {
|
||||
return encodeURIComponent(String(segment));
|
||||
}
|
||||
|
||||
// ---- Courses --------------------------------------------------------------
|
||||
|
||||
// listCourses — the courses the authorizing user can see. Bearer scope only (no
|
||||
// course in the path). The panel's picker uses this to choose which course to run
|
||||
// against.
|
||||
export function listCourses(scope: Scope, page?: Page): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(scope.apiBase, '/courses', pageParams(page)),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// getCourse — one course. `includes` maps to Canvas `include[]` associations; we
|
||||
// default to `syllabus_body` (an HTML syllabus) since the summarize-course action
|
||||
// grounds on it. Pass `[]` to fetch the bare course.
|
||||
export function getCourse(
|
||||
scope: Scope,
|
||||
courseId: number | string,
|
||||
includes: readonly string[] = ['syllabus_body'],
|
||||
): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(scope.apiBase, `/courses/${enc(courseId)}`, {
|
||||
include: includes.length ? [...includes] : undefined,
|
||||
}),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Assignments ----------------------------------------------------------
|
||||
|
||||
// listAssignments — the assignments in a course. Course-scoped path.
|
||||
export function listAssignments(scope: Scope, courseId: number | string, page?: Page): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(scope.apiBase, `/courses/${enc(courseId)}/assignments`, pageParams(page)),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// getAssignment — one assignment in full (description, due date, points, submission
|
||||
// types). This is what the feedback/question actions read.
|
||||
export function getAssignment(
|
||||
scope: Scope,
|
||||
courseId: number | string,
|
||||
assignmentId: number | string,
|
||||
): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(scope.apiBase, `/courses/${enc(courseId)}/assignments/${enc(assignmentId)}`),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Submissions ----------------------------------------------------------
|
||||
|
||||
// listSubmissions — the submissions for an assignment. Course + assignment scoped
|
||||
// path; `include[]=submission_comments` so existing comments come along for the
|
||||
// summarize/feedback context.
|
||||
export function listSubmissions(
|
||||
scope: Scope,
|
||||
courseId: number | string,
|
||||
assignmentId: number | string,
|
||||
page?: Page,
|
||||
): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(
|
||||
scope.apiBase,
|
||||
`/courses/${enc(courseId)}/assignments/${enc(assignmentId)}/submissions`,
|
||||
{ include: ['submission_comments'], ...pageParams(page) },
|
||||
),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// getSubmission — one student's submission (by user id) with its comment thread.
|
||||
export function getSubmission(
|
||||
scope: Scope,
|
||||
courseId: number | string,
|
||||
assignmentId: number | string,
|
||||
userId: number | string,
|
||||
): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(
|
||||
scope.apiBase,
|
||||
`/courses/${enc(courseId)}/assignments/${enc(assignmentId)}/submissions/${enc(userId)}`,
|
||||
{ include: ['submission_comments'] },
|
||||
),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// postSubmissionComment — add a text comment to a student's submission. Canvas
|
||||
// models a submission comment as an UPDATE to the submission (PUT
|
||||
// …/submissions/{user_id} with a `comment[text_comment]`), not a separate comments
|
||||
// collection — this is the ONE feedback write path, behind an explicit action.
|
||||
// Returns a PreparedWrite (PUT + JSON body).
|
||||
export function postSubmissionComment(
|
||||
scope: Scope,
|
||||
courseId: number | string,
|
||||
assignmentId: number | string,
|
||||
userId: number | string,
|
||||
text: string,
|
||||
): PreparedWrite {
|
||||
return {
|
||||
method: 'PUT',
|
||||
url: buildUrl(
|
||||
scope.apiBase,
|
||||
`/courses/${enc(courseId)}/assignments/${enc(assignmentId)}/submissions/${enc(userId)}`,
|
||||
),
|
||||
headers: jsonHeaders(scope.accessToken),
|
||||
body: JSON.stringify({ comment: { text_comment: text } }),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Discussions ----------------------------------------------------------
|
||||
|
||||
// listDiscussions — the discussion topics in a course (announcements included when
|
||||
// they are discussion-backed). Course-scoped path.
|
||||
export function listDiscussions(scope: Scope, courseId: number | string, page?: Page): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(scope.apiBase, `/courses/${enc(courseId)}/discussion_topics`, pageParams(page)),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// createAnnouncement — post a course announcement. In Canvas an announcement is a
|
||||
// discussion topic with `is_announcement: true` (POST …/discussion_topics) — the
|
||||
// second gated write path. Returns a PreparedWrite (POST + JSON body).
|
||||
export function createAnnouncement(
|
||||
scope: Scope,
|
||||
courseId: number | string,
|
||||
title: string,
|
||||
message: string,
|
||||
): PreparedWrite {
|
||||
return {
|
||||
method: 'POST',
|
||||
url: buildUrl(scope.apiBase, `/courses/${enc(courseId)}/discussion_topics`),
|
||||
headers: jsonHeaders(scope.accessToken),
|
||||
body: JSON.stringify({ title, message, is_announcement: true }),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Pages ----------------------------------------------------------------
|
||||
|
||||
// listPages — the wiki pages in a course. Course-scoped path.
|
||||
export function listPages(scope: Scope, courseId: number | string, page?: Page): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(scope.apiBase, `/courses/${enc(courseId)}/pages`, pageParams(page)),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// getPage — one wiki page in full (its HTML body). Canvas addresses a page by its
|
||||
// url slug or page id.
|
||||
export function getPage(
|
||||
scope: Scope,
|
||||
courseId: number | string,
|
||||
urlOrId: number | string,
|
||||
): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(scope.apiBase, `/courses/${enc(courseId)}/pages/${enc(urlOrId)}`),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Modules --------------------------------------------------------------
|
||||
|
||||
// listModules — the modules in a course, with their items inlined
|
||||
// (`include[]=items`) so the course-overview context sees the module structure.
|
||||
export function listModules(scope: Scope, courseId: number | string, page?: Page): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(scope.apiBase, `/courses/${enc(courseId)}/modules`, {
|
||||
include: ['items'],
|
||||
...pageParams(page),
|
||||
}),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Enrollments ----------------------------------------------------------
|
||||
|
||||
// listEnrollments — the enrollments in a course (who is a student / teacher / TA).
|
||||
export function listEnrollments(scope: Scope, courseId: number | string, page?: Page): PreparedGet {
|
||||
return {
|
||||
url: buildUrl(scope.apiBase, `/courses/${enc(courseId)}/enrollments`, pageParams(page)),
|
||||
headers: headers(scope.accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Pagination header (RFC 5988 Link) ------------------------------------
|
||||
|
||||
// The page relations Canvas returns in the `Link` header — each an absolute URL.
|
||||
export interface CanvasLinks {
|
||||
current?: string;
|
||||
next?: string;
|
||||
prev?: string;
|
||||
first?: string;
|
||||
last?: string;
|
||||
}
|
||||
|
||||
// parseLinkHeader reads Canvas's RFC 5988 `Link` header into the page relations.
|
||||
// Canvas returns entries like `<https://host/api/v1/courses?page=2&per_page=100>;
|
||||
// rel="next"`. Accepts a Headers instance or a plain header map so it works against
|
||||
// fetch Responses and test fixtures alike. Returns {} for a missing header. Pure.
|
||||
export function parseLinkHeader(
|
||||
h: Headers | Record<string, string | string[] | undefined>,
|
||||
): CanvasLinks {
|
||||
const raw =
|
||||
typeof (h as Headers)?.get === 'function'
|
||||
? (h as Headers).get('Link') ?? (h as Headers).get('link')
|
||||
: (h as Record<string, string | string[] | undefined>).Link ??
|
||||
(h as Record<string, string | string[] | undefined>).link;
|
||||
const val = Array.isArray(raw) ? raw.join(', ') : raw;
|
||||
const links: CanvasLinks = {};
|
||||
if (!val) return links;
|
||||
for (const part of String(val).split(',')) {
|
||||
const m = part.match(/<([^>]+)>\s*;\s*rel="?([^"\s;]+)"?/i);
|
||||
if (!m) continue;
|
||||
const rel = m[2].toLowerCase();
|
||||
if (rel === 'current' || rel === 'next' || rel === 'prev' || rel === 'first' || rel === 'last') {
|
||||
links[rel] = m[1];
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
// pageOfUrl extracts the 1-based `page` query param from a Canvas pagination URL
|
||||
// (the relations in parseLinkHeader carry absolute URLs). Returns undefined when
|
||||
// absent/unparseable. Pure — lets a same-origin proxy client re-request the next
|
||||
// page by number without following Canvas's absolute URL directly.
|
||||
export function pageOfUrl(url: string | undefined): number | undefined {
|
||||
if (!url) return undefined;
|
||||
const m = url.match(/[?&]page=([^&]+)/);
|
||||
if (!m) return undefined;
|
||||
const n = Number.parseInt(decodeURIComponent(m[1]), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
}
|
||||
|
||||
// nextPage is the page number to request for the next page, or undefined when
|
||||
// there is no `next` relation (the last page). Pure — the ONE way the panel decides
|
||||
// whether to keep paginating.
|
||||
export function nextPage(links: CanvasLinks): number | undefined {
|
||||
return pageOfUrl(links.next);
|
||||
}
|
||||
|
||||
// ---- Response parsing -----------------------------------------------------
|
||||
//
|
||||
// Canvas list endpoints return a bare JSON array of records; detail endpoints
|
||||
// return one object. We normalize the snake_case wire fields we surface into small
|
||||
// typed shapes at this boundary so nothing downstream touches the wire. Text bodies
|
||||
// (syllabus, page body, assignment description, discussion message) are Canvas HTML;
|
||||
// we keep them raw here and strip to plain text in course.ts (htmlToText) at render.
|
||||
|
||||
function arrayOf(data: any, key?: string): any[] {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (key && Array.isArray(data?.[key])) return data[key];
|
||||
return [];
|
||||
}
|
||||
|
||||
// One course as it appears in listCourses / getCourse.
|
||||
export interface Course {
|
||||
id: number;
|
||||
name: string;
|
||||
courseCode: string;
|
||||
workflowState: string;
|
||||
syllabusBody: string;
|
||||
termName: string;
|
||||
}
|
||||
|
||||
export function parseCourse(data: any): Course {
|
||||
return {
|
||||
id: Number(data?.id ?? 0),
|
||||
name: String(data?.name ?? ''),
|
||||
courseCode: String(data?.course_code ?? ''),
|
||||
workflowState: String(data?.workflow_state ?? ''),
|
||||
syllabusBody: String(data?.syllabus_body ?? ''),
|
||||
termName: String(data?.term?.name ?? data?.enrollment_term_id ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCourses(data: any): Course[] {
|
||||
return arrayOf(data, 'courses')
|
||||
.map(parseCourse)
|
||||
.filter((c) => c.id > 0);
|
||||
}
|
||||
|
||||
// One assignment.
|
||||
export interface Assignment {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
dueAt: string;
|
||||
pointsPossible: number;
|
||||
submissionTypes: string[];
|
||||
}
|
||||
|
||||
export function parseAssignment(data: any): Assignment {
|
||||
return {
|
||||
id: Number(data?.id ?? 0),
|
||||
name: String(data?.name ?? ''),
|
||||
description: String(data?.description ?? ''),
|
||||
dueAt: String(data?.due_at ?? ''),
|
||||
pointsPossible: Number(data?.points_possible ?? 0),
|
||||
submissionTypes: Array.isArray(data?.submission_types)
|
||||
? data.submission_types.map((s: any) => String(s))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAssignments(data: any): Assignment[] {
|
||||
return arrayOf(data, 'assignments')
|
||||
.map(parseAssignment)
|
||||
.filter((a) => a.id > 0);
|
||||
}
|
||||
|
||||
// One comment within a submission's thread.
|
||||
export interface SubmissionComment {
|
||||
id: number;
|
||||
authorName: string;
|
||||
comment: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function parseSubmissionComment(c: any): SubmissionComment {
|
||||
return {
|
||||
id: Number(c?.id ?? 0),
|
||||
authorName: String(c?.author_name ?? c?.author?.display_name ?? ''),
|
||||
comment: String(c?.comment ?? ''),
|
||||
createdAt: String(c?.created_at ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
// One student submission.
|
||||
export interface Submission {
|
||||
id: number;
|
||||
userId: number;
|
||||
assignmentId: number;
|
||||
body: string;
|
||||
submittedAt: string;
|
||||
workflowState: string;
|
||||
score: number | null;
|
||||
grade: string;
|
||||
late: boolean;
|
||||
missing: boolean;
|
||||
comments: SubmissionComment[];
|
||||
}
|
||||
|
||||
export function parseSubmission(data: any): Submission {
|
||||
return {
|
||||
id: Number(data?.id ?? 0),
|
||||
userId: Number(data?.user_id ?? 0),
|
||||
assignmentId: Number(data?.assignment_id ?? 0),
|
||||
body: String(data?.body ?? ''),
|
||||
submittedAt: String(data?.submitted_at ?? ''),
|
||||
workflowState: String(data?.workflow_state ?? ''),
|
||||
score: typeof data?.score === 'number' ? data.score : null,
|
||||
grade: data?.grade === null || data?.grade === undefined ? '' : String(data.grade),
|
||||
late: data?.late === true,
|
||||
missing: data?.missing === true,
|
||||
comments: Array.isArray(data?.submission_comments)
|
||||
? data.submission_comments.map(parseSubmissionComment)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSubmissions(data: any): Submission[] {
|
||||
return arrayOf(data, 'submissions')
|
||||
.map(parseSubmission)
|
||||
.filter((s) => s.id > 0 || s.userId > 0);
|
||||
}
|
||||
|
||||
// One discussion topic.
|
||||
export interface Discussion {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
postedAt: string;
|
||||
isAnnouncement: boolean;
|
||||
}
|
||||
|
||||
export function parseDiscussion(data: any): Discussion {
|
||||
return {
|
||||
id: Number(data?.id ?? 0),
|
||||
title: String(data?.title ?? ''),
|
||||
message: String(data?.message ?? ''),
|
||||
postedAt: String(data?.posted_at ?? ''),
|
||||
isAnnouncement: data?.is_announcement === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDiscussions(data: any): Discussion[] {
|
||||
return arrayOf(data, 'discussion_topics')
|
||||
.map(parseDiscussion)
|
||||
.filter((d) => d.id > 0);
|
||||
}
|
||||
|
||||
// One wiki page. The list omits the body; the detail (getPage) carries it.
|
||||
export interface Page_ {
|
||||
pageId: number;
|
||||
url: string;
|
||||
title: string;
|
||||
body: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function parsePage(data: any): Page_ {
|
||||
return {
|
||||
pageId: Number(data?.page_id ?? 0),
|
||||
url: String(data?.url ?? ''),
|
||||
title: String(data?.title ?? ''),
|
||||
body: String(data?.body ?? ''),
|
||||
updatedAt: String(data?.updated_at ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePages(data: any): Page_[] {
|
||||
return arrayOf(data, 'pages')
|
||||
.map(parsePage)
|
||||
.filter((p) => p.pageId > 0 || p.url.length > 0);
|
||||
}
|
||||
|
||||
// One module item (a page, assignment, quiz, file, external link, …).
|
||||
export interface ModuleItem {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
// One course module, with its items when Canvas inlined them.
|
||||
export interface Module {
|
||||
id: number;
|
||||
name: string;
|
||||
position: number;
|
||||
items: ModuleItem[];
|
||||
}
|
||||
|
||||
function parseModuleItem(i: any): ModuleItem {
|
||||
return {
|
||||
id: Number(i?.id ?? 0),
|
||||
title: String(i?.title ?? ''),
|
||||
type: String(i?.type ?? ''),
|
||||
position: Number(i?.position ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseModule(data: any): Module {
|
||||
return {
|
||||
id: Number(data?.id ?? 0),
|
||||
name: String(data?.name ?? ''),
|
||||
position: Number(data?.position ?? 0),
|
||||
items: Array.isArray(data?.items) ? data.items.map(parseModuleItem) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseModules(data: any): Module[] {
|
||||
return arrayOf(data, 'modules')
|
||||
.map(parseModule)
|
||||
.filter((m) => m.id > 0);
|
||||
}
|
||||
|
||||
// One enrollment (a person's role in the course).
|
||||
export interface Enrollment {
|
||||
id: number;
|
||||
userId: number;
|
||||
userName: string;
|
||||
type: string;
|
||||
role: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export function parseEnrollment(data: any): Enrollment {
|
||||
return {
|
||||
id: Number(data?.id ?? 0),
|
||||
userId: Number(data?.user_id ?? 0),
|
||||
userName: String(data?.user?.name ?? data?.user?.short_name ?? ''),
|
||||
type: String(data?.type ?? ''),
|
||||
role: String(data?.role ?? ''),
|
||||
state: String(data?.enrollment_state ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEnrollments(data: any): Enrollment[] {
|
||||
return arrayOf(data, 'enrollments')
|
||||
.map(parseEnrollment)
|
||||
.filter((e) => e.id > 0);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Canvas OAuth 2.0 (Authorization Code Grant) — pure request shaping. The client
|
||||
// secret is held by the server (server.ts) and never reaches the browser; these
|
||||
// functions build the exact URL/body of each OAuth call so the wire shape is
|
||||
// unit-testable without a network round-trip. The token exchange and refresh are
|
||||
// each one fetch in the server.
|
||||
//
|
||||
// Canvas uses form-encoded bodies with client_id + client_secret IN THE BODY (not
|
||||
// HTTP Basic) and requires the redirect_uri on the code exchange — the standard
|
||||
// OAuth2 web-server shape, documented at
|
||||
// canvas.instructure.com/doc/api/file.oauth_endpoints.html. The login host is the
|
||||
// institution's own Canvas host (see config.authBase): authorize at
|
||||
// /login/oauth2/auth, token at /login/oauth2/token.
|
||||
|
||||
import { authBase } from './config.js';
|
||||
|
||||
// authorizeUrl is where the install flow sends the user's browser to grant the
|
||||
// app. `state` is the CSRF token the server generates and re-checks on callback.
|
||||
// Canvas's Authorization Code grant takes response_type=code + client_id +
|
||||
// redirect_uri + state; scope is optional (only enforced when the Developer Key
|
||||
// enables scope enforcement), so it is only appended when non-empty.
|
||||
export function authorizeUrl(args: {
|
||||
host: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scopes?: readonly string[];
|
||||
state: string;
|
||||
}): string {
|
||||
const params: Record<string, string> = {
|
||||
client_id: args.clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: args.redirectUri,
|
||||
state: args.state,
|
||||
};
|
||||
if (args.scopes && args.scopes.length > 0) params.scope = args.scopes.join(' ');
|
||||
const q = new URLSearchParams(params);
|
||||
return `${authBase(args.host)}/login/oauth2/auth?${q.toString()}`;
|
||||
}
|
||||
|
||||
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a test
|
||||
// asserts the form body without opening a socket. Canvas's token endpoint takes
|
||||
// application/x-www-form-urlencoded.
|
||||
export interface PreparedRequest {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
// tokenUrl is the OAuth token endpoint for a host.
|
||||
export function tokenUrl(host: string): string {
|
||||
return `${authBase(host)}/login/oauth2/token`;
|
||||
}
|
||||
|
||||
// tokenExchange builds the code→token request against the host's
|
||||
// /login/oauth2/token. grant_type=authorization_code with the code, the key's
|
||||
// credentials, and the SAME redirect_uri that was used on /login/oauth2/auth
|
||||
// (Canvas requires it to match). The secret rides only in this server-side body.
|
||||
export function tokenExchange(args: {
|
||||
host: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}): PreparedRequest {
|
||||
return {
|
||||
url: tokenUrl(args.host),
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: args.clientId,
|
||||
client_secret: args.clientSecret,
|
||||
redirect_uri: args.redirectUri,
|
||||
code: args.code,
|
||||
}).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// refreshExchange builds the refresh-token→token request. Canvas access tokens are
|
||||
// short-lived (~1h); a refresh returns a fresh access_token but — unlike Procore —
|
||||
// Canvas does NOT rotate the refresh token (it returns none), so the server keeps
|
||||
// the original refresh token. No redirect_uri on refresh.
|
||||
export function refreshExchange(args: {
|
||||
host: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
refreshToken: string;
|
||||
}): PreparedRequest {
|
||||
return {
|
||||
url: tokenUrl(args.host),
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: args.clientId,
|
||||
client_secret: args.clientSecret,
|
||||
refresh_token: args.refreshToken,
|
||||
}).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// The Canvas person sub-object returned alongside a token (the authorizing user).
|
||||
export interface CanvasUser {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// The token response we care about. Canvas returns access_token (+ token_type,
|
||||
// user, expires_in, and refresh_token ONLY on the initial code exchange).
|
||||
// parseTokenResponse validates the one field we must have and surfaces Canvas's
|
||||
// own error otherwise.
|
||||
export interface CanvasTokenSet {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
user?: CanvasUser;
|
||||
}
|
||||
|
||||
export function parseTokenResponse(data: any): CanvasTokenSet {
|
||||
if (!data || typeof data !== 'object') throw new Error('empty token response');
|
||||
if (data.error) {
|
||||
const reason = data.error_description || data.message || data.error;
|
||||
throw new Error(`Canvas OAuth error: ${reason}`);
|
||||
}
|
||||
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
|
||||
throw new Error('Canvas OAuth response missing access_token');
|
||||
}
|
||||
const user =
|
||||
data.user && typeof data.user === 'object'
|
||||
? { id: Number(data.user.id ?? 0), name: String(data.user.name ?? '') }
|
||||
: undefined;
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
|
||||
expires_in: typeof data.expires_in === 'number' ? data.expires_in : undefined,
|
||||
token_type: typeof data.token_type === 'string' ? data.token_type : undefined,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
// carryRefreshToken applies Canvas's non-rotating refresh semantics: a refresh
|
||||
// response omits the refresh token, so the new token set inherits the previous
|
||||
// refresh token. Pure — the server calls this after every refresh so the session
|
||||
// never loses the ability to refresh again.
|
||||
export function carryRefreshToken(next: CanvasTokenSet, previous: CanvasTokenSet): CanvasTokenSet {
|
||||
return next.refresh_token ? next : { ...next, refresh_token: previous.refresh_token };
|
||||
}
|
||||
|
||||
// tokenExpiresAt computes the absolute epoch-seconds expiry from a token set and
|
||||
// the wall-clock second it was minted, applying a safety skew so a request is never
|
||||
// sent with a token about to expire mid-flight. Canvas does not return a mint
|
||||
// timestamp, so the caller supplies `mintedAt` (recorded when the token was
|
||||
// stored). Pure.
|
||||
export function tokenExpiresAt(token: CanvasTokenSet, mintedAt: number, skewSeconds = 60): number {
|
||||
const ttl = typeof token.expires_in === 'number' ? token.expires_in : 0;
|
||||
return mintedAt + ttl - skewSeconds;
|
||||
}
|
||||
|
||||
// isExpired reports whether a token set is at/past its (skew-adjusted) expiry as of
|
||||
// `now`, given when it was minted. The server checks this before each API call and
|
||||
// refreshes when true. Pure.
|
||||
export function isExpired(
|
||||
token: CanvasTokenSet,
|
||||
mintedAt: number,
|
||||
now: number,
|
||||
skewSeconds = 60,
|
||||
): boolean {
|
||||
return now >= tokenExpiresAt(token, mintedAt, skewSeconds);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Canvas LMS config — the institution's Canvas host, the Canvas REST API version
|
||||
// segment, the api.hanzo.ai model gateway, and the server-side secret set (Canvas
|
||||
// OAuth). Endpoints and the bearer choice mirror @hanzo/procore and @hanzo/docusign
|
||||
// so the productivity suite stays DRY; the education-specific pieces
|
||||
// (course-context windowing, the AI actions) live in course.ts / hanzo.ts /
|
||||
// actions.ts, not here.
|
||||
|
||||
// ---- Hanzo model gateway --------------------------------------------------
|
||||
|
||||
// Where the Hanzo model gateway lives. `@hanzo/ai` 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).
|
||||
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
|
||||
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-canvas';
|
||||
|
||||
// localStorage key for the pasted Hanzo API key (`hk-…`) in the web panel. The
|
||||
// panel is a static web page (iframe), not a host with roamingSettings, so the
|
||||
// zero-setup credential lives in localStorage — same as @hanzo/procore.
|
||||
export const APIKEY_STORAGE_KEY = 'hanzo.canvas.apiKey';
|
||||
|
||||
// Course text budget. A busy course has many assignments, pages, discussions, and
|
||||
// submissions; the whole corpus won't fit a model window and shouldn't be sent.
|
||||
// This caps the characters of course text we attach to any one request — chosen so
|
||||
// it fits comfortably inside a modern context window alongside the reply, and is
|
||||
// honest rather than optimal (we truncate visibly, never drop silently).
|
||||
export const COURSE_CHAR_BUDGET = 60_000;
|
||||
|
||||
// pickBearer chooses the credential to send to the Hanzo gateway: a pasted API key
|
||||
// wins over an OAuth token (an explicit key is a deliberate override), else the
|
||||
// token, else empty (anonymous — the gateway still serves public models). Pure —
|
||||
// unit-tested.
|
||||
export function pickBearer(apiKey: string, oauthToken: string): string {
|
||||
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
|
||||
}
|
||||
|
||||
// chatCompletionsURL / modelsURL — the model gateway endpoints.
|
||||
export function chatCompletionsURL(): string {
|
||||
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
|
||||
}
|
||||
export function modelsURL(): string {
|
||||
return `${HANZO_API_BASE_URL}/v1/models`;
|
||||
}
|
||||
|
||||
// ---- Canvas host + API ----------------------------------------------------
|
||||
//
|
||||
// Canvas LMS is self-hosted per institution: each school runs its own Canvas at
|
||||
// its own host (e.g. `school.instructure.com` or a custom domain). There is no
|
||||
// fixed host list — the host is a deployment value, so every Canvas URL (OAuth
|
||||
// login + REST API) is built from the configured host. We never hard-code an
|
||||
// institution host inline.
|
||||
//
|
||||
// Docs: canvas.instructure.com/doc/api/file.oauth.html and
|
||||
// canvas.instructure.com/doc/api/index.html (REST API).
|
||||
|
||||
// normalizeHost strips a scheme, a trailing slash, and any path/query so a raw
|
||||
// value like `https://school.instructure.com/` or `school.instructure.com`
|
||||
// resolves to the bare host `school.instructure.com`. Pure — the ONE place a host
|
||||
// is cleaned, so every URL builder gets a canonical host. Boundary guard.
|
||||
export function normalizeHost(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^https?:\/\//i, '')
|
||||
.replace(/\/.*$/, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// authBase turns a host into the OAuth login origin — `/login/oauth2/auth` and
|
||||
// `/login/oauth2/token` live here. Pure.
|
||||
export function authBase(host: string): string {
|
||||
return `https://${normalizeHost(host)}`;
|
||||
}
|
||||
|
||||
// The REST API version segment. v1 is Canvas's current (and only) REST version;
|
||||
// we never bump to a speculative v2 — one version, forward only. This is Canvas's
|
||||
// OWN `/api/v1` path, not a Hanzo gateway path (the /api/ ban is only for
|
||||
// api.hanzo.ai, which uses /v1).
|
||||
export const REST_API_VERSION = 'v1';
|
||||
|
||||
// apiBaseUrl turns a host into the REST root: `https://{host}/api/v1`. Every Canvas
|
||||
// API call is built on top of this. Pure — the request wrappers in canvas-api.ts
|
||||
// take this string.
|
||||
export function apiBaseUrl(host: string): string {
|
||||
return `${authBase(host)}/api/${REST_API_VERSION}`;
|
||||
}
|
||||
|
||||
// The OAuth scopes the app requests. Canvas only enforces scopes when the
|
||||
// Developer Key has "Enforce Scopes" enabled; a standard key ignores the `scope`
|
||||
// parameter (permissions follow the authorizing user's Canvas role). We default to
|
||||
// none so the app installs against either key type, and document the granular
|
||||
// URL-scopes to add (see RECOMMENDED_SCOPES) when enforcement is on. The authorize
|
||||
// URL only appends `scope` when this is non-empty.
|
||||
export const OAUTH_SCOPES = [] as const;
|
||||
|
||||
// RECOMMENDED_SCOPES documents the exact Canvas URL-scopes to request when the
|
||||
// Developer Key enforces scopes — the read surface this app uses plus the two
|
||||
// gated writes. Not sent by default (see OAUTH_SCOPES); referenced by the README
|
||||
// and available for a deployment that opts into scope enforcement.
|
||||
export const RECOMMENDED_SCOPES = [
|
||||
'url:GET|/api/v1/courses',
|
||||
'url:GET|/api/v1/courses/:id',
|
||||
'url:GET|/api/v1/courses/:course_id/assignments',
|
||||
'url:GET|/api/v1/courses/:course_id/assignments/:id',
|
||||
'url:GET|/api/v1/courses/:course_id/assignments/:assignment_id/submissions',
|
||||
'url:GET|/api/v1/courses/:course_id/assignments/:assignment_id/submissions/:user_id',
|
||||
'url:GET|/api/v1/courses/:course_id/discussion_topics',
|
||||
'url:GET|/api/v1/courses/:course_id/pages',
|
||||
'url:GET|/api/v1/courses/:course_id/pages/:url_or_id',
|
||||
'url:GET|/api/v1/courses/:course_id/modules',
|
||||
'url:GET|/api/v1/courses/:course_id/enrollments',
|
||||
'url:PUT|/api/v1/courses/:course_id/assignments/:assignment_id/submissions/:user_id',
|
||||
'url:POST|/api/v1/courses/:course_id/discussion_topics',
|
||||
] as const;
|
||||
|
||||
// ---- Server-side configuration (Canvas OAuth) -----------------------------
|
||||
//
|
||||
// These are read from the environment by src/server.ts. They NEVER reach the
|
||||
// browser bundle: the client id and host are public, but the client secret is
|
||||
// server-only and is validated to be present before the server will start
|
||||
// (readServerConfig throws on a missing secret). This is the ONLY place the secret
|
||||
// exists.
|
||||
|
||||
export interface ServerConfig {
|
||||
/** Canvas institution host, e.g. `school.instructure.com` (bare, normalized). */
|
||||
canvasHost: string;
|
||||
/** Canvas Developer Key client id (public). */
|
||||
canvasClientId: string;
|
||||
/** Canvas Developer Key client secret (SERVER ONLY — token exchange + refresh). */
|
||||
canvasClientSecret: string;
|
||||
/** OAuth redirect registered on the key (e.g. https://canvas.hanzo.ai/oauth/callback). */
|
||||
canvasRedirectUri: string;
|
||||
/** Listen port. */
|
||||
port: number;
|
||||
}
|
||||
|
||||
// readServerConfig fails fast (throws) if a required Canvas value is missing — a
|
||||
// server that cannot exchange OAuth codes (or does not know which Canvas host to
|
||||
// call) must not pretend to start. Pure given an env map, so it is unit-tested
|
||||
// without touching process.env. The host is normalized at this one boundary.
|
||||
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
|
||||
const canvasHost = env.CANVAS_HOST;
|
||||
const canvasClientId = env.CANVAS_CLIENT_ID;
|
||||
const canvasClientSecret = env.CANVAS_CLIENT_SECRET;
|
||||
const canvasRedirectUri = env.CANVAS_REDIRECT_URI;
|
||||
const missing: string[] = [];
|
||||
if (!canvasHost) missing.push('CANVAS_HOST');
|
||||
if (!canvasClientId) missing.push('CANVAS_CLIENT_ID');
|
||||
if (!canvasClientSecret) missing.push('CANVAS_CLIENT_SECRET');
|
||||
if (!canvasRedirectUri) missing.push('CANVAS_REDIRECT_URI');
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required environment: ${missing.join(', ')}`);
|
||||
}
|
||||
return {
|
||||
canvasHost: normalizeHost(canvasHost!),
|
||||
canvasClientId: canvasClientId!,
|
||||
canvasClientSecret: canvasClientSecret!,
|
||||
canvasRedirectUri: canvasRedirectUri!,
|
||||
port: Number(env.PORT) || 8791,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Course-context assembly — PURE, host-agnostic, fully unit-testable. Turns the
|
||||
// typed Canvas records (course/syllabus, modules, assignments, pages, discussions,
|
||||
// submissions, enrollments) into the plain text an AI action reads, windowed to a
|
||||
// character budget so a busy course never overflows the model or gets sent silently
|
||||
// truncated.
|
||||
//
|
||||
// There is ONE windowing contract, identical in spirit to @hanzo/procore's project
|
||||
// windowing: render ordered blocks, walk them in order, stop at the budget, always
|
||||
// include at least the first block, and report `truncated` honestly. The only
|
||||
// education-specific parts are how a record renders to a block and the HTML→text
|
||||
// step (Canvas content — syllabus, page bodies, assignment descriptions, discussion
|
||||
// messages — is HTML).
|
||||
|
||||
import type {
|
||||
Assignment,
|
||||
Course,
|
||||
Discussion,
|
||||
Enrollment,
|
||||
Module,
|
||||
Page_,
|
||||
Submission,
|
||||
} from './canvas-api.js';
|
||||
import { COURSE_CHAR_BUDGET } from './config.js';
|
||||
|
||||
// ---- HTML → text ----------------------------------------------------------
|
||||
|
||||
// htmlToText reduces Canvas rich-text HTML to readable plain text: block elements
|
||||
// become line breaks, tags are stripped, the common named/numeric entities are
|
||||
// decoded, and whitespace is collapsed. Deliberately small and dependency-free (no
|
||||
// DOM) — Canvas bodies are trusted-institution content we only need to READ, not
|
||||
// render, so a full sanitizer/ parser would be over-engineering. Pure.
|
||||
export function htmlToText(html: string): string {
|
||||
if (!html) return '';
|
||||
return html
|
||||
.replace(/<\s*(br|\/p|\/div|\/li|\/h[1-6]|\/tr)\s*\/?\s*>/gi, '\n')
|
||||
.replace(/<\s*li[^>]*>/gi, '• ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// clamp trims a rendered body to a maximum length with an ellipsis so one giant
|
||||
// page/syllabus can't dominate the budget before windowing even runs. Pure.
|
||||
function clamp(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text;
|
||||
}
|
||||
|
||||
// Per-body cap: a single HTML body is trimmed to this before it becomes a block, so
|
||||
// the windowing budget sees comparable blocks rather than one 40k-char page.
|
||||
const BODY_CAP = 4_000;
|
||||
|
||||
// ---- Record → text block --------------------------------------------------
|
||||
|
||||
// renderCourseOverview turns the course + its syllabus into the lead block: the
|
||||
// course identity and the (HTML-stripped, clamped) syllabus text.
|
||||
export function renderCourseOverview(course: Course): string {
|
||||
const lines: string[] = [];
|
||||
const head = `${course.name}${course.courseCode ? ` (${course.courseCode})` : ''}`.trim();
|
||||
lines.push(head);
|
||||
const meta: string[] = [];
|
||||
if (course.termName) meta.push(`Term: ${course.termName}`);
|
||||
if (course.workflowState) meta.push(`State: ${course.workflowState}`);
|
||||
if (meta.length) lines.push(meta.join(' · '));
|
||||
const syllabus = htmlToText(course.syllabusBody);
|
||||
if (syllabus) lines.push(`Syllabus:\n${clamp(syllabus, BODY_CAP)}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// renderAssignment turns one assignment into a labelled block.
|
||||
export function renderAssignment(a: Assignment): string {
|
||||
const head = `Assignment: ${a.name}`.trim();
|
||||
const meta: string[] = [];
|
||||
if (a.dueAt) meta.push(`Due: ${a.dueAt}`);
|
||||
if (a.pointsPossible) meta.push(`Points: ${a.pointsPossible}`);
|
||||
if (a.submissionTypes.length) meta.push(`Submit: ${a.submissionTypes.join(', ')}`);
|
||||
const lines = [meta.length ? `${head}\n${meta.join(' · ')}` : head];
|
||||
const desc = htmlToText(a.description);
|
||||
if (desc) lines.push(clamp(desc, BODY_CAP));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// renderSubmission turns one student submission into a labelled block, including
|
||||
// the score/status and any existing comment thread (order preserved).
|
||||
export function renderSubmission(s: Submission): string {
|
||||
const lines: string[] = [];
|
||||
const flags = [s.late ? 'late' : '', s.missing ? 'missing' : ''].filter(Boolean).join(', ');
|
||||
const head = `Submission (user ${s.userId})${flags ? ` [${flags}]` : ''}`;
|
||||
lines.push(head);
|
||||
const meta: string[] = [];
|
||||
if (s.workflowState) meta.push(`Status: ${s.workflowState}`);
|
||||
if (s.score !== null) meta.push(`Score: ${s.score}`);
|
||||
if (s.grade) meta.push(`Grade: ${s.grade}`);
|
||||
if (s.submittedAt) meta.push(`Submitted: ${s.submittedAt}`);
|
||||
if (meta.length) lines.push(meta.join(' · '));
|
||||
const body = htmlToText(s.body);
|
||||
if (body) lines.push(`Response: ${clamp(body, BODY_CAP)}`);
|
||||
for (const c of s.comments) {
|
||||
const who = c.authorName ? ` (${c.authorName})` : '';
|
||||
if (c.comment) lines.push(`Comment${who}: ${c.comment.trim()}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// renderDiscussion turns one discussion topic into a labelled block.
|
||||
export function renderDiscussion(d: Discussion): string {
|
||||
const kind = d.isAnnouncement ? 'Announcement' : 'Discussion';
|
||||
const head = `${kind}: ${d.title}`.trim();
|
||||
const body = htmlToText(d.message);
|
||||
return body ? `${head}\n${clamp(body, BODY_CAP)}` : head;
|
||||
}
|
||||
|
||||
// renderPage turns one wiki page into a labelled block (title + stripped body).
|
||||
export function renderPage(p: Page_): string {
|
||||
const head = `Page: ${p.title || p.url}`.trim();
|
||||
const body = htmlToText(p.body);
|
||||
return body ? `${head}\n${clamp(body, BODY_CAP)}` : head;
|
||||
}
|
||||
|
||||
// renderModule turns one module + its items into a labelled block.
|
||||
export function renderModule(m: Module): string {
|
||||
const head = `Module: ${m.name}`.trim();
|
||||
const items = m.items.map((i) => ` - ${i.title}${i.type ? ` [${i.type}]` : ''}`).filter(Boolean);
|
||||
return items.length ? `${head}\n${items.join('\n')}` : head;
|
||||
}
|
||||
|
||||
// renderEnrollment turns one enrollment into a single labelled line.
|
||||
export function renderEnrollment(e: Enrollment): string {
|
||||
const who = e.userName || `user ${e.userId}`;
|
||||
const role = e.role || e.type;
|
||||
return `${who}${role ? ` — ${role}` : ''}${e.state ? ` (${e.state})` : ''}`;
|
||||
}
|
||||
|
||||
// ---- Windowing to a budget ------------------------------------------------
|
||||
|
||||
// A named group of rendered blocks — a section of the course context (e.g.
|
||||
// "Assignments", "Modules"). The assembler concatenates sections in the order given
|
||||
// and windows the whole thing to the budget.
|
||||
export interface Section {
|
||||
title: string;
|
||||
blocks: string[];
|
||||
}
|
||||
|
||||
// The windowed course context: the rendered text, how many blocks were available vs
|
||||
// included, and whether anything was dropped (so the prompt and UI can say so
|
||||
// honestly).
|
||||
export interface CourseContext {
|
||||
text: string;
|
||||
totalBlocks: number;
|
||||
includedBlocks: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
// buildCourseContext concatenates sections IN ORDER and caps the rendered text at
|
||||
// `budget` characters. It walks blocks in order (across sections) and stops when
|
||||
// adding the next block would exceed the budget — always including at least the
|
||||
// first block (hard-cut to the budget if that one block alone is over). `truncated`
|
||||
// is true whenever not every available block made it in. Pure and total:
|
||||
// deterministic, no I/O. This is the ONE windowing algorithm for the package — the
|
||||
// same "fit ordered text to a budget" contract as @hanzo/procore.
|
||||
export function buildCourseContext(
|
||||
sections: Section[],
|
||||
budget: number = COURSE_CHAR_BUDGET,
|
||||
): CourseContext {
|
||||
const totalBlocks = sections.reduce((n, s) => n + s.blocks.length, 0);
|
||||
if (totalBlocks === 0) {
|
||||
return { text: '', totalBlocks: 0, includedBlocks: 0, truncated: false };
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let used = 0;
|
||||
let included = 0;
|
||||
let hardCut = false;
|
||||
|
||||
outer: for (const section of sections) {
|
||||
if (section.blocks.length === 0) continue;
|
||||
// The section header is charged to the first block that fits under it.
|
||||
let headerPending = `## ${section.title}\n`;
|
||||
for (const block of section.blocks) {
|
||||
const prefix = parts.length === 0 ? '' : '\n\n';
|
||||
const addition = prefix + headerPending + block;
|
||||
if (used + addition.length > budget) {
|
||||
if (parts.length === 0) {
|
||||
// First block alone overflows — hard-cut it to the budget so we always
|
||||
// send something rather than an empty context.
|
||||
const solo = (headerPending + block).slice(0, budget);
|
||||
parts.push(solo);
|
||||
used = solo.length;
|
||||
included = 1;
|
||||
hardCut = true;
|
||||
}
|
||||
break outer;
|
||||
}
|
||||
parts.push(addition);
|
||||
used += addition.length;
|
||||
included += 1;
|
||||
headerPending = ''; // header only precedes the first block of the section
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = included < totalBlocks || hardCut;
|
||||
return { text: parts.join(''), totalBlocks, includedBlocks: included, truncated };
|
||||
}
|
||||
|
||||
// contextNote is the one honest sentence prepended to the course text so the model
|
||||
// (and, echoed in the panel, the user) knows the scope of what it sees. Never claim
|
||||
// the whole course was sent when it wasn't. Pure.
|
||||
export function contextNote(ctx: CourseContext): string {
|
||||
if (ctx.totalBlocks === 0) return 'No course records were available.';
|
||||
return ctx.truncated
|
||||
? `Course records: ${ctx.includedBlocks} of ${ctx.totalBlocks} items (truncated to fit — answer only from what is shown and say so if the omitted part is needed).`
|
||||
: `Course records: all ${ctx.totalBlocks} item${ctx.totalBlocks === 1 ? '' : 's'}.`;
|
||||
}
|
||||
|
||||
// ---- Convenience section builders -----------------------------------------
|
||||
//
|
||||
// These turn a typed record list into a Section, so a caller (server or panel)
|
||||
// assembles a course context in a couple of lines. Kept here (pure) so the same
|
||||
// section shapes feed both surfaces and the tests.
|
||||
|
||||
export function courseSection(course: Course, title = 'Course'): Section {
|
||||
return { title, blocks: [renderCourseOverview(course)] };
|
||||
}
|
||||
export function moduleSection(modules: Module[], title = 'Modules'): Section {
|
||||
return { title, blocks: modules.map(renderModule) };
|
||||
}
|
||||
export function assignmentSection(assignments: Assignment[], title = 'Assignments'): Section {
|
||||
return { title, blocks: assignments.map(renderAssignment) };
|
||||
}
|
||||
export function pageSection(pages: Page_[], title = 'Pages'): Section {
|
||||
return { title, blocks: pages.map(renderPage) };
|
||||
}
|
||||
export function discussionSection(discussions: Discussion[], title = 'Discussions'): Section {
|
||||
return { title, blocks: discussions.map(renderDiscussion) };
|
||||
}
|
||||
export function submissionSection(submissions: Submission[], title = 'Submissions'): Section {
|
||||
return { title, blocks: submissions.map(renderSubmission) };
|
||||
}
|
||||
export function enrollmentSection(enrollments: Enrollment[], title = 'Enrollments'): Section {
|
||||
return { title, blocks: enrollments.map(renderEnrollment) };
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// The Hanzo call and its request/response shaping over a COURSE — thin over the
|
||||
// published `@hanzo/ai` headless client, host-agnostic, and fully unit-testable (no
|
||||
// Canvas SDK, no DOM). The panel and the server are the glue that read a course's
|
||||
// records and hand them here. Speaks the OpenAI-compatible /v1 wire protocol the
|
||||
// whole Hanzo suite uses (identical to @hanzo/procore) so the gateway sees one shape
|
||||
// from every surface.
|
||||
//
|
||||
// @hanzo/ai@^0.2.0 IS the headless client: createAiClient({ baseUrl, token }) →
|
||||
// .chat.completions.create({ model, messages }) + .models.list(). We import it and
|
||||
// do NOT reimplement the transport. This module adds only the course-aware prompt
|
||||
// assembly + the single `ask` primitive the actions layer on.
|
||||
|
||||
import {
|
||||
createAiClient,
|
||||
type AiClient,
|
||||
type ChatCompletion,
|
||||
type ChatCompletionMessage,
|
||||
} from '@hanzo/ai';
|
||||
import { DEFAULT_MODEL, HANZO_API_BASE_URL } from './config.js';
|
||||
import { contextNote, type CourseContext } from './course.js';
|
||||
|
||||
// SYSTEM_PROMPT grounds every answer in the course records. It forbids inventing
|
||||
// facts nobody recorded (the failure mode that makes a teaching assistant
|
||||
// untrustworthy), keeps student data handled respectfully, and stays honest about
|
||||
// truncated context. It states plainly that the output is a DRAFT for an educator to
|
||||
// review — not an authoritative grade, decision, or the final word to a student.
|
||||
export const SYSTEM_PROMPT =
|
||||
'You are Hanzo AI, an assistant that helps instructors and teaching staff work ' +
|
||||
'with their Canvas course — the syllabus, modules, assignments, pages, ' +
|
||||
'discussions, and student submissions. Work ONLY from the course records provided ' +
|
||||
'below — never invent assignments, due dates, grades, submissions, or facts that ' +
|
||||
'are not supported by the text. Be concise, precise, and constructive; when you ' +
|
||||
'reference student work, be specific and fair. If the records are marked truncated ' +
|
||||
'and a complete answer needs the omitted part, say so plainly rather than guessing. ' +
|
||||
'Everything you produce — feedback, questions, announcements, summaries — is a ' +
|
||||
'DRAFT for an educator to review and edit before it reaches a student; it is not an ' +
|
||||
'authoritative grade or decision.';
|
||||
|
||||
// ---- Prompt assembly ------------------------------------------------------
|
||||
|
||||
// Optional structured context about the course (name, code, term) the Canvas API
|
||||
// supplies. Attached as a short header above the records so answers can reference the
|
||||
// course without the model guessing.
|
||||
export interface CourseMeta {
|
||||
name?: string;
|
||||
courseCode?: string;
|
||||
term?: string;
|
||||
}
|
||||
|
||||
function courseHeader(meta: CourseMeta | undefined): string {
|
||||
if (!meta) return '';
|
||||
const parts: string[] = [];
|
||||
if (meta.name) parts.push(`Course: ${meta.name}`);
|
||||
if (meta.courseCode) parts.push(`Code: ${meta.courseCode}`);
|
||||
if (meta.term) parts.push(`Term: ${meta.term}`);
|
||||
return parts.length > 0 ? parts.join('\n') + '\n\n' : '';
|
||||
}
|
||||
|
||||
// buildMessages turns a task (the user's question, or one of the action prompts)
|
||||
// plus the windowed course context + optional metadata into the OpenAI-compatible
|
||||
// message list. The records are fenced so the model treats them as data, not
|
||||
// instructions, and the honest context note rides inside the user turn so it is never
|
||||
// lost. Pure — unit-tested.
|
||||
export function buildMessages(
|
||||
task: string,
|
||||
ctx: CourseContext,
|
||||
meta?: CourseMeta,
|
||||
): ChatCompletionMessage[] {
|
||||
const system: ChatCompletionMessage = { role: 'system', content: SYSTEM_PROMPT };
|
||||
const header = courseHeader(meta);
|
||||
const user: ChatCompletionMessage = {
|
||||
role: 'user',
|
||||
content:
|
||||
`${header}${contextNote(ctx)}\n\n` +
|
||||
`---- course records ----\n${ctx.text}\n---- end course records ----\n\n` +
|
||||
`---- task ----\n${task}`,
|
||||
};
|
||||
return [system, user];
|
||||
}
|
||||
|
||||
// extractContent pulls the assistant text out of a @hanzo/ai ChatCompletion,
|
||||
// tolerating the string-or-parts content shape the OpenAI schema allows. Throws on
|
||||
// empty content so callers surface the real gateway state. Pure — unit-tested.
|
||||
export function extractContent(res: ChatCompletion): string {
|
||||
const msg = res?.choices?.[0]?.message;
|
||||
const content = msg?.content;
|
||||
let text = '';
|
||||
if (typeof content === 'string') {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
text = content.map((part) => (part?.type === 'text' ? part.text : '')).join('');
|
||||
}
|
||||
if (text.length === 0) throw new Error('Hanzo API returned no content');
|
||||
return text;
|
||||
}
|
||||
|
||||
// ---- The single call path -------------------------------------------------
|
||||
|
||||
export interface AskOptions {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
token?: string;
|
||||
baseURL?: string;
|
||||
/** Injected client (tests). Defaults to a real createAiClient. */
|
||||
client?: AiClient;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// client resolves the @hanzo/ai client for a call: an injected one (tests) or a fresh
|
||||
// createAiClient pointed at the gateway with the caller's bearer.
|
||||
function client(opts: AskOptions): AiClient {
|
||||
return (
|
||||
opts.client ?? createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL })
|
||||
);
|
||||
}
|
||||
|
||||
// ask runs ONE non-streaming completion over a course and returns the answer. This
|
||||
// is the single path from every Canvas surface to the model — the actions, a freeform
|
||||
// question, and any server-side call all funnel here. token may be empty (the gateway
|
||||
// serves anonymous/limited models).
|
||||
export async function ask(
|
||||
task: string,
|
||||
ctx: CourseContext,
|
||||
meta: CourseMeta | undefined,
|
||||
opts: AskOptions = {},
|
||||
): Promise<string> {
|
||||
const res = await client(opts).chat.completions.create(
|
||||
{
|
||||
model: opts.model ?? DEFAULT_MODEL,
|
||||
messages: buildMessages(task, ctx, meta),
|
||||
stream: false,
|
||||
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
|
||||
},
|
||||
{ signal: opts.signal },
|
||||
);
|
||||
return extractContent(res);
|
||||
}
|
||||
|
||||
// listModels returns the model ids the caller may route to, from /v1/models via the
|
||||
// headless client. The endpoint is 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,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hanzo AI for Canvas</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="title">Hanzo AI</span>
|
||||
<select id="model" aria-label="Model"></select>
|
||||
<span class="host">Canvas</span>
|
||||
</header>
|
||||
|
||||
<div class="row scope">
|
||||
<select id="course" aria-label="Course"></select>
|
||||
<button id="load" class="secondary">Load</button>
|
||||
</div>
|
||||
<div class="row scope">
|
||||
<select id="assignment" aria-label="Assignment"></select>
|
||||
<button id="loadsubs" class="secondary" disabled>Load submissions</button>
|
||||
</div>
|
||||
<div class="records" id="records"></div>
|
||||
|
||||
<!-- One-click actions over the loaded course. Chips are built from the catalog. -->
|
||||
<div class="chips" id="chips"></div>
|
||||
|
||||
<label for="prompt">Ask about this course</label>
|
||||
<textarea id="prompt" placeholder="e.g. Which assignments are due next week? · Summarize what module 3 covers. · What are the common themes in the discussion posts?"></textarea>
|
||||
|
||||
<div class="row">
|
||||
<button id="run">Ask Hanzo</button>
|
||||
<button id="stop" class="secondary" disabled>Stop</button>
|
||||
<span class="spacer"></span>
|
||||
<button id="comment" class="secondary" disabled title="Load an assignment's submissions to enable">Post as submission comment</button>
|
||||
<button id="announce" class="secondary" disabled title="Load a course to enable">Post as announcement</button>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>Hanzo API key</summary>
|
||||
<div class="row">
|
||||
<input id="apikey" type="password" placeholder="hk-…" autocomplete="off" />
|
||||
<button id="savekey" class="secondary">Save</button>
|
||||
</div>
|
||||
<div id="authhint" class="hint"></div>
|
||||
</details>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<label for="output">Result</label>
|
||||
<textarea id="output" placeholder="Hanzo's draft appears here."></textarea>
|
||||
|
||||
<footer>Routed through api.hanzo.ai · grounded in your Canvas course records · a draft for an educator to review, not an authoritative grade or decision.</footer>
|
||||
|
||||
<script type="module" src="__ENTRY__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
// Public surface of @hanzo/canvas-lms: the pure, host-agnostic modules. The browser
|
||||
// panel (app.ts) and the Node service (server.ts) are entry points, not re-exported
|
||||
// here. A consumer embedding the course-analysis pipeline in their own service imports
|
||||
// from here.
|
||||
|
||||
export * from './config.js';
|
||||
export * from './canvas-oauth.js';
|
||||
export * from './canvas-api.js';
|
||||
export * from './course.js';
|
||||
export * from './hanzo.js';
|
||||
export * from './actions.js';
|
||||
@@ -0,0 +1,241 @@
|
||||
// Browser-side panel helpers — PURE where it counts (launch-context parsing +
|
||||
// proxy-request shaping), so the impure DOM wiring in app.ts stays thin and the logic
|
||||
// is unit-tested. The panel reaches Canvas ONLY through the server proxy (config:
|
||||
// same-origin /proxy/*), which injects the OAuth Bearer + refreshes it; the browser
|
||||
// never holds a Canvas token.
|
||||
|
||||
import {
|
||||
parseCourses,
|
||||
parseCourse,
|
||||
parseAssignments,
|
||||
parseAssignment,
|
||||
parseSubmissions,
|
||||
parseSubmission,
|
||||
parseDiscussions,
|
||||
parsePages,
|
||||
parsePage,
|
||||
parseModules,
|
||||
parseEnrollments,
|
||||
parseLinkHeader,
|
||||
nextPage,
|
||||
pageParams,
|
||||
type Course,
|
||||
type Assignment,
|
||||
type Submission,
|
||||
type Discussion,
|
||||
type Page_,
|
||||
type Module,
|
||||
type Enrollment,
|
||||
type Page,
|
||||
} from './canvas-api.js';
|
||||
|
||||
// The launch context: when Canvas embeds this app (via LTI or a course-nav link) it
|
||||
// passes the course in the URL query. The panel reads it so it opens already scoped.
|
||||
// Optional — without it the user picks from the course list.
|
||||
export interface LaunchContext {
|
||||
courseId: string;
|
||||
}
|
||||
|
||||
// parseLaunchContext reads a course id from a location search string, accepting the
|
||||
// plain `course_id` and Canvas's LTI custom-variable spellings. Pure — unit-tested
|
||||
// with plain strings.
|
||||
export function parseLaunchContext(search: string): LaunchContext {
|
||||
const q = new URLSearchParams(search);
|
||||
return {
|
||||
courseId:
|
||||
q.get('course_id') ??
|
||||
q.get('canvas_course_id') ??
|
||||
q.get('custom_canvas_course_id') ??
|
||||
q.get('courseId') ??
|
||||
'',
|
||||
};
|
||||
}
|
||||
|
||||
// A parsed list result: the typed items plus the page number to request next (from
|
||||
// the Link header's `rel="next"`), or undefined on the last page.
|
||||
export interface ListResult<T> {
|
||||
items: T[];
|
||||
next?: number;
|
||||
}
|
||||
|
||||
// The panel's Canvas client: reads through the same-origin server proxy. Every call
|
||||
// rides the session cookie (credentials: 'include'); the proxy injects the Bearer.
|
||||
// Pure over an injected fetch + base, so request shaping is unit-testable.
|
||||
export interface ProxyClient {
|
||||
listCourses(page?: Page): Promise<ListResult<Course>>;
|
||||
getCourse(courseId: number | string): Promise<Course>;
|
||||
listAssignments(courseId: number | string, page?: Page): Promise<ListResult<Assignment>>;
|
||||
getAssignment(courseId: number | string, assignmentId: number | string): Promise<Assignment>;
|
||||
listSubmissions(
|
||||
courseId: number | string,
|
||||
assignmentId: number | string,
|
||||
page?: Page,
|
||||
): Promise<ListResult<Submission>>;
|
||||
getSubmission(
|
||||
courseId: number | string,
|
||||
assignmentId: number | string,
|
||||
userId: number | string,
|
||||
): Promise<Submission>;
|
||||
listDiscussions(courseId: number | string, page?: Page): Promise<ListResult<Discussion>>;
|
||||
listPages(courseId: number | string, page?: Page): Promise<ListResult<Page_>>;
|
||||
getPage(courseId: number | string, urlOrId: number | string): Promise<Page_>;
|
||||
listModules(courseId: number | string, page?: Page): Promise<ListResult<Module>>;
|
||||
listEnrollments(courseId: number | string, page?: Page): Promise<ListResult<Enrollment>>;
|
||||
postSubmissionComment(
|
||||
courseId: number | string,
|
||||
assignmentId: number | string,
|
||||
userId: number | string,
|
||||
text: string,
|
||||
): Promise<void>;
|
||||
createAnnouncement(courseId: number | string, title: string, message: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ProxyClientOptions {
|
||||
/** Proxy base (defaults to same-origin ''). The REST path is appended after /proxy. */
|
||||
base?: string;
|
||||
/** Injected fetch (tests). Defaults to global fetch. */
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
// proxyUrl builds a same-origin proxy URL for a REST path + query, expanding array
|
||||
// values into Canvas's `key[]=v` repetition. Normalizes the base (drops a trailing
|
||||
// slash) so this is the ONE place base+path joining happens. Pure so tests assert the
|
||||
// exact shape (…/proxy/courses?per_page=100).
|
||||
export function proxyUrl(
|
||||
base: string,
|
||||
restPath: string,
|
||||
query: Record<string, string | string[] | undefined> = {},
|
||||
): string {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) if (item !== undefined && item !== '') q.append(`${k}[]`, item);
|
||||
} else if (v !== undefined && v !== '') {
|
||||
q.set(k, v);
|
||||
}
|
||||
}
|
||||
const qs = q.toString();
|
||||
return `${base.replace(/\/+$/, '')}/proxy${restPath}${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
|
||||
// createProxyClient returns a ProxyClient. The cookie credentials are attached once
|
||||
// here so every method stays a one-liner. List methods parse the Link header into the
|
||||
// next page number so a caller can page through the proxy without following Canvas's
|
||||
// absolute URLs.
|
||||
export function createProxyClient(opts: ProxyClientOptions = {}): ProxyClient {
|
||||
const base = opts.base ?? ''; // proxyUrl normalizes the trailing slash
|
||||
const doFetch = opts.fetch ?? fetch;
|
||||
|
||||
async function getJson(
|
||||
restPath: string,
|
||||
query: Record<string, string | string[] | undefined>,
|
||||
): Promise<{ data: any; next?: number }> {
|
||||
const resp = await doFetch(proxyUrl(base, restPath, query), { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error(await proxyError(resp));
|
||||
return { data: await resp.json(), next: nextPage(parseLinkHeader(resp.headers)) };
|
||||
}
|
||||
|
||||
async function write(restPath: string, method: 'POST' | 'PUT', body: unknown): Promise<void> {
|
||||
const resp = await doFetch(proxyUrl(base, restPath), {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await proxyError(resp));
|
||||
}
|
||||
|
||||
return {
|
||||
async listCourses(page?) {
|
||||
const { data, next } = await getJson('/courses', pageParams(page));
|
||||
return { items: parseCourses(data), next };
|
||||
},
|
||||
async getCourse(courseId) {
|
||||
const { data } = await getJson(`/courses/${enc(courseId)}`, { include: ['syllabus_body'] });
|
||||
return parseCourse(data);
|
||||
},
|
||||
async listAssignments(courseId, page?) {
|
||||
const { data, next } = await getJson(`/courses/${enc(courseId)}/assignments`, pageParams(page));
|
||||
return { items: parseAssignments(data), next };
|
||||
},
|
||||
async getAssignment(courseId, assignmentId) {
|
||||
const { data } = await getJson(
|
||||
`/courses/${enc(courseId)}/assignments/${enc(assignmentId)}`,
|
||||
{},
|
||||
);
|
||||
return parseAssignment(data);
|
||||
},
|
||||
async listSubmissions(courseId, assignmentId, page?) {
|
||||
const { data, next } = await getJson(
|
||||
`/courses/${enc(courseId)}/assignments/${enc(assignmentId)}/submissions`,
|
||||
{ include: ['submission_comments'], ...pageParams(page) },
|
||||
);
|
||||
return { items: parseSubmissions(data), next };
|
||||
},
|
||||
async getSubmission(courseId, assignmentId, userId) {
|
||||
const { data } = await getJson(
|
||||
`/courses/${enc(courseId)}/assignments/${enc(assignmentId)}/submissions/${enc(userId)}`,
|
||||
{ include: ['submission_comments'] },
|
||||
);
|
||||
return parseSubmission(data);
|
||||
},
|
||||
async listDiscussions(courseId, page?) {
|
||||
const { data, next } = await getJson(
|
||||
`/courses/${enc(courseId)}/discussion_topics`,
|
||||
pageParams(page),
|
||||
);
|
||||
return { items: parseDiscussions(data), next };
|
||||
},
|
||||
async listPages(courseId, page?) {
|
||||
const { data, next } = await getJson(`/courses/${enc(courseId)}/pages`, pageParams(page));
|
||||
return { items: parsePages(data), next };
|
||||
},
|
||||
async getPage(courseId, urlOrId) {
|
||||
const { data } = await getJson(`/courses/${enc(courseId)}/pages/${enc(urlOrId)}`, {});
|
||||
return parsePage(data);
|
||||
},
|
||||
async listModules(courseId, page?) {
|
||||
const { data, next } = await getJson(`/courses/${enc(courseId)}/modules`, {
|
||||
include: ['items'],
|
||||
...pageParams(page),
|
||||
});
|
||||
return { items: parseModules(data), next };
|
||||
},
|
||||
async listEnrollments(courseId, page?) {
|
||||
const { data, next } = await getJson(
|
||||
`/courses/${enc(courseId)}/enrollments`,
|
||||
pageParams(page),
|
||||
);
|
||||
return { items: parseEnrollments(data), next };
|
||||
},
|
||||
async postSubmissionComment(courseId, assignmentId, userId, text) {
|
||||
await write(
|
||||
`/courses/${enc(courseId)}/assignments/${enc(assignmentId)}/submissions/${enc(userId)}`,
|
||||
'PUT',
|
||||
{ comment: { text_comment: text } },
|
||||
);
|
||||
},
|
||||
async createAnnouncement(courseId, title, message) {
|
||||
await write(`/courses/${enc(courseId)}/discussion_topics`, 'POST', {
|
||||
title,
|
||||
message,
|
||||
is_announcement: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// proxyError extracts a readable message from a non-2xx proxy response.
|
||||
async function proxyError(resp: Response): Promise<string> {
|
||||
const text = await resp.text().catch(() => '');
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
return `Canvas proxy ${resp.status}: ${j?.error || j?.message || text.slice(0, 200)}`;
|
||||
} catch {
|
||||
return `Canvas proxy ${resp.status}: ${text.slice(0, 200) || 'request failed'}`;
|
||||
}
|
||||
}
|
||||
|
||||
function enc(segment: number | string): string {
|
||||
return encodeURIComponent(String(segment));
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// The Canvas install + API-proxy service. This is the ONLY place the Canvas client
|
||||
// secret and the OAuth tokens exist — the secret is read from the environment (never
|
||||
// bundled, never sent to the browser); tokens are minted by the OAuth flow and kept
|
||||
// server-side, refreshed transparently on expiry. It:
|
||||
//
|
||||
// GET /oauth/install → redirect the user to Canvas's OAuth consent
|
||||
// GET /oauth/callback → exchange the code for a token, open a session, and hand
|
||||
// the browser a session cookie
|
||||
// ALL /proxy/* → forward a browser request to the Canvas REST API with
|
||||
// the session's Bearer token, refreshing the token first
|
||||
// if it has expired. The browser never sees the token or
|
||||
// the secret.
|
||||
// GET /healthz → readiness
|
||||
//
|
||||
// It is a dependency-free Node http handler built on the pure modules (config,
|
||||
// canvas-oauth, canvas-api) so it is deployable behind hanzoai/ingress as a small
|
||||
// service at canvas.hanzo.ai. The pure logic it wraps is what the tests cover; the
|
||||
// http server is an integration concern.
|
||||
//
|
||||
// node dist/server.js (after build.js bundles it)
|
||||
//
|
||||
// Required environment (see config.readServerConfig):
|
||||
// CANVAS_HOST, CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REDIRECT_URI
|
||||
// PORT (default 8791)
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { apiBaseUrl, readServerConfig, OAUTH_SCOPES, type ServerConfig } from './config.js';
|
||||
import {
|
||||
authorizeUrl,
|
||||
tokenExchange,
|
||||
refreshExchange,
|
||||
parseTokenResponse,
|
||||
carryRefreshToken,
|
||||
isExpired,
|
||||
type CanvasTokenSet,
|
||||
} from './canvas-oauth.js';
|
||||
|
||||
// A server-side session: the tokens for one authorized user + when the token was
|
||||
// minted (Canvas does not return a mint timestamp, so we record wall-clock at
|
||||
// mint/refresh to compute expiry). In-memory here keyed by an opaque session id
|
||||
// carried in an HttpOnly cookie; a production deployment persists this to hanzoai/kv
|
||||
// (Valkey) so sessions survive a restart.
|
||||
interface Session {
|
||||
token: CanvasTokenSet;
|
||||
/** Wall-clock epoch seconds when the token was last minted/refreshed. */
|
||||
mintedAt: number;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
const SESSION_COOKIE = 'canvas_sid';
|
||||
|
||||
// A pending OAuth `state` (CSRF token) minted at /oauth/install and consumed at
|
||||
// /oauth/callback. A production deployment persists these (Valkey, short TTL); a Set
|
||||
// is enough for a single instance.
|
||||
const pendingStates = new Set<string>();
|
||||
|
||||
function nowSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function log(fields: Record<string, unknown>): void {
|
||||
console.log(JSON.stringify(fields));
|
||||
}
|
||||
|
||||
// sessionIdFromCookie reads the session id from the Cookie header. Minimal parser — we
|
||||
// only need our one cookie.
|
||||
function sessionIdFromCookie(req: IncomingMessage): string | undefined {
|
||||
const raw = req.headers.cookie;
|
||||
if (!raw) return undefined;
|
||||
for (const part of raw.split(';')) {
|
||||
const [k, ...v] = part.trim().split('=');
|
||||
if (k === SESSION_COOKIE) return decodeURIComponent(v.join('='));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// GET /oauth/install → 302 to Canvas's consent screen. `state` is a fresh CSRF token
|
||||
// re-checked on callback.
|
||||
function handleInstall(cfg: ServerConfig, res: ServerResponse): void {
|
||||
const state = randomUUID();
|
||||
pendingStates.add(state);
|
||||
const url = authorizeUrl({
|
||||
host: cfg.canvasHost,
|
||||
clientId: cfg.canvasClientId,
|
||||
redirectUri: cfg.canvasRedirectUri,
|
||||
scopes: OAUTH_SCOPES,
|
||||
state,
|
||||
});
|
||||
res.writeHead(302, { Location: url });
|
||||
res.end();
|
||||
}
|
||||
|
||||
// GET /oauth/callback?code=…&state=… → verify state, exchange the code for a token
|
||||
// (secret in the server-side body), open a session, and set the cookie.
|
||||
async function handleOAuthCallback(
|
||||
cfg: ServerConfig,
|
||||
url: URL,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
if (!code) return json(res, 400, { error: 'missing code' });
|
||||
if (!state || !pendingStates.delete(state)) return json(res, 400, { error: 'invalid state' });
|
||||
|
||||
const exReq = tokenExchange({
|
||||
host: cfg.canvasHost,
|
||||
clientId: cfg.canvasClientId,
|
||||
clientSecret: cfg.canvasClientSecret,
|
||||
code,
|
||||
redirectUri: cfg.canvasRedirectUri,
|
||||
});
|
||||
let token: CanvasTokenSet;
|
||||
try {
|
||||
const resp = await fetch(exReq.url, {
|
||||
method: 'POST',
|
||||
headers: exReq.headers,
|
||||
body: exReq.body,
|
||||
});
|
||||
token = parseTokenResponse(await resp.json().catch(() => ({})));
|
||||
} catch (e: any) {
|
||||
return json(res, 400, { error: e?.message || 'token exchange failed' });
|
||||
}
|
||||
|
||||
const sid = randomUUID();
|
||||
sessions.set(sid, { token, mintedAt: nowSeconds() });
|
||||
log({ msg: 'oauth: session opened', sid });
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': `${SESSION_COOKIE}=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/`,
|
||||
});
|
||||
res.end(JSON.stringify({ ok: true, installed: true }));
|
||||
}
|
||||
|
||||
// freshToken returns a session's access token, refreshing first if it has expired.
|
||||
// Canvas does not rotate the refresh token (the refresh response omits it), so we
|
||||
// carry the previous one forward via carryRefreshToken. Throws if the refresh fails
|
||||
// (the caller returns 401 and the user re-installs).
|
||||
async function freshToken(cfg: ServerConfig, sid: string, session: Session): Promise<string> {
|
||||
if (!isExpired(session.token, session.mintedAt, nowSeconds())) return session.token.access_token;
|
||||
const refreshToken = session.token.refresh_token;
|
||||
if (!refreshToken) throw new Error('session expired and no refresh token');
|
||||
const rReq = refreshExchange({
|
||||
host: cfg.canvasHost,
|
||||
clientId: cfg.canvasClientId,
|
||||
clientSecret: cfg.canvasClientSecret,
|
||||
refreshToken,
|
||||
});
|
||||
const resp = await fetch(rReq.url, { method: 'POST', headers: rReq.headers, body: rReq.body });
|
||||
const next = carryRefreshToken(parseTokenResponse(await resp.json().catch(() => ({}))), session.token);
|
||||
sessions.set(sid, { token: next, mintedAt: nowSeconds() });
|
||||
log({ msg: 'oauth: token refreshed', sid });
|
||||
return next.access_token;
|
||||
}
|
||||
|
||||
// ALL /proxy/* → forward to the Canvas REST API. The browser sends the REST path
|
||||
// (after /proxy); the server injects the Bearer token (never exposed to the browser)
|
||||
// and forwards the method, query, and body. This is the API proxy that keeps the
|
||||
// secret + tokens server-side while letting the panel read Courses/Assignments/…and
|
||||
// post a comment or announcement.
|
||||
async function handleProxy(
|
||||
cfg: ServerConfig,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
): Promise<void> {
|
||||
const sid = sessionIdFromCookie(req);
|
||||
const session = sid ? sessions.get(sid) : undefined;
|
||||
if (!sid || !session) return json(res, 401, { error: 'not authenticated' });
|
||||
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await freshToken(cfg, sid, session);
|
||||
} catch (e: any) {
|
||||
return json(res, 401, { error: e?.message || 'token refresh failed' });
|
||||
}
|
||||
|
||||
// The path after /proxy is the REST path relative to /api/v1, e.g.
|
||||
// /proxy/courses → ${apiBase}/courses. We forbid absolute/scheme-bearing targets so
|
||||
// the proxy can only ever reach the configured Canvas REST host.
|
||||
const restPath = url.pathname.replace(/^\/proxy/, '') || '/';
|
||||
const target = `${apiBaseUrl(cfg.canvasHost)}${restPath}${url.search}`;
|
||||
|
||||
const method = req.method || 'GET';
|
||||
const body = method === 'GET' || method === 'HEAD' ? undefined : await readRawBody(req);
|
||||
try {
|
||||
const upstream = await fetch(target, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
// Pass Canvas's Link header through so the panel can paginate.
|
||||
const link = upstream.headers.get('Link');
|
||||
const text = await upstream.text();
|
||||
const outHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (link) outHeaders.Link = link;
|
||||
res.writeHead(upstream.status, outHeaders);
|
||||
res.end(text);
|
||||
} catch (e: any) {
|
||||
log({ msg: 'proxy: upstream error', error: e?.message, target });
|
||||
return json(res, 502, { error: 'upstream request failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// readRawBody collects the raw request bytes as a utf8 string.
|
||||
async function readRawBody(req: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of req) chunks.push(c as Buffer);
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
// createHandler is the request router. Everything is a small handler over the pure
|
||||
// modules. Exported so a test can drive it with a mock req/res if desired.
|
||||
export function createHandler(cfg: ServerConfig) {
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/oauth/install') return handleInstall(cfg, res);
|
||||
if (req.method === 'GET' && url.pathname === '/oauth/callback')
|
||||
return await handleOAuthCallback(cfg, url, res);
|
||||
if (url.pathname === '/proxy' || url.pathname.startsWith('/proxy/'))
|
||||
return await handleProxy(cfg, req, res, url);
|
||||
if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
|
||||
return json(res, 404, { error: 'not found' });
|
||||
} catch (e: any) {
|
||||
log({ msg: 'unhandled error', error: e?.message });
|
||||
return json(res, 500, { error: 'internal error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// main boots the server when run directly. Import-safe: only the direct entry
|
||||
// listens, so tests import the handlers without opening a port.
|
||||
export function main(): void {
|
||||
const cfg = readServerConfig(process.env);
|
||||
const server = createServer(createHandler(cfg));
|
||||
server.listen(cfg.port, () => {
|
||||
log({ msg: 'hanzo canvas service up', port: cfg.port, host: cfg.canvasHost });
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--fg: #1a1a1a; --muted: #666; --bg: #fff; --line: #e3e3e3; --accent: #111;
|
||||
--ok: #1a7f37; --warn: #9a6700; --error: #cf222e;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fg: #eaeaea; --muted: #9a9a9a; --bg: #1e1e1e; --line: #333; --accent: #fff;
|
||||
--ok: #3fb950; --warn: #d29922; --error: #f85149;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: var(--fg); background: var(--bg); margin: 0 auto; padding: 14px;
|
||||
max-width: 720px;
|
||||
}
|
||||
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
header .title { font-weight: 600; font-size: 15px; }
|
||||
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
|
||||
textarea, select, input {
|
||||
width: 100%; font: inherit; color: var(--fg); background: var(--bg);
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 8px;
|
||||
}
|
||||
textarea#prompt { min-height: 56px; resize: vertical; }
|
||||
textarea#output { min-height: 220px; resize: vertical; }
|
||||
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
|
||||
.row.scope { margin-top: 4px; }
|
||||
.row.scope select { flex: 1; min-width: 160px; }
|
||||
button {
|
||||
font: inherit; border: 1px solid var(--line); background: var(--accent);
|
||||
color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer;
|
||||
}
|
||||
button.secondary { background: transparent; color: var(--fg); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
.spacer { flex: 1; }
|
||||
.records { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
|
||||
.status { min-height: 18px; font-size: 12px; margin-top: 10px; color: var(--muted); }
|
||||
.status.ok { color: var(--ok); } .status.warn { color: var(--warn); } .status.error { color: var(--error); }
|
||||
.hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||
footer { margin-top: 12px; font-size: 11px; color: var(--muted); }
|
||||
select#model { width: auto; max-width: 150px; padding: 4px 8px; font-size: 12px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.chip {
|
||||
padding: 5px 10px; border-radius: 999px; font-size: 13px;
|
||||
background: transparent; color: var(--fg); border: 1px solid var(--line);
|
||||
}
|
||||
.chip:hover:not(:disabled) { border-color: var(--accent); }
|
||||
details summary { cursor: pointer; font-size: 12px; color: var(--muted); margin-top: 10px; }
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { AiClient, ChatCompletion, ChatCompletionCreateParams } from '@hanzo/ai';
|
||||
import { ACTIONS, actionList, actionPrompt, isActionId, runAction } from '../src/actions.js';
|
||||
import { buildCourseContext } from '../src/course.js';
|
||||
|
||||
const ctx = buildCourseContext([{ title: 'Assignments', blocks: ['Assignment: Lab Report 1'] }]);
|
||||
|
||||
function mockClient(text: string) {
|
||||
const reply: ChatCompletion = {
|
||||
id: 'c', object: 'chat.completion', created: 0, model: 'zen5',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
|
||||
};
|
||||
const create = vi.fn(async (_p: ChatCompletionCreateParams) => reply);
|
||||
return { client: { chat: { completions: { create } }, models: { list: vi.fn() } } as unknown as AiClient, create };
|
||||
}
|
||||
|
||||
describe('actions: catalog', () => {
|
||||
it('exposes exactly the five documented actions', () => {
|
||||
expect(Object.keys(ACTIONS).sort()).toEqual(
|
||||
['draftAnnouncement', 'draftFeedback', 'generateQuestions', 'summarizeCourse', 'summarizeSubmissions'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('every action has a non-empty label and prompt', () => {
|
||||
for (const [id, a] of Object.entries(ACTIONS)) {
|
||||
expect(a.label.length, id).toBeGreaterThan(0);
|
||||
expect(a.prompt.length, id).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('actionList mirrors the catalog order and shape', () => {
|
||||
const list = actionList();
|
||||
expect(list.map((a) => a.id)).toEqual(Object.keys(ACTIONS));
|
||||
expect(list[0]).toEqual({ id: 'summarizeCourse', label: ACTIONS.summarizeCourse.label });
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions: prompt intent', () => {
|
||||
it('summarizeCourse grounds on the syllabus and modules', () => {
|
||||
const p = actionPrompt('summarizeCourse');
|
||||
expect(p).toMatch(/syllabus/i);
|
||||
expect(p).toMatch(/module/i);
|
||||
});
|
||||
it('draftFeedback stays grounded and returns paste-ready comment text', () => {
|
||||
const p = actionPrompt('draftFeedback');
|
||||
expect(p).toMatch(/do not invent/i);
|
||||
expect(p).toMatch(/submission comment/i);
|
||||
});
|
||||
it('generateQuestions asks for quiz questions and discussion prompts', () => {
|
||||
const p = actionPrompt('generateQuestions');
|
||||
expect(p).toMatch(/quiz questions/i);
|
||||
expect(p).toMatch(/discussion prompts/i);
|
||||
});
|
||||
it('summarizeSubmissions asks for submitted/missing/late and scores', () => {
|
||||
const p = actionPrompt('summarizeSubmissions');
|
||||
expect(p).toMatch(/missing or late/i);
|
||||
expect(p).toMatch(/scores or grades/i);
|
||||
});
|
||||
it('draftAnnouncement asks for a subject line and stays grounded on dates', () => {
|
||||
const p = actionPrompt('draftAnnouncement');
|
||||
expect(p).toMatch(/subject line/i);
|
||||
expect(p).toMatch(/do not invent dates/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions: isActionId / actionPrompt guards', () => {
|
||||
it('narrows known ids and rejects unknown', () => {
|
||||
expect(isActionId('summarizeCourse')).toBe(true);
|
||||
expect(isActionId('nope')).toBe(false);
|
||||
});
|
||||
it('actionPrompt throws on an unknown id', () => {
|
||||
expect(() => actionPrompt('nope')).toThrow(/Unknown action/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions: runAction', () => {
|
||||
it('routes the resolved prompt through ask (one code path to the model)', async () => {
|
||||
const { client, create } = mockClient('overview');
|
||||
const out = await runAction('summarizeCourse', ctx, { name: 'BIO-101' }, { client });
|
||||
expect(out).toBe('overview');
|
||||
const user = String(create.mock.calls[0][0].messages[1].content);
|
||||
expect(user).toContain(ACTIONS.summarizeCourse.prompt);
|
||||
expect(user).toContain('Assignment: Lab Report 1');
|
||||
});
|
||||
|
||||
it('rejects (not synchronously throws) on an unknown id', async () => {
|
||||
await expect(runAction('nope', ctx)).rejects.toThrow(/Unknown action/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEFAULT_PER_PAGE,
|
||||
pageParams,
|
||||
parseLinkHeader,
|
||||
pageOfUrl,
|
||||
nextPage,
|
||||
listCourses,
|
||||
getCourse,
|
||||
listAssignments,
|
||||
getAssignment,
|
||||
listSubmissions,
|
||||
getSubmission,
|
||||
postSubmissionComment,
|
||||
listDiscussions,
|
||||
createAnnouncement,
|
||||
listPages,
|
||||
getPage,
|
||||
listModules,
|
||||
listEnrollments,
|
||||
type Scope,
|
||||
} from '../src/canvas-api.js';
|
||||
import { apiBaseUrl } from '../src/config.js';
|
||||
|
||||
const scope: Scope = {
|
||||
apiBase: apiBaseUrl('school.instructure.com'),
|
||||
accessToken: 'at-123',
|
||||
};
|
||||
|
||||
function q(url: string): URLSearchParams {
|
||||
return new URL(url).searchParams;
|
||||
}
|
||||
|
||||
describe('api: pageParams', () => {
|
||||
it('emits nothing for no page', () => {
|
||||
expect(pageParams(undefined)).toEqual({});
|
||||
});
|
||||
it('emits page + per_page when set', () => {
|
||||
expect(pageParams({ page: 2, perPage: 50 })).toEqual({ page: '2', per_page: '50' });
|
||||
});
|
||||
it('clamps per_page to the documented max', () => {
|
||||
expect(pageParams({ perPage: 500 }).per_page).toBe(String(DEFAULT_PER_PAGE));
|
||||
});
|
||||
it('ignores non-positive values', () => {
|
||||
expect(pageParams({ page: 0, perPage: -5 })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: bearer scoping', () => {
|
||||
it('every read carries the Bearer token and no company header', () => {
|
||||
for (const req of [
|
||||
listCourses(scope),
|
||||
getCourse(scope, 7),
|
||||
listAssignments(scope, 7),
|
||||
getAssignment(scope, 7, 9),
|
||||
listSubmissions(scope, 7, 9),
|
||||
getSubmission(scope, 7, 9, 11),
|
||||
listDiscussions(scope, 7),
|
||||
listPages(scope, 7),
|
||||
getPage(scope, 7, 'intro'),
|
||||
listModules(scope, 7),
|
||||
listEnrollments(scope, 7),
|
||||
]) {
|
||||
expect(req.headers.Authorization).toBe('Bearer at-123');
|
||||
expect(req.headers).not.toHaveProperty('Procore-Company-Id');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: courses', () => {
|
||||
it('lists courses with pagination and no course in the path', () => {
|
||||
const req = listCourses(scope, { page: 3, perPage: 25 });
|
||||
const url = new URL(req.url);
|
||||
expect(url.pathname).toBe('/api/v1/courses');
|
||||
expect(q(req.url).get('page')).toBe('3');
|
||||
expect(q(req.url).get('per_page')).toBe('25');
|
||||
});
|
||||
it('gets one course defaulting to include[]=syllabus_body', () => {
|
||||
const req = getCourse(scope, 100);
|
||||
expect(new URL(req.url).pathname).toBe('/api/v1/courses/100');
|
||||
expect(q(req.url).getAll('include[]')).toEqual(['syllabus_body']);
|
||||
});
|
||||
it('gets a bare course when includes are empty', () => {
|
||||
const req = getCourse(scope, 100, []);
|
||||
expect(q(req.url).has('include[]')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: assignments + submissions', () => {
|
||||
it('lists assignments on a course path', () => {
|
||||
expect(new URL(listAssignments(scope, 100).url).pathname).toBe('/api/v1/courses/100/assignments');
|
||||
});
|
||||
it('gets one assignment by id', () => {
|
||||
expect(new URL(getAssignment(scope, 100, 55).url).pathname).toBe('/api/v1/courses/100/assignments/55');
|
||||
});
|
||||
it('lists submissions with submission_comments included', () => {
|
||||
const req = listSubmissions(scope, 100, 55, { perPage: 50 });
|
||||
expect(new URL(req.url).pathname).toBe('/api/v1/courses/100/assignments/55/submissions');
|
||||
expect(q(req.url).getAll('include[]')).toEqual(['submission_comments']);
|
||||
expect(q(req.url).get('per_page')).toBe('50');
|
||||
});
|
||||
it('gets one submission by user id', () => {
|
||||
const req = getSubmission(scope, 100, 55, 900);
|
||||
expect(new URL(req.url).pathname).toBe('/api/v1/courses/100/assignments/55/submissions/900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: postSubmissionComment (the feedback write path)', () => {
|
||||
it('PUTs a comment[text_comment] JSON body on the submission', () => {
|
||||
const req = postSubmissionComment(scope, 100, 55, 900, 'Great analysis of the data.');
|
||||
expect(req.method).toBe('PUT');
|
||||
expect(new URL(req.url).pathname).toBe('/api/v1/courses/100/assignments/55/submissions/900');
|
||||
expect(req.headers['Content-Type']).toBe('application/json');
|
||||
expect(req.headers.Authorization).toBe('Bearer at-123');
|
||||
expect(JSON.parse(req.body)).toEqual({ comment: { text_comment: 'Great analysis of the data.' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: createAnnouncement (the announcement write path)', () => {
|
||||
it('POSTs a discussion topic flagged is_announcement', () => {
|
||||
const req = createAnnouncement(scope, 100, 'Midterm moved', 'The midterm is now next Friday.');
|
||||
expect(req.method).toBe('POST');
|
||||
expect(new URL(req.url).pathname).toBe('/api/v1/courses/100/discussion_topics');
|
||||
expect(JSON.parse(req.body)).toEqual({
|
||||
title: 'Midterm moved',
|
||||
message: 'The midterm is now next Friday.',
|
||||
is_announcement: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: discussions / pages / modules / enrollments', () => {
|
||||
it('lists discussions on a course path', () => {
|
||||
expect(new URL(listDiscussions(scope, 100).url).pathname).toBe('/api/v1/courses/100/discussion_topics');
|
||||
});
|
||||
it('lists pages and gets one page by url slug', () => {
|
||||
expect(new URL(listPages(scope, 100).url).pathname).toBe('/api/v1/courses/100/pages');
|
||||
expect(new URL(getPage(scope, 100, 'week-1-intro').url).pathname).toBe('/api/v1/courses/100/pages/week-1-intro');
|
||||
});
|
||||
it('lists modules with items included', () => {
|
||||
const req = listModules(scope, 100);
|
||||
expect(new URL(req.url).pathname).toBe('/api/v1/courses/100/modules');
|
||||
expect(q(req.url).getAll('include[]')).toEqual(['items']);
|
||||
});
|
||||
it('lists enrollments on a course path', () => {
|
||||
expect(new URL(listEnrollments(scope, 100).url).pathname).toBe('/api/v1/courses/100/enrollments');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: path segment encoding', () => {
|
||||
it('encodes ids/slugs defensively so a stray value cannot traverse the path', () => {
|
||||
const req = getPage(scope, 'a/b', 'c d');
|
||||
expect(req.url).toContain('/courses/a%2Fb/pages/c%20d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: parseLinkHeader (RFC 5988)', () => {
|
||||
const header =
|
||||
'<https://school.instructure.com/api/v1/courses?page=1&per_page=10>; rel="current", ' +
|
||||
'<https://school.instructure.com/api/v1/courses?page=2&per_page=10>; rel="next", ' +
|
||||
'<https://school.instructure.com/api/v1/courses?page=1&per_page=10>; rel="first", ' +
|
||||
'<https://school.instructure.com/api/v1/courses?page=17&per_page=10>; rel="last"';
|
||||
|
||||
it('parses each relation URL from a Headers instance', () => {
|
||||
const links = parseLinkHeader(new Headers({ Link: header }));
|
||||
expect(links.current).toContain('page=1');
|
||||
expect(links.next).toBe('https://school.instructure.com/api/v1/courses?page=2&per_page=10');
|
||||
expect(links.first).toContain('page=1');
|
||||
expect(links.last).toContain('page=17');
|
||||
});
|
||||
|
||||
it('parses from a plain header map (either case)', () => {
|
||||
expect(parseLinkHeader({ Link: header }).next).toContain('page=2');
|
||||
expect(parseLinkHeader({ link: header }).last).toContain('page=17');
|
||||
});
|
||||
|
||||
it('returns {} for a missing/blank header', () => {
|
||||
expect(parseLinkHeader({})).toEqual({});
|
||||
expect(parseLinkHeader(new Headers())).toEqual({});
|
||||
});
|
||||
|
||||
it('tolerates a last-page header with no next relation', () => {
|
||||
const last =
|
||||
'<https://school.instructure.com/api/v1/courses?page=16&per_page=10>; rel="prev", ' +
|
||||
'<https://school.instructure.com/api/v1/courses?page=17&per_page=10>; rel="current"';
|
||||
const links = parseLinkHeader({ Link: last });
|
||||
expect(links.next).toBeUndefined();
|
||||
expect(links.prev).toContain('page=16');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: pageOfUrl + nextPage', () => {
|
||||
it('extracts the page number from a pagination URL', () => {
|
||||
expect(pageOfUrl('https://h/api/v1/courses?page=2&per_page=10')).toBe(2);
|
||||
expect(pageOfUrl('https://h/api/v1/courses?per_page=10')).toBeUndefined();
|
||||
expect(pageOfUrl(undefined)).toBeUndefined();
|
||||
});
|
||||
it('nextPage reads the next relation into a page number', () => {
|
||||
expect(nextPage({ next: 'https://h/api/v1/courses?page=3' })).toBe(3);
|
||||
expect(nextPage({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
HANZO_API_BASE_URL,
|
||||
DEFAULT_MODEL,
|
||||
APIKEY_STORAGE_KEY,
|
||||
COURSE_CHAR_BUDGET,
|
||||
pickBearer,
|
||||
chatCompletionsURL,
|
||||
modelsURL,
|
||||
normalizeHost,
|
||||
authBase,
|
||||
apiBaseUrl,
|
||||
readServerConfig,
|
||||
OAUTH_SCOPES,
|
||||
RECOMMENDED_SCOPES,
|
||||
} from '../src/config.js';
|
||||
|
||||
describe('config: gateway constants', () => {
|
||||
it('points at api.hanzo.ai with /v1 paths and 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 and a sane budget', () => {
|
||||
expect(DEFAULT_MODEL).toBe('zen5');
|
||||
expect(COURSE_CHAR_BUDGET).toBeGreaterThan(10_000);
|
||||
expect(APIKEY_STORAGE_KEY).toContain('canvas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: pickBearer', () => {
|
||||
it('prefers a pasted key over an oauth token', () => {
|
||||
expect(pickBearer('hk-abc', 'oauth-xyz')).toBe('hk-abc');
|
||||
});
|
||||
it('falls back to the oauth token, trimming whitespace', () => {
|
||||
expect(pickBearer(' ', ' tok ')).toBe('tok');
|
||||
});
|
||||
it('returns empty when both are blank (anonymous)', () => {
|
||||
expect(pickBearer('', '')).toBe('');
|
||||
expect(pickBearer(' ', '')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: normalizeHost', () => {
|
||||
it('strips a scheme, path, and trailing slash to a bare host', () => {
|
||||
expect(normalizeHost('https://school.instructure.com/')).toBe('school.instructure.com');
|
||||
expect(normalizeHost('http://canvas.school.edu/courses/1')).toBe('canvas.school.edu');
|
||||
expect(normalizeHost('school.instructure.com')).toBe('school.instructure.com');
|
||||
expect(normalizeHost(' school.instructure.com ')).toBe('school.instructure.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: authBase + apiBaseUrl', () => {
|
||||
it('builds the OAuth login origin from a host', () => {
|
||||
expect(authBase('school.instructure.com')).toBe('https://school.instructure.com');
|
||||
expect(authBase('https://school.instructure.com/')).toBe('https://school.instructure.com');
|
||||
});
|
||||
it('builds the /api/v1 REST root from a host', () => {
|
||||
expect(apiBaseUrl('school.instructure.com')).toBe('https://school.instructure.com/api/v1');
|
||||
expect(apiBaseUrl('canvas.school.edu')).toBe('https://canvas.school.edu/api/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: scopes', () => {
|
||||
it('requests no scopes by default (works with or without scope enforcement)', () => {
|
||||
expect(OAUTH_SCOPES).toEqual([]);
|
||||
});
|
||||
it('documents the granular read + write scopes for enforcement', () => {
|
||||
expect(RECOMMENDED_SCOPES.length).toBeGreaterThan(5);
|
||||
expect(RECOMMENDED_SCOPES).toContain('url:GET|/api/v1/courses');
|
||||
expect(RECOMMENDED_SCOPES.some((s) => s.startsWith('url:PUT|'))).toBe(true);
|
||||
expect(RECOMMENDED_SCOPES.some((s) => s.startsWith('url:POST|'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config: readServerConfig', () => {
|
||||
const base = {
|
||||
CANVAS_HOST: 'school.instructure.com',
|
||||
CANVAS_CLIENT_ID: 'cid',
|
||||
CANVAS_CLIENT_SECRET: 'secret',
|
||||
CANVAS_REDIRECT_URI: 'https://canvas.hanzo.ai/oauth/callback',
|
||||
};
|
||||
|
||||
it('reads a full config, normalizing the host and defaulting the port to 8791', () => {
|
||||
const cfg = readServerConfig({ ...base, CANVAS_HOST: 'https://school.instructure.com/' });
|
||||
expect(cfg.canvasHost).toBe('school.instructure.com');
|
||||
expect(cfg.canvasClientId).toBe('cid');
|
||||
expect(cfg.canvasClientSecret).toBe('secret');
|
||||
expect(cfg.canvasRedirectUri).toBe('https://canvas.hanzo.ai/oauth/callback');
|
||||
expect(cfg.port).toBe(8791);
|
||||
});
|
||||
|
||||
it('honours PORT', () => {
|
||||
expect(readServerConfig({ ...base, PORT: '9002' }).port).toBe(9002);
|
||||
});
|
||||
|
||||
it('throws listing every missing required value', () => {
|
||||
expect(() => readServerConfig({})).toThrow(/CANVAS_HOST/);
|
||||
expect(() => readServerConfig({})).toThrow(/CANVAS_CLIENT_ID/);
|
||||
expect(() => readServerConfig({})).toThrow(/CANVAS_CLIENT_SECRET/);
|
||||
expect(() => readServerConfig({})).toThrow(/CANVAS_REDIRECT_URI/);
|
||||
expect(() => readServerConfig({ CANVAS_HOST: 'h' })).toThrow(
|
||||
/CANVAS_CLIENT_ID, CANVAS_CLIENT_SECRET, CANVAS_REDIRECT_URI/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
htmlToText,
|
||||
renderCourseOverview,
|
||||
renderAssignment,
|
||||
renderSubmission,
|
||||
renderDiscussion,
|
||||
renderPage,
|
||||
renderModule,
|
||||
renderEnrollment,
|
||||
buildCourseContext,
|
||||
contextNote,
|
||||
courseSection,
|
||||
moduleSection,
|
||||
assignmentSection,
|
||||
pageSection,
|
||||
discussionSection,
|
||||
submissionSection,
|
||||
enrollmentSection,
|
||||
} from '../src/course.js';
|
||||
import type {
|
||||
Course,
|
||||
Assignment,
|
||||
Submission,
|
||||
Discussion,
|
||||
Page_,
|
||||
Module,
|
||||
Enrollment,
|
||||
} from '../src/canvas-api.js';
|
||||
|
||||
describe('course: htmlToText', () => {
|
||||
it('strips tags, breaks blocks, and decodes entities', () => {
|
||||
const html = '<h2>Syllabus</h2><p>Read & annotate ch. 1.</p><ul><li>Quiz</li><li>Lab</li></ul>';
|
||||
const text = htmlToText(html);
|
||||
expect(text).toContain('Syllabus');
|
||||
expect(text).toContain('Read & annotate ch. 1.');
|
||||
expect(text).toContain('• Quiz');
|
||||
expect(text).toContain('• Lab');
|
||||
expect(text).not.toContain('<');
|
||||
});
|
||||
it('decodes numeric entities and collapses whitespace', () => {
|
||||
expect(htmlToText('a'b')).toBe("a'b");
|
||||
expect(htmlToText('x y')).toBe('x y');
|
||||
});
|
||||
it('returns empty for empty/whitespace input', () => {
|
||||
expect(htmlToText('')).toBe('');
|
||||
expect(htmlToText('<p> </p>')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
const course: Course = {
|
||||
id: 1,
|
||||
name: 'Introduction to Biology',
|
||||
courseCode: 'BIO-101',
|
||||
workflowState: 'available',
|
||||
syllabusBody: '<p>Welcome to <strong>BIO-101</strong>. Weekly labs.</p>',
|
||||
termName: 'Fall 2026',
|
||||
};
|
||||
|
||||
describe('course: record rendering', () => {
|
||||
it('renders a course overview with the stripped syllabus', () => {
|
||||
const text = renderCourseOverview(course);
|
||||
expect(text).toContain('Introduction to Biology (BIO-101)');
|
||||
expect(text).toContain('Term: Fall 2026');
|
||||
expect(text).toContain('Syllabus:');
|
||||
expect(text).toContain('Welcome to BIO-101. Weekly labs.');
|
||||
expect(text).not.toContain('<strong>');
|
||||
});
|
||||
|
||||
it('renders an assignment with meta and stripped description', () => {
|
||||
const a: Assignment = {
|
||||
id: 1,
|
||||
name: 'Lab Report 1',
|
||||
description: '<p>Write up the osmosis lab.</p>',
|
||||
dueAt: '2026-09-15',
|
||||
pointsPossible: 25,
|
||||
submissionTypes: ['online_upload'],
|
||||
};
|
||||
const text = renderAssignment(a);
|
||||
expect(text).toContain('Assignment: Lab Report 1');
|
||||
expect(text).toContain('Due: 2026-09-15 · Points: 25 · Submit: online_upload');
|
||||
expect(text).toContain('Write up the osmosis lab.');
|
||||
});
|
||||
|
||||
it('renders a submission with score, flags, and comments', () => {
|
||||
const s: Submission = {
|
||||
id: 5001,
|
||||
userId: 900,
|
||||
assignmentId: 1,
|
||||
body: '<p>Water moves across the membrane.</p>',
|
||||
submittedAt: '2026-09-14',
|
||||
workflowState: 'submitted',
|
||||
score: 22,
|
||||
grade: '22',
|
||||
late: true,
|
||||
missing: false,
|
||||
comments: [{ id: 1, authorName: 'Prof. Ada', comment: 'Good start.', createdAt: '2026-09-15' }],
|
||||
};
|
||||
const text = renderSubmission(s);
|
||||
expect(text).toContain('Submission (user 900) [late]');
|
||||
expect(text).toContain('Status: submitted · Score: 22 · Grade: 22');
|
||||
expect(text).toContain('Response: Water moves across the membrane.');
|
||||
expect(text).toContain('Comment (Prof. Ada): Good start.');
|
||||
});
|
||||
|
||||
it('renders a discussion, page, module, and enrollment compactly', () => {
|
||||
const d: Discussion = { id: 10, title: 'Week 1', message: '<p>Introduce yourself.</p>', postedAt: '', isAnnouncement: false };
|
||||
expect(renderDiscussion(d)).toContain('Discussion: Week 1');
|
||||
expect(renderDiscussion(d)).toContain('Introduce yourself.');
|
||||
const ann: Discussion = { ...d, isAnnouncement: true, title: 'Welcome' };
|
||||
expect(renderDiscussion(ann)).toContain('Announcement: Welcome');
|
||||
|
||||
const p: Page_ = { pageId: 7, url: 'intro', title: 'Week 1 Intro', body: '<p>Read ch. 1.</p>', updatedAt: '' };
|
||||
expect(renderPage(p)).toContain('Page: Week 1 Intro');
|
||||
expect(renderPage(p)).toContain('Read ch. 1.');
|
||||
|
||||
const m: Module = {
|
||||
id: 20,
|
||||
name: 'Cells',
|
||||
position: 1,
|
||||
items: [{ id: 1, title: 'Intro page', type: 'Page', position: 1 }],
|
||||
};
|
||||
expect(renderModule(m)).toContain('Module: Cells');
|
||||
expect(renderModule(m)).toContain('- Intro page [Page]');
|
||||
|
||||
const e: Enrollment = { id: 30, userId: 900, userName: 'Sam Student', type: 'StudentEnrollment', role: 'StudentEnrollment', state: 'active' };
|
||||
expect(renderEnrollment(e)).toBe('Sam Student — StudentEnrollment (active)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('course: buildCourseContext', () => {
|
||||
it('returns empty for no blocks', () => {
|
||||
const ctx = buildCourseContext([{ title: 'Assignments', blocks: [] }]);
|
||||
expect(ctx).toEqual({ text: '', totalBlocks: 0, includedBlocks: 0, truncated: false });
|
||||
});
|
||||
|
||||
it('includes all blocks under section headers when within budget', () => {
|
||||
const ctx = buildCourseContext([
|
||||
{ title: 'Modules', blocks: ['a', 'b'] },
|
||||
{ title: 'Pages', blocks: ['c'] },
|
||||
]);
|
||||
expect(ctx.totalBlocks).toBe(3);
|
||||
expect(ctx.includedBlocks).toBe(3);
|
||||
expect(ctx.truncated).toBe(false);
|
||||
expect(ctx.text).toContain('## Modules');
|
||||
expect(ctx.text).toContain('## Pages');
|
||||
expect(ctx.text.indexOf('## Modules')).toBeLessThan(ctx.text.indexOf('## Pages'));
|
||||
});
|
||||
|
||||
it('windows in order and reports truncation when the budget is exceeded', () => {
|
||||
const blocks = ['x'.repeat(30), 'y'.repeat(30), 'z'.repeat(30)];
|
||||
const ctx = buildCourseContext([{ title: 'Pages', blocks }], 60);
|
||||
expect(ctx.totalBlocks).toBe(3);
|
||||
expect(ctx.includedBlocks).toBeLessThan(3);
|
||||
expect(ctx.truncated).toBe(true);
|
||||
expect(ctx.text).toContain('xxx');
|
||||
expect(ctx.text).not.toContain('zzz');
|
||||
expect(ctx.text.length).toBeLessThanOrEqual(60);
|
||||
});
|
||||
|
||||
it('always includes at least the first block, hard-cutting it if it alone overflows', () => {
|
||||
const huge = 'q'.repeat(500);
|
||||
const ctx = buildCourseContext([{ title: 'Pages', blocks: [huge, 'next'] }], 50);
|
||||
expect(ctx.includedBlocks).toBe(1);
|
||||
expect(ctx.truncated).toBe(true);
|
||||
expect(ctx.text.length).toBe(50);
|
||||
expect(ctx.text.startsWith('## Pages')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips empty sections without charging a header', () => {
|
||||
const ctx = buildCourseContext([
|
||||
{ title: 'Empty', blocks: [] },
|
||||
{ title: 'Pages', blocks: ['a'] },
|
||||
]);
|
||||
expect(ctx.text).not.toContain('## Empty');
|
||||
expect(ctx.text).toContain('## Pages');
|
||||
expect(ctx.includedBlocks).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('course: contextNote', () => {
|
||||
it('is honest about no records', () => {
|
||||
expect(contextNote({ text: '', totalBlocks: 0, includedBlocks: 0, truncated: false })).toMatch(/No course records/);
|
||||
});
|
||||
it('reports full coverage', () => {
|
||||
expect(contextNote({ text: 'x', totalBlocks: 3, includedBlocks: 3, truncated: false })).toMatch(/all 3 items/);
|
||||
});
|
||||
it('reports truncation with the counts', () => {
|
||||
expect(contextNote({ text: 'x', totalBlocks: 10, includedBlocks: 4, truncated: true })).toMatch(/4 of 10 items/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('course: section builders assemble a real context', () => {
|
||||
it('turns typed records into a windowed context end to end', () => {
|
||||
const ctx = buildCourseContext([
|
||||
courseSection(course),
|
||||
moduleSection([{ id: 20, name: 'Cells', position: 1, items: [] }]),
|
||||
assignmentSection([{ id: 1, name: 'Lab 1', description: '', dueAt: '', pointsPossible: 25, submissionTypes: [] }]),
|
||||
pageSection([{ pageId: 7, url: 'intro', title: 'Intro', body: '', updatedAt: '' }]),
|
||||
discussionSection([{ id: 10, title: 'Week 1', message: '', postedAt: '', isAnnouncement: false }]),
|
||||
submissionSection([
|
||||
{ id: 1, userId: 900, assignmentId: 1, body: '', submittedAt: '', workflowState: 'submitted', score: 22, grade: '22', late: false, missing: false, comments: [] },
|
||||
]),
|
||||
enrollmentSection([{ id: 30, userId: 900, userName: 'Sam', type: 'StudentEnrollment', role: 'StudentEnrollment', state: 'active' }]),
|
||||
]);
|
||||
expect(ctx.totalBlocks).toBe(7);
|
||||
expect(ctx.truncated).toBe(false);
|
||||
expect(ctx.text).toContain('## Course');
|
||||
expect(ctx.text).toContain('Introduction to Biology');
|
||||
expect(ctx.text).toContain('## Modules');
|
||||
expect(ctx.text).toContain('## Assignments');
|
||||
expect(ctx.text).toContain('## Pages');
|
||||
expect(ctx.text).toContain('## Discussions');
|
||||
expect(ctx.text).toContain('## Submissions');
|
||||
expect(ctx.text).toContain('## Enrollments');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { AiClient, ChatCompletion, ChatCompletionCreateParams, Model } from '@hanzo/ai';
|
||||
import {
|
||||
SYSTEM_PROMPT,
|
||||
buildMessages,
|
||||
extractContent,
|
||||
ask,
|
||||
listModels,
|
||||
type CourseMeta,
|
||||
} from '../src/hanzo.js';
|
||||
import { buildCourseContext, type CourseContext } from '../src/course.js';
|
||||
|
||||
const ctx: CourseContext = buildCourseContext([{ title: 'Assignments', blocks: ['Assignment: Lab Report 1'] }]);
|
||||
const meta: CourseMeta = { name: 'Intro to Biology', courseCode: 'BIO-101', term: 'Fall 2026' };
|
||||
|
||||
// completion builds a minimal @hanzo/ai ChatCompletion with the given content.
|
||||
function completion(content: ChatCompletion['choices'][number]['message']['content']): ChatCompletion {
|
||||
return {
|
||||
id: 'c1',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model: 'zen5',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
|
||||
};
|
||||
}
|
||||
|
||||
// mockClient captures the params the code sends and returns a canned completion.
|
||||
function mockClient(reply: ChatCompletion, models: Model[] = []) {
|
||||
const create = vi.fn(async (_p: ChatCompletionCreateParams, _o?: { signal?: AbortSignal }) => reply);
|
||||
const list = vi.fn(async (_o?: { signal?: AbortSignal }) => models);
|
||||
const client = { chat: { completions: { create } }, models: { list } } as unknown as AiClient;
|
||||
return { client, create, list };
|
||||
}
|
||||
|
||||
describe('hanzo: buildMessages', () => {
|
||||
it('produces a grounded system prompt + a fenced user turn with meta + context note', () => {
|
||||
const msgs = buildMessages('What is due next week?', ctx, meta);
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs[0].role).toBe('system');
|
||||
expect(msgs[0].content).toBe(SYSTEM_PROMPT);
|
||||
const user = String(msgs[1].content);
|
||||
expect(user).toContain('Course: Intro to Biology');
|
||||
expect(user).toContain('Code: BIO-101');
|
||||
expect(user).toContain('Term: Fall 2026');
|
||||
expect(user).toContain('---- course records ----');
|
||||
expect(user).toContain('Assignment: Lab Report 1');
|
||||
expect(user).toContain('---- task ----');
|
||||
expect(user).toContain('What is due next week?');
|
||||
});
|
||||
|
||||
it('omits the header block entirely when meta is undefined', () => {
|
||||
const user = String(buildMessages('q', ctx).at(1)!.content);
|
||||
expect(user).not.toContain('Course:');
|
||||
expect(user).toContain('---- task ----');
|
||||
});
|
||||
|
||||
it('SYSTEM_PROMPT forbids inventing records and marks output a draft', () => {
|
||||
expect(SYSTEM_PROMPT).toMatch(/never invent/i);
|
||||
expect(SYSTEM_PROMPT).toMatch(/draft/i);
|
||||
expect(SYSTEM_PROMPT).toMatch(/not an authoritative grade/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hanzo: extractContent', () => {
|
||||
it('reads a plain string content', () => {
|
||||
expect(extractContent(completion('hello'))).toBe('hello');
|
||||
});
|
||||
it('concatenates text parts of an array content', () => {
|
||||
const parts = [
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'image_url', image_url: { url: 'x' } },
|
||||
{ type: 'text', text: 'b' },
|
||||
] as any;
|
||||
expect(extractContent(completion(parts))).toBe('ab');
|
||||
});
|
||||
it('throws on empty content', () => {
|
||||
expect(() => extractContent(completion(''))).toThrow(/no content/);
|
||||
expect(() => extractContent(completion(undefined))).toThrow(/no content/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hanzo: ask', () => {
|
||||
it('sends model + messages non-streaming through the injected client and returns the text', async () => {
|
||||
const { client, create } = mockClient(completion('due: Lab Report 1'));
|
||||
const out = await ask('What is due?', ctx, meta, { client, model: 'zen5', temperature: 0.2 });
|
||||
expect(out).toBe('due: Lab Report 1');
|
||||
expect(create).toHaveBeenCalledOnce();
|
||||
const [params, options] = create.mock.calls[0];
|
||||
expect(params.model).toBe('zen5');
|
||||
expect(params.stream).toBe(false);
|
||||
expect(params.temperature).toBe(0.2);
|
||||
expect(params.messages[0].role).toBe('system');
|
||||
expect(options).toBeDefined();
|
||||
});
|
||||
|
||||
it('omits temperature from the wire when not provided', async () => {
|
||||
const { client, create } = mockClient(completion('ok'));
|
||||
await ask('q', ctx, undefined, { client });
|
||||
expect('temperature' in create.mock.calls[0][0]).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults the model to zen5', async () => {
|
||||
const { client, create } = mockClient(completion('ok'));
|
||||
await ask('q', ctx, undefined, { client });
|
||||
expect(create.mock.calls[0][0].model).toBe('zen5');
|
||||
});
|
||||
|
||||
it('propagates an abort signal to the client', async () => {
|
||||
const { client, create } = mockClient(completion('ok'));
|
||||
const controller = new AbortController();
|
||||
await ask('q', ctx, undefined, { client, signal: controller.signal });
|
||||
expect(create.mock.calls[0][1]?.signal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hanzo: listModels', () => {
|
||||
it('maps the model catalog to ids', async () => {
|
||||
const models: Model[] = [
|
||||
{ id: 'zen5', object: 'model' },
|
||||
{ id: 'zen5-pro', object: 'model' },
|
||||
];
|
||||
const { client } = mockClient(completion('x'), models);
|
||||
expect(await listModels({ client })).toEqual(['zen5', 'zen5-pro']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
authorizeUrl,
|
||||
tokenUrl,
|
||||
tokenExchange,
|
||||
refreshExchange,
|
||||
parseTokenResponse,
|
||||
carryRefreshToken,
|
||||
tokenExpiresAt,
|
||||
isExpired,
|
||||
} from '../src/canvas-oauth.js';
|
||||
|
||||
const HOST = 'school.instructure.com';
|
||||
|
||||
describe('oauth: authorizeUrl', () => {
|
||||
it('builds the consent URL with the standard Canvas params', () => {
|
||||
const url = new URL(
|
||||
authorizeUrl({
|
||||
host: HOST,
|
||||
clientId: 'cid',
|
||||
redirectUri: 'https://canvas.hanzo.ai/oauth/callback',
|
||||
state: 'st-1',
|
||||
}),
|
||||
);
|
||||
expect(url.origin).toBe('https://school.instructure.com');
|
||||
expect(url.pathname).toBe('/login/oauth2/auth');
|
||||
expect(url.searchParams.get('response_type')).toBe('code');
|
||||
expect(url.searchParams.get('client_id')).toBe('cid');
|
||||
expect(url.searchParams.get('redirect_uri')).toBe('https://canvas.hanzo.ai/oauth/callback');
|
||||
expect(url.searchParams.get('state')).toBe('st-1');
|
||||
});
|
||||
|
||||
it('omits scope when empty and normalizes a host with a scheme', () => {
|
||||
const url = new URL(
|
||||
authorizeUrl({ host: 'https://school.instructure.com/', clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: [] }),
|
||||
);
|
||||
expect(url.origin).toBe('https://school.instructure.com');
|
||||
expect(url.searchParams.has('scope')).toBe(false);
|
||||
});
|
||||
|
||||
it('space-joins scopes when present', () => {
|
||||
const url = new URL(
|
||||
authorizeUrl({
|
||||
host: HOST,
|
||||
clientId: 'c',
|
||||
redirectUri: 'https://x/cb',
|
||||
state: 's',
|
||||
scopes: ['url:GET|/api/v1/courses', 'url:GET|/api/v1/courses/:id'],
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get('scope')).toBe('url:GET|/api/v1/courses url:GET|/api/v1/courses/:id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: token endpoints', () => {
|
||||
it('resolves the token URL per host', () => {
|
||||
expect(tokenUrl(HOST)).toBe('https://school.instructure.com/login/oauth2/token');
|
||||
expect(tokenUrl('canvas.school.edu')).toBe('https://canvas.school.edu/login/oauth2/token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: tokenExchange', () => {
|
||||
it('form-encodes the code grant with client credentials + redirect_uri in the body', () => {
|
||||
const req = tokenExchange({
|
||||
host: HOST,
|
||||
clientId: 'cid',
|
||||
clientSecret: 'sec',
|
||||
code: 'the-code',
|
||||
redirectUri: 'https://canvas.hanzo.ai/oauth/callback',
|
||||
});
|
||||
expect(req.url).toBe('https://school.instructure.com/login/oauth2/token');
|
||||
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||
const body = new URLSearchParams(req.body);
|
||||
expect(body.get('grant_type')).toBe('authorization_code');
|
||||
expect(body.get('code')).toBe('the-code');
|
||||
expect(body.get('client_id')).toBe('cid');
|
||||
expect(body.get('client_secret')).toBe('sec');
|
||||
expect(body.get('redirect_uri')).toBe('https://canvas.hanzo.ai/oauth/callback');
|
||||
});
|
||||
|
||||
it('never puts the secret in the URL', () => {
|
||||
const req = tokenExchange({ host: HOST, clientId: 'c', clientSecret: 'SUPER', code: 'x', redirectUri: 'https://x/cb' });
|
||||
expect(req.url).not.toContain('SUPER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: refreshExchange', () => {
|
||||
it('form-encodes the refresh grant with client credentials and no redirect_uri', () => {
|
||||
const req = refreshExchange({ host: HOST, clientId: 'cid', clientSecret: 'sec', refreshToken: 'r-1' });
|
||||
expect(req.url).toBe('https://school.instructure.com/login/oauth2/token');
|
||||
const body = new URLSearchParams(req.body);
|
||||
expect(body.get('grant_type')).toBe('refresh_token');
|
||||
expect(body.get('refresh_token')).toBe('r-1');
|
||||
expect(body.get('client_id')).toBe('cid');
|
||||
expect(body.get('client_secret')).toBe('sec');
|
||||
expect(body.has('code')).toBe(false);
|
||||
expect(body.has('redirect_uri')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: parseTokenResponse', () => {
|
||||
it('parses a full token set including the Canvas user', () => {
|
||||
const t = parseTokenResponse({
|
||||
access_token: 'at',
|
||||
refresh_token: 'rt',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
user: { id: 7, name: 'Prof. Ada' },
|
||||
});
|
||||
expect(t.access_token).toBe('at');
|
||||
expect(t.refresh_token).toBe('rt');
|
||||
expect(t.expires_in).toBe(3600);
|
||||
expect(t.user).toEqual({ id: 7, name: 'Prof. Ada' });
|
||||
});
|
||||
|
||||
it('throws on a Canvas error payload with the description', () => {
|
||||
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'code used' })).toThrow(/code used/);
|
||||
});
|
||||
|
||||
it('throws when access_token is missing or empty', () => {
|
||||
expect(() => parseTokenResponse({})).toThrow(/missing access_token/);
|
||||
expect(() => parseTokenResponse({ access_token: '' })).toThrow(/missing access_token/);
|
||||
expect(() => parseTokenResponse(null)).toThrow(/empty token response/);
|
||||
});
|
||||
|
||||
it('drops non-string/non-number optional fields defensively', () => {
|
||||
const t = parseTokenResponse({ access_token: 'at', refresh_token: 123, expires_in: 'soon' });
|
||||
expect(t.refresh_token).toBeUndefined();
|
||||
expect(t.expires_in).toBeUndefined();
|
||||
expect(t.user).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: carryRefreshToken (Canvas does not rotate)', () => {
|
||||
it('keeps the previous refresh token when the refresh response omits it', () => {
|
||||
const prev = { access_token: 'a0', refresh_token: 'rt-keep' };
|
||||
const next = { access_token: 'a1' };
|
||||
expect(carryRefreshToken(next, prev).refresh_token).toBe('rt-keep');
|
||||
});
|
||||
it('uses a new refresh token if one is returned', () => {
|
||||
const prev = { access_token: 'a0', refresh_token: 'old' };
|
||||
const next = { access_token: 'a1', refresh_token: 'new' };
|
||||
expect(carryRefreshToken(next, prev).refresh_token).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth: expiry math', () => {
|
||||
it('computes expiry from mintedAt + expires_in minus skew', () => {
|
||||
const t = { access_token: 'a', expires_in: 3600 };
|
||||
expect(tokenExpiresAt(t, 1000, 60)).toBe(1000 + 3600 - 60);
|
||||
});
|
||||
|
||||
it('treats a token with no expires_in as immediately (skew-)expired', () => {
|
||||
const t = { access_token: 'a' };
|
||||
expect(tokenExpiresAt(t, 5000, 0)).toBe(5000);
|
||||
});
|
||||
|
||||
it('reports not-expired before, expired at/after the skewed boundary', () => {
|
||||
const t = { access_token: 'a', expires_in: 3600 };
|
||||
const minted = 1000;
|
||||
const boundary = minted + 3600 - 60;
|
||||
expect(isExpired(t, minted, boundary - 1, 60)).toBe(false);
|
||||
expect(isExpired(t, minted, boundary, 60)).toBe(true);
|
||||
expect(isExpired(t, minted, boundary + 100, 60)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { parseLaunchContext, proxyUrl, createProxyClient } from '../src/panel.js';
|
||||
|
||||
describe('panel: parseLaunchContext', () => {
|
||||
it('reads a plain course_id', () => {
|
||||
expect(parseLaunchContext('?course_id=370663')).toEqual({ courseId: '370663' });
|
||||
});
|
||||
it('accepts Canvas LTI custom-variable spellings and defaults to empty', () => {
|
||||
expect(parseLaunchContext('?custom_canvas_course_id=7')).toEqual({ courseId: '7' });
|
||||
expect(parseLaunchContext('?canvas_course_id=9')).toEqual({ courseId: '9' });
|
||||
expect(parseLaunchContext('')).toEqual({ courseId: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('panel: proxyUrl', () => {
|
||||
it('prefixes /proxy and appends a query, dropping empties', () => {
|
||||
expect(proxyUrl('', '/courses', { page: '', per_page: '100' })).toBe('/proxy/courses?per_page=100');
|
||||
});
|
||||
it('expands array values into Canvas key[]=v repetition', () => {
|
||||
expect(proxyUrl('', '/courses/1', { include: ['syllabus_body'] })).toBe(
|
||||
'/proxy/courses/1?include%5B%5D=syllabus_body',
|
||||
);
|
||||
});
|
||||
it('strips a trailing slash from the base', () => {
|
||||
expect(proxyUrl('https://canvas.hanzo.ai/', '/x')).toBe('https://canvas.hanzo.ai/proxy/x');
|
||||
});
|
||||
});
|
||||
|
||||
// jsonResponse builds a fetch Response-like with an optional Link header.
|
||||
function jsonResponse(body: unknown, link?: string): Response {
|
||||
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||
if (link) headers.set('Link', link);
|
||||
return new Response(JSON.stringify(body), { status: 200, headers });
|
||||
}
|
||||
|
||||
// fetchMockOf wraps a responder in a fetch-typed mock so calls[i] carries the
|
||||
// (url, init) arg tuple TypeScript expects.
|
||||
function fetchMockOf(responder: () => Response) {
|
||||
return vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => Promise.resolve(responder()));
|
||||
}
|
||||
|
||||
describe('panel: createProxyClient — scoping + shaping', () => {
|
||||
it('sends credentials and returns parsed items with the next page from the Link header', async () => {
|
||||
const link = '<https://school.instructure.com/api/v1/courses?page=2&per_page=100>; rel="next"';
|
||||
const fetchMock = fetchMockOf(() => jsonResponse([{ id: 1, name: 'Biology' }], link));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
const { items, next } = await client.listCourses({ perPage: 100 });
|
||||
|
||||
expect(items).toEqual([
|
||||
{ id: 1, name: 'Biology', courseCode: '', workflowState: '', syllabusBody: '', termName: '' },
|
||||
]);
|
||||
expect(next).toBe(2);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('/proxy/courses?per_page=100');
|
||||
expect((init as RequestInit).credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('has no next page when the Link header omits a next relation', async () => {
|
||||
const fetchMock = fetchMockOf(() => jsonResponse([]));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
const { next } = await client.listCourses();
|
||||
expect(next).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gets a course with include[]=syllabus_body', async () => {
|
||||
const fetchMock = fetchMockOf(() => jsonResponse({ id: 1, name: 'Biology', syllabus_body: '<p>Hi</p>' }));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
const course = await client.getCourse(1);
|
||||
expect(course.syllabusBody).toBe('<p>Hi</p>');
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/courses/1?include%5B%5D=syllabus_body');
|
||||
});
|
||||
|
||||
it('lists submissions on the assignment path with comments included', async () => {
|
||||
const fetchMock = fetchMockOf(() => jsonResponse([]));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
await client.listSubmissions(100, 55, { page: 2 });
|
||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
||||
'/proxy/courses/100/assignments/55/submissions?include%5B%5D=submission_comments&page=2',
|
||||
);
|
||||
});
|
||||
|
||||
it('postSubmissionComment PUTs the comment[text_comment] JSON body', async () => {
|
||||
const fetchMock = fetchMockOf(() => new Response('{}', { status: 200 }));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
await client.postSubmissionComment(100, 55, 900, 'Nice work.');
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('/proxy/courses/100/assignments/55/submissions/900');
|
||||
expect((init as RequestInit).method).toBe('PUT');
|
||||
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ comment: { text_comment: 'Nice work.' } });
|
||||
});
|
||||
|
||||
it('createAnnouncement POSTs a discussion topic flagged is_announcement', async () => {
|
||||
const fetchMock = fetchMockOf(() => new Response('{}', { status: 201 }));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
await client.createAnnouncement(100, 'Heads up', 'Class is online Friday.');
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('/proxy/courses/100/discussion_topics');
|
||||
expect((init as RequestInit).method).toBe('POST');
|
||||
expect(JSON.parse(String((init as RequestInit).body))).toEqual({
|
||||
title: 'Heads up',
|
||||
message: 'Class is online Friday.',
|
||||
is_announcement: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a proxy error with status + message', async () => {
|
||||
const fetchMock = fetchMockOf(() => new Response(JSON.stringify({ error: 'not authenticated' }), { status: 401 }));
|
||||
const client = createProxyClient({ fetch: fetchMock as any });
|
||||
await expect(client.listCourses()).rejects.toThrow(/401.*not authenticated/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseCourse,
|
||||
parseCourses,
|
||||
parseAssignment,
|
||||
parseAssignments,
|
||||
parseSubmission,
|
||||
parseSubmissions,
|
||||
parseDiscussions,
|
||||
parsePage,
|
||||
parsePages,
|
||||
parseModules,
|
||||
parseEnrollments,
|
||||
} from '../src/canvas-api.js';
|
||||
|
||||
// A realistic Canvas course detail payload (trimmed to fields we surface).
|
||||
const courseFixture = {
|
||||
id: 370663,
|
||||
name: 'Introduction to Biology',
|
||||
course_code: 'BIO-101',
|
||||
workflow_state: 'available',
|
||||
syllabus_body: '<p>Welcome to <strong>BIO-101</strong>.</p>',
|
||||
term: { id: 5, name: 'Fall 2026' },
|
||||
};
|
||||
|
||||
describe('parse: course', () => {
|
||||
it('reads name, code, state, syllabus, and term name', () => {
|
||||
const c = parseCourse(courseFixture);
|
||||
expect(c.id).toBe(370663);
|
||||
expect(c.name).toBe('Introduction to Biology');
|
||||
expect(c.courseCode).toBe('BIO-101');
|
||||
expect(c.workflowState).toBe('available');
|
||||
expect(c.syllabusBody).toContain('<strong>BIO-101</strong>');
|
||||
expect(c.termName).toBe('Fall 2026');
|
||||
});
|
||||
it('normalizes a bare array and drops rows without an id', () => {
|
||||
const rows = parseCourses([courseFixture, { name: 'no id' }, { id: 0, name: 'bad' }]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].courseCode).toBe('BIO-101');
|
||||
});
|
||||
it('reads a wrapped { courses } shape and defaults a missing term', () => {
|
||||
const rows = parseCourses({ courses: [{ id: 2, name: 'Annex' }] });
|
||||
expect(rows[0].termName).toBe('');
|
||||
expect(rows[0].syllabusBody).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: assignments', () => {
|
||||
it('reads name, description, due date, points, and submission types', () => {
|
||||
const a = parseAssignment({
|
||||
id: 1,
|
||||
name: 'Lab Report 1',
|
||||
description: '<p>Write up the osmosis lab.</p>',
|
||||
due_at: '2026-09-15T23:59:00Z',
|
||||
points_possible: 25,
|
||||
submission_types: ['online_text_entry', 'online_upload'],
|
||||
});
|
||||
expect(a).toEqual({
|
||||
id: 1,
|
||||
name: 'Lab Report 1',
|
||||
description: '<p>Write up the osmosis lab.</p>',
|
||||
dueAt: '2026-09-15T23:59:00Z',
|
||||
pointsPossible: 25,
|
||||
submissionTypes: ['online_text_entry', 'online_upload'],
|
||||
});
|
||||
});
|
||||
it('parses an array and drops idless rows', () => {
|
||||
const rows = parseAssignments([{ id: 1, name: 'A' }, { name: 'no id' }]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].submissionTypes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: submissions', () => {
|
||||
const submissionFixture = {
|
||||
id: 5001,
|
||||
user_id: 900,
|
||||
assignment_id: 1,
|
||||
body: '<p>Osmosis moves water across a membrane.</p>',
|
||||
submitted_at: '2026-09-14T10:00:00Z',
|
||||
workflow_state: 'submitted',
|
||||
score: 22,
|
||||
grade: '22',
|
||||
late: true,
|
||||
missing: false,
|
||||
submission_comments: [
|
||||
{ id: 1, author_name: 'Prof. Ada', comment: 'Good start.', created_at: '2026-09-15T09:00:00Z' },
|
||||
],
|
||||
};
|
||||
|
||||
it('reads score, grade, flags, body, and the comment thread', () => {
|
||||
const s = parseSubmission(submissionFixture);
|
||||
expect(s.userId).toBe(900);
|
||||
expect(s.workflowState).toBe('submitted');
|
||||
expect(s.score).toBe(22);
|
||||
expect(s.grade).toBe('22');
|
||||
expect(s.late).toBe(true);
|
||||
expect(s.missing).toBe(false);
|
||||
expect(s.body).toContain('Osmosis');
|
||||
expect(s.comments).toHaveLength(1);
|
||||
expect(s.comments[0].authorName).toBe('Prof. Ada');
|
||||
expect(s.comments[0].comment).toBe('Good start.');
|
||||
});
|
||||
|
||||
it('treats a null score/grade as null/empty and no comments as []', () => {
|
||||
const s = parseSubmission({ id: 1, user_id: 2, score: null, grade: null, workflow_state: 'unsubmitted' });
|
||||
expect(s.score).toBeNull();
|
||||
expect(s.grade).toBe('');
|
||||
expect(s.comments).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a row that has a user id even without a submission id', () => {
|
||||
const rows = parseSubmissions([{ user_id: 900, workflow_state: 'unsubmitted' }, {}]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].userId).toBe(900);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: discussions', () => {
|
||||
it('reads title, message, and the announcement flag', () => {
|
||||
const rows = parseDiscussions([
|
||||
{ id: 10, title: 'Week 1 discussion', message: '<p>Introduce yourself.</p>', posted_at: '2026-09-01T00:00:00Z' },
|
||||
{ id: 11, title: 'Welcome', message: '<p>Hi all</p>', is_announcement: true },
|
||||
]);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].title).toBe('Week 1 discussion');
|
||||
expect(rows[0].isAnnouncement).toBe(false);
|
||||
expect(rows[1].isAnnouncement).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: pages', () => {
|
||||
it('reads a page detail with its body', () => {
|
||||
const p = parsePage({ page_id: 7, url: 'week-1-intro', title: 'Week 1 Intro', body: '<p>Read chapter 1.</p>', updated_at: '2026-09-01T00:00:00Z' });
|
||||
expect(p).toEqual({ pageId: 7, url: 'week-1-intro', title: 'Week 1 Intro', body: '<p>Read chapter 1.</p>', updatedAt: '2026-09-01T00:00:00Z' });
|
||||
});
|
||||
it('keeps a list row with a url even without a page_id', () => {
|
||||
const rows = parsePages([{ url: 'syllabus', title: 'Syllabus' }, { title: 'no url or id' }]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].url).toBe('syllabus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: modules', () => {
|
||||
it('reads modules with inlined items', () => {
|
||||
const mods = parseModules([
|
||||
{
|
||||
id: 20,
|
||||
name: 'Cells',
|
||||
position: 1,
|
||||
items: [
|
||||
{ id: 1, title: 'Intro page', type: 'Page', position: 1 },
|
||||
{ id: 2, title: 'Lab Report 1', type: 'Assignment', position: 2 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(mods[0].name).toBe('Cells');
|
||||
expect(mods[0].items).toHaveLength(2);
|
||||
expect(mods[0].items[1]).toEqual({ id: 2, title: 'Lab Report 1', type: 'Assignment', position: 2 });
|
||||
});
|
||||
it('tolerates a module with no items', () => {
|
||||
expect(parseModules([{ id: 1, name: 'Empty' }])[0].items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse: enrollments', () => {
|
||||
it('reads the person, role, type, and state', () => {
|
||||
const rows = parseEnrollments([
|
||||
{ id: 30, user_id: 900, user: { name: 'Sam Student' }, type: 'StudentEnrollment', role: 'StudentEnrollment', enrollment_state: 'active' },
|
||||
]);
|
||||
expect(rows[0]).toEqual({
|
||||
id: 30,
|
||||
userId: 900,
|
||||
userName: 'Sam Student',
|
||||
type: 'StudentEnrollment',
|
||||
role: 'StudentEnrollment',
|
||||
state: 'active',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", "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": "1.0.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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user