Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af85967e66 | ||
|
|
733953fda9 | ||
|
|
ed84370130 | ||
|
|
f492c1e524 | ||
|
|
ea5357b2ad | ||
|
|
4b565be7af | ||
|
|
143d990a69 | ||
|
|
c4df0c18bc | ||
|
|
84fdccaec5 | ||
|
|
29102d5e9c | ||
|
|
dd357fc559 | ||
|
|
0f8d23817e | ||
|
|
0ab87cf79e | ||
|
|
7c93d33c20 | ||
|
|
8eaf705f2f | ||
|
|
794da15224 | ||
|
|
5f38148a23 | ||
|
|
84f2b23f29 | ||
|
|
40a61dc5d0 | ||
|
|
eb3ab6f07c | ||
|
|
e5bb4d5db6 | ||
|
|
8900f673ed | ||
|
|
510c6d0dc2 | ||
|
|
94d73cedb9 | ||
|
|
51963f855c | ||
|
|
b897a3fa35 | ||
|
|
3a6ecca0bf | ||
|
|
0d00a20cee | ||
|
|
335e911a61 | ||
|
|
f138c65497 | ||
|
|
3600ce8d7b | ||
|
|
4c8c6ed1b6 | ||
|
|
10043d3478 | ||
|
|
d395e0c7ee |
@@ -0,0 +1,25 @@
|
||||
name: Build image
|
||||
|
||||
# Builds + pushes ghcr.io/hanzoai/gui (the gui.hanzo.ai site, served on
|
||||
# :3000 by the repo's existing Dockerfile) on a v* semver tag via the
|
||||
# canonical reusable workflow, natively on the self-hosted arcd fleet
|
||||
# (NO GitHub-hosted runners). Semver-only tags. Deployable on DOKS.
|
||||
#
|
||||
# This is the CONTAINER-image path. The repo's publish-gui*.yml workflows
|
||||
# publish the npm library packages and are unaffected.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
|
||||
with:
|
||||
image: ghcr.io/hanzoai/gui
|
||||
secrets: inherit
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
tagged-release:
|
||||
name: Changelog
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, amd64]
|
||||
# needs: release
|
||||
|
||||
steps:
|
||||
|
||||
@@ -19,7 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, amd64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
run: bun run lint
|
||||
|
||||
unit-tests:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, amd64]
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=6144'
|
||||
steps:
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
run: bun turbo run test:web --filter='!@hanzogui/kitchen-sink' --concurrency=1
|
||||
|
||||
changes:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, amd64]
|
||||
outputs:
|
||||
integration-relevant: ${{ steps.filter.outputs.integration-relevant }}
|
||||
steps:
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
integration-tests:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration-relevant == 'true'
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, amd64]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Publish gui (all packages)
|
||||
|
||||
# Publishes the FULL gui surface — every @hanzogui/* primitive AND the
|
||||
# `hanzogui` + `@hanzo/gui` umbrellas — at the current on-disk version
|
||||
# (7.x), with freshly built `dist/`. This is the fix for the broken
|
||||
# 7.0.0 publish, where the primitives shipped without `dist/` and
|
||||
# `@hanzo/gui` therefore failed to resolve for standalone consumers.
|
||||
#
|
||||
# `publish-gui.yml` only ships the umbrella; this ships the primitives it
|
||||
# depends on so a clean `npm i @hanzo/gui` works end-to-end.
|
||||
#
|
||||
# Trigger: manual (workflow_dispatch, type "publish" to confirm) or by
|
||||
# pushing a `release/gui-all-v*` tag.
|
||||
#
|
||||
# NPM_TOKEN resolution mirrors publish-gui.yml:
|
||||
# 1. KMS via Universal Auth (KMS_CLIENT_ID/SECRET → short-lived token)
|
||||
# 2. KMS via long-lived HANZO_API_KEY (legacy bootstrap)
|
||||
# 3. Direct repo secret NPM_TOKEN (fallback)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: 'Type "publish" to publish ALL gui packages at the current version'
|
||||
required: true
|
||||
default: ''
|
||||
push:
|
||||
tags:
|
||||
- 'release/gui-all-v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish-all:
|
||||
name: Build + publish all gui packages
|
||||
runs-on: [self-hosted, linux, arm64]
|
||||
if: github.event_name == 'push' || github.event.inputs.confirm == 'publish'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install
|
||||
uses: ./.github/actions/install
|
||||
|
||||
- name: Build all packages
|
||||
# Fresh checkout has no dist; --republish skips release.ts's own
|
||||
# build, so build here explicitly. `npm pack` (inside release.ts)
|
||||
# will fail loudly if any dist tree is missing.
|
||||
run: bun run build
|
||||
|
||||
- name: Resolve NPM_TOKEN (KMS → fallback to repo secret)
|
||||
id: token
|
||||
env:
|
||||
DIRECT_NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
HANZO_API_KEY: ${{ secrets.HANZO_API_KEY }}
|
||||
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
|
||||
KMS_WORKSPACE_ID: ${{ vars.KMS_WORKSPACE_ID_GUI || 'e1359bf4-31b4-4dfa-bb90-323e2c298ad8' }}
|
||||
run: |
|
||||
set -eu
|
||||
npm_token=""
|
||||
if [ -n "${KMS_CLIENT_ID:-}" ] && [ -n "${KMS_CLIENT_SECRET:-}" ]; then
|
||||
HANZO_API_KEY=$(curl -sf "${KMS_ENDPOINT}/api/v1/auth/universal-auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
|
||||
| jq -r '.accessToken') || true
|
||||
fi
|
||||
if [ -n "${HANZO_API_KEY:-}" ]; then
|
||||
response=$(curl -sf \
|
||||
"${KMS_ENDPOINT}/api/v3/secrets/raw?workspaceId=${KMS_WORKSPACE_ID}&secretPath=/publish&environment=prod" \
|
||||
-H "Authorization: Bearer ${HANZO_API_KEY}" 2>/dev/null) || true
|
||||
if [ -n "${response:-}" ]; then
|
||||
npm_token=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="NPM_TOKEN") | .secretValue // empty')
|
||||
fi
|
||||
fi
|
||||
if [ -z "$npm_token" ]; then
|
||||
npm_token="${DIRECT_NPM_TOKEN:-}"
|
||||
if [ -n "$npm_token" ]; then
|
||||
echo "Using NPM_TOKEN from repo secret (KMS path unavailable)."
|
||||
fi
|
||||
fi
|
||||
if [ -z "$npm_token" ]; then
|
||||
echo "::error::No NPM_TOKEN available — need KMS_CLIENT_ID/SECRET, HANZO_API_KEY, or NPM_TOKEN repo secret."
|
||||
exit 1
|
||||
fi
|
||||
echo "::add-mask::${npm_token}"
|
||||
echo "npm_token=${npm_token}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure npm registry
|
||||
env:
|
||||
NPM_TOKEN: ${{ steps.token.outputs.npm_token }}
|
||||
run: |
|
||||
echo 'registry=https://registry.npmjs.org/' > ~/.npmrc
|
||||
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> ~/.npmrc
|
||||
|
||||
- name: Publish gui surface (primitives + umbrella + @hanzo/gui alias)
|
||||
# Targeted publish of just the @hanzogui/* closure + hanzogui +
|
||||
# @hanzo/gui, at the current on-disk version. Avoids release.ts's
|
||||
# whole-workspace fan-out (which fails packing unrelated
|
||||
# @hanzo_network/* packages) and its fragile tarball-rename step.
|
||||
# Idempotent — skips any name@version already on npm.
|
||||
env:
|
||||
NPM_TOKEN: ${{ steps.token.outputs.npm_token }}
|
||||
NODE_AUTH_TOKEN: ${{ steps.token.outputs.npm_token }}
|
||||
run: node ./scripts/publish-gui-surface.mjs
|
||||
@@ -28,7 +28,7 @@ permissions:
|
||||
jobs:
|
||||
publish:
|
||||
name: Build, smoke-test, publish
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, amd64]
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v5
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
tagged-release:
|
||||
name: Tagged Release
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, amd64]
|
||||
# needs: release
|
||||
|
||||
steps:
|
||||
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
name: Build Android App
|
||||
# Only run on main/v* branches (not PRs)
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || github.ref == 'refs/heads/rn82'
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, amd64]
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
android-cache-key: ${{ steps.final-cache-key.outputs.cache_key }}
|
||||
@@ -446,7 +446,7 @@ jobs:
|
||||
needs: build-android
|
||||
# Only run on main/v* branches (not PRs)
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || github.ref == 'refs/heads/rn82'
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, amd64]
|
||||
timeout-minutes: 45
|
||||
# Android emulator in CI is flaky with window focus issues - don't block on failures
|
||||
continue-on-error: true
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
Please read ./CONTRIBUTING.md as well
|
||||
|
||||
Note you need to re-build packages (`bun run build` in the package directory) as you change them, unless you or someone is running a `bun run watch` at root.
|
||||
|
||||
FOR LONG RUNNNING DEBUGGING run `bun run watch` in the background its faster and rebuilds all packages.
|
||||
|
||||
keep commits to one line, add a trailing "Fixes #" if associated with a GH issue, and start with a convential commit style - UNLESS its a change that shouldn't go into the changelog, in those cases you can do things like `docs: ` or `site: `.
|
||||
|
||||
# Hanzo GUI Testing Guide
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Kitchen Sink Tests
|
||||
|
||||
The kitchen-sink package contains the main integration tests for Hanzo GUI components. To run these tests:
|
||||
|
||||
1. **Start the web server** (in the background):
|
||||
|
||||
```bash
|
||||
cd apps/kitchen-sink
|
||||
bun run start:web
|
||||
```
|
||||
|
||||
To open a specific test case in the browser:
|
||||
|
||||
```bash
|
||||
open "http://localhost:9000/?test=YourTestCaseName"
|
||||
```
|
||||
|
||||
Test case names match the file names in `apps/kitchen-sink/src/usecases/` (e.g., `SelectFocusScopeCase`).
|
||||
|
||||
To open a component demo:
|
||||
|
||||
```bash
|
||||
open "http://localhost:9000/?demo=Select"
|
||||
```
|
||||
|
||||
Demo names match files in `apps/demos/src/` without the `Demo` suffix (e.g., `Select` for `SelectDemo.tsx`).
|
||||
|
||||
2. **Run all web tests** with different animation drivers:
|
||||
|
||||
```bash
|
||||
bun run test:web
|
||||
```
|
||||
|
||||
This uses `run-tests-parallel.ts` which first runs `default` + `webkit` projects sequentially, then runs all four animated driver projects (`css`, `native`, `reanimated`, `motion`) in parallel against a single shared dev server.
|
||||
|
||||
3. **Run tests with a specific animation driver**:
|
||||
|
||||
```bash
|
||||
# Using env var + playwright --project flag
|
||||
cd apps/kitchen-sink
|
||||
NODE_ENV=test HANZO_GUI_TEST_ANIMATION_DRIVER=css npx playwright test --project=animated-css
|
||||
|
||||
# Available projects: animated-css, animated-native, animated-reanimated, animated-motion
|
||||
```
|
||||
|
||||
4. **Run a specific test file**:
|
||||
|
||||
```bash
|
||||
# Using playwright directly
|
||||
cd apps/kitchen-sink
|
||||
npx playwright test tests/PopoverFocusScope.test.tsx
|
||||
|
||||
# Or with a specific driver
|
||||
NODE_ENV=test HANZO_GUI_TEST_ANIMATION_DRIVER=css npx playwright test tests/YourTest.animated.test.tsx --project=animated-css
|
||||
```
|
||||
|
||||
5. **Debug tests**:
|
||||
```bash
|
||||
bun run test:web:debug
|
||||
# or
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
Tests are located in `apps/kitchen-sink/tests/` and follow these naming conventions:
|
||||
|
||||
- `ComponentName.test.tsx` - Standard tests that run ONCE with the default animation driver
|
||||
- `ComponentName.animated.test.tsx` - Animation-dependent tests that run with ALL animation drivers (css, native, reanimated, motion)
|
||||
|
||||
This separation significantly speeds up the test suite since most tests don't need to run 4x across all animation drivers. Only use `.animated.test.tsx` for tests that specifically verify animation behavior across different drivers.
|
||||
|
||||
### Writing Tests
|
||||
|
||||
When writing tests for focus behavior or component interactions:
|
||||
|
||||
1. Use appropriate wait times for animations and focus changes
|
||||
2. Be aware that `trapFocus` behavior depends on the component's open state
|
||||
3. Test both trapped and non-trapped focus scenarios
|
||||
4. Consider browser focus behavior when `trapFocus` is false
|
||||
|
||||
### Common Issues
|
||||
|
||||
- If tests fail due to timing, add appropriate `waitForTimeout` calls
|
||||
- For focus tests, ensure elements are visible before testing focus state
|
||||
- When testing popover/dialog components, wait for animations to complete
|
||||
|
||||
## Commit Message Conventions
|
||||
|
||||
- Use `site:` prefix (not `fix(site):`) for gui.hanzo.ai changes since they don't go in the changelog
|
||||
- Use `ci:` prefix (not `fix(ci):`) for CI/workflow changes since they don't go in the changelog
|
||||
- Keep commit messages to a single line
|
||||
|
||||
## iOS Development
|
||||
|
||||
See [docs/using-ios.md](./docs/using-ios.md) for iOS native development and Detox testing tips.
|
||||
|
||||
## gui.hanzo.ai API Authentication
|
||||
|
||||
When making authenticated API calls from the client side in gui.hanzo.ai, always use the `authFetch` helper:
|
||||
|
||||
```ts
|
||||
import { authFetch } from '~/features/api/authFetch'
|
||||
|
||||
const response = await authFetch('/api/some-endpoint', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ... }),
|
||||
})
|
||||
```
|
||||
|
||||
**Why this matters:** Cookies alone are not reliable for auth in production due to cross-origin/SameSite issues. The `authFetch` helper automatically includes the Authorization header with the user's access token. All payment/subscription endpoints require this.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Additional notes (merged from LLM.md)
|
||||
|
||||
# gui — AI Assistant Context
|
||||
|
||||
# Hanzo GUI
|
||||
|
||||
<h3 align="center">
|
||||
Style library, design system, composable components, and more.
|
||||
</h3>
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GuiBuildOptions } from 'hanzogui'
|
||||
|
||||
export default {
|
||||
components: ['gui'],
|
||||
components: ['hanzogui'],
|
||||
logTimings: true,
|
||||
config: '@hanzogui/dev-config',
|
||||
outputCSS: './gui.generated.css',
|
||||
|
||||
@@ -61,7 +61,7 @@ const include = [
|
||||
'swr/mutation',
|
||||
'mdx-bundler/client',
|
||||
// core hanzo-gui packages must be pre-bundled together to avoid duplicate instances
|
||||
'gui',
|
||||
'hanzogui',
|
||||
'@hanzogui/core',
|
||||
'@hanzogui/web',
|
||||
// existing
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
Please read ./CONTRIBUTING.md as well
|
||||
|
||||
Note you need to re-build packages (`bun run build` in the package directory) as you change them, unless you or someone is running a `bun run watch` at root.
|
||||
|
||||
FOR LONG RUNNNING DEBUGGING run `bun run watch` in the background its faster and rebuilds all packages.
|
||||
|
||||
keep commits to one line, add a trailing "Fixes #" if associated with a GH issue, and start with a convential commit style - UNLESS its a change that shouldn't go into the changelog, in those cases you can do things like `docs: ` or `site: `.
|
||||
|
||||
# Hanzo GUI Testing Guide
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Kitchen Sink Tests
|
||||
|
||||
The kitchen-sink package contains the main integration tests for Hanzo GUI components. To run these tests:
|
||||
|
||||
1. **Start the web server** (in the background):
|
||||
|
||||
```bash
|
||||
cd apps/kitchen-sink
|
||||
bun run start:web
|
||||
```
|
||||
|
||||
To open a specific test case in the browser:
|
||||
|
||||
```bash
|
||||
open "http://localhost:9000/?test=YourTestCaseName"
|
||||
```
|
||||
|
||||
Test case names match the file names in `apps/kitchen-sink/src/usecases/` (e.g., `SelectFocusScopeCase`).
|
||||
|
||||
To open a component demo:
|
||||
|
||||
```bash
|
||||
open "http://localhost:9000/?demo=Select"
|
||||
```
|
||||
|
||||
Demo names match files in `apps/demos/src/` without the `Demo` suffix (e.g., `Select` for `SelectDemo.tsx`).
|
||||
|
||||
2. **Run all web tests** with different animation drivers:
|
||||
|
||||
```bash
|
||||
bun run test:web
|
||||
```
|
||||
|
||||
This uses `run-tests-parallel.ts` which first runs `default` + `webkit` projects sequentially, then runs all four animated driver projects (`css`, `native`, `reanimated`, `motion`) in parallel against a single shared dev server.
|
||||
|
||||
3. **Run tests with a specific animation driver**:
|
||||
|
||||
```bash
|
||||
# Using env var + playwright --project flag
|
||||
cd apps/kitchen-sink
|
||||
NODE_ENV=test HANZO_GUI_TEST_ANIMATION_DRIVER=css npx playwright test --project=animated-css
|
||||
|
||||
# Available projects: animated-css, animated-native, animated-reanimated, animated-motion
|
||||
```
|
||||
|
||||
4. **Run a specific test file**:
|
||||
|
||||
```bash
|
||||
# Using playwright directly
|
||||
cd apps/kitchen-sink
|
||||
npx playwright test tests/PopoverFocusScope.test.tsx
|
||||
|
||||
# Or with a specific driver
|
||||
NODE_ENV=test HANZO_GUI_TEST_ANIMATION_DRIVER=css npx playwright test tests/YourTest.animated.test.tsx --project=animated-css
|
||||
```
|
||||
|
||||
5. **Debug tests**:
|
||||
```bash
|
||||
bun run test:web:debug
|
||||
# or
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
Tests are located in `apps/kitchen-sink/tests/` and follow these naming conventions:
|
||||
|
||||
- `ComponentName.test.tsx` - Standard tests that run ONCE with the default animation driver
|
||||
- `ComponentName.animated.test.tsx` - Animation-dependent tests that run with ALL animation drivers (css, native, reanimated, motion)
|
||||
|
||||
This separation significantly speeds up the test suite since most tests don't need to run 4x across all animation drivers. Only use `.animated.test.tsx` for tests that specifically verify animation behavior across different drivers.
|
||||
|
||||
### Writing Tests
|
||||
|
||||
When writing tests for focus behavior or component interactions:
|
||||
|
||||
1. Use appropriate wait times for animations and focus changes
|
||||
2. Be aware that `trapFocus` behavior depends on the component's open state
|
||||
3. Test both trapped and non-trapped focus scenarios
|
||||
4. Consider browser focus behavior when `trapFocus` is false
|
||||
|
||||
### Common Issues
|
||||
|
||||
- If tests fail due to timing, add appropriate `waitForTimeout` calls
|
||||
- For focus tests, ensure elements are visible before testing focus state
|
||||
- When testing popover/dialog components, wait for animations to complete
|
||||
|
||||
## Commit Message Conventions
|
||||
|
||||
- Use `site:` prefix (not `fix(site):`) for gui.hanzo.ai changes since they don't go in the changelog
|
||||
- Use `ci:` prefix (not `fix(ci):`) for CI/workflow changes since they don't go in the changelog
|
||||
- Keep commit messages to a single line
|
||||
|
||||
## iOS Development
|
||||
|
||||
See [docs/using-ios.md](./docs/using-ios.md) for iOS native development and Detox testing tips.
|
||||
|
||||
## gui.hanzo.ai API Authentication
|
||||
|
||||
When making authenticated API calls from the client side in gui.hanzo.ai, always use the `authFetch` helper:
|
||||
|
||||
```ts
|
||||
import { authFetch } from '~/features/api/authFetch'
|
||||
|
||||
const response = await authFetch('/api/some-endpoint', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ... }),
|
||||
})
|
||||
```
|
||||
|
||||
**Why this matters:** Cookies alone are not reliable for auth in production due to cross-origin/SameSite issues. The `authFetch` helper automatically includes the Authorization header with the user's access token. All payment/subscription endpoints require this.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Additional notes (merged from LLM.md)
|
||||
|
||||
# gui — AI Assistant Context
|
||||
|
||||
# Hanzo GUI
|
||||
|
||||
<h3 align="center">
|
||||
Style library, design system, composable components, and more.
|
||||
</h3>
|
||||
+27
-2
@@ -17,7 +17,14 @@
|
||||
"./apps/kitchen-sink-shared",
|
||||
"./apps/sandbox",
|
||||
"./apps/tests/**/*",
|
||||
"./templates/*"
|
||||
"./templates/*",
|
||||
"./pkgs/ai",
|
||||
"./pkgs/net-ui",
|
||||
"./pkgs/net-state",
|
||||
"./pkgs/net-i18n",
|
||||
"./pkgs/net-message-ts",
|
||||
"./pkgs/net-brand-config",
|
||||
"./pkgs/net-logo"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "bun ./scripts/postinstall.ts",
|
||||
@@ -141,7 +148,25 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.83.2",
|
||||
"whatwg-url": "14.1.1"
|
||||
"whatwg-url": "14.1.1",
|
||||
"shiki": "^3.19.0",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"one": "1.12.5",
|
||||
"vxrn": "1.12.5",
|
||||
"@vxrn/compiler": "1.12.5",
|
||||
"@vxrn/resolve": "1.12.5",
|
||||
"@vxrn/color-scheme": "1.12.5",
|
||||
"@vxrn/mdx": "1.12.5",
|
||||
"@vxrn/safe-area": "1.12.5",
|
||||
"@vxrn/utils": "1.12.5",
|
||||
"@vxrn/debug": "1.12.5",
|
||||
"@vxrn/vendor": "1.12.5",
|
||||
"@vxrn/query-string": "1.12.5",
|
||||
"@vxrn/url-parse": "1.12.5",
|
||||
"@vxrn/vite-flow": "1.12.5",
|
||||
"@vxrn/vite-plugin-metro": "1.12.5",
|
||||
"@vxrn/tslib-lite": "1.12.5",
|
||||
"create-vxrn": "1.12.5"
|
||||
},
|
||||
"packageManager": "bun@1.3.9",
|
||||
"manypkg": {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# @hanzo/ai — build / migration plan
|
||||
|
||||
Goal: the Hanzo AI app as **one declarative SDK component** (`<HanzoAI {...brand} />`),
|
||||
so `hanzo/zoo/lux` desktop+mobile apps become ~80-line shims — exactly how
|
||||
`zooai/exchange` / `luxfi/exchange` consume `@luxfi/exchange`.
|
||||
|
||||
```
|
||||
@hanzo/ai (this pkg — the app) @luxfi/exchange (the analog)
|
||||
▲ ▲
|
||||
hanzoai/desktop · zooai/app · luxfi/app zooai/exchange · luxfi/exchange
|
||||
<HanzoAI {...brand}/> <Exchange {...brand}/>
|
||||
```
|
||||
|
||||
## Status
|
||||
- [x] Props contract — `BrandConfig` lifted from `hanzoai/desktop/libs/brand-config` → `src/types.ts`.
|
||||
- [x] `<HanzoAI>` component + `BrandProvider`/`useBrand` (prop-driven; replaces `getBrand()` env lookup).
|
||||
- [ ] **Move the app in** (the big rock).
|
||||
- [ ] **Decouple Tauri** (44 files).
|
||||
- [ ] **Bundle + publish** `@hanzo/ai` and its libs.
|
||||
- [ ] **Shims**: rewrite `hanzoai/desktop`, `zooai/app`, `luxfi/app` to the shim.
|
||||
|
||||
## The move (~955 source files)
|
||||
From `hanzoai/desktop`:
|
||||
| Source | Files | Destination |
|
||||
|---|---|---|
|
||||
| `apps/hanzo-desktop/src/**` | 263 | `pkgs/ai/src/app/**` (App.tsx → `./app/App`) |
|
||||
| `libs/hanzo-node-state/src` | 460 | `pkgs/state` → `@hanzo_network/hanzo-node-state` |
|
||||
| `libs/hanzo-ui/src` | 192 | `pkgs/hanzo-ui` → `@hanzo_network/hanzo-ui` |
|
||||
| `libs/hanzo-message-ts/src` | 32 | `pkgs/message-ts` → `@hanzo_network/hanzo-message-ts` |
|
||||
| `libs/hanzo-i18n/src` | 8 | `pkgs/hanzo-i18n` → `@hanzo_network/hanzo-i18n` |
|
||||
|
||||
Mechanical, but two rewrites are required during the move:
|
||||
1. `getBrand()` / `import.meta.env.VITE_BRAND` → `useBrand()` (from `./brand-context`).
|
||||
2. The **44** `@tauri-apps/*` import sites → the injected `host: HostAdapter` prop
|
||||
(`host.invoke` / `host.listen`), so the SDK runs on plain web *and* Tauri *and* Expo.
|
||||
On web with no `host`, those features degrade gracefully.
|
||||
|
||||
## Then the shim (per app) — the whole app:
|
||||
```tsx
|
||||
// luxfi/app — apps/web/src/main.tsx
|
||||
import HanzoAI from '@hanzo/ai'
|
||||
import brand from '@luxfi/brand'
|
||||
import { tauriHost } from './host' // desktop adapter
|
||||
createRoot(root).render(
|
||||
<HanzoAI {...brand} host={tauriHost}
|
||||
features={{ chat:true, wallet:true, mining:true }} />)
|
||||
```
|
||||
Mobile = `apps/mobile` (Expo) with the same `<HanzoAI {...brand} host={expoHost} />`.
|
||||
Build via Dockerfile from the pinned `@hanzo/ai`, brand overlaid — like the exchanges.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Live chat — VERIFIED working end-to-end (web, local) 2026-06-05
|
||||
|
||||
The web app at `:1500` chats with **zen-coder-24b** through a real **hanzo-node**,
|
||||
fully in the browser. Verified: prompt "Reply with exactly: E2E CHAT OK" →
|
||||
zen-coder generated `E2E CHAT OK` (7 tokens), streamed back to the chat UI.
|
||||
|
||||
The full path:
|
||||
|
||||
```
|
||||
browser :1500 ─(vite proxy /v1,/v2,/ws)→ hanzo-node :3700 ─job→ zen_engine provider
|
||||
→ responses-proxy :36906 (rewrites /v1/engine/responses → /v1/responses)
|
||||
→ hanzo-engine :36902 (zen-coder-24b) → "E2E CHAT OK" → streamed back to the UI
|
||||
```
|
||||
|
||||
## The non-obvious fixes that made it work
|
||||
|
||||
1. **The node speaks the OpenAI _Responses_ API, the engine serves it at a
|
||||
different path.** The node's `OpenAI` provider POSTs to
|
||||
`<url>/v1/engine/responses` (`{input:[...], max_output_tokens, ...}`), but
|
||||
hanzo-engine serves the Responses API at **`/v1/responses`** (`/v1/engine/...`
|
||||
→ 404 → the node reports "AI Provider API Error: Unknown error"). A tiny proxy
|
||||
rewrites the path (and injects `enable_thinking:false`). See
|
||||
`local-runtime/responses-proxy.py`.
|
||||
2. **Use `127.0.0.1`, never `localhost`.** `localhost` resolves to IPv6 `::1`
|
||||
here; the engine/embeddings bind IPv4 only → instant connection failure.
|
||||
3. **CORS:** the vite dev server proxies the app's own origin (`/v1`,`/v2`,`/ws`)
|
||||
to the node, so the app's `nodeAddress` is just `http://<host>:1500`.
|
||||
4. **Two migration import bugs** had to be fixed for the chat screens to render:
|
||||
`main-layout.tsx` was missing `Box`/`Boxes`/`Coins` lucide imports, and the
|
||||
merge pulled `react-resizable-panels@4` (renamed exports) — pinned to `^3.0.2`
|
||||
in `pkgs/net-ui` (the app uses `PanelGroup`/`PanelResizeHandle`).
|
||||
|
||||
## Reproduce it
|
||||
|
||||
```bash
|
||||
# 1. engine + embeddings already running: :36902 (zen-coder), :11436 (embed)
|
||||
# 2. responses-proxy (path rewrite):
|
||||
python3 pkgs/ai/local-runtime/responses-proxy.py & # :36906 → :36902
|
||||
# 3. WS proxy (the app derives ws://host:1501 behind the vite proxy):
|
||||
node pkgs/ai/local-runtime/ws-proxy.js & # :1501 → node WS :3701
|
||||
# 4. a /v1 hanzo-node wired to the proxy (fresh storage, no reg code):
|
||||
bash pkgs/ai/local-runtime/run-node.sh & # API :3700, zen_engine → :36906
|
||||
# 5. web app pointed at the node + engine (engine-api proxy avoids CORS):
|
||||
cd pkgs/ai/web && VITE_NODE_API=http://127.0.0.1:3700 VITE_NODE_WS=ws://127.0.0.1:3701 \
|
||||
VITE_ENGINE_BASE_URL=/engine-api VITE_ENGINE_API=http://127.0.0.1:36906 \
|
||||
bun x vite --config vite.config.ts # :1500
|
||||
# 6. open http://spark.local:1500 → agree → Quick Connect (node address = same
|
||||
# origin http://localhost:1500) → /home → chat. Reply streams from zen-coder.
|
||||
```
|
||||
|
||||
## Console — clean (the e2e asserts it)
|
||||
|
||||
The earlier web console errors are fixed and guarded by `e2e/chat.e2e.test.ts`:
|
||||
react-query "data cannot be undefined" (web `invoke` returns null), the
|
||||
`isPermissionGranted`/notification gap, the `:36900/v1/engine/models` CORS
|
||||
(routed via the `/engine-api` proxy), the `ws://…:1501` failure (ws-proxy), the
|
||||
`available_models` 401 (gated `useGetLLMProviders` on auth), and the framer
|
||||
`motion()` deprecation. Remaining: a rare, intermittent static-asset `404` and a
|
||||
node-side `Embedding "Query is not read-only"` warning — neither affects chat.
|
||||
@@ -0,0 +1,80 @@
|
||||
# @hanzo/ai — one app surface, every platform
|
||||
|
||||
The Hanzo / Zoo / Lux AI app is **one** thing: `@hanzo/ai`. Web, desktop and
|
||||
mobile are not three apps — they are the same app with two axes injected.
|
||||
|
||||
```
|
||||
┌────────────────────────── @hanzo/ai ──────────────────────────┐
|
||||
│ src/app/ (the migrated shinkai-fork app — ONE copy, one place) │
|
||||
│ + net-* libs (ui, state, i18n, message-ts, brand-config, logo, │
|
||||
│ artifacts) + src/host/* (the @tauri-apps shim surface) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
▲ ▲ ▲
|
||||
host = web (default) host = tauriHost host = expoHost
|
||||
brand = getBrand() brand = getBrand() brand = getBrand()
|
||||
│ │ │
|
||||
hanzo.app / .chat hanzoai/desktop @hanzo/gui
|
||||
zoo.cloud / lux.cloud zooai/app · luxfi/app (Expo mobile)
|
||||
```
|
||||
|
||||
## The two orthogonal axes (decomplected)
|
||||
|
||||
| Axis | What it is | How it's injected | Default |
|
||||
|---|---|---|---|
|
||||
| **Brand** | hanzo / zoo / lux identity, cloud endpoints, chain, IAM | `getBrand()` (driven by `VITE_BRAND` or hostname) **or** spread as props `<HanzoAI {...brand}/>` | `getBrand()` → hostname → HANZO |
|
||||
| **Platform** | how native calls (`invoke`, `listen`, fs, window…) resolve | `host` prop — a `HostAdapter` | web no-ops |
|
||||
|
||||
Everything else — the entire app — is shared. A new app is a ~10-line shim:
|
||||
|
||||
```tsx
|
||||
import HanzoAI, { getBrand } from '@hanzo/ai';
|
||||
import { tauriHost } from './tauri-host'; // desktop only; web omits it
|
||||
createRoot(root).render(
|
||||
<HanzoAI {...getBrand()} host={tauriHost} features={{ chat, wallet, agents }} />
|
||||
);
|
||||
```
|
||||
|
||||
## Web ⇄ desktop parity
|
||||
|
||||
The web shim and the desktop shim are **byte-identical except one prop**
|
||||
(`host={tauriHost}`). They import the same `@hanzo/ai`, which is the same
|
||||
`src/app`. So the rendered React tree — every screen, route and component — is
|
||||
identical. The only runtime difference is what `HostAdapter` does:
|
||||
|
||||
| Call | web (default host) | desktop (tauriHost) |
|
||||
|---|---|---|
|
||||
| `invoke(cmd, args)` | no-op, returns `undefined` (logs in dev) | Tauri IPC → Rust |
|
||||
| `listen(event, cb)` | no-op unlisten | Tauri event bus |
|
||||
| `getCurrentWindow()` | no-op Window (emit/listen/geometry) | real Tauri window |
|
||||
| fs / shell / process / updater | no-op / empty | Tauri plugins |
|
||||
|
||||
So on the web the app renders 1:1 with desktop; native-only actions (open a
|
||||
folder, auto-update, tray) simply do nothing instead of crashing. Cloud
|
||||
inference + chat work on both (they go over HTTP to the brand's
|
||||
`inferenceEndpoint`, not through `invoke`).
|
||||
|
||||
## Verification (2026-06-04)
|
||||
|
||||
| Surface | Build | Render |
|
||||
|---|---|---|
|
||||
| web dev server (`pkgs/ai/web`, imports `src`) | ✅ | ✅ Hanzo onboarding |
|
||||
| web prod (unminified, 55M) | ✅ 40s | ✅ |
|
||||
| web prod (minified, 13M / gzip 3.75M) | ✅ 44s | ✅ |
|
||||
| library `dist` (self-contained, react external) | ✅ | — |
|
||||
| **external shim** (`examples/web`, only react provided, app from `dist`) | ✅ | ✅ — proves SDK consumption |
|
||||
|
||||
The render blocker that had to be cleared first: app modules imported
|
||||
`useBrand` from the **`@hanzo/ai` package name**, which resolved to the built
|
||||
`dist` — pulling a second React + a second app copy into the source graph (a
|
||||
cycle → `useContext` of null). Fix: the brand store lives in
|
||||
`@hanzo_network/brand-config` (plain getters, not hooks), aliased to `src`;
|
||||
app code never imports the `@hanzo/ai` package by name.
|
||||
|
||||
## Turning the real repos into shims
|
||||
|
||||
`luxfi/app`, `zooai/app`, `hanzoai/desktop`, `hanzoai/app` each become the
|
||||
shim above. Per repo: depend on `@hanzo/ai` (+ `react`, `react-dom`, and for
|
||||
desktop `@tauri-apps/api`), set `VITE_BRAND`, drop all app source. The
|
||||
Dockerfile/Tauri config builds from the pinned SDK — exactly how
|
||||
`zooai/exchange` builds from `@luxfi/exchange`. (`@hanzo/ai` is not yet
|
||||
published to npm; until then the shims consume it via the workspace.)
|
||||
@@ -0,0 +1,51 @@
|
||||
# Testing @hanzo/ai — and TDD going forward
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bun run --cwd pkgs/ai test # unit + regression (vitest, jsdom) — fast, no external deps
|
||||
bun run --cwd pkgs/ai test:watch # vitest watch mode (use while writing a fix)
|
||||
bun run --cwd pkgs/ai test:e2e # Playwright e2e — needs the local runtime up (skips if not)
|
||||
```
|
||||
|
||||
`test` runs everything under `src/**/*.{test,spec}.{ts,tsx}` in jsdom. Config:
|
||||
`vitest.config.ts` mirrors the web build's alias graph (so `@/`, `@tauri-apps/*`
|
||||
→ host shims, `@hanzo_network/*` → src resolve), stubs the streamdown markdown
|
||||
stack (esbuild can't resolve its nested micromark), and loads
|
||||
`@testing-library/jest-dom` via `src/__tests__/setup.ts`. Pyodide /
|
||||
python-code-runner tests are excluded (30 MB wasm + network).
|
||||
|
||||
## What's covered
|
||||
|
||||
**Unit + regression** (`src/__tests__/`) — each guards a bug this stack actually hit:
|
||||
- `brand-store.test.ts` — `useBrand`/`getBrand` are plain getters callable
|
||||
outside render (the invalid-hook-call that blocked the whole web app).
|
||||
- `host-shims.test.ts` — `getCurrentWindow().emit` etc. exist; the injected host
|
||||
adapter switches web↔tauri (the "emit is not a function" mount crash).
|
||||
- `resizable.test.tsx` — `react-resizable-panels` v3 API present, net-ui
|
||||
resizable renders (the v4 "Element type is invalid" chat-view crash).
|
||||
- `no-missing-imports.test.ts` — critical screens import every `<Component>`
|
||||
they use (the dropped `Box`/`Boxes`/`Coins` lucide imports → "Box is not defined").
|
||||
|
||||
**App tests** (pre-existing, `src/app/**`) — engine client, machine-state queries,
|
||||
mining page, playground utils. **E2E** (`e2e/chat.e2e.test.ts`) — drives the real
|
||||
browser: onboarding renders, then connect → register → send → assert zen-coder's
|
||||
reply (and assert no "is not defined" / "Element type is invalid" page errors).
|
||||
|
||||
## TDD workflow (going forward)
|
||||
|
||||
Every fix and feature starts with a failing test:
|
||||
|
||||
1. **Red** — write a test that reproduces the bug or specifies the behavior, in
|
||||
`src/__tests__/` (unit) or `e2e/` (flow). Run `test:watch`; confirm it fails.
|
||||
2. **Green** — make the smallest change that passes it.
|
||||
3. **Refactor** — clean up with the test green.
|
||||
4. Run `bun run --cwd pkgs/ai test` before committing; keep it green.
|
||||
|
||||
Most bugs this session were import/dep/runtime issues that *static render checks
|
||||
missed but a test would have caught* — prefer a regression test over a manual
|
||||
re-check. For anything touching the chat path, add/extend the e2e.
|
||||
|
||||
The local runtime for `test:e2e` is in `pkgs/ai/local-runtime/` (see `CHAT.md`):
|
||||
restart `run-node.sh` (fresh storage) before a run so Quick Connect registers as
|
||||
the first device.
|
||||
@@ -0,0 +1,99 @@
|
||||
// End-to-end: the web app chats with zen-coder through a real hanzo-node, fully
|
||||
// in the browser. Requires the local runtime up (see pkgs/ai/CHAT.md +
|
||||
// local-runtime/): web :1500, node :3700, responses-proxy :36906, engine :36902.
|
||||
// Skips (not fails) when the runtime isn't reachable, so it's safe in CI.
|
||||
//
|
||||
// Run: bun run --cwd pkgs/ai test:e2e
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser } from 'playwright';
|
||||
|
||||
const WEB = process.env.E2E_WEB ?? 'http://localhost:1500';
|
||||
const NODE_ADDR = process.env.E2E_NODE_ADDR ?? 'http://localhost:1500'; // same-origin vite proxy → node
|
||||
const CHROMIUM = process.env.CHROMIUM_BIN ?? '/snap/bin/chromium';
|
||||
|
||||
async function reachable(url: string): Promise<boolean> {
|
||||
try {
|
||||
const r = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
||||
return r.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let browser: Browser | null = null;
|
||||
let runtimeUp = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
runtimeUp = (await reachable(WEB)) && (await reachable(`${WEB}/v1/node/health_check`));
|
||||
if (runtimeUp) {
|
||||
browser = await chromium.launch({ executablePath: CHROMIUM, args: ['--no-sandbox', '--disable-gpu'] });
|
||||
}
|
||||
});
|
||||
afterAll(async () => { await browser?.close(); });
|
||||
|
||||
describe('@hanzo/ai web e2e', () => {
|
||||
it('renders the onboarding screen (smoke)', async (ctx) => {
|
||||
if (!runtimeUp) return ctx.skip();
|
||||
const page = await browser!.newPage({ viewport: { width: 1280, height: 820 } });
|
||||
await page.goto(WEB, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2500);
|
||||
const text = await page.innerText('body');
|
||||
expect(text).toContain('Welcome to Hanzo');
|
||||
expect(text).toContain('Quick Connect');
|
||||
await page.close();
|
||||
}, 60000);
|
||||
|
||||
it('connects to the node, sends a message, and gets zen-coder’s reply', async (ctx) => {
|
||||
if (!runtimeUp) return ctx.skip();
|
||||
const page = await browser!.newPage({ viewport: { width: 1280, height: 820 } });
|
||||
const pageErrors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const failedReqs: string[] = [];
|
||||
page.on('pageerror', (e) => pageErrors.push(e.message));
|
||||
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
||||
page.on('response', (r) => { if (r.status() >= 400) failedReqs.push(`${r.status()} ${r.url()}`); });
|
||||
|
||||
await page.goto(WEB, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// agree to terms, then Quick Connect
|
||||
for (const sel of ['button[role=switch]', 'input[type=checkbox]']) {
|
||||
try { const el = await page.$(sel); if (el) { await el.click({ timeout: 1500 }); break; } } catch { /* */ }
|
||||
}
|
||||
await page.waitForTimeout(400);
|
||||
try { await page.click('a[href="/quick-connection"]', { timeout: 4000 }); }
|
||||
catch { await page.getByText('Quick Connect').click({ timeout: 4000 }); }
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// connect to the node (same-origin proxy address)
|
||||
await page.fill('input[name=node_address]', NODE_ADDR);
|
||||
await page.click('button:has-text("Connect")');
|
||||
await page.waitForTimeout(8000);
|
||||
expect(page.url()).toContain('/home');
|
||||
|
||||
// send a deterministic prompt
|
||||
const input = (await page.$('[placeholder*="Send a message"]')) ?? (await page.$('textarea'));
|
||||
expect(input, 'chat input should be present on /home').toBeTruthy();
|
||||
await input!.click();
|
||||
await page.keyboard.type('Reply with exactly the three words: E2E CHAT OK');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// poll for the model reply (user echo + assistant reply = ≥2 occurrences)
|
||||
let replied = false;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await page.waitForTimeout(2000);
|
||||
const body = await page.innerText('body');
|
||||
if ((body.match(/E2E CHAT OK/g) ?? []).length >= 2) { replied = true; break; }
|
||||
}
|
||||
await page.screenshot({ path: '/tmp/e2e-chat.png' }).catch(() => {});
|
||||
expect(replied, 'zen-coder should reply with the prompted text').toBe(true);
|
||||
// No fatal page errors (Box/resizable crashes)…
|
||||
expect(pageErrors.join('\n')).not.toMatch(/is not defined|Element type is invalid/);
|
||||
// …and none of the console errors we fixed (host shims, CORS, react-query, auth timing).
|
||||
const allErrors = [...pageErrors, ...consoleErrors, ...failedReqs].join('\n');
|
||||
expect(allErrors, allErrors).not.toMatch(
|
||||
/is not a function|Query data cannot be undefined|blocked by CORS|available_models|ws:\/\/localhost:1501/,
|
||||
);
|
||||
await page.close();
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{ "name": "@hanzo/desktop-shim", "private": true, "type": "module",
|
||||
"scripts": { "dev": "VITE_BRAND=hanzo vite", "build": "VITE_BRAND=hanzo vite build" },
|
||||
"dependencies": { "@hanzo/ai": "workspace:*",
|
||||
"react": "^19.0.0", "react-dom": "^19.0.0", "@tauri-apps/api": "^2.0.0" } }
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BrandConfig } from '@hanzo_network/brand-config';
|
||||
|
||||
// This app OWNS its brand. The @hanzo/ai SDK bundles no brands; it receives this
|
||||
// config via <HanzoAI {...brandConfig}/>. Edit values here — nothing brand-
|
||||
// specific is hardcoded in the SDK.
|
||||
export const brandConfig: BrandConfig = {
|
||||
brand: 'hanzo',
|
||||
name: 'Hanzo',
|
||||
productName: 'Hanzo Desktop',
|
||||
company: 'Hanzo AI',
|
||||
identifier: 'com.hanzo.desktop',
|
||||
logo: {
|
||||
light: 'libs/hanzo-logo/assets/hanzo/hanzo-logo.svg',
|
||||
dark: 'libs/hanzo-logo/assets/hanzo/hanzo-logo.svg',
|
||||
favicon: 'libs/hanzo-logo/assets/hanzo/hanzo-icon.svg',
|
||||
},
|
||||
colors: { primary: '#000000', bg: '#000000', fg: '#ffffff' },
|
||||
hosts: ['hanzo.ai', 'hanzo.network'],
|
||||
storeUrl: {
|
||||
mac: 'https://github.com/hanzoai/desktop/releases/latest',
|
||||
win: 'https://github.com/hanzoai/desktop/releases/latest',
|
||||
ios: '',
|
||||
android: '',
|
||||
},
|
||||
network: {
|
||||
rpc: 'https://rpc.hanzo.network',
|
||||
chainId: 36900,
|
||||
token: '$AI',
|
||||
aiMiningPrecompile: '0x0300000000000000000000000000000000000000',
|
||||
blockExplorer: 'https://explorer.hanzo.network',
|
||||
},
|
||||
overlayController: 'https://edge.hanzo.network',
|
||||
inferenceEndpoint: 'https://gateway.hanzo.ai',
|
||||
iam: {
|
||||
baseUrl: 'https://hanzo.id',
|
||||
clientId: 'hanzo-app',
|
||||
redirectUri: 'hanzo://oauth/hanzo',
|
||||
callbackEvent: 'hanzo-iam-callback',
|
||||
},
|
||||
machinesEnabled: true,
|
||||
};
|
||||
|
||||
export default brandConfig;
|
||||
@@ -0,0 +1,11 @@
|
||||
// hanzo-desktop — the entire app, as a thin shim over @hanzo/ai's DESKTOP build
|
||||
// (real @tauri-apps native APIs; brand via VITE_BRAND=hanzo at build).
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import HanzoAI from '@hanzo/ai/desktop';
|
||||
import { brandConfig } from './brand.config';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<HanzoAI {...brandConfig}
|
||||
features={{ chat: true, wallet: true, mining: true, tools: true, agents: true }}
|
||||
/>,
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": { "composite": true },
|
||||
"references": [],
|
||||
"exclude": ["types", "dist", "**/__tests__"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{ "name": "@lux/desktop-shim", "private": true, "type": "module",
|
||||
"scripts": { "dev": "VITE_BRAND=lux vite", "build": "VITE_BRAND=lux vite build" },
|
||||
"dependencies": { "@hanzo/ai": "workspace:*",
|
||||
"react": "^19.0.0", "react-dom": "^19.0.0", "@tauri-apps/api": "^2.0.0" } }
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BrandConfig } from '@hanzo_network/brand-config';
|
||||
|
||||
// This app OWNS its brand. The @hanzo/ai SDK bundles no brands; it receives this
|
||||
// config via <HanzoAI {...brandConfig}/>. Edit values here — nothing brand-
|
||||
// specific is hardcoded in the SDK.
|
||||
export const brandConfig: BrandConfig = {
|
||||
brand: 'lux',
|
||||
name: 'Lux',
|
||||
productName: 'Lux Desktop',
|
||||
company: 'Lux Partners',
|
||||
identifier: 'com.lux.desktop',
|
||||
logo: {
|
||||
light: 'libs/hanzo-logo/assets/lux/lux-logo.svg',
|
||||
dark: 'libs/hanzo-logo/assets/lux/lux-logo.svg',
|
||||
favicon: 'libs/hanzo-logo/assets/lux/lux-icon.svg',
|
||||
},
|
||||
colors: { primary: '#000000', bg: '#000000', fg: '#ffffff' },
|
||||
hosts: ['lux.network', 'lux.cloud', 'lux.exchange', 'lux.ai'],
|
||||
storeUrl: {
|
||||
mac: 'https://github.com/luxfi/desktop/releases/latest',
|
||||
win: 'https://github.com/luxfi/desktop/releases/latest',
|
||||
ios: '',
|
||||
android: '',
|
||||
},
|
||||
network: {
|
||||
rpc: 'https://api.lux.network',
|
||||
chainId: 96369,
|
||||
token: '$LUX',
|
||||
aiMiningPrecompile: '0x0300000000000000000000000000000000000000',
|
||||
blockExplorer: 'https://explorer.lux.network',
|
||||
},
|
||||
overlayController: 'https://edge.lux.cloud',
|
||||
inferenceEndpoint: 'https://gateway.hanzo.ai',
|
||||
iam: {
|
||||
baseUrl: 'https://lux.id',
|
||||
clientId: 'lux-app',
|
||||
redirectUri: 'lux://oauth/lux',
|
||||
callbackEvent: 'lux-iam-callback',
|
||||
},
|
||||
machinesEnabled: true,
|
||||
};
|
||||
|
||||
export default brandConfig;
|
||||
@@ -0,0 +1,11 @@
|
||||
// lux-desktop — the entire app, as a thin shim over @hanzo/ai's DESKTOP build
|
||||
// (real @tauri-apps native APIs; brand via VITE_BRAND=lux at build).
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import HanzoAI from '@hanzo/ai/desktop';
|
||||
import { brandConfig } from './brand.config';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<HanzoAI {...brandConfig}
|
||||
features={{ chat: true, wallet: true, mining: true, tools: true, agents: true }}
|
||||
/>,
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": { "composite": true },
|
||||
"references": [],
|
||||
"exclude": ["types", "dist", "**/__tests__"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Desktop (Tauri) host adapter. The ONLY place the desktop shim touches
|
||||
// @tauri-apps — everything else in @hanzo/ai goes through this injected host.
|
||||
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
||||
import { listen as tauriListen } from '@tauri-apps/api/event';
|
||||
import type { HostAdapter } from '@hanzo/ai';
|
||||
export const tauriHost: HostAdapter = {
|
||||
platform: 'tauri',
|
||||
invoke: (cmd, args) => tauriInvoke(cmd, args as Record<string, unknown>),
|
||||
listen: async (event, cb) => tauriListen(event, (e) => cb(e)),
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>hanzo.app — shim</title></head>
|
||||
<body style="margin:0;background:#000"><div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script></body></html>
|
||||
@@ -0,0 +1,4 @@
|
||||
{ "name": "@hanzo/web-shim", "private": true, "type": "module",
|
||||
"scripts": { "dev": "vite", "build": "vite build" },
|
||||
"dependencies": { "@hanzo/ai": "workspace:*",
|
||||
"react": "^19.0.0", "react-dom": "^19.0.0", "vite": "^7.0.0" } }
|
||||
@@ -0,0 +1,8 @@
|
||||
// hanzo.app — the whole app, consumed as a published SDK. Brand from the
|
||||
// bundled resolver (env/hostname); platform is the web default (no host prop).
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import HanzoAI, { getBrand } from '@hanzo/ai';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<HanzoAI {...getBrand()} features={{ chat: true, wallet: true, agents: true }} />,
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": { "composite": true },
|
||||
"references": [],
|
||||
"exclude": ["types", "dist", "**/__tests__"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// External-style consumption proof: this shim imports @hanzo/ai from its built
|
||||
// dist and provides ONLY react/react-dom. Everything else (the app, net-* libs,
|
||||
// @tauri-apps host shims, 3rd-party deps) is bundled inside dist. This is what
|
||||
// luxfi/app, zooai/app, hanzoai/desktop and hanzo.app become — a ~10-line shim.
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'node:path';
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
plugins: [react()],
|
||||
server: { port: 1503, strictPort: true, host: '0.0.0.0' },
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
alias: { '@hanzo/ai': resolve(__dirname, '../../dist/index.js') },
|
||||
},
|
||||
optimizeDeps: { include: ['react', 'react-dom', 'react-dom/client'] },
|
||||
build: { outDir: 'dist-shim', minify: 'esbuild', chunkSizeWarningLimit: 9000 },
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{ "name": "@zoo/desktop-shim", "private": true, "type": "module",
|
||||
"scripts": { "dev": "VITE_BRAND=zoo vite", "build": "VITE_BRAND=zoo vite build" },
|
||||
"dependencies": { "@hanzo/ai": "workspace:*",
|
||||
"react": "^19.0.0", "react-dom": "^19.0.0", "@tauri-apps/api": "^2.0.0" } }
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BrandConfig } from '@hanzo_network/brand-config';
|
||||
|
||||
// This app OWNS its brand. The @hanzo/ai SDK bundles no brands; it receives this
|
||||
// config via <HanzoAI {...brandConfig}/>. Edit values here — nothing brand-
|
||||
// specific is hardcoded in the SDK.
|
||||
export const brandConfig: BrandConfig = {
|
||||
brand: 'zoo',
|
||||
name: 'Zoo',
|
||||
productName: 'Zoo Desktop',
|
||||
company: 'Zoo Labs',
|
||||
identifier: 'com.zoo.desktop',
|
||||
logo: {
|
||||
light: 'libs/hanzo-logo/assets/zoo/zoo-logo.svg',
|
||||
dark: 'libs/hanzo-logo/assets/zoo/zoo-logo.svg',
|
||||
favicon: 'libs/hanzo-logo/assets/zoo/zoo-icon.svg',
|
||||
},
|
||||
colors: { primary: '#000000', bg: '#000000', fg: '#ffffff' },
|
||||
hosts: ['zoo.ngo', 'zoo.network', 'zoo.cloud'],
|
||||
storeUrl: {
|
||||
mac: 'https://github.com/zooai/desktop/releases/latest',
|
||||
win: 'https://github.com/zooai/desktop/releases/latest',
|
||||
ios: '',
|
||||
android: '',
|
||||
},
|
||||
network: {
|
||||
rpc: 'https://rpc.zoo.network',
|
||||
chainId: 200200,
|
||||
token: '$ZOO',
|
||||
aiMiningPrecompile: '0x0300000000000000000000000000000000000000',
|
||||
blockExplorer: 'https://explorer.zoo.network',
|
||||
},
|
||||
overlayController: 'https://edge.zoo.cloud',
|
||||
inferenceEndpoint: 'https://gateway.hanzo.ai',
|
||||
iam: {
|
||||
baseUrl: 'https://zoolabs.id',
|
||||
clientId: 'zoo-app',
|
||||
redirectUri: 'zoo://oauth/zoo',
|
||||
callbackEvent: 'zoo-iam-callback',
|
||||
},
|
||||
machinesEnabled: true,
|
||||
};
|
||||
|
||||
export default brandConfig;
|
||||
@@ -0,0 +1,11 @@
|
||||
// zoo-desktop — the entire app, as a thin shim over @hanzo/ai's DESKTOP build
|
||||
// (real @tauri-apps native APIs; brand via VITE_BRAND=zoo at build).
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import HanzoAI from '@hanzo/ai/desktop';
|
||||
import { brandConfig } from './brand.config';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<HanzoAI {...brandConfig}
|
||||
features={{ chat: true, wallet: true, mining: true, tools: true, agents: true }}
|
||||
/>,
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": { "composite": true },
|
||||
"references": [],
|
||||
"exclude": ["types", "dist", "**/__tests__"]
|
||||
}
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Adapter: hanzo-node's OpenAI provider POSTs /v1/engine/responses, but
|
||||
hanzo-engine serves the OpenAI Responses API at /v1/responses. Rewrite the path
|
||||
and force enable_thinking=false (Qwen-family thinking streams reasoning + hangs
|
||||
the node). Pure streaming passthrough otherwise. Run: python3 responses-proxy.py
|
||||
"""
|
||||
import json, urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
UP = "http://127.0.0.1:36902" # hanzo-engine (zen-coder). 127.0.0.1 — NOT localhost (IPv6 ::1 fails).
|
||||
class H(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
def log_message(self, *a): pass
|
||||
def _fwd(self, method):
|
||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||
body = self.rfile.read(n) if n else b""
|
||||
path = self.path.replace("/v1/engine/", "/v1/")
|
||||
if body and ("responses" in path or "chat/completions" in path):
|
||||
try:
|
||||
o = json.loads(body); o.setdefault("enable_thinking", False); o["model"] = "default"
|
||||
body = json.dumps(o).encode()
|
||||
except Exception: pass
|
||||
try:
|
||||
req = urllib.request.Request(UP + path, data=body if body else None, method=method)
|
||||
for k, v in self.headers.items():
|
||||
if k.lower() not in ("host","content-length","connection","accept-encoding"): req.add_header(k, v)
|
||||
r = urllib.request.urlopen(req, timeout=180)
|
||||
self.send_response(r.status)
|
||||
for k, v in r.headers.items():
|
||||
if k.lower() not in ("connection","transfer-encoding","content-length"): self.send_header(k, v)
|
||||
self.end_headers()
|
||||
while True:
|
||||
c = r.read(4096)
|
||||
if not c: break
|
||||
self.wfile.write(c); self.wfile.flush()
|
||||
except Exception:
|
||||
try: self.send_response(502); self.end_headers()
|
||||
except Exception: pass
|
||||
def do_GET(self): self._fwd("GET")
|
||||
def do_POST(self): self._fwd("POST")
|
||||
ThreadingHTTPServer(("127.0.0.1", 36906), H).serve_forever()
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# A standalone /v1 hanzo-node wired to the zen engine (via responses-proxy :36906)
|
||||
# and embeddings (:11436). Fresh storage, no registration code → the web app's
|
||||
# Quick Connect auto-registers. NODE bin = the desktop build (adjust if needed).
|
||||
NODE_BIN="${HANZO_NODE_BIN:-$HOME/work/hanzo/desktop/apps/hanzo-desktop/src-tauri/target/debug/hanzo-node}"
|
||||
export RUST_BACKTRACE=1 \
|
||||
NODE_API_IP=0.0.0.0 NODE_API_PORT=3700 NODE_WS_PORT=3701 NODE_IP=127.0.0.1 NODE_PORT=3702 \
|
||||
NODE_API_HTTPS_PORT=3703 NODE_ZAP_PORT=3704 \
|
||||
GLOBAL_IDENTITY_NAME="@@localhost.sep-hanzo" API_V2_KEY="hanzo-e2e-2026" \
|
||||
NODE_STORAGE_PATH="/tmp/hanzo-e2e-storage" \
|
||||
EMBEDDINGS_SERVER_URL="http://127.0.0.1:11436" FIRST_DEVICE_NEEDS_REGISTRATION_CODE=false \
|
||||
STARTING_NUM_QR_DEVICES=0 LOG_ALL=1 \
|
||||
INITIAL_AGENT_NAMES="zen_engine" INITIAL_AGENT_URLS="http://127.0.0.1:36906" \
|
||||
INITIAL_AGENT_MODELS="openai:default" INITIAL_AGENT_API_KEYS="local" \
|
||||
DEFAULT_EMBEDDING_MODEL="zenlm/zen-embedding-0.6B" EMBEDDING_VECTOR_DIMENSIONS=1024
|
||||
exec "$NODE_BIN"
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
// TCP proxy: the web app derives its WS URL as ws://<host>:<httpPort+1>/ws
|
||||
// (shinkai convention). Behind the vite proxy that's :1501, which has no
|
||||
// service — forward it to the node's WS port. Raw TCP relays the WS upgrade.
|
||||
// (ESM: the @hanzo/ai package is "type":"module", so use import, not require.)
|
||||
import net from 'node:net';
|
||||
const LISTEN = Number(process.env.WS_PROXY_PORT || 1501);
|
||||
const [TH, TP] = (process.env.NODE_WS || '127.0.0.1:3701').split(':');
|
||||
net
|
||||
.createServer((c) => {
|
||||
const up = net.connect(Number(TP), TH);
|
||||
c.on('error', () => up.destroy());
|
||||
up.on('error', () => c.destroy());
|
||||
c.pipe(up);
|
||||
up.pipe(c);
|
||||
})
|
||||
.listen(LISTEN, '0.0.0.0', () => console.log(`ws-proxy :${LISTEN} -> ${TH}:${TP}`));
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"name": "@hanzo/ai",
|
||||
"version": "0.1.1",
|
||||
"description": "The Hanzo AI app as one declarative SDK component \u2014 <HanzoAI {...brand} />. Brand is a prop; hanzo/zoo/lux apps are thin shims (the @luxfi/exchange pattern, for AI).",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./desktop": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist-desktop/index.js",
|
||||
"require": "./dist-desktop/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"BUILD.md",
|
||||
"dist-desktop"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=12288 vite build && NODE_OPTIONS=--max-old-space-size=12288 vite build --config vite.desktop.config.ts && cp types/public.d.ts dist/index.d.ts",
|
||||
"dev": "vite build --watch",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:web": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-popover": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@rjsf/core": "^5.22.0",
|
||||
"@rjsf/utils": "^5.24.13",
|
||||
"@rjsf/validator-ajv8": "^5.22.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@zxing/browser": "^0.2.0",
|
||||
"axios": "^1.17.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cronstrue": "^2.50.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"ethers": "^6.16.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"filesize": "^11.0.17",
|
||||
"framer-motion": "^12.40.0",
|
||||
"i18next": "^24.0.0",
|
||||
"immer": "^11.1.8",
|
||||
"lucide-react": "^0.460.0",
|
||||
"primereact": "^10.8.0",
|
||||
"prism-react-editor": "^3.0.0",
|
||||
"pyodide": "^0.29.4",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-error-boundary": "^4.1.0",
|
||||
"react-hook-form": "^7.77.0",
|
||||
"react-hotkeys-hook": "^4.6.2",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-intersection-observer": "^10.0.3",
|
||||
"react-plotly.js": "^2.6.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router": "^7.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
"recharts": "^2.13.0",
|
||||
"sonner": "^0.3.5",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"ts-deepmerge": "^7.0.3",
|
||||
"yjs": "^13.6.0",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"react-scan": "^0.5.7",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.0.4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Regression: the invalid-hook-call that blocked the web render. The brand
|
||||
// store must be PLAIN module getters (not React hooks), because the migrated
|
||||
// app calls useBrand()/getBrand() from module scope, utils and event handlers.
|
||||
//
|
||||
// Neutral by construction: this test uses FIXTURE brands (not hanzo/zoo/lux) —
|
||||
// the package bundles no brands; hosts provide their own via setBrand/registerBrands.
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
setBrand,
|
||||
getBrand,
|
||||
useBrand,
|
||||
getBrandFromHostname,
|
||||
registerBrands,
|
||||
type BrandConfig,
|
||||
} from '@hanzo_network/brand-config';
|
||||
|
||||
const fixture = (id: string, host: string): BrandConfig => ({
|
||||
brand: id,
|
||||
name: id,
|
||||
productName: id,
|
||||
company: id,
|
||||
identifier: `com.${id}.desktop`,
|
||||
logo: { light: '', dark: '', favicon: '' },
|
||||
colors: { primary: '#000000', bg: '#000000', fg: '#ffffff' },
|
||||
hosts: [host],
|
||||
storeUrl: { mac: '', win: '', ios: '', android: '' },
|
||||
network: {
|
||||
rpc: '',
|
||||
chainId: 1,
|
||||
token: `$${id.toUpperCase()}`,
|
||||
aiMiningPrecompile: '0x0300000000000000000000000000000000000000',
|
||||
blockExplorer: '',
|
||||
},
|
||||
overlayController: `https://edge.${host}`,
|
||||
inferenceEndpoint: 'https://gateway.example',
|
||||
iam: {
|
||||
baseUrl: `https://${id}.id`,
|
||||
clientId: `${id}-app`,
|
||||
redirectUri: `${id}://oauth/${id}`,
|
||||
callbackEvent: `${id}-iam-callback`,
|
||||
},
|
||||
});
|
||||
|
||||
const ALPHA = fixture('alpha', 'alpha.test');
|
||||
const BETA = fixture('beta', 'beta.test');
|
||||
|
||||
describe('@hanzo_network/brand-config — neutral injectable brand store', () => {
|
||||
beforeEach(() => setBrand(ALPHA));
|
||||
|
||||
it('useBrand is a plain getter, callable OUTSIDE a React render', () => {
|
||||
// If useBrand were a real hook, calling it here (no component, no renderer)
|
||||
// would throw "invalid hook call" — the exact bug that broke the web app.
|
||||
expect(typeof useBrand).toBe('function');
|
||||
const b = useBrand();
|
||||
expect(b).toBeTruthy();
|
||||
expect(typeof b.brand).toBe('string');
|
||||
});
|
||||
|
||||
it('setBrand injects the active brand; useBrand/getBrand return it', () => {
|
||||
setBrand(ALPHA);
|
||||
expect(useBrand().brand).toBe('alpha');
|
||||
setBrand(BETA);
|
||||
expect(getBrand().brand).toBe('beta');
|
||||
});
|
||||
|
||||
it('no bundled brands: registerBrands + getBrandFromHostname resolves by host', () => {
|
||||
registerBrands([ALPHA, BETA]);
|
||||
expect(getBrandFromHostname('alpha.test')?.brand).toBe('alpha');
|
||||
expect(getBrandFromHostname('app.beta.test')?.brand).toBe('beta');
|
||||
expect(getBrandFromHostname('unknown.example')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('each brand config carries its cloud + chain identity', () => {
|
||||
expect(ALPHA.inferenceEndpoint).toBeTruthy();
|
||||
expect(ALPHA.overlayController).toContain('alpha');
|
||||
expect(ALPHA.network.token).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Regression: "getCurrentWindow(...).emit is not a function" crashed the mount
|
||||
// after the brand fix. The web host shims must expose the full surface the app
|
||||
// touches, as no-op-safe defaults.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getCurrentWindow, getAllWindows } from '../host/window';
|
||||
import { isNative, platformName, invoke, listen, emit, setHost } from '../host/runtime';
|
||||
import * as notification from '../host/notification';
|
||||
|
||||
describe('host window shim (web-safe)', () => {
|
||||
it('getCurrentWindow() exposes emit/listen/once + lifecycle as functions', () => {
|
||||
const w = getCurrentWindow();
|
||||
expect(w.label).toBe('main');
|
||||
for (const m of ['emit', 'listen', 'once', 'onCloseRequested', 'onResized', 'setTitle', 'close', 'show'] as const) {
|
||||
expect(typeof (w as Record<string, unknown>)[m]).toBe('function');
|
||||
}
|
||||
expect(getAllWindows().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('emit resolves and listen returns an unlisten function (no-op on web)', async () => {
|
||||
const w = getCurrentWindow();
|
||||
await expect(w.emit('evt', { a: 1 })).resolves.toBeUndefined();
|
||||
const unlisten = await w.listen('evt', () => {});
|
||||
expect(typeof unlisten).toBe('function');
|
||||
expect(() => unlisten()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('host runtime (injected platform adapter)', () => {
|
||||
it('defaults to web; invoke returns null (not undefined → react-query error)', async () => {
|
||||
expect(platformName()).toBe('web');
|
||||
expect(isNative()).toBe(false);
|
||||
// null, NOT undefined — React Query rejects undefined query results.
|
||||
await expect(invoke('any_cmd')).resolves.toBeNull();
|
||||
const un = await listen('e', () => {});
|
||||
expect(typeof un).toBe('function');
|
||||
await expect(emit('e', {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('setHost switches to an injected native adapter (tauri/expo)', async () => {
|
||||
const calls: string[] = [];
|
||||
setHost({ platform: 'tauri', invoke: async (c: string) => { calls.push(c); return 'ok'; } });
|
||||
expect(platformName()).toBe('tauri');
|
||||
expect(isNative()).toBe(true);
|
||||
await expect(invoke('greet')).resolves.toBe('ok');
|
||||
expect(calls).toEqual(['greet']);
|
||||
setHost({ platform: 'web' }); // reset for other tests
|
||||
});
|
||||
});
|
||||
|
||||
describe('host notification shim (web-safe)', () => {
|
||||
it('exposes isPermissionGranted/requestPermission/sendNotification', () => {
|
||||
// Regression: "isPermissionGranted is not a function" — the shim was a stub.
|
||||
expect(typeof notification.isPermissionGranted).toBe('function');
|
||||
expect(typeof notification.requestPermission).toBe('function');
|
||||
expect(typeof notification.sendNotification).toBe('function');
|
||||
});
|
||||
|
||||
it('isPermissionGranted resolves a boolean; sendNotification never throws', async () => {
|
||||
await expect(notification.isPermissionGranted()).resolves.toEqual(expect.any(Boolean));
|
||||
expect(() => notification.sendNotification('hi')).not.toThrow();
|
||||
expect(() => notification.sendNotification({ title: 'a', body: 'b' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Regression: a migration codemod dropped lucide imports (Box/Boxes/Coins) from
|
||||
// main-layout.tsx, so the chat screens crashed with "Box is not defined" — only
|
||||
// caught by running the real UI. This static guard fails if a critical screen
|
||||
// uses a <Component> it never imports or declares.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Components that are ambient/built-in (used as <X> but not import-able by name).
|
||||
const AMBIENT = new Set(['Fragment', 'Suspense', 'StrictMode', 'Profiler']);
|
||||
|
||||
/** Returns JSX components used in `src` that are neither imported nor declared. */
|
||||
function missingComponents(src: string): string[] {
|
||||
const known = new Set<string>(AMBIENT);
|
||||
|
||||
// import clauses (multi-line aware): default, named ({A, B as C, type D}), namespace
|
||||
for (const m of src.matchAll(/import\s+([^;]*?)\s+from\s+['"][^'"]+['"]/gs)) {
|
||||
const clause = m[1].trim();
|
||||
const named = clause.match(/\{([\s\S]*?)\}/);
|
||||
if (named) {
|
||||
for (const part of named[1].split(',')) {
|
||||
const name = part.replace(/\btype\b/, '').trim().split(/\s+as\s+/).pop()?.trim();
|
||||
if (name) known.add(name);
|
||||
}
|
||||
}
|
||||
if (!clause.startsWith('{') && !clause.startsWith('*')) {
|
||||
const def = clause.match(/^(\w+)/);
|
||||
if (def) known.add(def[1]);
|
||||
}
|
||||
const ns = clause.match(/\*\s+as\s+(\w+)/);
|
||||
if (ns) known.add(ns[1]);
|
||||
}
|
||||
// local declarations (const/let/var/function/class X)
|
||||
for (const m of src.matchAll(/\b(?:const|let|var|function|class)\s+(\w+)/g)) known.add(m[1]);
|
||||
|
||||
// destructured locals: `const { t, Trans } = useTranslation()`, `const [a] = …`
|
||||
for (const m of src.matchAll(/\b(?:const|let|var)\s+(?:\{([^}]*)\}|\[([^\]]*)\])\s*=/g)) {
|
||||
for (const part of (m[1] || m[2] || '').split(',')) {
|
||||
const name = part.split(':').pop()?.replace(/\.{3}/, '').trim();
|
||||
if (name && /^[A-Za-z_]\w*$/.test(name)) known.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// JSX open tags `<Xxx` NOT preceded by an identifier char (excludes generics
|
||||
// like forwardRef<HTMLDivElement,…>) and followed by whitespace, > or />.
|
||||
const used = new Set<string>();
|
||||
for (const m of src.matchAll(/(?<![A-Za-z0-9_.])<([A-Z]\w+)(?=[\s/>])/g)) used.add(m[1]);
|
||||
|
||||
return [...used].filter((u) => !known.has(u)).sort();
|
||||
}
|
||||
|
||||
// Critical screens that render right after connect — exactly where the bug bit.
|
||||
const CRITICAL = [
|
||||
'pages/layout/main-layout.tsx',
|
||||
'pages/home.tsx',
|
||||
'pages/containers.tsx',
|
||||
];
|
||||
|
||||
describe('no missing JSX component imports (critical screens)', () => {
|
||||
for (const file of CRITICAL) {
|
||||
it(`${file}: every <Component> used is imported or declared`, () => {
|
||||
const src = readFileSync(resolve(__dirname, '../app', file), 'utf8');
|
||||
expect(missingComponents(src)).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Regression: the merge pulled react-resizable-panels@4 (Group/Separator), but
|
||||
// the app's net-ui resizable.tsx uses the v3 API (PanelGroup/PanelResizeHandle).
|
||||
// v4 made those undefined → "Element type is invalid" crashed the chat view.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as RRP from 'react-resizable-panels';
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from '@hanzo_network/hanzo-ui/components/resizable';
|
||||
|
||||
describe('react-resizable-panels — v3 API present (not v4)', () => {
|
||||
it('exposes PanelGroup / Panel / PanelResizeHandle', () => {
|
||||
expect(RRP.PanelGroup).toBeDefined();
|
||||
expect(RRP.Panel).toBeDefined();
|
||||
expect(RRP.PanelResizeHandle).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('net-ui resizable components', () => {
|
||||
it('are all defined (undefined → "Element type is invalid")', () => {
|
||||
expect(ResizablePanelGroup).toBeDefined();
|
||||
expect(ResizablePanel).toBeDefined();
|
||||
expect(ResizableHandle).toBeDefined();
|
||||
});
|
||||
|
||||
it('render a panel group without throwing', () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel defaultSize={50}>left</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={50}>right</ResizablePanel>
|
||||
</ResizablePanelGroup>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// Vitest global setup: extend expect with @testing-library/jest-dom matchers
|
||||
// (toBeInTheDocument, toHaveTextContent, …) used across the app's component tests.
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { I18nProvider } from '@hanzo_network/hanzo-i18n';
|
||||
import { QueryProvider } from '@hanzo_network/hanzo-node-state';
|
||||
import { Toaster, TooltipProvider } from '@hanzo_network/hanzo-ui';
|
||||
import { info } from '@tauri-apps/plugin-log';
|
||||
import { useEffect } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { BrowserRouter as Router } from 'react-router';
|
||||
|
||||
import FullPageErrorFallback from './components/error-boundary';
|
||||
import { OAuthConnect } from './components/oauth/oauth-connect';
|
||||
import { useEmbeddingMigrationToast } from './lib/embedding-migration/embedding-migration-hooks';
|
||||
import { useEmbeddingStartupCheck } from './lib/embedding-migration/embedding-startup-check-hooks';
|
||||
import { AnalyticsProvider } from './lib/posthog-provider';
|
||||
import AppRoutes from './routes';
|
||||
import { useSyncStorageSecondary } from './store/sync-utils';
|
||||
|
||||
// Component that wraps router content and calls hooks that need router context
|
||||
function RouterContent() {
|
||||
useEmbeddingStartupCheck(); // This needs router context for useNavigate()
|
||||
|
||||
return <AppRoutes />;
|
||||
}
|
||||
|
||||
// Component that wraps features requiring QueryClient
|
||||
function AppWithQuery() {
|
||||
useEmbeddingMigrationToast(); // This doesn't need router context
|
||||
|
||||
return (
|
||||
<>
|
||||
<OAuthConnect />
|
||||
<Router>
|
||||
<RouterContent />
|
||||
</Router>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
void info('initializing main');
|
||||
}, []);
|
||||
useSyncStorageSecondary();
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<I18nProvider>
|
||||
<ErrorBoundary FallbackComponent={FullPageErrorFallback}>
|
||||
<AnalyticsProvider>
|
||||
<QueryProvider>
|
||||
<AppWithQuery />
|
||||
</QueryProvider>
|
||||
</AnalyticsProvider>
|
||||
</ErrorBoundary>
|
||||
</I18nProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function AddAgentFromIdModal() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [agentId, setAgentId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setAgentId('@@');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleAdd = () => {
|
||||
// TODO: Add agent using the provided Hanzo ID
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
{t('networkAgentsPage.addAgentFromId')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('networkAgentsPage.addAgentFromId')}</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<p>{t('networkAgentsPage.addAgentFromIdDescription')}</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder={t('networkAgentsPage.addAgentFromIdPlaceholder')}
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
/>
|
||||
<DialogFooter className="flex-row gap-1">
|
||||
<Button variant="outline" size="md" onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="md" onClick={handleAdd}>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import AgentForm from './agent-form';
|
||||
|
||||
function AddAgentPage() {
|
||||
return <AgentForm mode="add" />;
|
||||
}
|
||||
|
||||
export default AddAgentPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import {
|
||||
type ToolOffering,
|
||||
type ToolUsageType,
|
||||
} from '@hanzo_network/hanzo-message-ts/api/tools/types';
|
||||
import { useSetToolOffering } from '@hanzo_network/hanzo-node-state/v2/mutations/setToolOffering/useSetToolOffering';
|
||||
import { type FormattedNetworkAgent } from '@hanzo_network/hanzo-node-state/v2/queries/getNetworkAgents/types';
|
||||
import { useGetWalletList } from '@hanzo_network/hanzo-node-state/v2/queries/getWalletList/useGetWalletList';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Button,
|
||||
Input,
|
||||
Textarea,
|
||||
Card,
|
||||
RadioGroup,
|
||||
Label,
|
||||
RadioGroupItem,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '../../store/auth';
|
||||
|
||||
interface ConfigureAgentDialogProps {
|
||||
agent: FormattedNetworkAgent;
|
||||
}
|
||||
|
||||
export default function ConfigureAgentDialog({
|
||||
agent,
|
||||
}: ConfigureAgentDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pricingType, setPricingType] = useState<'free' | 'paid'>('free');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const auth = useAuth((s) => s.auth);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: walletInfo } = useGetWalletList({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const currentOffering = agent.apiData.tool_offering;
|
||||
|
||||
const { mutateAsync: updateOffering, isPending } = useSetToolOffering({
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
toast.success('Agent updated successfully');
|
||||
},
|
||||
onError: (_error) => {
|
||||
toast.error('Failed to update agent');
|
||||
},
|
||||
});
|
||||
|
||||
const formatUSDCAmount = (rawAmount: string): string => {
|
||||
if (!rawAmount || isNaN(Number(rawAmount))) return '0.000000';
|
||||
const usdcAmount = Number(rawAmount) / 1000000;
|
||||
return usdcAmount.toFixed(6);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentOffering) {
|
||||
setDescription(currentOffering.meta_description || '');
|
||||
const perUse = currentOffering.usage_type.PerUse;
|
||||
if (
|
||||
typeof perUse === 'object' &&
|
||||
'Payment' in perUse &&
|
||||
perUse.Payment.length > 0 &&
|
||||
perUse.Payment[0].maxAmountRequired !== ''
|
||||
) {
|
||||
setPricingType('paid');
|
||||
setAmount(perUse.Payment[0].maxAmountRequired || '');
|
||||
} else {
|
||||
setPricingType('free');
|
||||
}
|
||||
}
|
||||
}, [currentOffering]);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (
|
||||
!currentOffering ||
|
||||
!walletInfo?.payment_wallet?.data?.address?.address_id
|
||||
) {
|
||||
toast.warning('Please connect your wallet to update the agent');
|
||||
return;
|
||||
}
|
||||
|
||||
const usage: ToolUsageType =
|
||||
pricingType === 'free'
|
||||
? { PerUse: 'Free' }
|
||||
: {
|
||||
PerUse: {
|
||||
Payment: [
|
||||
{
|
||||
scheme: 'exact',
|
||||
mimeType: 'application/json',
|
||||
asset: 'USDC',
|
||||
outputSchema: {},
|
||||
resource: 'https://hanzo.ai',
|
||||
extra: { name: 'USDC', version: '1' },
|
||||
payTo: walletInfo.payment_wallet.data.address.address_id,
|
||||
description: description,
|
||||
maxTimeoutSeconds: 300,
|
||||
network: 'base-sepolia',
|
||||
maxAmountRequired: amount,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const offering: ToolOffering = {
|
||||
meta_description: description,
|
||||
tool_key: currentOffering.tool_key,
|
||||
usage_type: usage,
|
||||
};
|
||||
|
||||
await updateOffering({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
offering,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
if (
|
||||
currentOffering.usage_type &&
|
||||
'PerUse' in currentOffering.usage_type
|
||||
) {
|
||||
const perUse = currentOffering.usage_type.PerUse;
|
||||
if (perUse === 'Free') {
|
||||
setPricingType('free');
|
||||
} else if (
|
||||
typeof perUse === 'object' &&
|
||||
'Payment' in perUse &&
|
||||
perUse.Payment.length > 0
|
||||
) {
|
||||
setPricingType('paid');
|
||||
setAmount(perUse.Payment[0].maxAmountRequired || '');
|
||||
} else {
|
||||
setPricingType('free');
|
||||
}
|
||||
} else {
|
||||
setPricingType('free');
|
||||
}
|
||||
setDescription(currentOffering?.meta_description || '');
|
||||
}
|
||||
setOpen(open);
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="md">
|
||||
<Settings className="h-4 w-4" />
|
||||
{t('common.configure')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent showCloseButton className="max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure Agent: {agent.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[500px] flex-col gap-5 overflow-y-scroll">
|
||||
<div className="space-y-4">
|
||||
<label className="mb-0 block text-sm font-medium text-white">
|
||||
Pricing Model
|
||||
</label>
|
||||
<p className="text-text-secondary mt-1 mb-3 text-xs">
|
||||
Users will be charged this amount each time they use your agent.
|
||||
</p>
|
||||
<RadioGroup
|
||||
value={pricingType}
|
||||
onValueChange={(value: 'free' | 'paid') => setPricingType(value)}
|
||||
className="px-1"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="border-divider flex items-center gap-0 rounded-lg border px-4">
|
||||
<RadioGroupItem value="free" id="pricing-free" />
|
||||
<Label
|
||||
htmlFor="pricing-free"
|
||||
className="w-full px-4 py-3 font-medium"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Free</p>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Free to use your agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="border-divider flex items-center gap-0 rounded-lg border px-4">
|
||||
<RadioGroupItem value="paid" id="pricing-paid" />
|
||||
<Label
|
||||
htmlFor="pricing-paid"
|
||||
className="w-full px-4 py-3 font-medium"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Paid (USDC)</p>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Monetize your agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{pricingType === 'paid' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||
animate={{ opacity: 1, height: 'auto', y: 0 }}
|
||||
exit={{ opacity: 0, height: 0, y: -10 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<Card className="bg-bg-dark -mt-3 border-none px-5 py-2">
|
||||
<Label htmlFor="price" className="text-sm font-medium">
|
||||
Price per use (USDC units)
|
||||
</Label>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="1.00"
|
||||
value={amount}
|
||||
className="!h-full py-2"
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-text-secondary mt-1 text-xs">
|
||||
= {formatUSDCAmount(amount)} USDC per use.
|
||||
</p>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Agent description
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder={t('agents.publishDialog.description')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
resize="vertical"
|
||||
className="!min-h-[100px] pt-3"
|
||||
/>
|
||||
<p className="text-text-secondary mt-1 text-xs">
|
||||
Help users understand what your agent does.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
className="min-w-[100px]"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
isLoading={isPending}
|
||||
className="min-w-[100px]"
|
||||
size="md"
|
||||
>
|
||||
{t('common.update')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import AgentForm from './agent-form';
|
||||
|
||||
function EditAgentPage() {
|
||||
return <AgentForm mode="edit" />;
|
||||
}
|
||||
|
||||
export default EditAgentPage;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { useImportAgent } from '@hanzo_network/hanzo-node-state/v2/mutations/importAgent/useImportAgent';
|
||||
import {
|
||||
Button,
|
||||
buttonVariants,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
FileUploader,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { ImportIcon } from '@hanzo_network/hanzo-ui/assets';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAnalytics } from '../../lib/posthog-provider';
|
||||
import { useAuth } from '../../store/auth';
|
||||
|
||||
const importAgentFormSchema = z.object({
|
||||
file: z.any(),
|
||||
});
|
||||
type ImportAgentFormSchema = z.infer<typeof importAgentFormSchema>;
|
||||
|
||||
export default function ImportAgentModal() {
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isImportModalOpen, setImportModalOpen] = useState(false);
|
||||
const { captureAnalyticEvent } = useAnalytics();
|
||||
|
||||
const importAgentForm = useForm<ImportAgentFormSchema>({
|
||||
resolver: zodResolver(importAgentFormSchema),
|
||||
});
|
||||
|
||||
const { mutateAsync: importAgent, isPending } = useImportAgent({
|
||||
onSuccess: (data) => {
|
||||
setImportModalOpen(false);
|
||||
toast.success('Agent imported successfully', {
|
||||
action: {
|
||||
label: 'View',
|
||||
onClick: () => {
|
||||
void navigate(`/agents/edit/${data.agent_id}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
captureAnalyticEvent('Agent Imported', undefined);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to import agent', {
|
||||
description: error.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ImportAgentFormSchema) => {
|
||||
await importAgent({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
file: data.file?.[0],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
importAgentForm.reset();
|
||||
}
|
||||
setImportModalOpen(open);
|
||||
}}
|
||||
open={isImportModalOpen}
|
||||
>
|
||||
<DialogTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
}),
|
||||
'min-w-[100px] gap-1',
|
||||
)}
|
||||
>
|
||||
<ImportIcon className="size-4" />
|
||||
<span>{t('agents.importModal.action')} </span>
|
||||
</DialogTrigger>
|
||||
<DialogContent showCloseButton className="max-w-[500px]">
|
||||
<DialogHeader className="pb-0">
|
||||
<DialogTitle>{t('agents.importModal.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...importAgentForm}>
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={importAgentForm.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={importAgentForm.control}
|
||||
name="file"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="sr-only">{t('common.file')}</FormLabel>
|
||||
<FormControl>
|
||||
<FileUploader
|
||||
accept={['zip'].join(',')}
|
||||
descriptionText={t('agents.importModal.chooseFile')}
|
||||
maxFiles={1}
|
||||
onChange={(acceptedFiles) => {
|
||||
field.onChange(acceptedFiles);
|
||||
}}
|
||||
shouldDisableScrolling
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
isLoading={isPending}
|
||||
size="auto"
|
||||
type="submit"
|
||||
>
|
||||
{t('agents.importModal.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { type Agent } from '@hanzo_network/hanzo-message-ts/api/agents/types';
|
||||
import {
|
||||
type ToolOffering,
|
||||
type ToolUsageType,
|
||||
} from '@hanzo_network/hanzo-message-ts/api/tools/types';
|
||||
import { useSetToolOffering } from '@hanzo_network/hanzo-node-state/v2/mutations/setToolOffering/useSetToolOffering';
|
||||
import { useGetAgents } from '@hanzo_network/hanzo-node-state/v2/queries/getAgents/useGetAgents';
|
||||
import { useGetToolsWithOfferings } from '@hanzo_network/hanzo-node-state/v2/queries/getToolsWithOfferings/useGetToolsWithOfferings';
|
||||
import { useGetWalletList } from '@hanzo_network/hanzo-node-state/v2/queries/getWalletList/useGetWalletList';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Button,
|
||||
SearchInput,
|
||||
Input,
|
||||
Textarea,
|
||||
Card,
|
||||
RadioGroup,
|
||||
Label,
|
||||
RadioGroupItem,
|
||||
Checkbox,
|
||||
Badge,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { AIAgentIcon } from '@hanzo_network/hanzo-ui/assets';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRightIcon, PlusIcon } from 'lucide-react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { BRAND } from '../../config/brand';
|
||||
import { useAuth } from '../../store/auth';
|
||||
type WizardStep = 'select' | 'configure' | 'publishing' | 'success';
|
||||
|
||||
export default function PublishAgentDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [currentStep, setCurrentStep] = useState<WizardStep>('select');
|
||||
const [selected, setSelected] = useState<Agent | null>(null);
|
||||
const [pricingType, setPricingType] = useState<'free' | 'paid'>('free');
|
||||
const [payTo, setPayTo] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
||||
|
||||
const auth = useAuth((s) => s.auth);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: walletInfo } = useGetWalletList({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const { data: agents } = useGetAgents({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const { data: offerings } = useGetToolsWithOfferings({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const publishedKeys = useMemo(
|
||||
() => new Set((offerings ?? []).map((o) => o.tool_offering.tool_key)),
|
||||
[offerings],
|
||||
);
|
||||
|
||||
const filteredAgents = useMemo(
|
||||
() =>
|
||||
(agents ?? []).filter((agent) =>
|
||||
agent.name.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
[agents, search],
|
||||
);
|
||||
|
||||
const asset = 'USDC';
|
||||
|
||||
const { mutateAsync: publish, isPending } = useSetToolOffering({
|
||||
onSuccess: () => {
|
||||
setSelected(null);
|
||||
setPayTo('');
|
||||
setAmount('');
|
||||
setDescription('');
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const formatUSDCAmount = (rawAmount: string): string => {
|
||||
if (!rawAmount || isNaN(Number(rawAmount))) return '0.000000';
|
||||
const usdcAmount = Number(rawAmount) / 1000000;
|
||||
return usdcAmount.toFixed(6);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selected && walletInfo?.payment_wallet?.data?.address?.address_id) {
|
||||
setPayTo(walletInfo.payment_wallet.data.address.address_id);
|
||||
}
|
||||
}, [selected, walletInfo]);
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!selected) return;
|
||||
const usage: ToolUsageType = {
|
||||
PerUse: {
|
||||
Payment: [
|
||||
{
|
||||
scheme: 'exact',
|
||||
mimeType: 'application/json',
|
||||
asset: asset,
|
||||
outputSchema: {},
|
||||
resource: 'https://hanzo.ai',
|
||||
extra: { name: asset, version: '1' },
|
||||
payTo: payTo,
|
||||
description: description,
|
||||
maxTimeoutSeconds: 300,
|
||||
network: 'base-sepolia',
|
||||
maxAmountRequired: amount,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
if (!selected.tools.length) {
|
||||
toast.warning('Please select an agent with tools to publish');
|
||||
return;
|
||||
}
|
||||
|
||||
const offering: ToolOffering = {
|
||||
meta_description: description,
|
||||
tool_key: selected.tools[0],
|
||||
usage_type: usage,
|
||||
};
|
||||
|
||||
await publish({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
offering,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCurrentStep('select');
|
||||
setSelected(null);
|
||||
setPayTo('');
|
||||
setAmount('');
|
||||
setDescription('');
|
||||
setAcceptedTerms(false);
|
||||
}
|
||||
setOpen(open);
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!walletInfo?.payment_wallet?.data?.address?.address_id}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t('agents.publishDialog.open')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent showCloseButton className="max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('agents.publishDialog.open')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{(currentStep === 'select' || currentStep === 'configure') && (
|
||||
<div className="-mx-[24px]">
|
||||
<div className="bg-bg-tertiary border-divider my-2 w-full border-b py-4">
|
||||
<div className="mx-auto flex max-w-[400px] flex-col">
|
||||
<div className="flex w-full items-center px-2">
|
||||
<div
|
||||
className={cn(
|
||||
'ml-[40px] flex items-center justify-center rounded-full text-sm font-semibold',
|
||||
'z-10 h-7 w-7',
|
||||
currentStep === 'select'
|
||||
? 'bg-brand text-white'
|
||||
: currentStep === 'configure'
|
||||
? 'bg-brand'
|
||||
: 'bg-bg-quaternary text-text-secondary',
|
||||
)}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div className="relative -mx-2 h-2 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1/2 right-0 left-0 h-4 -translate-y-1/2 rounded',
|
||||
currentStep === 'configure'
|
||||
? 'bg-brand'
|
||||
: 'bg-bg-quaternary',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-[50px] flex items-center justify-center rounded-full text-sm font-semibold',
|
||||
'z-10 h-7 w-7',
|
||||
currentStep === 'configure'
|
||||
? 'bg-brand text-white'
|
||||
: 'bg-bg-quaternary text-text-secondary',
|
||||
)}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex w-full items-center px-2">
|
||||
<div className="flex-1 pl-1 text-left">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
currentStep === 'select'
|
||||
? 'text-text-default'
|
||||
: 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
Choose Agent
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 pr-1 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
currentStep === 'configure'
|
||||
? 'text-text-default'
|
||||
: 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
Configure & Publish
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 'select' && (
|
||||
<>
|
||||
<SearchInput
|
||||
placeholder={t('agents.publishDialog.searchAgents')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
classNames={{ input: 'bg-transparent' }}
|
||||
/>
|
||||
<div className="max-h-[472px] min-h-[300px] overflow-y-auto px-2 py-2">
|
||||
{filteredAgents.length > 0 && (
|
||||
<RadioGroup
|
||||
value={selected?.agent_id}
|
||||
onValueChange={(value) =>
|
||||
setSelected(
|
||||
filteredAgents.find((a) => a.agent_id === value) ?? null,
|
||||
)
|
||||
}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{filteredAgents?.map((agent) => (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
className={cn(
|
||||
'border-divider flex items-center gap-0 rounded-lg border px-4',
|
||||
publishedKeys.has(agent.tools[0]) && 'hidden',
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={agent.agent_id}
|
||||
id={agent.agent_id}
|
||||
disabled={publishedKeys.has(agent.tools[0])}
|
||||
/>
|
||||
<Label htmlFor={agent.agent_id} className="font-medium">
|
||||
<Card className="flex items-center gap-3 border-0 bg-transparent p-4 shadow-none">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg">
|
||||
<AIAgentIcon name={agent.name} size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-sm font-medium">
|
||||
{agent.name}{' '}
|
||||
{agent.tools.length > 0 && (
|
||||
<Badge
|
||||
variant="inputAdornment"
|
||||
className="text-text-secondary text-xs font-bold"
|
||||
>
|
||||
{agent.tools.length
|
||||
? `${agent.tools.length} tools`
|
||||
: 'No tools available'}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-text-secondary line-clamp-1 text-sm">
|
||||
{agent.ui_description}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="mt-1 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
className="min-w-[100px]"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-[100px]"
|
||||
onClick={() => setCurrentStep('configure')}
|
||||
size="md"
|
||||
disabled={!selected}
|
||||
>
|
||||
{t('common.continue')}
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
{currentStep === 'configure' && (
|
||||
<>
|
||||
<div className="flex !max-h-[500px] flex-col gap-5 overflow-y-scroll">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Selected Agent
|
||||
</label>
|
||||
<Input
|
||||
className="!h-[40px] py-2"
|
||||
value={selected?.name}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Payment Address
|
||||
</label>
|
||||
<Input
|
||||
className="!h-[40px] py-2"
|
||||
placeholder={t('agents.publishDialog.paymentAddress')}
|
||||
value={payTo}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="mb-0 block text-sm font-medium text-white">
|
||||
Pricing Model
|
||||
</label>
|
||||
<p className="text-text-secondary mt-1 mb-3 text-xs">
|
||||
Users will be charged this amount each time they use your
|
||||
agent.
|
||||
</p>
|
||||
<RadioGroup
|
||||
value={pricingType}
|
||||
onValueChange={(value: 'free' | 'paid') =>
|
||||
setPricingType(value)
|
||||
}
|
||||
className="px-1"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="border-divider flex items-center gap-0 rounded-lg border px-4">
|
||||
<RadioGroupItem
|
||||
value="free"
|
||||
id="pricing-free"
|
||||
className="peer"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="pricing-free"
|
||||
className="w-full px-4 py-3 font-medium"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Free</p>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Free to use your agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="border-divider flex items-center gap-0 rounded-lg border px-4">
|
||||
<RadioGroupItem
|
||||
value="paid"
|
||||
id="pricing-paid"
|
||||
className="peer"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="pricing-paid"
|
||||
className="w-full px-4 py-3 font-medium"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Paid (USDC)</p>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Monetize your agent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<AnimatePresence>
|
||||
{pricingType === 'paid' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||
animate={{ opacity: 1, height: 'auto', y: 0 }}
|
||||
exit={{ opacity: 0, height: 0, y: -10 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
<Card className="bg-bg-dark -mt-3 border-none px-5 py-2">
|
||||
<Label htmlFor="price" className="text-sm font-medium">
|
||||
Price per use (USDC units)
|
||||
</Label>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="1.00"
|
||||
value={amount}
|
||||
className="!h-full py-2"
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-text-secondary mt-1 text-xs">
|
||||
={' '}
|
||||
{amount
|
||||
? formatUSDCAmount(amount)
|
||||
: formatUSDCAmount('1.00')}{' '}
|
||||
USDC per use.
|
||||
</p>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Agent description
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder={t('agents.publishDialog.description')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
resize="vertical"
|
||||
className="!min-h-[100px] pt-3"
|
||||
/>
|
||||
<p className="text-text-secondary mt-1 text-xs">
|
||||
Help users understand what your agent does.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={acceptedTerms}
|
||||
onCheckedChange={(checked) =>
|
||||
setAcceptedTerms(checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="terms"
|
||||
className="cursor-pointer text-sm leading-relaxed"
|
||||
>
|
||||
I understand that {BRAND.name} reserves the right to remove
|
||||
any agent that violates our content policy.
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
className="min-w-[100px]"
|
||||
onClick={() => setCurrentStep('select')}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePublish}
|
||||
isLoading={isPending}
|
||||
className="min-w-[100px]"
|
||||
size="md"
|
||||
>
|
||||
{t('agents.publishDialog.publish')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { type RJSFSchema } from '@rjsf/utils';
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import {
|
||||
type HanzoTool,
|
||||
type ToolConfigBase,
|
||||
} from '@hanzo_network/hanzo-message-ts/api/tools/types';
|
||||
import { useGetTool } from '@hanzo_network/hanzo-node-state/v2/queries/getTool/useGetTool';
|
||||
import {
|
||||
Button,
|
||||
generateTemplates,
|
||||
JsonForm,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Skeleton,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { Trash } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useAuth } from '../../store/auth';
|
||||
|
||||
export const TooConfigOverrideForm = ({
|
||||
toolRouterKey,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
toolRouterKey: string;
|
||||
value: any;
|
||||
onChange: (e: any) => void;
|
||||
}) => {
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const { data, isSuccess, isPending } = useGetTool({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
toolKey: toolRouterKey ?? '',
|
||||
});
|
||||
const tool: HanzoTool | undefined = data?.content?.[0] as HanzoTool;
|
||||
const properties = useMemo(() => {
|
||||
const properties = (tool as any)?.configurations?.properties || {};
|
||||
return Object.entries(properties).map(([key, value]: [string, any]) => {
|
||||
const name = key
|
||||
?.split(/(?=[A-Z])|_/)
|
||||
.map(
|
||||
(word: string) =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
|
||||
)
|
||||
.join(' ');
|
||||
return {
|
||||
key,
|
||||
name,
|
||||
description: value.description,
|
||||
};
|
||||
});
|
||||
}, [tool]);
|
||||
|
||||
const internalValue = useRef<any>(value);
|
||||
const updateDynamicSchema = useCallback(() => {
|
||||
const properties = (tool as any)?.configurations?.properties || {};
|
||||
const toolConfig = ((tool as any)?.config as ToolConfigBase[]) || [];
|
||||
const toolConfigAsMap = new Map(
|
||||
toolConfig.map((config) => [
|
||||
config.BasicConfig.key_name,
|
||||
config.BasicConfig,
|
||||
]),
|
||||
);
|
||||
const propertiesToShow = Object.fromEntries(
|
||||
Object.entries(properties).filter(
|
||||
([jsonSchemaKey, _]: [string, unknown]) => {
|
||||
const toolConfig = toolConfigAsMap.get(jsonSchemaKey);
|
||||
const requiresToolConfigurationOrOverride =
|
||||
toolConfig?.required && !toolConfig?.key_value;
|
||||
const hasOverrideValue =
|
||||
internalValue.current[jsonSchemaKey] !== undefined;
|
||||
return hasOverrideValue || requiresToolConfigurationOrOverride;
|
||||
},
|
||||
),
|
||||
);
|
||||
const requiredProperties =
|
||||
(tool as any)?.configurations?.required?.filter((property: string) => {
|
||||
return propertiesToShow[property] !== undefined;
|
||||
}) || [];
|
||||
const newSchema = {
|
||||
...(tool as any)?.configurations,
|
||||
properties: propertiesToShow,
|
||||
required: requiredProperties,
|
||||
} as unknown as RJSFSchema;
|
||||
setDynamicSchema(newSchema);
|
||||
}, [tool]);
|
||||
|
||||
const [dynamicSchema, setDynamicSchema] = useState<RJSFSchema | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
updateDynamicSchema();
|
||||
}, [tool, updateDynamicSchema]);
|
||||
|
||||
const onAddConfigurationOverride = (configuration: string) => {
|
||||
console.log('onAddConfigurationOverride', configuration);
|
||||
const newConfig = { ...internalValue.current };
|
||||
newConfig[configuration] = null;
|
||||
mutateValue(newConfig);
|
||||
updateDynamicSchema();
|
||||
};
|
||||
|
||||
const onDeleteConfigurationOverride = (configuration: string) => {
|
||||
console.log('onDeleteConfigurationOverride2', configuration);
|
||||
const newConfig = { ...internalValue.current };
|
||||
delete newConfig[configuration];
|
||||
mutateValue(newConfig);
|
||||
updateDynamicSchema();
|
||||
};
|
||||
|
||||
const mutateValue = (newConfig: any) => {
|
||||
console.log('tryCallOnChange', newConfig);
|
||||
internalValue.current = newConfig;
|
||||
if (onChange) {
|
||||
onChange(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
const templates = generateTemplates();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{isPending && <Skeleton className="flex-1 animate-pulse rounded" />}
|
||||
{isSuccess && tool && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(newValue) => {
|
||||
onAddConfigurationOverride(newValue);
|
||||
}}
|
||||
value="default"
|
||||
>
|
||||
<SelectTrigger className="w-full p-4">
|
||||
<SelectValue>Select a configuration to override</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-w-[720px] min-w-[180px] overflow-y-auto">
|
||||
{properties.map((property, index) => (
|
||||
<SelectItem key={index} value={property.key}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{property.name}
|
||||
</span>
|
||||
<span className="text-text-tertiary text-xs">
|
||||
{property.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{dynamicSchema && (
|
||||
<JsonForm
|
||||
className="py-1"
|
||||
formData={internalValue.current}
|
||||
liveValidate={true}
|
||||
noHtml5Validate={true}
|
||||
onChange={(e) => {
|
||||
console.log('onChange', e);
|
||||
if (e.errors.length === 0) {
|
||||
mutateValue(e.formData);
|
||||
}
|
||||
}}
|
||||
schema={dynamicSchema}
|
||||
showErrorList={false}
|
||||
templates={{
|
||||
FieldTemplate: (props) => {
|
||||
if (templates.FieldTemplate && props.id !== 'root') {
|
||||
return (
|
||||
<div className="border-divider flex w-full items-center gap-2 rounded-lg border p-2">
|
||||
<div className="flex-grow">
|
||||
<templates.FieldTemplate
|
||||
classNames="w-full"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-shrink-0 items-center justify-center px-2">
|
||||
<Button
|
||||
className="h-6 w-6 bg-red-500/10 text-red-500 transition-colors"
|
||||
onClick={() => {
|
||||
console.log('delete', props);
|
||||
onDeleteConfigurationOverride(props.label);
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (templates.FieldTemplate) {
|
||||
return <templates.FieldTemplate {...props} />;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}}
|
||||
uiSchema={{
|
||||
'ui:submitButtonOptions': {
|
||||
norender: true,
|
||||
},
|
||||
}}
|
||||
validator={validator}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
AnthropicIcon,
|
||||
AyaCohereIcon,
|
||||
DeepSeekIcon,
|
||||
ExoIcon,
|
||||
GeminiIcon,
|
||||
GoogleIcon,
|
||||
GrokIcon,
|
||||
GroqIcon,
|
||||
LmStudioIcon,
|
||||
MetaIcon,
|
||||
MistralIcon,
|
||||
OpenAIIcon,
|
||||
ZenIcon,
|
||||
OpenRouterIcon,
|
||||
PerplexityIcon,
|
||||
QwenIcon,
|
||||
HanzoIcon,
|
||||
TogetherAI,
|
||||
} from '@hanzo_network/hanzo-ui/assets';
|
||||
|
||||
export enum ModelProvider {
|
||||
Aya = 'aya',
|
||||
Claude = 'claude',
|
||||
DeepSeek = 'deepseek',
|
||||
Exo = 'exo',
|
||||
Gemini = 'gemini',
|
||||
Google = 'google',
|
||||
Grok = 'grok',
|
||||
Groq = 'groq',
|
||||
LmStudio = 'lmstudio',
|
||||
Meta = 'meta',
|
||||
Mistral = 'mistral',
|
||||
OpenAI = 'openai',
|
||||
OpenRouter = 'openrouter',
|
||||
Perplexity = 'perplexity',
|
||||
Qwen = 'qwen',
|
||||
'Hanzo-Backend' = 'hanzo-backend',
|
||||
TogetherAI = 'togetherai',
|
||||
}
|
||||
|
||||
export type ModelProviderKey = Lowercase<keyof typeof ModelProvider>;
|
||||
|
||||
export const providerMappings = {
|
||||
[ModelProvider.Aya]: AyaCohereIcon,
|
||||
[ModelProvider.Claude]: AnthropicIcon,
|
||||
[ModelProvider.DeepSeek]: DeepSeekIcon,
|
||||
[ModelProvider.Exo]: ExoIcon,
|
||||
[ModelProvider.Gemini]: GeminiIcon,
|
||||
[ModelProvider.Google]: GoogleIcon,
|
||||
[ModelProvider.Grok]: GrokIcon,
|
||||
[ModelProvider.Groq]: GroqIcon,
|
||||
[ModelProvider.LmStudio]: LmStudioIcon,
|
||||
[ModelProvider.Mistral]: MistralIcon,
|
||||
[ModelProvider.Meta]: MetaIcon,
|
||||
// Local Hanzo/Zen engine is exposed as an OpenAI-compatible provider — brand it with the Zen enso ring.
|
||||
[ModelProvider.OpenAI]: ZenIcon,
|
||||
'openai-legacy': OpenAIIcon,
|
||||
[ModelProvider.OpenRouter]: OpenRouterIcon,
|
||||
[ModelProvider.Perplexity]: PerplexityIcon,
|
||||
[ModelProvider.Qwen]: QwenIcon,
|
||||
[ModelProvider['Hanzo-Backend']]: HanzoIcon,
|
||||
[ModelProvider.TogetherAI]: TogetherAI,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AisIcon } from '@hanzo_network/hanzo-ui/assets';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type ModelProviderKey, providerMappings } from './constants';
|
||||
|
||||
export interface ProviderIconProps {
|
||||
className?: string;
|
||||
provider?: ModelProviderKey | string;
|
||||
ref?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const ProviderIcon = ({
|
||||
provider: originProvider,
|
||||
ref,
|
||||
...rest
|
||||
}: ProviderIconProps) => {
|
||||
const Icon = useMemo(() => {
|
||||
if (!originProvider) return AisIcon;
|
||||
const provider = originProvider.toLowerCase();
|
||||
if (providerMappings[provider as ModelProviderKey]) {
|
||||
return providerMappings[provider as ModelProviderKey];
|
||||
}
|
||||
return AisIcon;
|
||||
}, [originProvider]);
|
||||
|
||||
const props = {
|
||||
...rest,
|
||||
ref,
|
||||
};
|
||||
|
||||
return <Icon {...props} />;
|
||||
};
|
||||
|
||||
ProviderIcon.displayName = 'ProviderIcon';
|
||||
|
||||
export default ProviderIcon;
|
||||
@@ -0,0 +1,487 @@
|
||||
# Frontend Streaming Architecture
|
||||
|
||||
This document explains the frontend streaming architecture for real-time AI chat responses, detailing how streaming tokens are handled efficiently without causing performance issues.
|
||||
|
||||
## Overview
|
||||
|
||||
The streaming system uses a **hybrid approach** that separates ephemeral streaming state from persistent React Query cache to minimize re-renders and provide smooth streaming UX:
|
||||
|
||||
- **WebSocket** - Real-time token delivery from backend
|
||||
- **Zustand Store** - Ephemeral streaming state (avoids expensive React Query re-renders)
|
||||
- **React Query** - Persistent message data and caching
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ User sends │──────│ HTTP API │──────│ Backend │
|
||||
│ message │ │ (mutation) │ │ (Hanzo) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
WebSocket (streaming tokens) │
|
||||
◄─────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Zustand Store │ │ React Query │ │ StreamingMessage│
|
||||
│ (streaming) │ │ (final data) │ │ (renders) │
|
||||
└───────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Why Separate Streaming Store from React Query?
|
||||
|
||||
If we updated React Query on every token (~20 tokens/second), the entire message list would re-render ~20 times per second, causing:
|
||||
|
||||
- Poor performance and UI jank
|
||||
- Excessive re-renders of all message components
|
||||
- Poor user experience during streaming
|
||||
|
||||
**Solution**: Use Zustand store for ephemeral streaming state, only update React Query once when streaming completes.
|
||||
|
||||
### Token Buffering Strategy
|
||||
|
||||
Tokens are buffered in refs and flushed at **~20 FPS** (50ms intervals) for smooth streaming:
|
||||
|
||||
- Prevents excessive state updates
|
||||
- Balances smoothness with performance
|
||||
- Human perception threshold: ~20 FPS is smooth enough
|
||||
|
||||
## Architecture Components
|
||||
|
||||
### 1. WebSocket Message Handler (`websocket-message.tsx`)
|
||||
|
||||
Manages WebSocket connection and processes incoming messages.
|
||||
|
||||
**Key Responsibilities:**
|
||||
|
||||
- Subscribe/unsubscribe to inbox topics via WebSocket
|
||||
- Buffer incoming tokens for batched updates
|
||||
- Detect message types (user, assistant, stream tokens, widgets)
|
||||
- Finalize streaming and update React Query with final data
|
||||
|
||||
**Hooks:**
|
||||
|
||||
- `useWebSocketMessage` - Handles message streaming
|
||||
- `useWebSocketTools` - Handles tool call widgets
|
||||
|
||||
**Token Buffering:**
|
||||
|
||||
```typescript
|
||||
const FLUSH_INTERVAL_MS = 50; // 20 FPS
|
||||
|
||||
// Tokens are buffered in refs, not state
|
||||
const tokenBufferRef = useRef('');
|
||||
const reasoningBufferRef = useRef('');
|
||||
|
||||
// Flushed periodically to streaming store
|
||||
if (!flushScheduledRef.current) {
|
||||
flushScheduledRef.current = true;
|
||||
flushTimeoutRef.current = setTimeout(() => {
|
||||
flushTimeoutRef.current = null;
|
||||
flushTokenBuffer();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Streaming Store (`context/streaming-context.tsx`)
|
||||
|
||||
A Zustand store that holds ephemeral streaming data separately from React Query.
|
||||
|
||||
**Store Structure:**
|
||||
|
||||
```typescript
|
||||
type StreamingStore = {
|
||||
// Ephemeral streaming content (cleared after finalization)
|
||||
streams: Map<string, StreamingContent>;
|
||||
|
||||
// Persistent reasoning durations (last 5 inboxes)
|
||||
reasoningDurations: Map<string, number>;
|
||||
|
||||
// Methods...
|
||||
startStreaming: (inboxId: string) => void;
|
||||
appendContent: (inboxId: string, content: string) => void;
|
||||
appendReasoning: (inboxId: string, reasoning: string) => void;
|
||||
endStreaming: (inboxId: string) => void;
|
||||
clearInbox: (inboxId: string) => void;
|
||||
saveReasoningDuration: (inboxId: string, duration: number) => void;
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**StreamingContent Type:**
|
||||
|
||||
```typescript
|
||||
type StreamingContent = {
|
||||
content: string; // Main message content
|
||||
reasoning: { text: string; status: TextStatus } | null;
|
||||
toolCalls: ToolCall[];
|
||||
isStreaming: boolean;
|
||||
reasoningStartTime: number | null; // For duration calculation
|
||||
reasoningDuration: number; // Calculated duration
|
||||
};
|
||||
```
|
||||
|
||||
### 3. StreamingMessage Component (`components/streaming-message.tsx`)
|
||||
|
||||
Wrapper component that subscribes to streaming store for the optimistic assistant message.
|
||||
|
||||
**Purpose:**
|
||||
|
||||
- Only the optimistic (streaming) message subscribes to streaming store
|
||||
- Other messages use static React Query data (no re-renders)
|
||||
- Provides smooth streaming UX without affecting other messages
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
export const StreamingMessage = memo(function StreamingMessage({
|
||||
message,
|
||||
messageId,
|
||||
...
|
||||
}) {
|
||||
const isOptimisticMessage =
|
||||
messageId === OPTIMISTIC_ASSISTANT_MESSAGE_ID &&
|
||||
message.role === 'assistant';
|
||||
|
||||
// Only subscribe for the optimistic message
|
||||
const streamingContent = useStreamingContent(
|
||||
isOptimisticMessage ? inboxId : '',
|
||||
);
|
||||
|
||||
// Merge streaming content with message prop
|
||||
const mergedMessage = useMemo(() => {
|
||||
if (hasStreamingContent) {
|
||||
return {
|
||||
...message,
|
||||
content: streamingContent.content || message.content,
|
||||
reasoning: streamingContent.reasoning ?? message.reasoning,
|
||||
toolCalls: streamingContent.toolCalls.length > 0
|
||||
? streamingContent.toolCalls
|
||||
: message.toolCalls,
|
||||
};
|
||||
}
|
||||
return message;
|
||||
}, [isOptimisticMessage, message, streamingContent]);
|
||||
|
||||
return <Message message={mergedMessage} ... />;
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Message List (`components/message-list.tsx`)
|
||||
|
||||
Renders the list of messages, using `StreamingMessage` for the optimistic message.
|
||||
|
||||
```typescript
|
||||
const MessageComponent = isOptimisticMessage
|
||||
? StreamingMessage // Subscribes to streaming store
|
||||
: Message; // Static, uses React Query data
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Phase 1: Optimistic Update
|
||||
|
||||
When user sends a message, `useSendMessageToJob.onMutate` runs **before** the API call:
|
||||
|
||||
```typescript
|
||||
onMutate: async (variables) => {
|
||||
// Create optimistic messages immediately
|
||||
const newMessages = [
|
||||
generateOptimisticUserMessage(variables.message, files),
|
||||
generateOptimisticAssistantMessage(variables.provider), // status: 'running'
|
||||
];
|
||||
|
||||
// Update React Query cache
|
||||
queryClient.setQueryData(queryKey, (old) => ({
|
||||
...old,
|
||||
pages: [...old.pages.slice(0, -1), [...lastPage, ...newMessages]],
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 2: WebSocket Streaming
|
||||
|
||||
WebSocket message flow:
|
||||
|
||||
```
|
||||
1. HanzoMessage (user echo) → startStreaming(inboxId)
|
||||
2. Stream (token) → Buffer → appendContent/appendReasoning
|
||||
3. Widget (tool call) → updateToolCall(inboxId, toolCall, index)
|
||||
4. Stream (is_done: true) → flushTokenBuffer({ markComplete: true })
|
||||
5. HanzoMessage (assistant) → Finalize with real data
|
||||
```
|
||||
|
||||
**Message Type Handling:**
|
||||
|
||||
- **User Message**: Initializes streaming state for new message
|
||||
- **Stream Tokens**: Buffered and flushed at 20 FPS
|
||||
- **Tool Requests**: Update tool calls in streaming store
|
||||
- **Final Message**: Contains real message ID, content, reasoning, tool calls
|
||||
|
||||
### Phase 3: Finalization
|
||||
|
||||
When streaming completes:
|
||||
|
||||
```typescript
|
||||
// 1. Save reasoning duration (persists after clearInbox)
|
||||
if (streamedContent) {
|
||||
const durationToSave = streamedContent.reasoningDuration;
|
||||
if (durationToSave > 0) {
|
||||
saveReasoningDuration(inboxId, durationToSave);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Update React Query with final data
|
||||
queryClient.setQueryData(queryKey, (old) => {
|
||||
const updated = {
|
||||
...lastMsg,
|
||||
messageId: realMessageId, // Replace OPTIMISTIC_ASSISTANT_MESSAGE_ID
|
||||
content: finalContent,
|
||||
status: { type: 'complete' },
|
||||
reasoning: finalReasoning,
|
||||
toolCalls: finalToolCalls,
|
||||
};
|
||||
return { ...old, pages };
|
||||
});
|
||||
|
||||
// 3. Mark streaming as ended (keeps data briefly for StreamingMessage)
|
||||
endStreaming(inboxId);
|
||||
|
||||
// 4. Invalidate if tool calls generated files
|
||||
if (hasToolCalls) {
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 5. Clear streaming state after delay (2 seconds)
|
||||
setTimeout(() => {
|
||||
clearInbox(inboxId);
|
||||
}, 2000);
|
||||
```
|
||||
|
||||
## Reasoning Duration Tracking
|
||||
|
||||
### Overview
|
||||
|
||||
The system tracks reasoning duration client-side for the **last message per inbox**, persisting it even after streaming state is cleared.
|
||||
|
||||
### Implementation
|
||||
|
||||
1. **During Streaming:**
|
||||
|
||||
- `reasoningStartTime` is set when first reasoning token arrives
|
||||
- `reasoningDuration` is calculated when content starts (reasoning ends)
|
||||
|
||||
2. **On Finalization:**
|
||||
|
||||
- Duration is saved to persistent `reasoningDurations` map (keyed by `inboxId`)
|
||||
- Stored separately from ephemeral streaming state
|
||||
|
||||
3. **Display:**
|
||||
|
||||
- Only shown for the last message in inbox (`isLastMessage` check)
|
||||
- Retrieved from persistent store via `useReasoningDuration(inboxId)`
|
||||
|
||||
4. **Memory Management:**
|
||||
- Limited to **last 5 inboxes** maximum
|
||||
- Oldest entries are removed when limit is exceeded
|
||||
- Uses Map insertion order to track recency
|
||||
|
||||
### Code Example
|
||||
|
||||
```typescript
|
||||
// Save duration when finalizing
|
||||
saveReasoningDuration(inboxId, calculatedDuration);
|
||||
|
||||
// Retrieve for display (only last message)
|
||||
const streamingDuration = useReasoningDuration(isLastMessage ? inboxId : '');
|
||||
```
|
||||
|
||||
## Memory Management
|
||||
|
||||
### Cleanup Strategy
|
||||
|
||||
1. **Immediate Cleanup (on new message):**
|
||||
|
||||
- Old streaming state cleared when new user message arrives
|
||||
- Cancels pending timeouts
|
||||
|
||||
2. **Delayed Cleanup (after finalization):**
|
||||
|
||||
- Streaming state cleared after 2 seconds
|
||||
- Allows `StreamingMessage` to use final data during transition
|
||||
- Prevents brief flash of stale React Query data
|
||||
|
||||
3. **Timeout Management:**
|
||||
|
||||
- All `setTimeout` calls are tracked in refs
|
||||
- Cancelled on unmount and when new message arrives
|
||||
- Prevents memory leaks and race conditions
|
||||
|
||||
4. **Reasoning Duration Limits:**
|
||||
- Limited to last 5 inboxes
|
||||
- Automatically removes oldest entries
|
||||
- Prevents unbounded growth
|
||||
|
||||
### Cleanup Code Example
|
||||
|
||||
```typescript
|
||||
// Track all timeouts
|
||||
const flushTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const clearInboxTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const invalidateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flushTimeoutRef.current) clearTimeout(flushTimeoutRef.current);
|
||||
if (clearInboxTimeoutRef.current) clearTimeout(clearInboxTimeoutRef.current);
|
||||
if (invalidateTimeoutRef.current) clearTimeout(invalidateTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### 1. Token Buffering
|
||||
|
||||
- **20 FPS update rate** - Smooth enough for human perception
|
||||
- Batched updates reduce state change frequency
|
||||
- Prevents excessive re-renders
|
||||
|
||||
### 2. Isolated Re-renders
|
||||
|
||||
- Only `StreamingMessage` subscribes to streaming store
|
||||
- Rest of message list doesn't re-render during streaming
|
||||
- Memoization with custom comparators
|
||||
|
||||
### 3. Message Filtering
|
||||
|
||||
- Messages filtered by `inboxId` before processing
|
||||
- Prevents unnecessary updates for other inboxes
|
||||
|
||||
### 4. Optimistic Updates
|
||||
|
||||
- Immediate UI feedback before API response
|
||||
- Smooth transition from optimistic to real data
|
||||
|
||||
## WebSocket Subscription Management
|
||||
|
||||
### Shared Connection
|
||||
|
||||
- Single WebSocket connection shared across all hook instances (`{ share: true }`)
|
||||
- Efficient resource usage
|
||||
|
||||
### Topic-Based Subscriptions
|
||||
|
||||
- Subscribe to `inbox` topic with `inboxId` as subtopic
|
||||
- Messages automatically filtered by `inboxId` in handler
|
||||
- Supports multiple inbox subscriptions simultaneously
|
||||
|
||||
### Current Behavior
|
||||
|
||||
- Subscriptions persist when switching inboxes
|
||||
- Messages filtered by current `inboxId`
|
||||
- Cleanup on component unmount
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
components/chat/
|
||||
├── context/
|
||||
│ └── streaming-context.tsx # Zustand streaming store
|
||||
├── components/
|
||||
│ ├── message-list.tsx # Message list with scroll handling
|
||||
│ ├── message.tsx # Individual message component
|
||||
│ └── streaming-message.tsx # Wrapper for streaming messages
|
||||
└── websocket-message.tsx # WebSocket handler
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
// In chat conversation component
|
||||
const ChatConversation = () => {
|
||||
const { inboxId } = useParams();
|
||||
|
||||
// Enable WebSocket streaming
|
||||
useWebSocketMessage({ inboxId, enabled: !!inboxId });
|
||||
useWebSocketTools({ inboxId, enabled: !!inboxId });
|
||||
|
||||
// Get messages from React Query
|
||||
const { data } = useChatConversationWithOptimisticUpdates({ inboxId });
|
||||
|
||||
return <MessageList messages={data} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### 1. Check streaming store state
|
||||
|
||||
```typescript
|
||||
const content = useStreamingContent(inboxId);
|
||||
console.log('Streaming:', content?.isStreaming);
|
||||
console.log('Content length:', content?.content.length);
|
||||
console.log('Reasoning:', content?.reasoning?.text);
|
||||
```
|
||||
|
||||
### 2. Check WebSocket connection
|
||||
|
||||
```typescript
|
||||
const { readyState } = useWebSocketMessage({ enabled, inboxId });
|
||||
// readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
|
||||
```
|
||||
|
||||
### 3. Monitor React Query cache
|
||||
|
||||
```typescript
|
||||
const data = queryClient.getQueryData(queryKey);
|
||||
console.log('Last message:', data?.pages?.at(-1)?.at(-1));
|
||||
```
|
||||
|
||||
### 4. Check reasoning duration
|
||||
|
||||
```typescript
|
||||
const duration = useReasoningDuration(inboxId);
|
||||
console.log('Reasoning duration:', duration);
|
||||
```
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Race Condition Prevention
|
||||
|
||||
- `hasStreamCompletedRef` prevents duplicate finalization
|
||||
- Timeout cancellation prevents race conditions
|
||||
- Ordered finalization: React Query update → endStreaming → clearInbox
|
||||
|
||||
### State Transition Flow
|
||||
|
||||
```
|
||||
Optimistic Message (status: 'running')
|
||||
↓
|
||||
Start Streaming (isStreaming: true)
|
||||
↓
|
||||
Append Tokens (content/reasoning/toolCalls)
|
||||
↓
|
||||
End Streaming (isStreaming: false, content preserved)
|
||||
↓
|
||||
Update React Query (status: 'complete', real messageId)
|
||||
↓
|
||||
Clear Inbox (after 2s delay)
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Try-catch blocks around WebSocket message parsing
|
||||
- Fallback finalization if parsing fails
|
||||
- Graceful degradation for missing data
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Potential enhancements:
|
||||
|
||||
- Move to SSE instead of Websockets
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
Button,
|
||||
CopyToClipboardIcon,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import * as fs from '@tauri-apps/plugin-fs';
|
||||
import { BaseDirectory } from '@tauri-apps/plugin-fs';
|
||||
import { ChevronsRight, DownloadIcon } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
|
||||
import { useChatStore } from './context/chat-context';
|
||||
|
||||
const ArtifactPreview = () => {
|
||||
const artifact = useChatStore((state) => state.selectedArtifact);
|
||||
const setArtifact = useChatStore((state) => state.setSelectedArtifact);
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false);
|
||||
|
||||
const handleRender = () => {
|
||||
if (!iframeRef.current?.contentWindow) return;
|
||||
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: 'UPDATE_COMPONENT', code: artifact?.code },
|
||||
'*',
|
||||
);
|
||||
};
|
||||
|
||||
const handleMessage = (event: any) => {
|
||||
if (event?.data?.type === 'INIT_COMPLETE') {
|
||||
setIframeLoaded(true);
|
||||
handleRender();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleRender();
|
||||
}, [artifact]);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tabs
|
||||
className="flex h-screen w-full flex-col overflow-hidden"
|
||||
defaultValue="preview"
|
||||
>
|
||||
<div className={'flex h-screen flex-grow justify-stretch p-3'}>
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="text-text-secondary flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setArtifact(null);
|
||||
}}
|
||||
size="icon"
|
||||
variant="tertiary"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
<span className="sr-only">Close Artifact Panel</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent className="flex flex-col items-center gap-1">
|
||||
<p>Close Artifact Panel</p>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<h1 className="line-clamp-1 text-sm font-medium text-white">
|
||||
{artifact?.title}
|
||||
</h1>
|
||||
</div>
|
||||
<TabsList className="grid grid-cols-2 rounded-lg border border-gray-400 bg-transparent p-0.5">
|
||||
<TabsTrigger
|
||||
className="flex h-8 items-center gap-1.5 text-xs font-semibold"
|
||||
value="source"
|
||||
>
|
||||
Code
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="flex h-8 items-center gap-1.5 text-xs font-semibold"
|
||||
value="preview"
|
||||
>
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
className="mt-1 h-full overflow-y-scroll px-4 py-2 font-mono break-words whitespace-pre-line"
|
||||
value="source"
|
||||
>
|
||||
<div className="flex h-10 items-center justify-between gap-3 rounded-t-lg bg-gray-300 pr-3 pl-4">
|
||||
{/* by default App.tsx */}
|
||||
<h2 className="text-text-secondary text-xs font-semibold">
|
||||
App.tsx
|
||||
</h2>
|
||||
{iframeLoaded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<CopyToClipboardIcon
|
||||
className="text-text-secondary flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 bg-transparent transition-colors hover:bg-gray-300 hover:text-white [&>svg]:h-3 [&>svg]:w-3"
|
||||
string={artifact?.code ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent className="flex flex-col items-center gap-1">
|
||||
<p>Copy Code</p>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="text-text-secondary flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 bg-transparent transition-colors hover:bg-gray-300 hover:text-white [&>svg]:h-3 [&>svg]:w-3"
|
||||
onClick={async () => {
|
||||
const file = new Blob([artifact?.code ?? ''], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
const dataUrl = await new Promise<string>(
|
||||
(resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
resolve(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
);
|
||||
const path = await save({
|
||||
defaultPath: `${artifact?.title?.replace(
|
||||
/[^a-z0-9]/gi,
|
||||
'_',
|
||||
)}.tsx`,
|
||||
});
|
||||
if (path) {
|
||||
const arrayBuffer = await fetch(dataUrl).then(
|
||||
(response) => response.arrayBuffer(),
|
||||
);
|
||||
const content = new Uint8Array(arrayBuffer);
|
||||
await fs.writeFile(path, content, {
|
||||
baseDir: BaseDirectory.Download,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent className="flex flex-col items-center gap-1">
|
||||
<p>Download Code</p>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
PreTag="div"
|
||||
codeTagProps={{ style: { fontSize: '0.8rem' } }}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
width: '100%',
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: 0,
|
||||
}}
|
||||
language={'jsx'}
|
||||
style={oneDark}
|
||||
>
|
||||
{artifact?.code ?? ''}
|
||||
</SyntaxHighlighter>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
className="h-full w-full flex-grow px-4 py-2"
|
||||
value="preview"
|
||||
>
|
||||
<div className="size-full" ref={contentRef}>
|
||||
<iframe
|
||||
className="size-full"
|
||||
loading="lazy"
|
||||
ref={iframeRef}
|
||||
src={'/src/windows/hanzo-artifacts/index.html'}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
export default ArtifactPreview;
|
||||
@@ -0,0 +1,509 @@
|
||||
import { useBrand } from '@hanzo_network/brand-config';
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { ModelPrefix } from '@hanzo_network/hanzo-message-ts/api/jobs/index';
|
||||
import { extractJobIdFromInbox } from '@hanzo_network/hanzo-message-ts/utils/inbox_name_handler';
|
||||
import { useUpdateAgentInJob } from '@hanzo_network/hanzo-node-state/v2/mutations/updateAgentInJob/useUpdateAgentInJob';
|
||||
import { useUpdateChatConfig } from '@hanzo_network/hanzo-node-state/v2/mutations/updateChatConfig/useUpdateChatConfig';
|
||||
import { useGetAgents } from '@hanzo_network/hanzo-node-state/v2/queries/getAgents/useGetAgents';
|
||||
import { useGetChatConfig } from '@hanzo_network/hanzo-node-state/v2/queries/getChatConfig/useGetChatConfig';
|
||||
import { useGetLLMProviders } from '@hanzo_network/hanzo-node-state/v2/queries/getLLMProviders/useGetLLMProviders';
|
||||
import { useGetProviderFromJob } from '@hanzo_network/hanzo-node-state/v2/queries/getProviderFromJob/useGetProviderFromJob';
|
||||
import {
|
||||
Badge,
|
||||
buttonVariants,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { AIAgentIcon } from '@hanzo_network/hanzo-ui/assets';
|
||||
import { formatText } from '@hanzo_network/hanzo-ui/helpers';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { BoltIcon, BotIcon, ChevronDownIcon, PlusIcon } from 'lucide-react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { BRAND } from '../../../config/brand';
|
||||
import { useAuth } from '../../../store/auth';
|
||||
import { useHanzoNodeManager } from '../../../store/hanzo-node-manager';
|
||||
import MODEL_CATALOG from '../../../lib/hanzo-node-manager/model-catalog.json';
|
||||
import { getProviderModelLabel } from '../../../lib/hanzo-node-manager/local-model-names';
|
||||
import ProviderIcon from '../../ais/provider-icon';
|
||||
import { CODE_GENERATOR_MODEL_ID } from '../../tools/constants';
|
||||
import { actionButtonClassnames } from '../conversation-footer';
|
||||
|
||||
// Local-engine model descriptions, keyed by bare model name (no provider prefix).
|
||||
const localModelDescriptionMap = MODEL_CATALOG.reduce(
|
||||
(acc, model) => {
|
||||
acc[model.name] = model.description;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
const nonOllamaProviderModels = {
|
||||
'hanzo-backend:free_text_inference':
|
||||
`${useBrand().name} AI model for text generation.`,
|
||||
'hanzo-backend:code_generator':
|
||||
`${useBrand().name} AI model for generating tool code.`,
|
||||
'openai:gpt-4o':
|
||||
'Powerful OpenAI model known for its ability to generate human-like text and handle complex tasks.',
|
||||
'openai:gpt-4o-mini':
|
||||
'Lightweight OpenAI model for concise text and image generation.',
|
||||
'openai:gpt-4o-2024-08-06':
|
||||
'Latest OpenAI GPT-4 model for diverse and accurate content generation.',
|
||||
'openai:gpt-4-1106-preview':
|
||||
'OpenAI GPT-4 Turbo model optimized for speed and cost.',
|
||||
'openai:gpt-4-vision-preview':
|
||||
'OpenAI GPT-4 model with image understanding capabilities.',
|
||||
'openai:gpt-3.5-turbo-1106':
|
||||
'Cost‑efficient OpenAI model for general text tasks.',
|
||||
'openai:gpt-4.1': 'Newest GPT‑4.1 model for high quality responses.',
|
||||
'openai:gpt-4.1-mini': 'Smaller GPT‑4.1 model offering lower cost.',
|
||||
'openai:gpt-4.1-nano': 'Fastest GPT‑4.1 variant for quick replies.',
|
||||
'openai:4o-preview': 'Preview version of GPT‑4o with multimodal support.',
|
||||
'openai:4o-mini': 'Compact GPT‑4o model balancing speed and quality.',
|
||||
'openai:o1': 'OpenAI lightweight reasoning model.',
|
||||
'openai:o1-mini': 'Smaller variant of OpenAI o1 model.',
|
||||
'openai:o3-mini': 'Mini version of OpenAI o3 model.',
|
||||
} as Record<string, string>;
|
||||
|
||||
const localEnginePrefix = `${ModelPrefix.LocalEngine}:`;
|
||||
|
||||
export function AIModelSelectorBase({
|
||||
value,
|
||||
onValueChange,
|
||||
className,
|
||||
variant = 'simple',
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
className?: string;
|
||||
variant?: 'simple' | 'card';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const { isSuccess: isLlmProviderSuccess, llmProviders } = useGetLLMProviders(
|
||||
{
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
select: (data) => {
|
||||
return data
|
||||
.map((provider) => {
|
||||
let description =
|
||||
'A versatile AI model for text generation and understanding.';
|
||||
if (provider.model.startsWith(localEnginePrefix)) {
|
||||
const model = provider.model.split(':');
|
||||
description = localModelDescriptionMap[model[1]] || '';
|
||||
} else if (provider.model.includes('claude')) {
|
||||
description =
|
||||
'Safe and thoughtful Anthropic AI model with advanced coding capabilities';
|
||||
} else {
|
||||
description = remoteProviderModelDescriptions[provider.model] || '';
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
description: description,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(model) =>
|
||||
model.model.toLowerCase() !==
|
||||
CODE_GENERATOR_MODEL_ID.toLowerCase(),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { data: agents, isSuccess: isAgentsSuccess } = useGetAgents({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
const isRegularChatPage =
|
||||
location.pathname.includes('inboxes') || location.pathname.includes('home');
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const selectedIcon = useMemo(() => {
|
||||
const selectedProvider = llmProviders?.find(
|
||||
(llmProvider) => llmProvider.id === value,
|
||||
);
|
||||
if (selectedProvider) {
|
||||
return (
|
||||
<ProviderIcon
|
||||
className="mx-1 size-4"
|
||||
provider={selectedProvider.model.split(':')[0]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const selectedAgent = agents?.find((agent) => agent.agent_id === value);
|
||||
if (selectedAgent) {
|
||||
return (
|
||||
<AIAgentIcon
|
||||
name={selectedAgent.name}
|
||||
size={variant === 'simple' ? 'xs' : 'xs'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <BotIcon className="mr-1 size-4" />;
|
||||
}, [agents, llmProviders, value, variant]);
|
||||
|
||||
const selectedAgentName = useMemo(() => {
|
||||
const selectedAgent = agents?.find((agent) => agent.agent_id === value);
|
||||
if (selectedAgent) {
|
||||
return selectedAgent.name;
|
||||
}
|
||||
const selectedLlmProvider = llmProviders?.find(
|
||||
(llmProvider) => llmProvider.id === value,
|
||||
);
|
||||
if (selectedLlmProvider) {
|
||||
return getProviderModelLabel(
|
||||
selectedLlmProvider.model,
|
||||
formatText(selectedLlmProvider.name || selectedLlmProvider.id),
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}, [agents, llmProviders, value]);
|
||||
|
||||
const isLocalHanzoNodeIsUse = useHanzoNodeManager(
|
||||
(state) => state.isInUse,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setIsDialogOpen} open={isDialogOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
actionButtonClassnames,
|
||||
|
||||
'w-auto justify-between truncate',
|
||||
variant === 'card' &&
|
||||
'bg-bg-secondary hover:bg-bg-tertiary h-auto w-auto max-w-md min-w-[240px] gap-3 rounded-xl border border-gray-500 p-1.5 px-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{variant === 'simple' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{selectedIcon}
|
||||
<span className="capitalize">
|
||||
{selectedAgentName ?? 'Select'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="icon h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
{variant === 'card' && (
|
||||
<>
|
||||
{selectedIcon}
|
||||
<div className="flex flex-col items-start justify-start text-left">
|
||||
<span className="text-base font-medium capitalize">
|
||||
{selectedAgentName}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="ml-auto size-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
align="center"
|
||||
className="flex flex-col gap-1"
|
||||
side="top"
|
||||
>
|
||||
<span className="text-center text-sm">
|
||||
{t('llmProviders.switch')}
|
||||
</span>
|
||||
{isRegularChatPage && (
|
||||
<div className="flex items-center gap-4 text-left">
|
||||
<div className="text-text-secondary flex items-center justify-center gap-2 text-xs">
|
||||
<CommandShortcut>⌘ [</CommandShortcut> or
|
||||
<CommandShortcut>⌘ ]</CommandShortcut>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-text-secondary text-xs">
|
||||
Prev / Next AI
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<DialogContent className="size-full max-h-[60vh] w-full max-w-3xl border p-1 py-2">
|
||||
<DialogTitle className="sr-only">
|
||||
{t('llmProviders.switch')}
|
||||
</DialogTitle>
|
||||
<Command
|
||||
className="[&_[cmdk-input-wrapper]]:border-divider h-full [&_[cmdk-input-wrapper]]:pb-1"
|
||||
disablePointerSelection
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
onValueChange={onValueChange}
|
||||
value={value}
|
||||
>
|
||||
<CommandInput placeholder={t('common.search')} />
|
||||
<CommandEmpty className="text-text-secondary py-5 text-center text-sm">
|
||||
{t('common.noResultsFound')}
|
||||
</CommandEmpty>
|
||||
<CommandList className="flex max-h-full flex-col">
|
||||
<CommandGroup
|
||||
className="py-4"
|
||||
heading={
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="font-inter text-text-default text-base font-medium">
|
||||
{t('agents.label')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-sm font-normal">
|
||||
{t('agentsPage.exploreAgentsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'xs',
|
||||
}),
|
||||
)}
|
||||
to="/add-agent"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
{t('common.add')}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isAgentsSuccess &&
|
||||
agents.map((agent) => (
|
||||
<CommandItem
|
||||
className="flex cursor-pointer items-center justify-between gap-1.5 rounded-md px-2 py-2 transition-colors"
|
||||
key={agent.agent_id}
|
||||
onSelect={() => {
|
||||
setIsDialogOpen(false);
|
||||
}}
|
||||
value={agent.agent_id}
|
||||
>
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<AIAgentIcon name={agent.name} size="sm" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="inline-flex items-center gap-1.5 text-base font-medium capitalize">
|
||||
{agent.name}
|
||||
</span>
|
||||
<span className="text-text-secondary line-clamp-1 text-sm">
|
||||
{agent.ui_description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
className="text-text-secondary hover:text-text-default size-8 shrink-0 rounded-lg p-2"
|
||||
to={`/agents/edit/${agent.agent_id}`}
|
||||
>
|
||||
<BoltIcon className="size-full" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
align="center"
|
||||
alignOffset={-10}
|
||||
className="z-[2000000001] max-w-md"
|
||||
side="top"
|
||||
>
|
||||
<p>{t('agents.configureAgent')}</p>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator className="" />
|
||||
<CommandGroup
|
||||
className="py-4"
|
||||
heading={
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="font-inter text-text-default text-base font-medium">
|
||||
{t('aisPage.label')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-sm font-normal">
|
||||
{t('aisPage.shortDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: 'xs',
|
||||
}),
|
||||
)}
|
||||
to={
|
||||
isLocalHanzoNodeIsUse ? '/install-ai-models' : '/add-ai'
|
||||
}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
{t('common.add')}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLlmProviderSuccess &&
|
||||
llmProviders?.length > 0 &&
|
||||
llmProviders?.map((llmProvider) => (
|
||||
<CommandItem
|
||||
className="flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 transition-colors"
|
||||
key={llmProvider.id}
|
||||
onSelect={() => {
|
||||
setIsDialogOpen(false);
|
||||
}}
|
||||
value={llmProvider.id}
|
||||
>
|
||||
<div className="bg-bg-secondary border-border flex size-8 shrink-0 items-center justify-center gap-2 rounded-lg border p-2">
|
||||
<ProviderIcon
|
||||
className="mt-0.5 size-5 shrink-0"
|
||||
provider={llmProvider.model.split(':')[0]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-base font-medium capitalize">
|
||||
{getProviderModelLabel(
|
||||
llmProvider.model,
|
||||
formatText(llmProvider?.name || llmProvider.id || ''),
|
||||
)}
|
||||
{location.pathname.includes('tools') &&
|
||||
llmProvider.model.toLowerCase() ===
|
||||
CODE_GENERATOR_MODEL_ID.toLowerCase() && (
|
||||
<Badge
|
||||
className="ml-2 border bg-emerald-900/40 px-1 py-0 text-xs font-medium text-emerald-400"
|
||||
variant="secondary"
|
||||
>
|
||||
{t('common.recommended')}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-text-secondary line-clamp-2 text-sm">
|
||||
{llmProvider?.description}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const AIModelSelector = memo(AIModelSelectorBase);
|
||||
|
||||
export function AiUpdateSelectionActionBarBase({
|
||||
inboxId,
|
||||
}: {
|
||||
inboxId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth((state) => state.auth);
|
||||
|
||||
const { data: provider } = useGetProviderFromJob({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
});
|
||||
|
||||
const { mutateAsync: updateAgentInJob, isPending } = useUpdateAgentInJob({
|
||||
onError: (error) => {
|
||||
toast.error(t('llmProviders.errors.updateAgent'), {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data: agents } = useGetAgents({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const { data: chatConfig } = useGetChatConfig(
|
||||
{
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
},
|
||||
{ enabled: !!inboxId },
|
||||
);
|
||||
|
||||
const { mutateAsync: updateChatConfig } = useUpdateChatConfig({
|
||||
onError: (error) => {
|
||||
toast.error('Use tools update failed', {
|
||||
description: error.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateToolUsage = async (enabled?: boolean) => {
|
||||
await updateChatConfig({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: extractJobIdFromInbox(inboxId),
|
||||
jobConfig: {
|
||||
stream: chatConfig?.stream,
|
||||
custom_prompt: chatConfig?.custom_prompt ?? '',
|
||||
temperature: chatConfig?.temperature,
|
||||
top_p: chatConfig?.top_p,
|
||||
top_k: chatConfig?.top_k,
|
||||
use_tools: enabled,
|
||||
thinking: chatConfig?.thinking,
|
||||
reasoning_effort: chatConfig?.reasoning_effort,
|
||||
web_search_enabled: chatConfig?.web_search_enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AIModelSelector
|
||||
onValueChange={async (value) => {
|
||||
if (!provider || isPending) return;
|
||||
const jobId = extractJobIdFromInbox(inboxId ?? '');
|
||||
await updateAgentInJob({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId,
|
||||
newAgentId: value,
|
||||
});
|
||||
const selectedAgent = agents?.find((agent) => agent.agent_id === value);
|
||||
const hasTools = (selectedAgent?.tools ?? [])?.length > 0;
|
||||
await handleUpdateToolUsage(hasTools);
|
||||
}}
|
||||
value={provider?.agent?.id ?? ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const AiUpdateSelectionActionBar = memo(
|
||||
AiUpdateSelectionActionBarBase,
|
||||
(prevProps, nextProps) => prevProps.inboxId === nextProps.inboxId,
|
||||
);
|
||||
@@ -0,0 +1,635 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { PopoverClose } from '@radix-ui/react-popover';
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { extractJobIdFromInbox } from '@hanzo_network/hanzo-message-ts/utils/inbox_name_handler';
|
||||
import { useUpdateChatConfig } from '@hanzo_network/hanzo-node-state/v2/mutations/updateChatConfig/useUpdateChatConfig';
|
||||
import { useGetChatConfig } from '@hanzo_network/hanzo-node-state/v2/queries/getChatConfig/useGetChatConfig';
|
||||
import { useGetLLMProviders } from '@hanzo_network/hanzo-node-state/v2/queries/getLLMProviders/useGetLLMProviders';
|
||||
import { useGetProviderFromJob } from '@hanzo_network/hanzo-node-state/v2/queries/getProviderFromJob/useGetProviderFromJob';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Slider,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { ChatSettingsIcon } from '@hanzo_network/hanzo-ui/assets';
|
||||
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
import { useForm, type UseFormReturn } from 'react-hook-form';
|
||||
import { useParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAuth } from '../../../store/auth';
|
||||
import { useSettings } from '../../../store/settings';
|
||||
import {
|
||||
getThinkingConfig,
|
||||
type ThinkingConfig,
|
||||
} from '../../../utils/thinking-config';
|
||||
import { ARTIFACTS_SYSTEM_PROMPT } from '../constants';
|
||||
import { actionButtonClassnames } from '../conversation-footer';
|
||||
|
||||
export const chatConfigFormSchema = z.object({
|
||||
stream: z.boolean(),
|
||||
useTools: z.boolean(),
|
||||
thinking: z.boolean(),
|
||||
reasoningEffort: z.enum(['low', 'medium', 'high']).optional(),
|
||||
webSearchEnabled: z.boolean().optional(),
|
||||
customPrompt: z.string().optional(),
|
||||
temperature: z.number(),
|
||||
topP: z.number(),
|
||||
topK: z.number(),
|
||||
});
|
||||
|
||||
export type ChatConfigFormSchemaType = z.infer<typeof chatConfigFormSchema>;
|
||||
|
||||
interface ChatConfigFormProps {
|
||||
form: UseFormReturn<ChatConfigFormSchemaType>;
|
||||
thinkingConfig?: ThinkingConfig;
|
||||
}
|
||||
|
||||
function ChatConfigForm({ form, thinkingConfig }: ChatConfigFormProps) {
|
||||
const optInExperimental = useSettings((state) => state.optInExperimental);
|
||||
|
||||
// Check if thinking is enabled (either forced or manually enabled)
|
||||
const isThinkingEnabled =
|
||||
thinkingConfig?.forceEnabled || form.watch('thinking');
|
||||
const shouldDisableSliders =
|
||||
thinkingConfig?.supportsThinking && isThinkingEnabled;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stream"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex w-full flex-col gap-3">
|
||||
<div className="flex gap-3">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="static space-y-1.5 text-xs text-white">
|
||||
Enable Stream
|
||||
</FormLabel>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webSearchEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex w-full flex-col gap-3">
|
||||
<div className="flex gap-3">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="static space-y-1.5 text-xs text-white">
|
||||
Enable Web Search
|
||||
</FormLabel>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="temperature"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex gap-2.5">
|
||||
<FormControl>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="grid w-full gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
className={`text-xs ${shouldDisableSliders ? 'opacity-60' : ''}`}
|
||||
htmlFor="temperature"
|
||||
>
|
||||
Temperature
|
||||
{shouldDisableSliders && (
|
||||
<span className="ml-1 text-xs text-white">
|
||||
(Disabled for thinking models)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<span className="text-text-secondary hover:border-border w-12 rounded-md border border-transparent px-2 py-0.5 text-right text-xs">
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
aria-label="Temperature"
|
||||
disabled={shouldDisableSliders}
|
||||
id="temperature"
|
||||
max={1}
|
||||
onValueChange={(vals) => {
|
||||
if (!shouldDisableSliders) {
|
||||
field.onChange(vals[0]);
|
||||
}
|
||||
}}
|
||||
step={0.1}
|
||||
value={[field.value]}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
className="w-[300px] bg-gray-600 px-2 py-3 text-xs"
|
||||
side="left"
|
||||
>
|
||||
Temperature is a parameter that affects the randomness of AI
|
||||
outputs. Higher temp = more unexpected, lower temp = more
|
||||
predictable.
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="topP"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex gap-2.5">
|
||||
<FormControl>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="grid w-full gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
className={`text-xs ${shouldDisableSliders ? 'opacity-60' : ''}`}
|
||||
htmlFor="topP"
|
||||
>
|
||||
Top P
|
||||
{shouldDisableSliders && (
|
||||
<span className="ml-1 text-xs text-white">
|
||||
(Disabled for thinking models)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<span className="text-text-secondary hover:border-border w-12 rounded-md border border-transparent px-2 py-0.5 text-right text-xs">
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
aria-label="Top P"
|
||||
disabled={shouldDisableSliders}
|
||||
id="topP"
|
||||
max={1}
|
||||
min={0}
|
||||
onValueChange={(vals) => {
|
||||
if (!shouldDisableSliders) {
|
||||
field.onChange(vals[0]);
|
||||
}
|
||||
}}
|
||||
step={0.1}
|
||||
value={[field.value]}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
className="w-[300px] bg-gray-600 px-2 py-3 text-xs"
|
||||
side="left"
|
||||
>
|
||||
Adjust the probability threshold to increase the relevance of
|
||||
results. For example, a threshold of 0.9 could be optimal for
|
||||
targeted, specific applications, whereas a threshold of 0.95
|
||||
or 0.97 might be preferred for tasks that require broader,
|
||||
more creative responses.
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="topK"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex gap-2.5">
|
||||
<FormControl>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="grid w-full gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
className={`text-xs ${shouldDisableSliders ? 'opacity-60' : ''}`}
|
||||
htmlFor="topK"
|
||||
>
|
||||
Top K
|
||||
{shouldDisableSliders && (
|
||||
<span className="ml-1 text-xs text-white">
|
||||
(Disabled for thinking models)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<span className="text-text-secondary hover:border-border w-12 rounded-md border border-transparent px-2 py-0.5 text-right text-xs">
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
aria-label="Top K"
|
||||
disabled={shouldDisableSliders}
|
||||
id="topK"
|
||||
max={100}
|
||||
onValueChange={(vals) => {
|
||||
if (!shouldDisableSliders) {
|
||||
field.onChange(vals[0]);
|
||||
}
|
||||
}}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
className="w-[300px] bg-gray-600 px-2 py-3 text-xs"
|
||||
side="left"
|
||||
>
|
||||
Adjust the count of key words for creating sequences. This
|
||||
parameter governs the extent of the generated passage,
|
||||
forestalling too much repetition. Selecting a higher figure
|
||||
yields longer narratives, whereas a smaller figure keeps the
|
||||
text brief.
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customPrompt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>System Prompt</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="!min-h-[130px] resize-none text-xs"
|
||||
spellCheck={false}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{thinkingConfig?.reasoningLevel && isThinkingEnabled && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reasoningEffort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs text-white">
|
||||
Reasoning Effort
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select reasoning effort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{optInExperimental && (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex gap-3">
|
||||
<Switch
|
||||
checked={form.watch('customPrompt') === ARTIFACTS_SYSTEM_PROMPT}
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue(
|
||||
'customPrompt',
|
||||
checked ? ARTIFACTS_SYSTEM_PROMPT : '',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="static space-y-1.5 text-xs text-white">
|
||||
Enable UI Artifacts
|
||||
</FormLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateChatConfigActionBarBase({
|
||||
inboxId,
|
||||
}: {
|
||||
inboxId: string;
|
||||
}) {
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const { data: chatConfig } = useGetChatConfig(
|
||||
{
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
},
|
||||
{ enabled: !!inboxId },
|
||||
);
|
||||
|
||||
const { data: provider } = useGetProviderFromJob({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const thinkingConfig = useMemo(() => {
|
||||
const modelName = provider?.agent?.model ?? provider?.agent?.id;
|
||||
return getThinkingConfig(modelName);
|
||||
}, [provider?.agent?.id, provider?.agent?.model]);
|
||||
|
||||
const form = useForm<ChatConfigFormSchemaType>({
|
||||
resolver: zodResolver(chatConfigFormSchema),
|
||||
defaultValues: {
|
||||
stream: chatConfig?.stream,
|
||||
customPrompt: chatConfig?.custom_prompt ?? '',
|
||||
temperature: chatConfig?.temperature,
|
||||
topP: chatConfig?.top_p,
|
||||
topK: chatConfig?.top_k,
|
||||
useTools: chatConfig?.use_tools,
|
||||
thinking: chatConfig?.thinking,
|
||||
reasoningEffort: chatConfig?.reasoning_effort,
|
||||
webSearchEnabled: chatConfig?.web_search_enabled,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateChatConfig } = useUpdateChatConfig({
|
||||
onSuccess: () => {
|
||||
toast.success('Chat settings updated successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Chat settings update failed', {
|
||||
description: error.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-enable thinking if force enabled for the model
|
||||
useEffect(() => {
|
||||
if (thinkingConfig.forceEnabled && chatConfig) {
|
||||
form.setValue('thinking', true);
|
||||
}
|
||||
}, [thinkingConfig.forceEnabled, chatConfig, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatConfig) {
|
||||
form.reset({
|
||||
stream: chatConfig.stream,
|
||||
customPrompt: chatConfig.custom_prompt ?? '',
|
||||
temperature: chatConfig.temperature,
|
||||
topP: chatConfig.top_p,
|
||||
topK: chatConfig.top_k,
|
||||
useTools: chatConfig.use_tools,
|
||||
thinking: chatConfig.thinking,
|
||||
reasoningEffort: chatConfig.reasoning_effort,
|
||||
webSearchEnabled: chatConfig.web_search_enabled,
|
||||
});
|
||||
}
|
||||
}, [chatConfig, form]);
|
||||
|
||||
const onSubmit = async (data: ChatConfigFormSchemaType) => {
|
||||
if (!inboxId) return;
|
||||
await updateChatConfig({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: extractJobIdFromInbox(inboxId),
|
||||
jobConfig: {
|
||||
stream: data.stream,
|
||||
custom_prompt: data.customPrompt ?? '',
|
||||
temperature: data.temperature,
|
||||
top_p: data.topP,
|
||||
top_k: data.topK,
|
||||
use_tools: data.useTools,
|
||||
thinking: data.thinking,
|
||||
reasoning_effort: data.reasoningEffort,
|
||||
web_search_enabled: data.webSearchEnabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* <ToolsDisabledAlert isToolsDisabled={!form.watch('useTools')} /> */}
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
stream: chatConfig?.stream,
|
||||
customPrompt: chatConfig?.custom_prompt ?? '',
|
||||
temperature: chatConfig?.temperature,
|
||||
topP: chatConfig?.top_p,
|
||||
topK: chatConfig?.top_k,
|
||||
useTools: chatConfig?.use_tools,
|
||||
thinking: chatConfig?.thinking,
|
||||
reasoningEffort: chatConfig?.reasoning_effort,
|
||||
webSearchEnabled: chatConfig?.web_search_enabled,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(actionButtonClassnames, 'p-2')}
|
||||
type="button"
|
||||
>
|
||||
<ChatSettingsIcon className="h-full w-full" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="min-w-[380px] px-6 py-7 text-xs"
|
||||
side="top"
|
||||
>
|
||||
<h2 className="text-text-secondary mb-5 text-xs leading-1 uppercase">
|
||||
Chat Settings
|
||||
</h2>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex w-full flex-col justify-between gap-10 overflow-hidden"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<ChatConfigForm form={form} thinkingConfig={thinkingConfig} />
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<PopoverClose asChild>
|
||||
<Button
|
||||
className="min-w-[100px]"
|
||||
rounded="lg"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
<span>{t('common.cancel')}</span>
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
<PopoverClose asChild>
|
||||
<Button
|
||||
className="min-w-[100px]"
|
||||
rounded="lg"
|
||||
size="xs"
|
||||
type={'submit'}
|
||||
>
|
||||
<span>{t('common.save')}</span>
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</PopoverContent>
|
||||
<TooltipContent>Chat Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const UpdateChatConfigActionBar = memo(
|
||||
UpdateChatConfigActionBarBase,
|
||||
(prevProps, nextProps) => prevProps.inboxId === nextProps.inboxId,
|
||||
);
|
||||
|
||||
export function CreateChatConfigActionBar({
|
||||
form,
|
||||
currentAI,
|
||||
}: {
|
||||
form: UseFormReturn<ChatConfigFormSchemaType>;
|
||||
currentAI?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth((state) => state.auth);
|
||||
|
||||
const { data: llmProviders } = useGetLLMProviders({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const thinkingConfig = useMemo(() => {
|
||||
if (!currentAI || !llmProviders) {
|
||||
return {
|
||||
supportsThinking: false,
|
||||
forceEnabled: false,
|
||||
reasoningLevel: false,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedProvider = llmProviders.find(
|
||||
(provider) => provider.id === currentAI,
|
||||
);
|
||||
const modelName = selectedProvider?.model;
|
||||
return getThinkingConfig(modelName);
|
||||
}, [currentAI, llmProviders]);
|
||||
|
||||
// Auto-enable thinking if force enabled for the model
|
||||
useEffect(() => {
|
||||
if (thinkingConfig.forceEnabled) {
|
||||
form.setValue('thinking', true);
|
||||
}
|
||||
}, [thinkingConfig.forceEnabled, form]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(actionButtonClassnames, 'p-2')}
|
||||
type="button"
|
||||
>
|
||||
<ChatSettingsIcon className="h-full w-full" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="max-h-[50vh] min-w-[380px] overflow-auto px-6 py-7 text-xs"
|
||||
side="bottom"
|
||||
>
|
||||
<h2 className="text-text-secondary mb-5 text-xs leading-1 uppercase">
|
||||
Chat Settings
|
||||
</h2>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex w-full flex-col justify-between gap-10 overflow-hidden"
|
||||
// onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<ChatConfigForm form={form} thinkingConfig={thinkingConfig} />
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<PopoverClose asChild>
|
||||
<Button
|
||||
className="h-9 min-w-[100px] gap-2 rounded-xl"
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<span>Reset to defaults</span>
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
<PopoverClose asChild>
|
||||
<Button className="min-w-[100px]" rounded="lg" size="xs">
|
||||
<span>{t('common.save')}</span>
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</PopoverContent>
|
||||
<TooltipContent>Chat Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { Paperclip } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { actionButtonClassnames } from '../conversation-footer';
|
||||
|
||||
type FileUploadInputProps = {
|
||||
disabled?: boolean;
|
||||
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
onClick: () => void;
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
function FileSelectionActionBarBase({
|
||||
onClick,
|
||||
inputProps,
|
||||
disabled,
|
||||
showLabel,
|
||||
}: FileUploadInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!showLabel) {
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(actionButtonClassnames, 'p-2')}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Paperclip className="h-full w-full" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent align="center" side="top">
|
||||
{t('common.uploadFile')} <br />
|
||||
{t('common.uploadAFileDescription')}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<input {...inputProps} disabled={disabled} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={cn(actionButtonClassnames, 'w-auto justify-start gap-2.5')}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
<span className="">{t('common.uploadFile')}</span>
|
||||
</button>
|
||||
|
||||
<input {...inputProps} disabled={disabled} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
export const FileSelectionActionBar = React.memo(
|
||||
FileSelectionActionBarBase,
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.disabled === nextProps.disabled;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { Folder } from 'lucide-react';
|
||||
|
||||
import { actionButtonClassnames } from '../conversation-footer';
|
||||
|
||||
type OpenChatFolderActionBarProps = {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
function OpenChatFolderActionBarBase({
|
||||
onClick,
|
||||
disabled,
|
||||
showLabel,
|
||||
}: OpenChatFolderActionBarProps) {
|
||||
const { t } = useTranslation();
|
||||
if (showLabel) {
|
||||
return (
|
||||
<button
|
||||
className={cn(actionButtonClassnames, 'w-full justify-start gap-2.5')}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Folder className="size-4" />
|
||||
<span className="">{t('chat.openChatFolder')}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(actionButtonClassnames, 'p-2', {
|
||||
'opacity-50': disabled,
|
||||
})}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Folder className="h-full w-full" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent align="center" side="top">
|
||||
{t('chat.openChatFolder')}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export const OpenChatFolderActionBar = OpenChatFolderActionBarBase;
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { PromptLibraryIcon } from '@hanzo_network/hanzo-ui/assets';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { usePromptSelectionStore } from '../../prompt/context/prompt-selection-context';
|
||||
import { actionButtonClassnames } from '../conversation-footer';
|
||||
|
||||
function PromptSelectionActionBarBase({
|
||||
disabled,
|
||||
showLabel,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
}) {
|
||||
const setPromptSelectionDrawerOpen = usePromptSelectionStore(
|
||||
(state) => state.setPromptSelectionDrawerOpen,
|
||||
);
|
||||
|
||||
if (!showLabel) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(actionButtonClassnames)}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setPromptSelectionDrawerOpen(true);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PromptLibraryIcon className="h-full w-full" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent align="center" side="top">
|
||||
Prompt Library
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={cn(actionButtonClassnames, 'w-full justify-start gap-2.5')}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setPromptSelectionDrawerOpen(true);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<PromptLibraryIcon className="size-4" />
|
||||
<span className="">Prompt Library</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const PromptSelectionActionBar = memo(
|
||||
PromptSelectionActionBarBase,
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.disabled === nextProps.disabled;
|
||||
},
|
||||
);
|
||||
export default PromptSelectionActionBar;
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { extractJobIdFromInbox } from '@hanzo_network/hanzo-message-ts/utils/inbox_name_handler';
|
||||
import { useUpdateChatConfig } from '@hanzo_network/hanzo-node-state/v2/mutations/updateChatConfig/useUpdateChatConfig';
|
||||
import { useGetChatConfig } from '@hanzo_network/hanzo-node-state/v2/queries/getChatConfig/useGetChatConfig';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useAuth } from '../../../store/auth';
|
||||
import { actionButtonClassnames } from '../conversation-footer';
|
||||
|
||||
interface ThinkingSwitchActionBarProps {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
forceEnabled?: boolean;
|
||||
}
|
||||
|
||||
function ThinkingSwitchActionBarBase({
|
||||
disabled,
|
||||
checked,
|
||||
onClick,
|
||||
forceEnabled = false,
|
||||
}: ThinkingSwitchActionBarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
actionButtonClassnames,
|
||||
'w-auto gap-2',
|
||||
checked &&
|
||||
'bg-gray-900 text-cyan-400 hover:bg-gray-900 hover:text-cyan-500',
|
||||
forceEnabled && 'opacity-75',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Brain
|
||||
className={cn(
|
||||
'size-4',
|
||||
checked ? 'text-cyan-400' : 'text-text-secondary',
|
||||
)}
|
||||
/>
|
||||
<span>{t('hanzoNode.models.labels.thinkingCapability')}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
<p className="text-text-secondary mt-1 text-xs">
|
||||
{forceEnabled
|
||||
? 'Thinking Mode is always enabled for this model and cannot be turned off.'
|
||||
: checked
|
||||
? 'Click to disable AI Thinking Mode'
|
||||
: 'Click to enable AI Thinking Mode'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const ThinkingSwitchActionBar = memo(
|
||||
ThinkingSwitchActionBarBase,
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.checked === nextProps.checked &&
|
||||
prevProps.disabled === nextProps.disabled &&
|
||||
prevProps.forceEnabled === nextProps.forceEnabled,
|
||||
);
|
||||
|
||||
export function UpdateThinkingSwitchActionBarBase({
|
||||
forceEnabled = false,
|
||||
inboxId,
|
||||
}: {
|
||||
forceEnabled?: boolean;
|
||||
inboxId: string;
|
||||
}) {
|
||||
const auth = useAuth((state) => state.auth);
|
||||
|
||||
const { data: chatConfig } = useGetChatConfig(
|
||||
{
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
},
|
||||
{ enabled: !!inboxId },
|
||||
);
|
||||
|
||||
const { mutateAsync: updateChatConfig, isPending } = useUpdateChatConfig({
|
||||
onError: (error) => {
|
||||
toast.error('Thinking mode update failed', {
|
||||
description: error.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateThinking = async () => {
|
||||
if (forceEnabled) return; // Don't allow toggling when forced enabled
|
||||
|
||||
await updateChatConfig({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: extractJobIdFromInbox(inboxId),
|
||||
jobConfig: {
|
||||
stream: chatConfig?.stream,
|
||||
custom_prompt: chatConfig?.custom_prompt ?? '',
|
||||
temperature: chatConfig?.temperature,
|
||||
top_p: chatConfig?.top_p,
|
||||
top_k: chatConfig?.top_k,
|
||||
use_tools: chatConfig?.use_tools,
|
||||
thinking: !chatConfig?.thinking,
|
||||
reasoning_effort: chatConfig?.reasoning_effort,
|
||||
web_search_enabled: chatConfig?.web_search_enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ThinkingSwitchActionBar
|
||||
checked={forceEnabled || !!chatConfig?.thinking}
|
||||
disabled={isPending || forceEnabled}
|
||||
onClick={() => handleUpdateThinking()}
|
||||
forceEnabled={forceEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const UpdateThinkingSwitchActionBar = memo(
|
||||
UpdateThinkingSwitchActionBarBase,
|
||||
(prevProps, nextProps) => prevProps.inboxId === nextProps.inboxId,
|
||||
);
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { extractJobIdFromInbox } from '@hanzo_network/hanzo-message-ts/utils/inbox_name_handler';
|
||||
import { useUpdateChatConfig } from '@hanzo_network/hanzo-node-state/v2/mutations/updateChatConfig/useUpdateChatConfig';
|
||||
import { useGetChatConfig } from '@hanzo_network/hanzo-node-state/v2/queries/getChatConfig/useGetChatConfig';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import {
|
||||
ToolsDisabledIcon,
|
||||
ToolsIcon,
|
||||
} from '@hanzo_network/hanzo-ui/assets';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { memo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useAuth } from '../../../store/auth';
|
||||
import { actionButtonClassnames } from '../conversation-footer';
|
||||
|
||||
interface ToolsSwitchActionBarProps {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ToolsSwitchActionBarBase({
|
||||
disabled,
|
||||
checked,
|
||||
onClick,
|
||||
}: ToolsSwitchActionBarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
actionButtonClassnames,
|
||||
'w-auto gap-2',
|
||||
checked &&
|
||||
'bg-gray-900 text-cyan-400 hover:bg-gray-900 hover:text-cyan-500',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{checked ? (
|
||||
<ToolsIcon className="size-4" />
|
||||
) : (
|
||||
<ToolsDisabledIcon className="size-4" />
|
||||
)}
|
||||
<span>{t('tools.label')}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{checked ? 'Disable' : 'Enable'} AI Actions (Tools)
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const ToolsSwitchActionBar = memo(
|
||||
ToolsSwitchActionBarBase,
|
||||
(prevProps, nextProps) => prevProps.checked === nextProps.checked,
|
||||
);
|
||||
|
||||
export function UpdateToolsSwitchActionBarBase({
|
||||
inboxId,
|
||||
}: {
|
||||
inboxId: string;
|
||||
}) {
|
||||
const auth = useAuth((state) => state.auth);
|
||||
|
||||
const { data: chatConfig } = useGetChatConfig(
|
||||
{
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
},
|
||||
{ enabled: !!inboxId },
|
||||
);
|
||||
|
||||
const { mutateAsync: updateChatConfig, isPending } = useUpdateChatConfig({
|
||||
onError: (error) => {
|
||||
toast.error('Use tools update failed', {
|
||||
description: error.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateTool = async () => {
|
||||
await updateChatConfig({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: extractJobIdFromInbox(inboxId),
|
||||
jobConfig: {
|
||||
stream: chatConfig?.stream,
|
||||
custom_prompt: chatConfig?.custom_prompt ?? '',
|
||||
temperature: chatConfig?.temperature,
|
||||
top_p: chatConfig?.top_p,
|
||||
top_k: chatConfig?.top_k,
|
||||
use_tools: !chatConfig?.use_tools,
|
||||
thinking: chatConfig?.thinking,
|
||||
reasoning_effort: chatConfig?.reasoning_effort,
|
||||
web_search_enabled: chatConfig?.web_search_enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolsSwitchActionBar
|
||||
checked={!!chatConfig?.use_tools}
|
||||
disabled={isPending}
|
||||
onClick={() => handleUpdateTool()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const UpdateToolsSwitchActionBar = memo(
|
||||
UpdateToolsSwitchActionBarBase,
|
||||
(prevProps, nextProps) => prevProps.inboxId === nextProps.inboxId,
|
||||
);
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { extractJobIdFromInbox } from '@hanzo_network/hanzo-message-ts/utils';
|
||||
import { useUpdateJobScope } from '@hanzo_network/hanzo-node-state/v2/mutations/updateJobScope/useUpdateJobScope';
|
||||
import { useGetListDirectoryContents } from '@hanzo_network/hanzo-node-state/v2/queries/getDirectoryContents/useGetListDirectoryContents';
|
||||
import { useGetJobFolderName } from '@hanzo_network/hanzo-node-state/v2/queries/getJobFolderName/useGetJobFolderName';
|
||||
import { useGetJobScope } from '@hanzo_network/hanzo-node-state/v2/queries/getJobScope/useGetJobScope';
|
||||
import {
|
||||
Badge,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import {
|
||||
DirectoryTypeIcon,
|
||||
FilesIcon,
|
||||
FileTypeIcon,
|
||||
} from '@hanzo_network/hanzo-ui/assets';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { X } from 'lucide-react';
|
||||
import { useParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useAuth } from '../../../store/auth';
|
||||
import { useSetJobScope } from '../context/set-job-scope-context';
|
||||
import { actionButtonClassnames } from '../conversation-footer';
|
||||
|
||||
type OpenChatFolderActionBarProps = {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
aiFilesCount?: number;
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
function VectorFsActionBarBase({
|
||||
onClick,
|
||||
aiFilesCount = 0,
|
||||
disabled,
|
||||
showLabel,
|
||||
}: OpenChatFolderActionBarProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!showLabel) {
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
actionButtonClassnames,
|
||||
'w-auto gap-2',
|
||||
disabled && 'opacity-50',
|
||||
aiFilesCount > 0 &&
|
||||
'bg-gray-900 text-cyan-400 hover:bg-gray-900 hover:text-cyan-300',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{aiFilesCount > 0 ? (
|
||||
<Badge className="bg-bg-dark text-text-default border-divider inline-flex size-4 items-center justify-center rounded-full p-0 text-center text-[10px]">
|
||||
{aiFilesCount}
|
||||
</Badge>
|
||||
) : (
|
||||
<FilesIcon className="size-4" />
|
||||
)}
|
||||
{t('vectorFs.localFiles')}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent align="center" side="top">
|
||||
{t('vectorFs.localFiles')}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={cn(actionButtonClassnames, 'w-full justify-start gap-2.5')}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<FilesIcon className="size-4" />
|
||||
<span className="">{t('vectorFs.localFiles')}</span>
|
||||
{aiFilesCount > 0 ? (
|
||||
<Badge className="bg-bg-dark border-divider text-text-default inline-flex size-4 items-center justify-center rounded-full p-0 text-center text-[10px]">
|
||||
{aiFilesCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateVectorFsActionBar() {
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const { inboxId: encodedInboxId = '' } = useParams();
|
||||
const inboxId = decodeURIComponent(encodedInboxId);
|
||||
const setSetJobScopeOpen = useSetJobScope(
|
||||
(state) => state.setSetJobScopeOpen,
|
||||
);
|
||||
|
||||
const { data: jobScope, isSuccess } = useGetJobScope(
|
||||
{
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
},
|
||||
{ enabled: !!inboxId },
|
||||
);
|
||||
|
||||
const { data: jobFolderData } = useGetJobFolderName(
|
||||
{
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
},
|
||||
{
|
||||
enabled: !!inboxId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: fileInfoArray, isSuccess: isVRFilesSuccess } =
|
||||
useGetListDirectoryContents(
|
||||
{
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
path: decodeURIComponent(jobFolderData?.folder_name ?? '') ?? '',
|
||||
},
|
||||
{
|
||||
enabled: !!jobFolderData?.folder_name,
|
||||
retry: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const hasFilesJobFolder = isVRFilesSuccess && fileInfoArray.length > 0;
|
||||
|
||||
const filesAndFoldersCount = isSuccess
|
||||
? jobScope.vector_fs_folders.length +
|
||||
jobScope.vector_fs_items.length +
|
||||
(hasFilesJobFolder ? 1 : 0)
|
||||
: 0;
|
||||
|
||||
const handleUpdateVectorFs = async () => {
|
||||
setSetJobScopeOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<VectorFsActionBarBase
|
||||
aiFilesCount={filesAndFoldersCount}
|
||||
onClick={handleUpdateVectorFs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function VectorFsActionBarPreview() {
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const { inboxId: encodedInboxId = '' } = useParams();
|
||||
const inboxId = decodeURIComponent(encodedInboxId);
|
||||
const setSetJobScopeOpen = useSetJobScope(
|
||||
(state) => state.setSetJobScopeOpen,
|
||||
);
|
||||
|
||||
const { data: jobScope, isSuccess } = useGetJobScope(
|
||||
{
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
},
|
||||
{ enabled: !!inboxId },
|
||||
);
|
||||
|
||||
const allItems = isSuccess
|
||||
? [
|
||||
...(jobScope?.vector_fs_folders ?? []).map((folder) => ({
|
||||
type: 'folder',
|
||||
name: folder,
|
||||
})),
|
||||
...(jobScope?.vector_fs_items ?? []).map((item) => ({
|
||||
type: 'file',
|
||||
name: item,
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
|
||||
const { mutateAsync: updateJobScope, isPending: isUpdatingJobScope } =
|
||||
useUpdateJobScope({
|
||||
onSuccess: () => {
|
||||
setSetJobScopeOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to update conversation context', {
|
||||
description: error.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{allItems.length > 0 && (
|
||||
<div className="no-scrollbar bg-bg-quaternary/10 scroll border-divider h-16 overflow-hidden border-b">
|
||||
<div className="flex items-center gap-3 overflow-x-auto p-2.5">
|
||||
{allItems.map((item) => (
|
||||
<div
|
||||
className="border-divider relative flex h-10 w-[180px] shrink-0 items-center gap-1.5 rounded-lg border px-1 py-1.5 pr-2.5"
|
||||
key={item.name}
|
||||
>
|
||||
<div className="flex w-6 shrink-0 items-center justify-center">
|
||||
{item.type === 'file' ? (
|
||||
<FileTypeIcon className="text-text-secondary size-4 shrink-0" />
|
||||
) : (
|
||||
<DirectoryTypeIcon className="text-text-secondary size-4 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-left text-xs">
|
||||
<span className="line-clamp-1 break-all">{item.name}</span>
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
'bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary border-divider absolute -top-2 -right-2 h-5 w-5 cursor-pointer rounded-full border p-1 transition-colors hover:text-white',
|
||||
isUpdatingJobScope && 'opacity-50',
|
||||
)}
|
||||
disabled={isUpdatingJobScope}
|
||||
onClick={async () => {
|
||||
const filteredFolders = (
|
||||
jobScope?.vector_fs_folders ?? []
|
||||
).filter((folder) => folder !== item.name);
|
||||
const filteredFiles = (
|
||||
jobScope?.vector_fs_items ?? []
|
||||
).filter((file) => file !== item.name);
|
||||
|
||||
await updateJobScope({
|
||||
jobId: extractJobIdFromInbox(inboxId),
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobScope: {
|
||||
vector_fs_items: filteredFiles,
|
||||
vector_fs_folders: filteredFolders,
|
||||
},
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-full w-full" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export const VectorFsActionBar = VectorFsActionBarBase;
|
||||
@@ -0,0 +1,339 @@
|
||||
import { type ChatConversationInfiniteData } from '@hanzo_network/hanzo-node-state/v2/queries/getChatConversation/types';
|
||||
import { Skeleton } from '@hanzo_network/hanzo-ui';
|
||||
import {
|
||||
getRelativeDateLabel,
|
||||
groupMessagesByDate,
|
||||
} from '@hanzo_network/hanzo-ui/helpers';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import {
|
||||
type FetchPreviousPageOptions,
|
||||
type InfiniteQueryObserverResult,
|
||||
} from '@tanstack/react-query';
|
||||
import React, {
|
||||
Fragment,
|
||||
memo,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
import { Message } from './message';
|
||||
|
||||
function useScrollToBottom(
|
||||
scrollRef: RefObject<HTMLDivElement | null>,
|
||||
detach = false,
|
||||
) {
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
function scrollDomToBottom() {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (scrollContainer) {
|
||||
requestAnimationFrame(() => {
|
||||
setAutoScroll(true);
|
||||
scrollContainer.scrollTo(0, scrollContainer.scrollHeight);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && !detach) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
scrollDomToBottom,
|
||||
};
|
||||
}
|
||||
|
||||
export const MessageList = memo(
|
||||
({
|
||||
noMoreMessageLabel,
|
||||
paginatedMessages,
|
||||
isSuccess,
|
||||
isLoading,
|
||||
isFetchingPreviousPage,
|
||||
hasPreviousPage,
|
||||
fetchPreviousPage,
|
||||
containerClassName,
|
||||
lastMessageContent,
|
||||
editAndRegenerateMessage,
|
||||
regenerateMessage,
|
||||
forkMessage,
|
||||
disabledRetryAndEdit,
|
||||
messageExtra,
|
||||
hidePythonExecution,
|
||||
minimalistMode,
|
||||
}: {
|
||||
noMoreMessageLabel: string;
|
||||
isSuccess: boolean;
|
||||
isLoading: boolean;
|
||||
isFetchingPreviousPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
paginatedMessages: ChatConversationInfiniteData | undefined;
|
||||
fetchPreviousPage: (
|
||||
options?: FetchPreviousPageOptions | undefined,
|
||||
) => Promise<
|
||||
InfiniteQueryObserverResult<ChatConversationInfiniteData, Error>
|
||||
>;
|
||||
regenerateMessage?: (messageId: string) => void;
|
||||
forkMessage?: (messageId: string) => void;
|
||||
editAndRegenerateMessage?: (content: string, messageHash: string) => void;
|
||||
containerClassName?: string;
|
||||
lastMessageContent?: React.ReactNode;
|
||||
disabledRetryAndEdit?: boolean;
|
||||
messageExtra?: React.ReactNode;
|
||||
hidePythonExecution?: boolean;
|
||||
minimalistMode?: boolean;
|
||||
}) => {
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const previousChatHeightRef = useRef<number>(0);
|
||||
const { ref, inView } = useInView();
|
||||
const messageList = paginatedMessages?.pages.flat() ?? [];
|
||||
|
||||
const { autoScroll, setAutoScroll, scrollDomToBottom } =
|
||||
useScrollToBottom(chatContainerRef);
|
||||
|
||||
const fetchPreviousMessages = useCallback(async () => {
|
||||
setAutoScroll(false);
|
||||
await fetchPreviousPage();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchPreviousPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPreviousPage && inView) {
|
||||
void fetchPreviousMessages();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasPreviousPage, inView]);
|
||||
|
||||
// adjust the scroll position of a chat container after new messages are fetched
|
||||
useLayoutEffect(() => {
|
||||
if (!isFetchingPreviousPage && inView) {
|
||||
const chatContainerElement = chatContainerRef.current;
|
||||
if (!chatContainerElement) return;
|
||||
const currentHeight = chatContainerElement.scrollHeight;
|
||||
const previousHeight = previousChatHeightRef.current;
|
||||
|
||||
if (!autoScroll) {
|
||||
chatContainerElement.scrollTop =
|
||||
currentHeight - previousHeight + chatContainerElement.scrollTop;
|
||||
} else {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
|
||||
chatContainerElement.scrollTop = currentHeight - previousHeight;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paginatedMessages, isFetchingPreviousPage, inView]);
|
||||
|
||||
useEffect(() => {
|
||||
const chatContainerElement = chatContainerRef.current;
|
||||
if (!chatContainerElement) return;
|
||||
const handleScroll = async () => {
|
||||
const currentHeight = chatContainerElement.scrollHeight;
|
||||
const currentScrollTop = chatContainerElement.scrollTop;
|
||||
previousChatHeightRef.current = currentHeight;
|
||||
const scrollThreshold = 20;
|
||||
const isNearBottom =
|
||||
currentScrollTop + chatContainerElement.clientHeight >=
|
||||
currentHeight - scrollThreshold;
|
||||
|
||||
setAutoScroll(isNearBottom);
|
||||
|
||||
if (inView && hasPreviousPage && !isFetchingPreviousPage) {
|
||||
previousChatHeightRef.current = currentHeight - currentScrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
chatContainerElement.addEventListener('scroll', handleScroll, {
|
||||
passive: true,
|
||||
});
|
||||
return () => {
|
||||
chatContainerElement.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
fetchPreviousMessages,
|
||||
hasPreviousPage,
|
||||
inView,
|
||||
isFetchingPreviousPage,
|
||||
paginatedMessages?.pages?.length,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageList?.length % 2 === 1) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messageList?.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSuccess]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'scroll size-full overflow-y-auto overscroll-none will-change-scroll',
|
||||
'flex-1 overflow-y-auto',
|
||||
containerClassName,
|
||||
)}
|
||||
ref={chatContainerRef}
|
||||
style={{ contain: 'strict' }}
|
||||
>
|
||||
{isSuccess &&
|
||||
!isFetchingPreviousPage &&
|
||||
!hasPreviousPage &&
|
||||
(paginatedMessages?.pages ?? [])?.length > 1 && (
|
||||
<div className="text-text-secondary py-2 text-center text-xs">
|
||||
{noMoreMessageLabel}
|
||||
</div>
|
||||
)}
|
||||
<div className="">
|
||||
{isLoading && (
|
||||
<div className="container flex flex-col space-y-8">
|
||||
{[...Array(10).keys()].map((index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-[85%] gap-2',
|
||||
index % 2 !== 0
|
||||
? 'mr-auto ml-0 flex-col'
|
||||
: 'mr-0 ml-auto w-[300px] items-start',
|
||||
)}
|
||||
key={`skeleton-${index}`}
|
||||
>
|
||||
{index % 2 !== 0 ? (
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Skeleton
|
||||
className="size-6 shrink-0 rounded-full"
|
||||
key={`avatar-${index}`}
|
||||
/>
|
||||
<Skeleton
|
||||
className="h-6 w-[100px] shrink-0 rounded-md"
|
||||
key={`name-${index}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Skeleton
|
||||
className={cn(
|
||||
'w-full rounded-lg px-2.5 py-3',
|
||||
index % 2 !== 0
|
||||
? 'bg-bg-secondary h-32 rounded-bl-none'
|
||||
: 'h-10',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(hasPreviousPage || isFetchingPreviousPage) && (
|
||||
<div className="flex flex-col space-y-3" ref={ref}>
|
||||
{[...Array(4).keys()].map((index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-[85%] gap-2',
|
||||
index % 2 === 0
|
||||
? 'mr-auto ml-0 flex-col'
|
||||
: 'mr-0 ml-auto w-[300px] items-start',
|
||||
)}
|
||||
key={`skeleton-prev-${index}`}
|
||||
>
|
||||
{index % 2 !== 0 ? (
|
||||
<Skeleton
|
||||
className="bg-bg-quaternary size-6 shrink-0 rounded-full"
|
||||
key={`prev-avatar-${index}`}
|
||||
/>
|
||||
) : null}
|
||||
<Skeleton
|
||||
className={cn(
|
||||
'w-full rounded-lg px-2.5 py-3',
|
||||
index % 2 !== 0
|
||||
? 'bg-bg-secondary h-32 rounded-bl-none'
|
||||
: 'h-10',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isSuccess && messageList?.length > 0 && (
|
||||
<Fragment>
|
||||
{Object.entries(groupMessagesByDate(messageList)).map(
|
||||
([date, messages]) => {
|
||||
return (
|
||||
<div key={date}>
|
||||
{!minimalistMode && (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-bg-tertiary relative z-10 m-auto my-2 flex h-[26px] w-fit min-w-[100px] items-center justify-center rounded-xl px-2.5 capitalize',
|
||||
'sticky top-5',
|
||||
)}
|
||||
>
|
||||
<span className="text-text-tertiary text-sm font-medium">
|
||||
{getRelativeDateLabel(
|
||||
new Date(messages[0].createdAt || ''),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{messages.map((message, messageIndex) => {
|
||||
const previousMessage = messages[messageIndex - 1];
|
||||
|
||||
const disabledRetryAndEditValue =
|
||||
disabledRetryAndEdit ?? messageIndex === 0;
|
||||
|
||||
const handleRetryMessage = () => {
|
||||
regenerateMessage?.(message?.messageId ?? '');
|
||||
};
|
||||
|
||||
const handleForkMessage = () => {
|
||||
forkMessage?.(message?.messageId ?? '');
|
||||
};
|
||||
|
||||
const handleEditMessage = (message: string) => {
|
||||
editAndRegenerateMessage?.(
|
||||
message,
|
||||
previousMessage?.messageId ?? '',
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Message
|
||||
disabledEdit={disabledRetryAndEditValue}
|
||||
handleEditMessage={handleEditMessage}
|
||||
handleForkMessage={handleForkMessage}
|
||||
handleRetryMessage={handleRetryMessage}
|
||||
hidePythonExecution={hidePythonExecution}
|
||||
key={`${message.messageId}::${messageIndex}`}
|
||||
message={message}
|
||||
messageId={message.messageId}
|
||||
minimalistMode={minimalistMode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{messageExtra}
|
||||
{lastMessageContent}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
MessageList.displayName = 'MessageList';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
import { OPTIMISTIC_ASSISTANT_MESSAGE_ID } from '@hanzo_network/hanzo-node-state/v2/constants';
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type FormattedMessage,
|
||||
} from '@hanzo_network/hanzo-node-state/v2/queries/getChatConversation/types';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { useStreamingContent } from '../context/streaming-context';
|
||||
import { Message } from './message';
|
||||
|
||||
type StreamingMessageProps = {
|
||||
message: FormattedMessage;
|
||||
messageId: string;
|
||||
inboxId: string;
|
||||
handleRetryMessage?: () => void;
|
||||
handleForkMessage?: () => void;
|
||||
disabledRetry?: boolean;
|
||||
disabledEdit?: boolean;
|
||||
handleEditMessage?: (message: string) => void;
|
||||
hidePythonExecution?: boolean;
|
||||
minimalistMode?: boolean;
|
||||
isLastMessage?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper component that provides streaming content for the optimistic assistant message.
|
||||
* This allows only the currently streaming message to re-render during streaming,
|
||||
* rather than the entire message list.
|
||||
*/
|
||||
export const StreamingMessage = memo(function StreamingMessage({
|
||||
message,
|
||||
messageId,
|
||||
inboxId: inboxIdProp,
|
||||
handleRetryMessage,
|
||||
handleForkMessage,
|
||||
disabledRetry,
|
||||
disabledEdit,
|
||||
handleEditMessage,
|
||||
hidePythonExecution,
|
||||
minimalistMode,
|
||||
isLastMessage,
|
||||
}: StreamingMessageProps) {
|
||||
// Try to get inboxId from prop first, fallback to useParams for backwards compatibility
|
||||
const { inboxId: encodedInboxId = '' } = useParams();
|
||||
const inboxIdFromParams = encodedInboxId
|
||||
? decodeURIComponent(encodedInboxId)
|
||||
: '';
|
||||
const inboxId = inboxIdProp || inboxIdFromParams;
|
||||
|
||||
// Only subscribe to streaming content for the optimistic message
|
||||
const isOptimisticMessage =
|
||||
messageId === OPTIMISTIC_ASSISTANT_MESSAGE_ID &&
|
||||
message.role === 'assistant';
|
||||
|
||||
const streamingContent = useStreamingContent(
|
||||
isOptimisticMessage ? inboxId : '',
|
||||
);
|
||||
|
||||
// Merge streaming content with the message for the optimistic assistant message
|
||||
const mergedMessage = useMemo(() => {
|
||||
if (!isOptimisticMessage) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const assistantMsg = message as AssistantMessage;
|
||||
|
||||
// Check if we have streaming content (either active or just ended)
|
||||
// When streaming ends, the store keeps content with isStreaming=false
|
||||
// This bridges the gap until React Query updates with final data
|
||||
const hasStreamingContent =
|
||||
streamingContent &&
|
||||
(streamingContent.content ||
|
||||
streamingContent.reasoning?.text ||
|
||||
streamingContent.toolCalls.length > 0);
|
||||
|
||||
// If streaming has ended (isStreaming === false), update status to complete
|
||||
// This immediately hides the dots loader even before React Query updates
|
||||
// We check streamingContent exists (not null/undefined) and isStreaming is false
|
||||
const shouldMarkComplete =
|
||||
streamingContent && streamingContent.isStreaming === false;
|
||||
|
||||
if (hasStreamingContent) {
|
||||
return {
|
||||
...assistantMsg,
|
||||
content: streamingContent.content || assistantMsg.content,
|
||||
reasoning: streamingContent.reasoning ?? assistantMsg.reasoning,
|
||||
toolCalls:
|
||||
streamingContent.toolCalls.length > 0
|
||||
? streamingContent.toolCalls
|
||||
: assistantMsg.toolCalls,
|
||||
// Update status to complete if streaming has ended
|
||||
status: shouldMarkComplete
|
||||
? { type: 'complete' as const, reason: 'unknown' as const }
|
||||
: assistantMsg.status,
|
||||
} as FormattedMessage;
|
||||
}
|
||||
|
||||
// If streaming has ended but no content yet, still mark as complete
|
||||
// This handles the edge case where is_stream: false arrives before any Stream messages
|
||||
if (shouldMarkComplete && assistantMsg.status.type === 'running') {
|
||||
return {
|
||||
...assistantMsg,
|
||||
status: { type: 'complete' as const, reason: 'unknown' as const },
|
||||
} as FormattedMessage;
|
||||
}
|
||||
|
||||
// No streaming content available - use the message prop directly
|
||||
// This happens either before streaming starts or after React Query
|
||||
// has been updated with the final data
|
||||
return message;
|
||||
}, [isOptimisticMessage, message, streamingContent]);
|
||||
|
||||
return (
|
||||
<Message
|
||||
disabledEdit={disabledEdit}
|
||||
disabledRetry={disabledRetry}
|
||||
handleEditMessage={handleEditMessage}
|
||||
handleForkMessage={handleForkMessage}
|
||||
handleRetryMessage={handleRetryMessage}
|
||||
hidePythonExecution={hidePythonExecution}
|
||||
isLastMessage={isLastMessage}
|
||||
key={`${messageId}::streaming`}
|
||||
message={mergedMessage}
|
||||
messageId={messageId}
|
||||
minimalistMode={minimalistMode}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
export const ARTIFACTS_SYSTEM_PROMPT = `
|
||||
The assistant can create and reference artifacts during conversations. Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
|
||||
|
||||
# Good artifacts are...
|
||||
- Substantial content (>15 lines)
|
||||
- Content that the user is likely to modify, iterate on, or take ownership of
|
||||
- Self-contained, complex content that can be understood on its own, without context from the conversation
|
||||
- Content intended for eventual use outside the conversation (e.g., reports, emails, presentations)
|
||||
- Content likely to be referenced or reused multiple times
|
||||
|
||||
# Don't use artifacts for...
|
||||
- Simple, informational, or short content, such as brief code snippets, mathematical equations, or small examples
|
||||
- Primarily explanatory, instructional, or illustrative content, such as examples provided to clarify a concept
|
||||
- Suggestions, commentary, or feedback on existing artifacts
|
||||
- Conversational or explanatory content that doesn't represent a standalone piece of work
|
||||
- Content that is dependent on the current conversational context to be useful
|
||||
- Content that is unlikely to be modified or iterated upon by the user
|
||||
- Request from users that appears to be a one-off question
|
||||
|
||||
# Usage notes
|
||||
- One artifact per message unless specifically requested
|
||||
- Prefer in-line content (don't use artifacts) when possible. Unnecessary use of artifacts can be jarring for users.
|
||||
- If a user asks the assistant to "draw an SVG" or "make a website," the assistant does not need to explain that it doesn't have these capabilities. Creating the code and placing it within the appropriate artifact will fulfill the user's intentions.
|
||||
- The assistant errs on the side of simplicity and avoids overusing artifacts for content that can be effectively presented within the conversation.
|
||||
|
||||
Instructions:
|
||||
|
||||
When collaborating with the user on creating content that falls into compatible categories, the assistant should follow these steps:
|
||||
|
||||
1. Briefly before invoking an artifact, think for one sentence in <antthinking> tags about how it evaluates against the criteria for a good and bad artifact. Consider if the content would work just fine without an artifact. If it's artifact-worthy, in another sentence determine if it's a new artifact or an update to an existing one (most common). For updates, reuse the prior identifier.
|
||||
|
||||
2. Wrap the content in opening and closing <antartifact> tags.
|
||||
|
||||
3. Assign an identifier to the identifier attribute of the opening <antartifact> tag. For updates, reuse the prior identifier. For new artifacts, the identifier should be descriptive and relevant to the content, using kebab-case (e.g., "example-code-snippet"). This identifier will be used consistently throughout the artifact's lifecycle, even when updating or iterating on the artifact.
|
||||
|
||||
4. Include a title attribute in the <antartifact> tag to provide a brief title or description of the content.
|
||||
|
||||
5. Add a type attribute to the opening <antartifact> tag to specify the type of content the artifact represents. Assign one of the following values to the type attribute:
|
||||
|
||||
- Code: "application/vnd.ant.code"
|
||||
- Use for code snippets or scripts in any programming language.
|
||||
- Include the language name as the value of the language attribute (e.g., language="python").
|
||||
- Do not use triple backticks when putting code in an artifact.
|
||||
- Documents: "text/markdown"
|
||||
- Plain text, Markdown, or other formatted text documents
|
||||
|
||||
- SVG: "image/svg+xml"
|
||||
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
||||
- The assistant should specify the viewbox of the SVG rather than defining a width/height
|
||||
|
||||
- React Components: "application/vnd.ant.react"
|
||||
- You are an expert frontend React engineer who is also a great UI/UX designer
|
||||
- Use this for displaying either: React elements, e.g. <strong>Hello World!</strong>, React pure functional components, e.g. () => <strong>Hello World!</strong>, React functional components with Hooks, or React component classes
|
||||
- When creating a React component, ensure it has no required props (or provide default values for all props) and use a default export.
|
||||
- Use Tailwind classes for styling. DO NOT USE ARBITRARY VALUES or other styles (e.g. h-[600px]).
|
||||
- Base React is available to be imported. To use hooks, first import it at the top of the artifact, e.g. import { useState } from "react"
|
||||
- Make it everything in single file App.tsx
|
||||
- The assistant should use prebuilt components from the shadcn/ui library after it is imported: import { alert, AlertDescription, AlertTitle, AlertDialog, AlertDialogAction } from '@/components/ui/alert';. If using components from the shadcn/ui library, the assistant mentions this to the user and offers to help them install the components if necessary.
|
||||
- You must always use shadcn for react, due to how nice the styling is
|
||||
- The lucide-react library is available to be imported. e.g. import { Plus } from "lucide-react"; & <Plus className="size-4 text-red-900" />
|
||||
- NO OTHER LIBRARIES (e.g. zod, hookform) ARE INSTALLED OR ABLE TO BE IMPORTED.
|
||||
- Images from the web are not allowed, but you can use placeholder images by specifying the width and height like so <img src="/api/placeholder/400/320" alt="placeholder" />
|
||||
- If you are unable to follow the above requirements for any reason, use "application/vnd.ant.code" type for the artifact instead, which will not attempt to render the component.
|
||||
- ONLY IF the user asks for a dashboard, graph or chart, the recharts library is available to be imported, e.g. \`import {LineChart, XAxis,...} from "recharts" <LineChart...> <XAxis dataKey= "name" >...\`. Please only use this when needed.
|
||||
|
||||
Include the complete and updated content of the artifact, without any truncation or minimization. Don't use "// rest of the code remains the same...".
|
||||
|
||||
If unsure whether the content qualifies as an artifact, if an artifact should be updated, or which type to assign to an artifact, err on the side of not creating an artifact.
|
||||
|
||||
Here are some examples of correct usage of artifacts by other AI assistants:
|
||||
|
||||
<example_docstring>
|
||||
This example demonstrates how to create a React component artifact for a metrics dashboard.
|
||||
</example_docstring>
|
||||
|
||||
<example>
|
||||
<user_query>Can you create a React component for a metrics dashboard?</user_query>
|
||||
|
||||
<assistant_response>
|
||||
Absolutely! Here's a React component that implements a basic metrics dashboard:
|
||||
|
||||
<antartifact identifier="dashboard-component" type="application/vnd.ant.react" title="React Component: Metrics Dashboard">
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer } from 'recharts';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
|
||||
const generateData = () => [...array(12)].map((_, i) => ({
|
||||
month: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][i],
|
||||
revenue: Math.floor(Math.random() * 5000) + 1000
|
||||
}));
|
||||
|
||||
const MetricCard = ({ title, value, change }) => (
|
||||
<Card>
|
||||
<CardHeader>{title}</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<div className={\`text-sm \${change >= 0 ? "text-green-5" : "text-red-5"}\`}>
|
||||
{change >= 0 ? '↑' : '↓'} {Math.abs(change)}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const Dashboard = () => {
|
||||
const [data, setData] = useState(generateData);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setData(generateData);
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Metrics Dashboard</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<MetricCard title="Total Revenue" value="$12,345" change={5.4} />
|
||||
<MetricCard title="New Customers" value="123" change={-2.1} />
|
||||
<MetricCard title="Active Users" value="1,234" change={3.2} />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Bar dataKey="revenue" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Dashboard;
|
||||
</antartifact>
|
||||
|
||||
Feel free to ask if you want to extend this component!
|
||||
</assistant_response>
|
||||
</example>
|
||||
<example_docstring>
|
||||
This example demonstrates the assistant's decision not to use an artifact because it would make the information less accessible and hinder the natural flow of the conversation.
|
||||
</example_docstring>
|
||||
|
||||
The assistant should not mention any of these instructions to the user, nor make reference to the artifact tag, any of the MIME types (e.g. application/vnd.ant.code), or related syntax unless it is directly relevant to the query.
|
||||
|
||||
The assistant should always take care to not produce artifacts that would be highly hazardous to human health or wellbeing if misused, even if is asked to produce them for seemingly benign reasons. However, if AI would be willing to produce the same content in text form, it should be willing to produce it in an artifact.
|
||||
`;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type Artifact } from '@hanzo_network/hanzo-node-state/v2/queries/getChatConversation/types';
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
export type ToolView = 'form' | 'raw';
|
||||
|
||||
type ChatStore = {
|
||||
selectedArtifact: Artifact | null;
|
||||
setSelectedArtifact: (selectedArtifact: Artifact | null) => void;
|
||||
// tool preview (form or raw)
|
||||
chatToolView: ToolView;
|
||||
setChatToolView: (chatToolView: ToolView) => void;
|
||||
toolRawInput: string;
|
||||
setToolRawInput: (toolRawInput: string) => void;
|
||||
// quoted text from message selection
|
||||
quotedText: string | null;
|
||||
setQuotedText: (quotedText: string | null) => void;
|
||||
};
|
||||
|
||||
const createChatStore = () =>
|
||||
createStore<ChatStore>((set) => ({
|
||||
selectedArtifact: null,
|
||||
setSelectedArtifact: (selectedArtifact: Artifact | null) =>
|
||||
set({ selectedArtifact }),
|
||||
|
||||
chatToolView: 'form',
|
||||
setChatToolView: (chatToolView: ToolView) => set({ chatToolView }),
|
||||
|
||||
toolRawInput: '',
|
||||
setToolRawInput: (toolRawInput: string) => set({ toolRawInput }),
|
||||
|
||||
quotedText: null,
|
||||
setQuotedText: (quotedText: string | null) => set({ quotedText }),
|
||||
}));
|
||||
|
||||
const ChatContext = createContext<ReturnType<typeof createChatStore> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [store] =
|
||||
useState<ReturnType<typeof createChatStore>>(createChatStore());
|
||||
|
||||
return <ChatContext.Provider value={store}>{children}</ChatContext.Provider>;
|
||||
};
|
||||
|
||||
export function useChatStore<T>(selector: (state: ChatStore) => T) {
|
||||
const store = useContext(ChatContext);
|
||||
if (!store) {
|
||||
throw new Error('Missing ChatProvider');
|
||||
}
|
||||
const value = useStore(store, selector);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { type HanzoPath } from '@hanzo_network/hanzo-message-ts/api/jobs/types';
|
||||
import { type TreeCheckboxSelectionKeys } from 'primereact/tree';
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
import {
|
||||
KnowledgeSearchDrawer,
|
||||
SetJobScopeDrawer,
|
||||
} from '../set-conversation-context';
|
||||
|
||||
type SetJobScopeStore = {
|
||||
isSetJobScopeOpen: boolean;
|
||||
setSetJobScopeOpen: (isSetJobScopeOpen: boolean) => void;
|
||||
selectedKeys: TreeCheckboxSelectionKeys | null;
|
||||
onSelectedKeysChange: (value: TreeCheckboxSelectionKeys | null) => void;
|
||||
selectedFileKeysRef: Map<string, HanzoPath>;
|
||||
selectedFolderKeysRef: Map<string, HanzoPath>;
|
||||
|
||||
isKnowledgeSearchOpen: boolean;
|
||||
setKnowledgeSearchOpen: (isKnowledgeSearchOpen: boolean) => void;
|
||||
|
||||
resetJobScope: () => void;
|
||||
};
|
||||
|
||||
const createVectorFsStore = () =>
|
||||
createStore<SetJobScopeStore>((set) => ({
|
||||
isSetJobScopeOpen: false,
|
||||
setSetJobScopeOpen: (isSetJobScopeOpen) => {
|
||||
set({ isSetJobScopeOpen });
|
||||
},
|
||||
selectedKeys: null,
|
||||
onSelectedKeysChange: (selectedKeys) => {
|
||||
set({ selectedKeys });
|
||||
},
|
||||
selectedFileKeysRef: new Map<string, HanzoPath>(),
|
||||
selectedFolderKeysRef: new Map<string, HanzoPath>(),
|
||||
|
||||
isKnowledgeSearchOpen: false,
|
||||
setKnowledgeSearchOpen: (isKnowledgeSearchOpen) => {
|
||||
set({ isKnowledgeSearchOpen });
|
||||
},
|
||||
resetJobScope: () => {
|
||||
set({
|
||||
isSetJobScopeOpen: false,
|
||||
selectedKeys: null,
|
||||
selectedFileKeysRef: new Map<string, HanzoPath>(),
|
||||
selectedFolderKeysRef: new Map<string, HanzoPath>(),
|
||||
isKnowledgeSearchOpen: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const SetJobScopeContext = createContext<ReturnType<
|
||||
typeof createVectorFsStore
|
||||
> | null>(null);
|
||||
|
||||
export const SetJobScopeProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [store] = useState<ReturnType<typeof createVectorFsStore>>(
|
||||
createVectorFsStore(),
|
||||
);
|
||||
return (
|
||||
<SetJobScopeContext.Provider value={store}>
|
||||
{children}
|
||||
<SetJobScopeDrawer />
|
||||
<KnowledgeSearchDrawer />
|
||||
</SetJobScopeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useSetJobScope<T>(selector: (state: SetJobScopeStore) => T) {
|
||||
const store = useContext(SetJobScopeContext);
|
||||
if (!store) {
|
||||
throw new Error('Missing SetJobScopeProvider');
|
||||
}
|
||||
const value = useStore(store, selector);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
type ToolCall,
|
||||
type TextStatus,
|
||||
} from '@hanzo_network/hanzo-node-state/v2/queries/getChatConversation/types';
|
||||
import type React from 'react';
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
/**
|
||||
* Streaming data for a single inbox - holds ephemeral content during streaming
|
||||
* This is kept separate from React Query to avoid expensive re-renders of the entire message list
|
||||
*/
|
||||
export type StreamingContent = {
|
||||
content: string;
|
||||
reasoning: {
|
||||
text: string;
|
||||
status: TextStatus;
|
||||
} | null;
|
||||
toolCalls: ToolCall[];
|
||||
isStreaming: boolean;
|
||||
// reasoning client-side only for now
|
||||
/** Timestamp when reasoning started (for duration calculation) */
|
||||
reasoningStartTime: number | null;
|
||||
/** Calculated reasoning duration in seconds */
|
||||
reasoningDuration: number;
|
||||
};
|
||||
|
||||
type StreamingStore = {
|
||||
/**
|
||||
* Map of inboxId -> streaming content
|
||||
* Only the currently streaming message is stored here
|
||||
*/
|
||||
streams: Map<string, StreamingContent>;
|
||||
|
||||
/**
|
||||
* Map of inboxId -> reasoning duration (in seconds) for the last message
|
||||
* Persists after clearInbox to preserve duration for the last completed message
|
||||
*/
|
||||
reasoningDurations: Map<string, number>;
|
||||
|
||||
/**
|
||||
* Get streaming content for a specific inbox
|
||||
*/
|
||||
getStreamingContent: (inboxId: string) => StreamingContent | undefined;
|
||||
|
||||
/**
|
||||
* Start streaming for an inbox - initializes the streaming state
|
||||
*/
|
||||
startStreaming: (inboxId: string) => void;
|
||||
|
||||
/**
|
||||
* Append content to the streaming message
|
||||
*/
|
||||
appendContent: (inboxId: string, content: string) => void;
|
||||
|
||||
/**
|
||||
* Append reasoning to the streaming message
|
||||
*/
|
||||
appendReasoning: (inboxId: string, reasoning: string) => void;
|
||||
|
||||
/**
|
||||
* Mark reasoning as complete
|
||||
*/
|
||||
completeReasoning: (inboxId: string) => void;
|
||||
|
||||
/**
|
||||
* Update tool calls for the streaming message
|
||||
*/
|
||||
updateToolCall: (inboxId: string, toolCall: ToolCall, index: number) => void;
|
||||
|
||||
/**
|
||||
* End streaming for an inbox - marks as not streaming but preserves content
|
||||
* until React Query updates with final data
|
||||
*/
|
||||
endStreaming: (inboxId: string) => void;
|
||||
|
||||
/**
|
||||
* Clear streaming data for a specific inbox
|
||||
* Call this after React Query has been successfully updated with final data
|
||||
*/
|
||||
clearInbox: (inboxId: string) => void;
|
||||
|
||||
/**
|
||||
* Save reasoning duration for the last message in an inbox
|
||||
* This persists after clearInbox to preserve duration for the last completed message
|
||||
*/
|
||||
saveReasoningDuration: (inboxId: string, duration: number) => void;
|
||||
|
||||
/**
|
||||
* Get reasoning duration for the last message in an inbox
|
||||
*/
|
||||
getReasoningDuration: (inboxId: string) => number | undefined;
|
||||
|
||||
/**
|
||||
* Clear all streaming state (useful on logout/cleanup)
|
||||
*/
|
||||
clearAll: () => void;
|
||||
};
|
||||
|
||||
const calculateReasoningDuration = (content: StreamingContent): number => {
|
||||
if (content.reasoningDuration > 0) return content.reasoningDuration;
|
||||
if (!content.reasoningStartTime) return 0;
|
||||
return Math.round((Date.now() - content.reasoningStartTime) / 1000);
|
||||
};
|
||||
|
||||
// Maximum number of inboxes to keep reasoning durations for
|
||||
const MAX_REASONING_DURATIONS = 5;
|
||||
|
||||
const createStreamingStore = () =>
|
||||
createStore<StreamingStore>((set, get) => ({
|
||||
streams: new Map(),
|
||||
reasoningDurations: new Map(),
|
||||
|
||||
getStreamingContent: (inboxId: string) => {
|
||||
return get().streams.get(inboxId);
|
||||
},
|
||||
|
||||
startStreaming: (inboxId: string) => {
|
||||
set((state) => {
|
||||
const newStreams = new Map(state.streams);
|
||||
const newDurations = new Map(state.reasoningDurations);
|
||||
newDurations.delete(inboxId);
|
||||
newStreams.set(inboxId, {
|
||||
content: '',
|
||||
reasoning: null,
|
||||
toolCalls: [],
|
||||
isStreaming: true,
|
||||
reasoningStartTime: null,
|
||||
reasoningDuration: 0,
|
||||
});
|
||||
return { streams: newStreams, reasoningDurations: newDurations };
|
||||
});
|
||||
},
|
||||
|
||||
appendContent: (inboxId: string, content: string) => {
|
||||
set((state) => {
|
||||
const current = state.streams.get(inboxId);
|
||||
if (!current?.isStreaming) return state;
|
||||
|
||||
const newStreams = new Map(state.streams);
|
||||
newStreams.set(inboxId, {
|
||||
...current,
|
||||
content: current.content + content,
|
||||
// Mark reasoning as complete when content starts
|
||||
reasoning: current.reasoning
|
||||
? {
|
||||
...current.reasoning,
|
||||
status: { type: 'complete', reason: 'unknown' },
|
||||
}
|
||||
: null,
|
||||
// Calculate reasoning duration when content starts (reasoning ends)
|
||||
reasoningDuration: calculateReasoningDuration(current),
|
||||
});
|
||||
return { streams: newStreams };
|
||||
});
|
||||
},
|
||||
|
||||
appendReasoning: (inboxId: string, reasoning: string) => {
|
||||
set((state) => {
|
||||
const current = state.streams.get(inboxId);
|
||||
if (!current?.isStreaming) return state;
|
||||
|
||||
const newStreams = new Map(state.streams);
|
||||
const currentReasoning = current.reasoning ?? {
|
||||
text: '',
|
||||
status: { type: 'running' as const },
|
||||
};
|
||||
// Track start time when first reasoning token arrives
|
||||
const reasoningStartTime = current.reasoningStartTime ?? Date.now();
|
||||
newStreams.set(inboxId, {
|
||||
...current,
|
||||
reasoning: {
|
||||
...currentReasoning,
|
||||
text: currentReasoning.text + reasoning,
|
||||
status: { type: 'running' },
|
||||
},
|
||||
reasoningStartTime,
|
||||
});
|
||||
return { streams: newStreams };
|
||||
});
|
||||
},
|
||||
|
||||
completeReasoning: (inboxId: string) => {
|
||||
set((state) => {
|
||||
const current = state.streams.get(inboxId);
|
||||
if (!current?.isStreaming || !current.reasoning) return state;
|
||||
|
||||
const newStreams = new Map(state.streams);
|
||||
newStreams.set(inboxId, {
|
||||
...current,
|
||||
reasoning: {
|
||||
...current.reasoning,
|
||||
status: { type: 'complete', reason: 'unknown' },
|
||||
},
|
||||
reasoningDuration: calculateReasoningDuration(current),
|
||||
});
|
||||
return { streams: newStreams };
|
||||
});
|
||||
},
|
||||
|
||||
updateToolCall: (inboxId: string, toolCall: ToolCall, index: number) => {
|
||||
set((state) => {
|
||||
const current = state.streams.get(inboxId);
|
||||
if (!current) return state;
|
||||
|
||||
const newStreams = new Map(state.streams);
|
||||
const newToolCalls = [...current.toolCalls];
|
||||
|
||||
if (index < newToolCalls.length) {
|
||||
// Update existing tool call
|
||||
newToolCalls[index] = { ...newToolCalls[index], ...toolCall };
|
||||
} else {
|
||||
// Add new tool call
|
||||
newToolCalls.push(toolCall);
|
||||
}
|
||||
|
||||
newStreams.set(inboxId, {
|
||||
...current,
|
||||
toolCalls: newToolCalls,
|
||||
});
|
||||
return { streams: newStreams };
|
||||
});
|
||||
},
|
||||
|
||||
endStreaming: (inboxId: string) => {
|
||||
set((state) => {
|
||||
const current = state.streams.get(inboxId);
|
||||
if (!current) return state;
|
||||
|
||||
// Don't delete the entry - just mark as not streaming
|
||||
// This keeps the content available for StreamingMessage to use
|
||||
// until React Query updates with the final data
|
||||
const newStreams = new Map(state.streams);
|
||||
newStreams.set(inboxId, {
|
||||
...current,
|
||||
isStreaming: false,
|
||||
reasoningDuration: calculateReasoningDuration(current),
|
||||
});
|
||||
return { streams: newStreams };
|
||||
});
|
||||
},
|
||||
|
||||
clearInbox: (inboxId: string) => {
|
||||
set((state) => {
|
||||
if (!state.streams.has(inboxId)) return state;
|
||||
|
||||
const newStreams = new Map(state.streams);
|
||||
newStreams.delete(inboxId);
|
||||
return { streams: newStreams };
|
||||
});
|
||||
},
|
||||
|
||||
saveReasoningDuration: (inboxId: string, duration: number) => {
|
||||
set((state) => {
|
||||
const newDurations = new Map(state.reasoningDurations);
|
||||
if (newDurations.has(inboxId)) {
|
||||
newDurations.delete(inboxId);
|
||||
}
|
||||
newDurations.set(inboxId, duration);
|
||||
|
||||
if (newDurations.size > MAX_REASONING_DURATIONS) {
|
||||
const entriesToRemove = newDurations.size - MAX_REASONING_DURATIONS;
|
||||
const keysToRemove = Array.from(newDurations.keys()).slice(
|
||||
0,
|
||||
entriesToRemove,
|
||||
);
|
||||
for (const key of keysToRemove) {
|
||||
newDurations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return { reasoningDurations: newDurations };
|
||||
});
|
||||
},
|
||||
|
||||
getReasoningDuration: (inboxId: string) => {
|
||||
return get().reasoningDurations.get(inboxId);
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ streams: new Map(), reasoningDurations: new Map() });
|
||||
},
|
||||
}));
|
||||
|
||||
const StreamingContext = createContext<ReturnType<
|
||||
typeof createStreamingStore
|
||||
> | null>(null);
|
||||
|
||||
export const StreamingProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [store] =
|
||||
useState<ReturnType<typeof createStreamingStore>>(createStreamingStore);
|
||||
|
||||
return (
|
||||
<StreamingContext.Provider value={store}>
|
||||
{children}
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useStreamingStore<T>(selector: (state: StreamingStore) => T) {
|
||||
const store = useContext(StreamingContext);
|
||||
if (!store) {
|
||||
throw new Error('Missing StreamingProvider');
|
||||
}
|
||||
const value = useStore(store, selector);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get streaming content for a specific inbox
|
||||
* Returns undefined if not streaming
|
||||
*/
|
||||
export function useStreamingContent(inboxId: string) {
|
||||
const selector = useCallback(
|
||||
(state: StreamingStore) => state.streams.get(inboxId),
|
||||
[inboxId],
|
||||
);
|
||||
return useStreamingStore(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if an inbox is currently streaming
|
||||
* Returns true only when actively streaming (not when content is preserved after completion)
|
||||
*/
|
||||
export function useIsStreaming(inboxId: string) {
|
||||
const selector = useCallback(
|
||||
(state: StreamingStore) => state.streams.get(inboxId)?.isStreaming ?? false,
|
||||
[inboxId],
|
||||
);
|
||||
return useStreamingStore(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the reasoning duration for the last message in an inbox
|
||||
* Returns the calculated duration in seconds, or 0 if not available
|
||||
* Only tracks the last message duration (persists after clearInbox)
|
||||
* Returns 0 if inboxId is empty string (to avoid showing duration for non-last messages)
|
||||
*/
|
||||
export function useReasoningDuration(inboxId: string) {
|
||||
const selector = useCallback(
|
||||
(state: StreamingStore) => {
|
||||
// Return 0 if inboxId is empty (means it's not the last message)
|
||||
if (!inboxId) return 0;
|
||||
// Check persistent storage for the last message duration
|
||||
const persisted = state.reasoningDurations.get(inboxId);
|
||||
if (persisted !== undefined) return persisted;
|
||||
// Fallback to 0 if not found
|
||||
return 0;
|
||||
},
|
||||
[inboxId],
|
||||
);
|
||||
return useStreamingStore(selector);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type WidgetToolState } from '@hanzo_network/hanzo-message-ts/api/general/types';
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
type ToolsStore = {
|
||||
widget: WidgetToolState | null;
|
||||
setWidget: (widget: WidgetToolState | null) => void;
|
||||
};
|
||||
|
||||
const createToolsStore = () =>
|
||||
createStore<ToolsStore>((set) => ({
|
||||
// TODO: external widgets eg: PaymentCard, later we should unify to toolCalls
|
||||
widget: null,
|
||||
setWidget: (widget) => set({ widget }),
|
||||
}));
|
||||
|
||||
const ToolsContext = createContext<ReturnType<typeof createToolsStore> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export const ToolsProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [store] =
|
||||
useState<ReturnType<typeof createToolsStore>>(createToolsStore());
|
||||
|
||||
return (
|
||||
<ToolsContext.Provider value={store}>{children}</ToolsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useToolsStore<T>(selector: (state: ToolsStore) => T) {
|
||||
const store = useContext(ToolsContext);
|
||||
if (!store) {
|
||||
throw new Error('Missing ToolsProvider');
|
||||
}
|
||||
const value = useStore(store, selector);
|
||||
|
||||
return value;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,534 @@
|
||||
import { DotsVerticalIcon } from '@radix-ui/react-icons';
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { extractJobIdFromInbox } from '@hanzo_network/hanzo-message-ts/utils';
|
||||
import { useExportMessagesFromInbox } from '@hanzo_network/hanzo-node-state/v2/mutations/exportMessagesFromInbox/useExportMessagesFromInbox';
|
||||
import { useGetAgents } from '@hanzo_network/hanzo-node-state/v2/queries/getAgents/useGetAgents';
|
||||
import { useGetLLMProviders } from '@hanzo_network/hanzo-node-state/v2/queries/getLLMProviders/useGetLLMProviders';
|
||||
import { useGetProviderFromJob } from '@hanzo_network/hanzo-node-state/v2/queries/getProviderFromJob/useGetProviderFromJob';
|
||||
import { getProviderModelLabel } from '../../lib/hanzo-node-manager/local-model-names';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Badge,
|
||||
Button,
|
||||
buttonVariants,
|
||||
ScrollArea,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import {
|
||||
AgentIcon,
|
||||
ScheduledTasksIcon,
|
||||
ToolsIcon,
|
||||
} from '@hanzo_network/hanzo-ui/assets';
|
||||
import { formatText } from '@hanzo_network/hanzo-ui/helpers';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import * as fs from '@tauri-apps/plugin-fs';
|
||||
import { BaseDirectory } from '@tauri-apps/plugin-fs';
|
||||
import cronstrue from 'cronstrue';
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
DownloadIcon,
|
||||
} from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
|
||||
import { toast } from 'sonner';
|
||||
import { useGetCurrentInbox } from '../../hooks/use-current-inbox';
|
||||
import { useAuth } from '../../store/auth';
|
||||
import { useSettings } from '../../store/settings';
|
||||
import ProviderIcon from '../ais/provider-icon';
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
let sanitized = name.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
sanitized = sanitized.replace(/_+/g, '_');
|
||||
sanitized = sanitized.replace(/^_+|_+$/g, '');
|
||||
return sanitized || 'chat';
|
||||
}
|
||||
|
||||
const ConversationHeaderWithInboxId = () => {
|
||||
const currentInbox = useGetCurrentInbox();
|
||||
const { inboxId: encodedInboxId = '' } = useParams();
|
||||
const inboxId = decodeURIComponent(encodedInboxId);
|
||||
const auth = useAuth((state) => state.auth);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: provider } = useGetProviderFromJob({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
});
|
||||
const { data: agents } = useGetAgents({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const isAgentInbox = provider?.provider_type === 'Agent';
|
||||
|
||||
const isChatSidebarCollapsed = useSettings(
|
||||
(state) => state.isChatSidebarCollapsed,
|
||||
);
|
||||
const setChatSidebarCollapsed = useSettings(
|
||||
(state) => state.setChatSidebarCollapsed,
|
||||
);
|
||||
|
||||
const selectedAgent = agents?.find(
|
||||
(agent) => agent.agent_id === provider?.agent?.id,
|
||||
);
|
||||
|
||||
const { data: llmProvider } = useGetLLMProviders({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const selectedModel = llmProvider?.find(
|
||||
(provider) => provider.id === selectedAgent?.llm_provider_id,
|
||||
);
|
||||
|
||||
const { mutateAsync: exportMessages } = useExportMessagesFromInbox({
|
||||
onSuccess: async (response, variables) => {
|
||||
const sanitizedName = sanitizeFileName(
|
||||
currentInbox?.custom_name || inboxId,
|
||||
);
|
||||
const extension = variables.format;
|
||||
const file = new Blob([response ?? ''], {
|
||||
type: 'application/octet-stream',
|
||||
});
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const content = new Uint8Array(arrayBuffer);
|
||||
|
||||
const savePath = await save({
|
||||
defaultPath: `${sanitizedName}.${extension}`,
|
||||
filters: [
|
||||
{ name: `${extension.toUpperCase()} File`, extensions: [extension] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!savePath) {
|
||||
toast.info('File saving cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(savePath, content, {
|
||||
baseDir: BaseDirectory.Download,
|
||||
});
|
||||
|
||||
toast.success('Chat exported successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to export chat', {
|
||||
description: error.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border-divider flex h-[58px] items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="text-text-secondary flex items-center gap-2"
|
||||
onClick={() => setChatSidebarCollapsed(!isChatSidebarCollapsed)}
|
||||
size="icon"
|
||||
variant="tertiary"
|
||||
>
|
||||
{isChatSidebarCollapsed ? (
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isChatSidebarCollapsed ? 'Open' : 'Close'} Chat Sidebar
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent className="flex flex-col items-center gap-1">
|
||||
<p> Toggle Chat Sidebar</p>
|
||||
<div className="text-text-secondary flex items-center justify-center gap-2 text-center">
|
||||
<span>⌘</span>
|
||||
<span>B</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
|
||||
<div className="inline w-full flex-1 truncate text-sm font-medium whitespace-nowrap text-white capitalize">
|
||||
{isAgentInbox && selectedAgent ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-white capitalize">
|
||||
{selectedAgent?.name}{' '}
|
||||
{selectedAgent?.cron_tasks?.length &&
|
||||
selectedAgent?.cron_tasks?.length > 0 && (
|
||||
<Badge
|
||||
className="border bg-emerald-900/40 px-1 py-0 text-xs font-medium text-emerald-400"
|
||||
variant="secondary"
|
||||
>
|
||||
Scheduled
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-text-secondary flex items-center text-xs">
|
||||
<span className="max-w-[300px] truncate">
|
||||
{selectedAgent?.ui_description || 'No description'}
|
||||
</span>
|
||||
<span className="px-2">⋅</span>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<span className="text-text-secondary text-xs hover:cursor-pointer hover:text-white hover:underline">
|
||||
{t('common.viewDetails')}
|
||||
</span>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="pr-1.5" side="right">
|
||||
<SheetHeader className="">
|
||||
<div className="flex items-center gap-4">
|
||||
<AgentIcon className="size-5" />
|
||||
<SheetTitle className="font-inter inline-flex items-center gap-2 text-xl font-medium tracking-wide capitalize">
|
||||
{selectedAgent.name}
|
||||
{selectedAgent?.cron_tasks?.length &&
|
||||
selectedAgent?.cron_tasks?.length > 0 && (
|
||||
<Badge
|
||||
className="font-inter border bg-emerald-900/40 px-1 py-0 text-xs font-medium tracking-normal text-emerald-400"
|
||||
variant="secondary"
|
||||
>
|
||||
Scheduled
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-130px)] pr-3">
|
||||
<div className="py-6">
|
||||
<h3 className="text-text-secondary mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
{t('common.about')}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed">
|
||||
{selectedAgent.ui_description}
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-text-secondary mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
AI Model
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-4 items-center justify-center rounded-lg">
|
||||
<ProviderIcon
|
||||
className="size-full"
|
||||
provider={selectedModel?.id.split(':')[0]}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{getProviderModelLabel(
|
||||
selectedModel?.model,
|
||||
selectedModel?.name || selectedModel?.id || '',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
className="mt-6"
|
||||
defaultValue={[
|
||||
'instructions',
|
||||
'tools',
|
||||
'knowledge',
|
||||
'tasks',
|
||||
]}
|
||||
type="multiple"
|
||||
>
|
||||
<AccordionItem
|
||||
className="border-b-0"
|
||||
value="instructions"
|
||||
>
|
||||
<AccordionTrigger className="py-3 hover:no-underline">
|
||||
<div className="text-text-secondary flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t('agents.systemInstructions')}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="bg-bg-default border-divider rounded-lg border p-4">
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{selectedAgent.config?.custom_system_prompt ||
|
||||
'No system instructions found.'}
|
||||
</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem className="border-b-0" value="tools">
|
||||
<AccordionTrigger className="py-3 hover:no-underline">
|
||||
<div className="text-text-secondary flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Available Tools{' '}
|
||||
{selectedAgent.tools.length > 0 && (
|
||||
<span className="text-text-secondary text-xs">
|
||||
({selectedAgent.tools.length})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-3">
|
||||
{selectedAgent.tools.length === 0 && (
|
||||
<p className="text-text-secondary text-sm">
|
||||
{t('tools.commandEmpty')}
|
||||
</p>
|
||||
)}
|
||||
{selectedAgent.tools.map((tool, index) => (
|
||||
<div
|
||||
className="bg-bg-default border-divider relative flex cursor-default items-center gap-2 rounded-lg border p-2 pr-8 text-sm transition-colors"
|
||||
key={index}
|
||||
>
|
||||
<ToolsIcon className="h-4 w-4" />
|
||||
<span className="flex-1 truncate">
|
||||
{formatText(
|
||||
tool.split(':::')?.at(-1) ?? '',
|
||||
)}
|
||||
</span>
|
||||
<Link
|
||||
className="text-text-secondary absolute right-2 hover:text-white"
|
||||
to={`/tools/${tool}`}
|
||||
>
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
View Tool Details
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
className="border-b-0"
|
||||
value="knowledge"
|
||||
>
|
||||
<AccordionTrigger className="py-3 hover:no-underline">
|
||||
<div className="text-text-secondary flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Knowledge Sources{' '}
|
||||
{(
|
||||
selectedAgent.scope?.vector_fs_folders ?? []
|
||||
).length > 0 ||
|
||||
((
|
||||
selectedAgent.scope?.vector_fs_items ?? []
|
||||
)?.length > 0 && (
|
||||
<span className="text-text-secondary text-xs">
|
||||
(
|
||||
{(
|
||||
selectedAgent.scope
|
||||
?.vector_fs_folders ?? []
|
||||
).length +
|
||||
(
|
||||
selectedAgent.scope
|
||||
?.vector_fs_items ?? []
|
||||
).length}
|
||||
)
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-2">
|
||||
{(selectedAgent.scope?.vector_fs_folders ?? [])
|
||||
.length === 0 &&
|
||||
(selectedAgent.scope?.vector_fs_items ?? [])
|
||||
.length === 0 && (
|
||||
<p className="text-text-secondary text-sm">
|
||||
No knowledge sources found.
|
||||
</p>
|
||||
)}
|
||||
{selectedAgent.scope?.vector_fs_folders?.map(
|
||||
(item, index) => (
|
||||
<div
|
||||
className="bg-bg-default border-divider flex items-center justify-start gap-2 rounded-lg border p-2 capitalize"
|
||||
key={index}
|
||||
>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
{item?.split('/').at(-1)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{selectedAgent.scope?.vector_fs_items?.map(
|
||||
(item, index) => (
|
||||
<div
|
||||
className="bg-bg-default border-divider flex items-center justify-start gap-2 rounded-lg border p-2 capitalize"
|
||||
key={index}
|
||||
>
|
||||
<FileIcon className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
{item?.split('/').at(-1) ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem className="border-b-0" value="tasks">
|
||||
<AccordionTrigger className="py-3 hover:no-underline">
|
||||
<div className="text-text-secondary flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Scheduled Tasks
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-2">
|
||||
{(selectedAgent.cron_tasks ?? []).length ===
|
||||
0 && (
|
||||
<p className="text-text-secondary text-sm">
|
||||
{t('tasksPage.noTasksTitle')}
|
||||
</p>
|
||||
)}
|
||||
{selectedAgent.cron_tasks?.map((task) => (
|
||||
<div
|
||||
className="bg-bg-default border-divider relative flex items-start gap-2 rounded-lg border p-2 pr-6 capitalize"
|
||||
key={task.task_id}
|
||||
>
|
||||
<ScheduledTasksIcon className="mt-1 h-4 w-4" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">{task.name}</p>
|
||||
<p className="text-text-secondary text-xs">
|
||||
{cronstrue.toString(task.cron, {
|
||||
throwExceptionOnParseError: false,
|
||||
})}{' '}
|
||||
({task.cron})
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
className="text-text-secondary absolute top-2 right-2 hover:text-white"
|
||||
to={`/tasks/${task.task_id}`}
|
||||
>
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
View Task Details
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter>
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'outline',
|
||||
}),
|
||||
)}
|
||||
to={`/agents/edit/${selectedAgent.agent_id}`}
|
||||
>
|
||||
Edit Agent
|
||||
</Link>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
currentInbox?.custom_name || currentInbox?.inbox_id
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'tertiary', size: 'icon' }),
|
||||
'border-0 hover:bg-gray-500/40',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="sr-only">{t('common.moreOptions')}</span>
|
||||
<DotsVerticalIcon className="text-text-secondary" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px] px-2.5 py-2">
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await exportMessages({
|
||||
inboxId,
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
format: 'json',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="mr-3 h-4 w-4" />
|
||||
Export as JSON
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await exportMessages({
|
||||
inboxId,
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
format: 'csv',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="mr-3 h-4 w-4" />
|
||||
Export as CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await exportMessages({
|
||||
inboxId,
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
format: 'txt',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="mr-3 h-4 w-4" />
|
||||
Export as TXT
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConversationHeader = () => {
|
||||
return <ConversationHeaderWithInboxId />;
|
||||
};
|
||||
|
||||
export default memo(ConversationHeader, () => true);
|
||||
@@ -0,0 +1,113 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { t, useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import {
|
||||
type UpdateInboxNameFormSchema,
|
||||
updateInboxNameFormSchema,
|
||||
} from '@hanzo_network/hanzo-node-state/forms/chat/inbox';
|
||||
import { useUpdateInboxName } from '@hanzo_network/hanzo-node-state/v2/mutations/updateInboxName/useUpdateInboxName';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
Input,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { Edit3 } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { useAuth } from '../../store/auth';
|
||||
|
||||
export const InboxNameInput = ({
|
||||
closeEditable,
|
||||
inboxId,
|
||||
inboxName,
|
||||
}: {
|
||||
closeEditable: () => void;
|
||||
inboxId: string;
|
||||
inboxName: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const updateInboxNameForm = useForm<UpdateInboxNameFormSchema>({
|
||||
resolver: zodResolver(updateInboxNameFormSchema),
|
||||
});
|
||||
const { name: inboxNameValue } = updateInboxNameForm.watch();
|
||||
const { mutateAsync: updateInboxName } = useUpdateInboxName();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (data: UpdateInboxNameFormSchema) => {
|
||||
if (!auth) return;
|
||||
|
||||
await updateInboxName({
|
||||
nodeAddress: auth.node_address,
|
||||
token: auth.api_v2_key,
|
||||
inboxId,
|
||||
inboxName: data.name,
|
||||
});
|
||||
closeEditable();
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...updateInboxNameForm}>
|
||||
<form
|
||||
className="relative flex w-full items-center"
|
||||
onSubmit={updateInboxNameForm.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="w-full">
|
||||
<FormField
|
||||
control={updateInboxNameForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<div className="flex h-[46px] items-center rounded-lg bg-bg-secondary">
|
||||
<Edit3 className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 transform text-white" />
|
||||
|
||||
<FormItem className="space-y-0 pl-7 text-xs">
|
||||
<FormLabel className="sr-only static">
|
||||
{t('inboxes.updateName')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="placeholder:!text-text-placeholder h-full border-none bg-transparent py-2 pr-16 text-xs caret-white focus-visible:ring-0 focus-visible:ring-white"
|
||||
placeholder={inboxName}
|
||||
{...field}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inboxNameValue ? (
|
||||
<Button
|
||||
className="absolute top-1/2 right-1 h-8 -translate-y-1/2 transform text-xs text-white"
|
||||
size="sm"
|
||||
type="submit"
|
||||
variant="default"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="absolute top-1/2 right-1 h-8 -translate-y-1/2 transform text-xs text-white"
|
||||
onClick={closeEditable}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,581 @@
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: <explanation> */
|
||||
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: <explanation> */
|
||||
import { DialogClose } from '@radix-ui/react-dialog';
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { extractJobIdFromInbox } from '@hanzo_network/hanzo-message-ts/utils';
|
||||
import { useRemoveJob } from '@hanzo_network/hanzo-node-state/v2/mutations/removeJob/useRemoveJob';
|
||||
import { useRemoveJobs } from '@hanzo_network/hanzo-node-state/v2/mutations/removeMultipleJobs/useRemoveMultipleJobs';
|
||||
import { useGetInboxesWithPagination } from '@hanzo_network/hanzo-node-state/v2/queries/getInboxes/useGetInboxesWithPagination';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
ScrollArea,
|
||||
SearchInput,
|
||||
Skeleton,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Edit3Icon,
|
||||
ExternalLinkIcon,
|
||||
SearchXIcon,
|
||||
Trash2Icon,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useAuth } from '../../store/auth';
|
||||
import { InboxNameInput } from './inbox-name-input';
|
||||
|
||||
type ManageChatsDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function ManageChatsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ManageChatsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedInboxIds, setSelectedInboxIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||
const [editingInboxId, setEditingInboxId] = useState<string | null>(null);
|
||||
const [hoveredInboxId, setHoveredInboxId] = useState<string | null>(null);
|
||||
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||
const [inboxToDelete, setInboxToDelete] = useState<string | null>(null);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
// Reset all state when dialog closes
|
||||
setSearchQuery('');
|
||||
setSelectedInboxIds(new Set());
|
||||
setLastSelectedId(null);
|
||||
setEditingInboxId(null);
|
||||
setHoveredInboxId(null);
|
||||
setIsDeleteConfirmOpen(false);
|
||||
setInboxToDelete(null);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
const {
|
||||
data: inboxesPagination,
|
||||
isPending,
|
||||
isSuccess,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useGetInboxesWithPagination({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const { mutateAsync: removeJob } = useRemoveJob({
|
||||
onSuccess: () => {
|
||||
toast.success(t('chat.actions.chatsDeleted', { count: 1 }));
|
||||
setInboxToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to delete chat', {
|
||||
description: error?.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: removeJobs, isPending: isDeleting } = useRemoveJobs({
|
||||
onSuccess: (response) => {
|
||||
const succeededCount = response.succeeded.length;
|
||||
const failedCount = response.failed.length;
|
||||
|
||||
if (response.status === 'success') {
|
||||
toast.success(
|
||||
t('chat.actions.chatsDeleted', { count: succeededCount }),
|
||||
);
|
||||
} else if (response.status === 'partial') {
|
||||
toast.warning(
|
||||
t('chat.actions.chatsPartiallyDeleted', {
|
||||
succeeded: succeededCount,
|
||||
failed: failedCount,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedInboxIds(new Set());
|
||||
setIsDeleteConfirmOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to delete chats', {
|
||||
description: error?.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const allInboxes = useMemo(() => {
|
||||
if (!inboxesPagination?.pages) return [];
|
||||
return inboxesPagination.pages
|
||||
.flatMap((page) => page.inboxes)
|
||||
.filter((inbox) => inbox.inbox_id?.startsWith('job_inbox::'));
|
||||
}, [inboxesPagination]);
|
||||
|
||||
// Filter inboxes based on search query
|
||||
const filteredInboxes = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allInboxes;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allInboxes.filter((inbox) => {
|
||||
const name =
|
||||
inbox.last_message && inbox.custom_name === inbox.inbox_id
|
||||
? inbox.last_message.job_message.content
|
||||
: inbox.custom_name;
|
||||
return name?.toLowerCase().includes(query);
|
||||
});
|
||||
}, [allInboxes, searchQuery]);
|
||||
|
||||
// Reset selection when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery('');
|
||||
setSelectedInboxIds(new Set());
|
||||
setIsDeleteConfirmOpen(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleToggleSelect = useCallback(
|
||||
(inboxId: string, shiftKey = false) => {
|
||||
setSelectedInboxIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
const isSelecting = !newSet.has(inboxId);
|
||||
|
||||
if (shiftKey && lastSelectedId && isSelecting) {
|
||||
const currentIndex = filteredInboxes.findIndex(
|
||||
(i) => i.inbox_id === inboxId,
|
||||
);
|
||||
const lastIndex = filteredInboxes.findIndex(
|
||||
(i) => i.inbox_id === lastSelectedId,
|
||||
);
|
||||
|
||||
if (currentIndex !== -1 && lastIndex !== -1) {
|
||||
const start = Math.min(currentIndex, lastIndex);
|
||||
const end = Math.max(currentIndex, lastIndex);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
newSet.add(filteredInboxes[i].inbox_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (newSet.has(inboxId)) {
|
||||
newSet.delete(inboxId);
|
||||
} else {
|
||||
newSet.add(inboxId);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
|
||||
setLastSelectedId(inboxId);
|
||||
},
|
||||
[filteredInboxes, lastSelectedId],
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const allIds = new Set(filteredInboxes.map((inbox) => inbox.inbox_id));
|
||||
setSelectedInboxIds(allIds);
|
||||
}, [filteredInboxes]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
setSelectedInboxIds(new Set());
|
||||
}, []);
|
||||
|
||||
const handleNavigateToChat = useCallback(
|
||||
(inboxId: string) => {
|
||||
handleOpenChange(false);
|
||||
void navigate(`/inboxes/${encodeURIComponent(inboxId)}`);
|
||||
},
|
||||
[navigate, handleOpenChange],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (inboxId: string) => {
|
||||
if (!auth) return;
|
||||
await removeJob({
|
||||
nodeAddress: auth.node_address,
|
||||
token: auth.api_v2_key,
|
||||
jobId: extractJobIdFromInbox(inboxId),
|
||||
});
|
||||
},
|
||||
[auth, removeJob],
|
||||
);
|
||||
|
||||
const handleDeleteSelected = useCallback(async () => {
|
||||
if (!auth || selectedInboxIds.size === 0) return;
|
||||
|
||||
const jobIds = Array.from(selectedInboxIds).map((inboxId) =>
|
||||
extractJobIdFromInbox(inboxId),
|
||||
);
|
||||
|
||||
await removeJobs({
|
||||
nodeAddress: auth.node_address,
|
||||
token: auth.api_v2_key,
|
||||
jobIds,
|
||||
});
|
||||
}, [auth, selectedInboxIds, removeJobs]);
|
||||
|
||||
const allSelected =
|
||||
filteredInboxes.length > 0 &&
|
||||
filteredInboxes.every((inbox) => selectedInboxIds.has(inbox.inbox_id));
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent
|
||||
showCloseButton
|
||||
className="flex h-[85vh] max-h-[700px] flex-col sm:max-w-[700px]"
|
||||
>
|
||||
<DialogTitle className="text-lg">
|
||||
{t('chat.actions.manageChats')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('chat.actions.manageChatsDescription')}
|
||||
</DialogDescription>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<SearchInput
|
||||
classNames={{
|
||||
container: 'flex-1',
|
||||
}}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('chat.actions.searchChats')}
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 pr-4 [&>div>div]:!block">
|
||||
<div
|
||||
className={cn(
|
||||
'py-1.5',
|
||||
selectedInboxIds.size > 0 && 'pb-20',
|
||||
)}
|
||||
>
|
||||
{isPending &&
|
||||
Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton
|
||||
className="h-[72px] w-full shrink-0 rounded-lg bg-gray-300"
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isSuccess && filteredInboxes.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="bg-bg-secondary mb-3 flex size-12 items-center justify-center rounded-full">
|
||||
<SearchXIcon className="text-text-tertiary h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm font-medium">
|
||||
{t('chat.actions.noChatsFound')}
|
||||
</p>
|
||||
{searchQuery && (
|
||||
<p className="text-text-tertiary mt-1 text-xs">
|
||||
Try adjusting your search terms
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSuccess &&
|
||||
filteredInboxes.map((inbox) => {
|
||||
const isSelected = selectedInboxIds.has(inbox.inbox_id);
|
||||
const isEditing = editingInboxId === inbox.inbox_id;
|
||||
const displayName =
|
||||
inbox.last_message && inbox.custom_name === inbox.inbox_id
|
||||
? inbox.last_message.job_message.content?.slice(0, 80)
|
||||
: inbox.custom_name?.slice(0, 80);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div
|
||||
className="bg-bg-secondary flex items-center gap-4 px-2 py-1.5"
|
||||
key={inbox.inbox_id}
|
||||
>
|
||||
<InboxNameInput
|
||||
closeEditable={() => setEditingInboxId(null)}
|
||||
inboxId={inbox.inbox_id}
|
||||
inboxName={displayName || inbox.inbox_id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isHovered = hoveredInboxId === inbox.inbox_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-4 rounded-lg border border-transparent px-4 py-3 transition-colors hover:bg-gray-400/10',
|
||||
isSelected && 'border-brand/30 bg-brand/5',
|
||||
)}
|
||||
key={inbox.inbox_id}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Don't toggle selection if clicking on buttons or inputs
|
||||
if (target.closest('button') || target.closest('input')) {
|
||||
return;
|
||||
}
|
||||
handleToggleSelect(inbox.inbox_id, e.shiftKey);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredInboxId(inbox.inbox_id)}
|
||||
onMouseLeave={() => setHoveredInboxId(null)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className="shrink-0"
|
||||
onCheckedChange={() => handleToggleSelect(inbox.inbox_id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex flex-1 cursor-pointer items-center gap-3 overflow-hidden">
|
||||
<span className="text-text-default truncate text-sm font-medium">
|
||||
{displayName || inbox.inbox_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Date and Actions container - fixed width to prevent layout shift */}
|
||||
<div className="relative flex h-8 w-[104px] shrink-0 items-center justify-end overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{isHovered ? (
|
||||
<motion.div
|
||||
key="actions"
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
staggerChildren: 0.03,
|
||||
},
|
||||
}}
|
||||
className="flex items-center gap-0.5"
|
||||
exit={{
|
||||
opacity: 0,
|
||||
x: 8,
|
||||
transition: { duration: 0.15 },
|
||||
}}
|
||||
initial={{ opacity: 0, x: 8 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Button
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() =>
|
||||
handleNavigateToChat(inbox.inbox_id)
|
||||
}
|
||||
size="auto"
|
||||
title={t('common.open')}
|
||||
variant="tertiary"
|
||||
>
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15, delay: 0.03 }}
|
||||
>
|
||||
<Button
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() =>
|
||||
setEditingInboxId(inbox.inbox_id)
|
||||
}
|
||||
size="auto"
|
||||
title={t('common.rename')}
|
||||
variant="tertiary"
|
||||
>
|
||||
<Edit3Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.15, delay: 0.06 }}
|
||||
>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 text-red-500 hover:bg-red-500/10 hover:text-red-500"
|
||||
onClick={() => setInboxToDelete(inbox.inbox_id)}
|
||||
size="auto"
|
||||
title={t('common.delete')}
|
||||
variant="tertiary"
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.span
|
||||
key="date"
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
},
|
||||
}}
|
||||
className="text-text-tertiary text-xs whitespace-nowrap"
|
||||
exit={{
|
||||
opacity: 0,
|
||||
x: -8,
|
||||
transition: { duration: 0.15 },
|
||||
}}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
>
|
||||
{formatDistanceToNow(
|
||||
new Date(inbox.datetime_created),
|
||||
{ addSuffix: true },
|
||||
)}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasNextPage && (
|
||||
<button
|
||||
className="text-text-secondary hover:text-text-default mx-auto mt-4 block w-full py-3 text-center text-sm"
|
||||
disabled={isFetchingNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
type="button"
|
||||
>
|
||||
{isFetchingNextPage ? 'Loading more...' : 'Load more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{selectedInboxIds.size > 0 && (
|
||||
<div className="border-divider bg-bg-default absolute right-0 bottom-0 left-0 flex items-center justify-between gap-3 rounded-b-lg border-t px-6 py-4">
|
||||
<span className="text-text-secondary text-sm font-medium">
|
||||
{t('chat.actions.selectedCount', {
|
||||
count: selectedInboxIds.size,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={allSelected ? handleDeselectAll : handleSelectAll}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{allSelected
|
||||
? t('chat.actions.deselectAll')
|
||||
: t('chat.actions.selectAll')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsDeleteConfirmOpen(true)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2Icon className="mr-1.5 h-4 w-4" />
|
||||
{t('chat.actions.deleteSelectedChats')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
onOpenChange={setIsDeleteConfirmOpen}
|
||||
open={isDeleteConfirmOpen}
|
||||
>
|
||||
<DialogContent showCloseButton className="sm:max-w-[425px]">
|
||||
<DialogTitle>
|
||||
{t('chat.actions.deleteSelectedChatsConfirmationTitle', {
|
||||
count: selectedInboxIds.size,
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('chat.actions.deleteSelectedChatsConfirmationDescription')}
|
||||
</DialogDescription>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
className="min-w-[100px] flex-1"
|
||||
disabled={isDeleting}
|
||||
onClick={() => setIsDeleteConfirmOpen(false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-[100px] flex-1"
|
||||
disabled={isDeleting}
|
||||
isLoading={isDeleting}
|
||||
onClick={handleDeleteSelected}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Single Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
onOpenChange={(open) => !open && setInboxToDelete(null)}
|
||||
open={!!inboxToDelete}
|
||||
>
|
||||
<DialogContent showCloseButton className="sm:max-w-[425px]">
|
||||
<DialogTitle>
|
||||
{t('chat.actions.deleteInboxConfirmationTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('chat.actions.deleteInboxConfirmationDescription')}
|
||||
</DialogDescription>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
className="min-w-[100px] flex-1"
|
||||
onClick={() => setInboxToDelete(null)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-[100px] flex-1"
|
||||
onClick={() => inboxToDelete && handleDelete(inboxToDelete)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import {
|
||||
type PaymentRequest,
|
||||
type WidgetToolData,
|
||||
type WidgetToolType,
|
||||
} from '@hanzo_network/hanzo-message-ts/api/general/types';
|
||||
import { usePayInvoice } from '@hanzo_network/hanzo-node-state/v2/mutations/payInvoice/usePayInvoice';
|
||||
import { useRejectInvoice } from '@hanzo_network/hanzo-node-state/v2/mutations/rejectInvoice/useRejectInvoice';
|
||||
import { useGetWalletList } from '@hanzo_network/hanzo-node-state/v2/queries/getWalletList/useGetWalletList';
|
||||
import { Button, Dialog, DialogContent } from '@hanzo_network/hanzo-ui';
|
||||
import { CryptoWalletIcon } from '@hanzo_network/hanzo-ui/assets';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CheckCircle, ExternalLinkIcon, Loader2, XCircle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { useAuth } from '../../store/auth';
|
||||
import {
|
||||
formatBalanceAmount,
|
||||
getBasescanAddressUrl,
|
||||
truncateAddress,
|
||||
} from '../crypto-wallet/utils';
|
||||
import { useToolsStore } from './context/tools-context';
|
||||
|
||||
export default function MessageExtra() {
|
||||
const widget = useToolsStore((state) => state.widget);
|
||||
const setWidget = useToolsStore((state) => state.setWidget);
|
||||
const name = widget?.name as WidgetToolType;
|
||||
const metadata = widget?.data as WidgetToolData;
|
||||
|
||||
if (metadata == null || name == null) return null;
|
||||
|
||||
if (name === 'PaymentRequest' && 'invoice' in metadata) {
|
||||
return <Payment data={metadata} cleanWidget={() => setWidget(null)} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Payment({
|
||||
data,
|
||||
cleanWidget,
|
||||
}: {
|
||||
data: PaymentRequest;
|
||||
cleanWidget: () => void;
|
||||
}) {
|
||||
// const [selectedPlan, setSelectedPlan] = React.useState<
|
||||
// 'one-time' | 'download' | 'both'
|
||||
// >('one-time');
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(true);
|
||||
|
||||
const [status, setStatus] = React.useState<
|
||||
'idle' | 'pending' | 'success' | 'error'
|
||||
>('idle');
|
||||
|
||||
const auth = useAuth((state) => state.auth);
|
||||
|
||||
const { data: walletInfo } = useGetWalletList({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const walletExist =
|
||||
walletInfo?.payment_wallet || walletInfo?.receiving_wallet;
|
||||
|
||||
const { mutateAsync: payInvoice } = usePayInvoice({
|
||||
onSuccess: () => {
|
||||
setStatus('success');
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
cleanWidget();
|
||||
}, 3000);
|
||||
},
|
||||
onError: () => {
|
||||
setStatus('error');
|
||||
},
|
||||
});
|
||||
const { mutateAsync: rejectInvoice } = useRejectInvoice();
|
||||
|
||||
// const hasPerUse = !!data?.usage_type?.PerUse;
|
||||
// const hasDownload = !!data?.usage_type?.Downloadable;
|
||||
|
||||
const token = data.wallet_balances.data.find((balance) => {
|
||||
const payment =
|
||||
data.usage_type?.PerUse &&
|
||||
typeof data.usage_type.PerUse === 'object' &&
|
||||
'Payment' in data.usage_type.PerUse
|
||||
? data.usage_type.PerUse.Payment?.[0]
|
||||
: undefined;
|
||||
return payment?.extra.name === balance.asset.asset_id;
|
||||
})?.asset;
|
||||
|
||||
const tokenDecimals = token?.decimals;
|
||||
const tokenId = token?.asset_id;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="w-full max-w-lg"
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="min-h-[300px] max-w-3xl rounded-xl"
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="size-full">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{status === 'idle' && (
|
||||
<motion.div
|
||||
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ y: 8, opacity: 0, filter: 'blur(4px)' }}
|
||||
initial={{ y: -32, opacity: 0, filter: 'blur(4px)' }}
|
||||
key="idle"
|
||||
className="space-y-6"
|
||||
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="inline-flex items-center gap-2 text-center text-base font-medium">
|
||||
<CryptoWalletIcon />{' '}
|
||||
{t('networkAgentsPage.toolPaymentRequired')}
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{t('networkAgentsPage.toolPaymentRequiredDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bg-quaternary flex items-center justify-between rounded-md p-3">
|
||||
<p className="font-medium">
|
||||
{t('networkAgentsPage.costPerUse')}
|
||||
</p>
|
||||
<p className="font-inter text-xl font-semibold">
|
||||
{data.usage_type.PerUse === 'Free'
|
||||
? 'Free'
|
||||
: 'Payment' in data.usage_type.PerUse
|
||||
? `${formatBalanceAmount(
|
||||
data.usage_type.PerUse.Payment[0]
|
||||
.maxAmountRequired ?? '0',
|
||||
tokenDecimals,
|
||||
)} ${tokenId}`
|
||||
: data.usage_type.PerUse.DirectDelegation}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-bg-quaternary rounded-lg p-4">
|
||||
<h4 className="mb-3 font-medium">
|
||||
{t('networkAgentsPage.networkToolDetails')}
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">
|
||||
{t('networkAgentsPage.tool')}:
|
||||
</span>
|
||||
<span>{data.tool_key}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">
|
||||
{t('networkAgentsPage.author')}:
|
||||
</span>
|
||||
<span>{data.invoice.provider_name}</span>
|
||||
</div>
|
||||
{data.usage_type?.PerUse &&
|
||||
typeof data.usage_type.PerUse === 'object' &&
|
||||
'Payment' in data.usage_type.PerUse &&
|
||||
data.usage_type.PerUse.Payment?.[0].payTo && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">
|
||||
{t('networkAgentsPage.paymentRecipient')}:
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-white">
|
||||
{truncateAddress(
|
||||
data.usage_type?.PerUse &&
|
||||
typeof data.usage_type.PerUse ===
|
||||
'object' &&
|
||||
'Payment' in data.usage_type.PerUse
|
||||
? (data.usage_type.PerUse.Payment?.[0]
|
||||
.payTo ?? '')
|
||||
: '',
|
||||
)}
|
||||
<a
|
||||
href={getBasescanAddressUrl(
|
||||
data.usage_type?.PerUse &&
|
||||
typeof data.usage_type.PerUse ===
|
||||
'object' &&
|
||||
'Payment' in data.usage_type.PerUse
|
||||
? (data.usage_type.PerUse.Payment?.[0]
|
||||
.payTo ?? '')
|
||||
: '',
|
||||
data.usage_type?.PerUse &&
|
||||
typeof data.usage_type.PerUse ===
|
||||
'object' &&
|
||||
'Payment' in data.usage_type.PerUse
|
||||
? (data.usage_type.PerUse.Payment?.[0]
|
||||
.network ?? '')
|
||||
: '',
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-text-secondary ml-1 hover:text-white"
|
||||
>
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* <RadioGroup
|
||||
className="flex items-center justify-center gap-4"
|
||||
onValueChange={(value) =>
|
||||
setSelectedPlan(value as 'one-time' | 'download' | 'both')
|
||||
}
|
||||
value={selectedPlan}
|
||||
>
|
||||
{hasPerUse && (
|
||||
<div className="relative max-w-[200px] flex-1">
|
||||
<RadioGroupItem
|
||||
className="peer sr-only"
|
||||
id="one-time"
|
||||
value="one-time"
|
||||
/>
|
||||
<Label
|
||||
className="hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-brand [&:has([data-state=checked])]:border-brand flex w-full flex-col items-center justify-between rounded-md border-2 border-gray-400 bg-gray-500 p-4"
|
||||
htmlFor="one-time"
|
||||
>
|
||||
<span className="font-inter text-2xl font-semibold">
|
||||
{data.usage_type.PerUse === 'Free'
|
||||
? 'Free'
|
||||
: 'Payment' in data.usage_type.PerUse
|
||||
? `${formatAmount(
|
||||
data.usage_type.PerUse.Payment[0]
|
||||
.maxAmountRequired ?? '0',
|
||||
tokenDecimals,
|
||||
)} ${tokenId}
|
||||
`
|
||||
: data.usage_type.PerUse.DirectDelegation}
|
||||
</span>
|
||||
<span className="text-text-secondary">
|
||||
one-time use
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{hasDownload && (
|
||||
<div className="relative max-w-[200px] flex-1">
|
||||
<RadioGroupItem
|
||||
className="peer sr-only"
|
||||
id="download"
|
||||
value="download"
|
||||
/>
|
||||
<Label
|
||||
className="hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-brand [&:has([data-state=checked])]:border-brand flex w-full flex-col items-center justify-between rounded-md border-2 border-gray-400 bg-gray-500 p-4"
|
||||
htmlFor="download"
|
||||
>
|
||||
<span className="font-inter text-xl font-semibold">
|
||||
{data.usage_type.Downloadable === 'Free'
|
||||
? 'Free'
|
||||
: 'Payment' in data.usage_type.Downloadable
|
||||
? `${formatAmount(
|
||||
data.usage_type.Downloadable.Payment[0]
|
||||
.maxAmountRequired ?? '0',
|
||||
tokenDecimals,
|
||||
)} ${tokenId}
|
||||
`
|
||||
: data.usage_type.Downloadable.DirectDelegation}
|
||||
</span>
|
||||
<span className="text-text-secondary">for download</span>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</RadioGroup> */}
|
||||
{walletExist ? (
|
||||
<div className="bg-bg-quaternary rounded-lg p-4">
|
||||
<h4 className="mb-2 font-medium">
|
||||
{t('networkAgentsPage.yourWallet')}
|
||||
</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-secondary">
|
||||
{t('networkAgentsPage.walletAddress')}:
|
||||
</span>
|
||||
<div className="flex flex-col items-end justify-start gap-2">
|
||||
{truncateAddress(
|
||||
walletInfo?.payment_wallet?.data?.address
|
||||
?.address_id ?? '',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between text-sm">
|
||||
<span className="text-text-secondary">
|
||||
{t('networkAgentsPage.usdcBalance')}:
|
||||
</span>
|
||||
<div className="flex flex-col items-end justify-start gap-0.5">
|
||||
{data.wallet_balances.data.map((balance) => (
|
||||
<div
|
||||
className="text-right"
|
||||
key={balance.asset.asset_id}
|
||||
>
|
||||
{formatBalanceAmount(
|
||||
balance.amount,
|
||||
balance.asset.decimals,
|
||||
)}{' '}
|
||||
<span className="text-text-secondary font-medium">
|
||||
{balance.asset.asset_id}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bg-quaternary rounded-lg p-4 text-sm">
|
||||
{t('networkAgentsPage.walletNotSetup')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex max-w-xs items-center justify-between gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={async () => {
|
||||
if (!auth) return;
|
||||
await rejectInvoice({
|
||||
nodeAddress: auth.node_address,
|
||||
token: auth.api_v2_key,
|
||||
payload: { invoice_id: data.invoice.invoice_id },
|
||||
});
|
||||
cleanWidget();
|
||||
}}
|
||||
size="md"
|
||||
variant="outline"
|
||||
>
|
||||
{t('common.noThanks')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={async () => {
|
||||
if (!auth) return;
|
||||
setStatus('pending');
|
||||
await payInvoice({
|
||||
nodeAddress: auth.node_address,
|
||||
token: auth.api_v2_key,
|
||||
payload: {
|
||||
invoice_id: data.invoice.invoice_id,
|
||||
data_for_tool: data.function_args,
|
||||
},
|
||||
});
|
||||
}}
|
||||
size="md"
|
||||
>
|
||||
{data.usage_type.PerUse === 'Free'
|
||||
? t('networkAgentsPage.proceedFree')
|
||||
: t('networkAgentsPage.confirmPayment')}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{status === 'pending' && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="h-full pt-12"
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
key="pending"
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center pt-8">
|
||||
<Loader2 className="mb-4 size-8 animate-spin text-cyan-500" />
|
||||
<span className="mb-2 text-lg text-white">
|
||||
{t('networkAgentsPage.processingPayment')}
|
||||
</span>
|
||||
<p className="text-text-secondary text-sm">
|
||||
{t('networkAgentsPage.pleaseWait')}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<motion.div
|
||||
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
|
||||
initial={{ y: -32, opacity: 0, filter: 'blur(4px)' }}
|
||||
key="success"
|
||||
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
|
||||
>
|
||||
<div className="mx-auto flex max-w-sm flex-col items-center justify-center pt-8 text-center">
|
||||
<CheckCircle className="mb-4 size-8 text-green-500" />
|
||||
<span className="mb-2 text-lg font-semibold text-white">
|
||||
{t('networkAgentsPage.paymentSuccessful')}
|
||||
</span>
|
||||
<span className="text-text-secondary mb-10 text-sm">
|
||||
{t('networkAgentsPage.paymentSuccessfulDescription')}
|
||||
</span>
|
||||
<Button
|
||||
className="mx-auto min-w-[200px] rounded-md"
|
||||
onClick={() => {
|
||||
cleanWidget();
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t('common.dismiss')}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<motion.div
|
||||
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ y: 8, opacity: 0, filter: 'blur(4px)' }}
|
||||
initial={{ y: -32, opacity: 0, filter: 'blur(4px)' }}
|
||||
key="success"
|
||||
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
|
||||
>
|
||||
<div>
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<XCircle className="mb-4 size-8 text-red-500" />
|
||||
<span className="mb-2 text-lg font-semibold text-white">
|
||||
Payment Failed!
|
||||
</span>
|
||||
<span className="text-text-secondary text-sm">
|
||||
Please try again.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
className="mx-auto min-w-[200px] rounded-md"
|
||||
onClick={() => {
|
||||
setStatus('idle');
|
||||
}}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
|
||||
type ErrorRenderProps = { error: string };
|
||||
|
||||
export const ErrorRender = ({ error }: ErrorRenderProps) => {
|
||||
const i18n = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<div className="mb-2 flex items-center">
|
||||
<ExclamationTriangleIcon className="mr-2 h-5 w-5 text-red-600" />
|
||||
<h3 className="font-bold text-red-700">
|
||||
{i18n.t('codeRunner.errorOccurred')}
|
||||
</h3>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-sm border border-red-100 bg-white p-3 text-sm whitespace-pre-wrap text-red-800">
|
||||
{error}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { loadPyodide, type PyodideInterface } from 'pyodide';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { usePyodideInstance } from '../usePyodideInstance';
|
||||
|
||||
// Mock pyodide
|
||||
vi.mock('pyodide', () => ({
|
||||
loadPyodide: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('usePyodideInstance', () => {
|
||||
let mockPyodide: PyodideInterface;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock Pyodide instance
|
||||
mockPyodide = {
|
||||
FS: {
|
||||
mount: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
isDir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
rmdir: vi.fn(),
|
||||
syncfs: vi.fn(),
|
||||
filesystems: {
|
||||
IDBFS: 'IDBFS',
|
||||
},
|
||||
},
|
||||
} as unknown as PyodideInterface;
|
||||
|
||||
// Mock loadPyodide to return our mock instance
|
||||
(loadPyodide as ReturnType<typeof vi.fn>).mockResolvedValue(mockPyodide);
|
||||
});
|
||||
|
||||
it('should initialize Pyodide and file system service', async () => {
|
||||
mockPyodide.FS.syncfs.mockImplementation(
|
||||
// @ts-expect-error populate
|
||||
(populate: boolean, callback: (err: Error | null) => void) =>
|
||||
callback(null),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => usePyodideInstance());
|
||||
|
||||
// Initially, both pyodide and fileSystemService should be null
|
||||
expect(result.current.pyodide).toBeNull();
|
||||
expect(result.current.fileSystemService).toBeNull();
|
||||
|
||||
// Initialize
|
||||
const { pyodide, fileSystemService } =
|
||||
await result.current.initializePyodide();
|
||||
|
||||
// After initialization
|
||||
expect(pyodide).toBe(mockPyodide);
|
||||
expect(fileSystemService).toBeDefined();
|
||||
expect(loadPyodide).toHaveBeenCalledWith({
|
||||
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.26.2/full/',
|
||||
stdout: console.log,
|
||||
stderr: console.error,
|
||||
});
|
||||
expect(mockPyodide.FS.mount).toHaveBeenCalledWith(
|
||||
'IDBFS',
|
||||
{},
|
||||
'/home/pyodide',
|
||||
);
|
||||
expect(mockPyodide.FS.syncfs).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reuse existing Pyodide instance', async () => {
|
||||
mockPyodide.FS.syncfs.mockImplementation(
|
||||
// @ts-expect-error populate
|
||||
(populate: boolean, callback: (err: Error | null) => void) =>
|
||||
callback(null),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => usePyodideInstance());
|
||||
|
||||
// First initialization
|
||||
const first = await result.current.initializePyodide();
|
||||
expect(loadPyodide).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second initialization
|
||||
const second = await result.current.initializePyodide();
|
||||
expect(loadPyodide).toHaveBeenCalledTimes(1); // Should not be called again
|
||||
expect(second.pyodide).toBe(first.pyodide);
|
||||
expect(second.fileSystemService).toBe(first.fileSystemService);
|
||||
});
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
(loadPyodide as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error('Failed to load Pyodide'),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => usePyodideInstance());
|
||||
|
||||
await expect(result.current.initializePyodide()).rejects.toThrow(
|
||||
'Failed to load Pyodide',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle file system initialization errors', async () => {
|
||||
mockPyodide.FS.syncfs.mockImplementation(
|
||||
// @ts-expect-error populate
|
||||
(populate: boolean, callback: (err: Error | null) => void) =>
|
||||
callback(new Error('Sync failed')),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => usePyodideInstance());
|
||||
|
||||
await expect(result.current.initializePyodide()).rejects.toThrow(
|
||||
'Sync failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { loadPyodide, type PyodideInterface } from 'pyodide';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { type IFileSystemService, PyodideFileSystemService } from '../services/file-system-service';
|
||||
|
||||
export function usePyodideInstance() {
|
||||
const pyodideRef = useRef<PyodideInterface | null>(null);
|
||||
const fileSystemServiceRef = useRef<IFileSystemService | null>(null);
|
||||
|
||||
const initializePyodide = useCallback(async () => {
|
||||
if (pyodideRef.current) {
|
||||
console.log('Pyodide is already initialized.');
|
||||
return { pyodide: pyodideRef.current, fileSystemService: fileSystemServiceRef.current! };
|
||||
}
|
||||
|
||||
console.time('initialize pyodide');
|
||||
const pyodide = await loadPyodide({
|
||||
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.26.2/full/',
|
||||
stdout: console.log,
|
||||
stderr: console.error,
|
||||
});
|
||||
console.log('Pyodide initialized');
|
||||
|
||||
pyodideRef.current = pyodide;
|
||||
fileSystemServiceRef.current = new PyodideFileSystemService(pyodide);
|
||||
|
||||
try {
|
||||
await fileSystemServiceRef.current.initialize();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize file system:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.timeEnd('initialize pyodide');
|
||||
return { pyodide, fileSystemService: fileSystemServiceRef.current };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pyodide: pyodideRef.current,
|
||||
fileSystemService: fileSystemServiceRef.current,
|
||||
initializePyodide,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import Plot from 'react-plotly.js';
|
||||
|
||||
import { ErrorRender } from './error-render';
|
||||
import { type RunResult } from './python-code-runner-web-worker';
|
||||
import { StderrRender } from './stderr-render';
|
||||
import { StdoutRender } from './stdout-render';
|
||||
|
||||
export type OutputRender = {
|
||||
result: RunResult;
|
||||
};
|
||||
|
||||
export const OutputRender = ({ result }: { result: RunResult }) => {
|
||||
const i18n = useTranslation();
|
||||
|
||||
if (result?.state === 'error') {
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<ErrorRender error={result.message} />
|
||||
<StdoutRender stdout={result.stdout || []} />
|
||||
<StderrRender stderr={result.stderr || []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{result?.result?.figures?.map((figure, index) => {
|
||||
return figure.type === 'plotly' ? (
|
||||
<div className="mb-4" key={index}>
|
||||
<Plot
|
||||
config={{
|
||||
responsive: true,
|
||||
displayModeBar: true,
|
||||
scrollZoom: false,
|
||||
}}
|
||||
data={JSON.parse(figure.data).data}
|
||||
layout={{
|
||||
...JSON.parse(figure.data).layout,
|
||||
autosize: true,
|
||||
margin: { l: 50, r: 50, b: 50, t: 50, pad: 4 },
|
||||
width: '100%',
|
||||
height: 400,
|
||||
}}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
useResizeHandler={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: figure.data }}
|
||||
key={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<details className="rounded-md bg-gray-100 p-4">
|
||||
<summary className="mb-2 cursor-pointer font-bold">
|
||||
{i18n.t('codeRunner.output')}
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap">
|
||||
{result.result.rawOutput}
|
||||
</pre>
|
||||
</details>
|
||||
<StdoutRender stdout={result.stdout || []} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
|
||||
import { loadPyodide, type PyodideInterface } from 'pyodide';
|
||||
|
||||
export type CodeOutput = {
|
||||
rawOutput: string;
|
||||
figures: { type: 'plotly' | 'html'; data: string }[];
|
||||
};
|
||||
|
||||
export type RunResult =
|
||||
| {
|
||||
state: 'success';
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
result: {
|
||||
rawOutput: string;
|
||||
figures: { type: 'plotly' | 'html'; data: string }[];
|
||||
};
|
||||
}
|
||||
| {
|
||||
state: 'error';
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type PythonCodeRunnerWebWorkerMessage = {
|
||||
type: 'run-done';
|
||||
payload: RunResult;
|
||||
};
|
||||
|
||||
const INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.26.2/full/';
|
||||
|
||||
let pyodide: PyodideInterface;
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
|
||||
// Flag to check if Pyodide has been initialized
|
||||
let isInitialized = false;
|
||||
|
||||
// Wrap user code with additional Python setup
|
||||
const wrapCode = (code: string): string => {
|
||||
const wrappedCode = `
|
||||
import sys
|
||||
import pandas as pd
|
||||
import plotly.graph_objects as go
|
||||
import plotly.express as px
|
||||
import plotly.io as pio
|
||||
import json
|
||||
import array
|
||||
import os
|
||||
import lxml
|
||||
|
||||
import requests
|
||||
from requests.models import Response
|
||||
|
||||
class CustomSession(requests.Session):
|
||||
def request(self, method, url, *args, **kwargs):
|
||||
try:
|
||||
print('Fetching URL:', url)
|
||||
headers = kwargs.get('headers', {})
|
||||
body = kwargs.get('data', None) or kwargs.get('json', None)
|
||||
print('headers', headers);
|
||||
print('method', method);
|
||||
if body:
|
||||
print('body', body);
|
||||
response_content = custom_fetch(url, headers, method, body)
|
||||
response = Response()
|
||||
response._content = response_content.encode(encoding="utf-8")
|
||||
response.status_code = 200 # Assuming success
|
||||
return response
|
||||
except Exception as e:
|
||||
print(f"CustomSession request error: {e}")
|
||||
raise
|
||||
|
||||
requests.get = CustomSession().get
|
||||
requests.post = CustomSession().post
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("AGG")
|
||||
|
||||
# Ensure the working directory exists
|
||||
os.makedirs('/working', exist_ok=True)
|
||||
|
||||
# Function to capture DataFrame display as HTML
|
||||
def capture_df_display(df):
|
||||
return df.to_html()
|
||||
|
||||
output = None
|
||||
outputError = None
|
||||
figures = []
|
||||
|
||||
def execute_user_code():
|
||||
${code
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')}
|
||||
return locals()
|
||||
|
||||
try:
|
||||
user_code_result = execute_user_code()
|
||||
# Capture the last variable in the scope
|
||||
last_var = list(user_code_result.values())[-1] if user_code_result else None
|
||||
if isinstance(last_var, pd.DataFrame):
|
||||
output = capture_df_display(last_var)
|
||||
elif isinstance(last_var, (str, int, float, list, dict)):
|
||||
output = json.dumps(last_var)
|
||||
else:
|
||||
output = ''
|
||||
|
||||
for var_name, var_value in user_code_result.items():
|
||||
if isinstance(var_value, go.Figure):
|
||||
figures.append({ 'type': 'plotly', 'data': pio.to_json(var_value) })
|
||||
if isinstance(var_value, pd.DataFrame):
|
||||
figures.append({ 'type': 'html', 'data': capture_df_display(var_value) })
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
outputError = "%s - %s" % (str(e), traceback.format_exc())
|
||||
|
||||
figures = json.dumps(figures)
|
||||
(output, outputError, figures)
|
||||
`;
|
||||
console.log('Running code:', wrappedCode);
|
||||
return wrappedCode;
|
||||
};
|
||||
|
||||
// Function to find imports from Python code
|
||||
const findImportsFromCodeString = async (code: string): Promise<string[]> => {
|
||||
const wrappedCode = `
|
||||
from pyodide.code import find_imports
|
||||
import json
|
||||
code = """${code.replace(/"""/g, '\\"\\"\\"')}"""
|
||||
imports = find_imports(code)
|
||||
json.dumps(imports)
|
||||
`;
|
||||
const jsonResult = await pyodide.runPythonAsync(wrappedCode);
|
||||
const result = JSON.parse(jsonResult);
|
||||
return result;
|
||||
};
|
||||
|
||||
// New function to find imports using regex
|
||||
const findImportsUsingRegex = (code: string): string[] => {
|
||||
const importRegex = /(?:from\s+(\S+)\s+import\s+[\s\S]+|import\s+(\S+))/g;
|
||||
const matches = code.matchAll(importRegex);
|
||||
const imports = new Set<string>();
|
||||
|
||||
for (const match of matches) {
|
||||
if (match[1]) {
|
||||
imports.add(match[1]);
|
||||
}
|
||||
if (match[2]) {
|
||||
imports.add(match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(imports);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to install dependencies for the given Python code using micropip.
|
||||
*
|
||||
* This method does its best effort to install all dependencies using micropip,
|
||||
* but it's important to note that not all packages may be available or compatible
|
||||
* with the Pyodide environment. Even after this method completes, some dependencies
|
||||
* might still not be found or properly installed due to various constraints of
|
||||
* the web-based Python runtime.
|
||||
*
|
||||
* This just do the best effort to install micropip dependencies
|
||||
* Native pyodide dependencies are install under the hood when call runPythonAsync
|
||||
*
|
||||
* The function performs the following steps:
|
||||
* 1. Loads the 'micropip' package.
|
||||
* 2. Finds imports from both the wrapped code template and the user's code.
|
||||
* 3. Attempts to install each found dependency using micropip.
|
||||
*
|
||||
* @param code - The Python code string to analyze for dependencies.
|
||||
* @returns A Promise that resolves when the installation attempts are complete.
|
||||
*/
|
||||
const installDependencies = async (code: string): Promise<void> => {
|
||||
console.time('install micropip dependencies');
|
||||
await pyodide.loadPackage(['micropip']);
|
||||
const micropip = pyodide.pyimport('micropip');
|
||||
|
||||
const codeDependencies = [
|
||||
// Our code wrapper contains dependencies so we need to install them
|
||||
...(await findImportsFromCodeString(wrapCode(''))),
|
||||
...(await findImportsFromCodeString(code)),
|
||||
...findImportsUsingRegex(code), // Merge results from regex-based detection
|
||||
];
|
||||
|
||||
// Remove duplicates by converting to a Set and back to an Array
|
||||
const uniqueDependencies = Array.from(new Set(codeDependencies));
|
||||
|
||||
console.log(
|
||||
'Trying to install the following dependencies:',
|
||||
uniqueDependencies,
|
||||
);
|
||||
|
||||
const installPromises = uniqueDependencies.map((dependency) =>
|
||||
micropip.install(dependency),
|
||||
);
|
||||
await Promise.allSettled(installPromises);
|
||||
console.timeEnd('install micropip dependencies');
|
||||
};
|
||||
|
||||
// Function to execute Python code
|
||||
const run = async (code: string) => {
|
||||
console.time('run code');
|
||||
const wrappedCode = wrapCode(code);
|
||||
const [output, outputError, figures] =
|
||||
await pyodide.runPythonAsync(wrappedCode);
|
||||
if (outputError) {
|
||||
throw new Error(outputError);
|
||||
}
|
||||
console.timeEnd('run code');
|
||||
return { rawOutput: output, figures: JSON.parse(figures) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronously fetches a web page by polling for messages.
|
||||
*
|
||||
* @param url - The URL of the page to fetch.
|
||||
* @param headers - The headers to include in the request.
|
||||
* @param method - The HTTP method ('GET' or 'POST').
|
||||
* @param body - The body of the request (optional).
|
||||
* @returns The page's body as a string.
|
||||
* @throws Will throw an error if the HTTP request fails.
|
||||
*/
|
||||
const fetchPage = (
|
||||
url: string,
|
||||
headers: any,
|
||||
method: 'GET' | 'POST',
|
||||
body: any = null,
|
||||
): string => {
|
||||
// console.log('fetchPage called with url:', url);
|
||||
// console.log('fetchPage called with headers:', headers);
|
||||
// console.log('fetchPage called with method:', method);
|
||||
// if (body) {
|
||||
// console.log('fetchPage called with body:', body);
|
||||
// }
|
||||
|
||||
const filteredHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers.toJs())) {
|
||||
if (typeof key === 'string' && typeof value === 'string') {
|
||||
filteredHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
console.log('filteredHeaders', filteredHeaders);
|
||||
|
||||
// Process body based on its type
|
||||
let processedBody: any = null;
|
||||
if (body) {
|
||||
if (typeof body === 'string') {
|
||||
processedBody = body; // If it's a string, use it directly
|
||||
} else if (body && typeof body.toJs === 'function') {
|
||||
// Check if body has a toJs method, indicating it might be a Proxy
|
||||
try {
|
||||
const jsBody = body.toJs();
|
||||
processedBody = {};
|
||||
const proxyEntries = Object.entries(jsBody);
|
||||
for (const [key, value] of proxyEntries) {
|
||||
processedBody[key] = value;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing Proxy-like body:', error);
|
||||
}
|
||||
} else if (typeof body === 'object') {
|
||||
processedBody = JSON.stringify(body); // Convert JSON object to string
|
||||
}
|
||||
}
|
||||
console.log('Final processedBody:', processedBody);
|
||||
|
||||
const bufferSize = 512 * 1024; // Fixed buffer size of 512kb
|
||||
const sharedBuffer = new SharedArrayBuffer(bufferSize);
|
||||
const syncArray = new Int32Array(sharedBuffer, 0, 1);
|
||||
const dataArray = new Uint8Array(sharedBuffer, 4);
|
||||
|
||||
try {
|
||||
self.postMessage({
|
||||
type: 'page', // Updated message type
|
||||
method, // Include method in the message
|
||||
meta: url,
|
||||
headers: filteredHeaders,
|
||||
body: processedBody, // Use processed body
|
||||
sharedBuffer,
|
||||
});
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
let result = '';
|
||||
let moreChunks = true;
|
||||
|
||||
while (moreChunks) {
|
||||
// Busy-wait loop
|
||||
while (syncArray[0] === 0) {
|
||||
// This loop will block the thread until syncArray[0] changes
|
||||
}
|
||||
|
||||
if (syncArray[0] === -1) {
|
||||
const errorMessage = textDecoder.decode(dataArray);
|
||||
console.error('Error fetching page:', errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Read the current chunk
|
||||
const chunk = textDecoder.decode(dataArray).replace(/\0/g, '').trim();
|
||||
result += chunk;
|
||||
|
||||
// Check if more chunks are needed
|
||||
if (syncArray[0] === 1) {
|
||||
moreChunks = false; // Success, all chunks received
|
||||
} else {
|
||||
// Signal readiness for the next chunk
|
||||
syncArray[0] = 0;
|
||||
Atomics.notify(syncArray, 0);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Total received data of length: ${result.length}`);
|
||||
console.log('result: ', result);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error('An error occurred:', e);
|
||||
throw new Error(
|
||||
'Failed to fetch page: ' +
|
||||
(e instanceof Error ? e.message : 'Unknown error'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize Pyodide and set up the filesystem
|
||||
const initialize = async () => {
|
||||
if (isInitialized) {
|
||||
console.log('Pyodide is already initialized.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.time('initialize');
|
||||
pyodide = await loadPyodide({
|
||||
indexURL: INDEX_URL,
|
||||
stdout: (message) => {
|
||||
console.log('python stdout:', message);
|
||||
stdout.push(message);
|
||||
},
|
||||
stderr: (message) => {
|
||||
console.log('python stderr:', message);
|
||||
stderr.push(message);
|
||||
},
|
||||
fullStdLib: false,
|
||||
});
|
||||
console.log('Pyodide initialized');
|
||||
|
||||
// **Mount IDBFS to persist filesystem in IndexedDB**
|
||||
try {
|
||||
pyodide.FS.mount(
|
||||
pyodide.FS.filesystems.IDBFS,
|
||||
{ autoPersist: true },
|
||||
'/home/pyodide',
|
||||
);
|
||||
|
||||
// Use syncFilesystem to synchronize the filesystem
|
||||
await syncFilesystem(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to set up IDBFS:', error);
|
||||
}
|
||||
|
||||
// **Inject fetchPage into Python's global scope**
|
||||
pyodide.globals.set('custom_fetch', fetchPage);
|
||||
|
||||
isInitialized = true;
|
||||
console.timeEnd('initialize');
|
||||
};
|
||||
|
||||
// Function to synchronize the filesystem to IndexedDB
|
||||
const syncFilesystem = async (save = false) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pyodide.FS.syncfs(save, (err: any) => {
|
||||
if (err) {
|
||||
console.error('syncfs error:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
console.log(`syncfs ${save ? 'synced from' : 'synced to'} IndexedDB`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Message handler for the web worker
|
||||
self.onmessage = async (event) => {
|
||||
switch (event.data?.type) {
|
||||
case 'run':
|
||||
console.time('total run time');
|
||||
try {
|
||||
// Initialize Pyodide if not already done
|
||||
await initialize();
|
||||
|
||||
// Install dependencies
|
||||
await installDependencies(event.data.payload.code);
|
||||
|
||||
// Run the Python code
|
||||
const runResult = await run(event.data.payload.code);
|
||||
|
||||
// // Synchronize the filesystem to save changes to IndexedDB
|
||||
await syncFilesystem(false); // Change to true to save changes
|
||||
console.log('> synced filesystem');
|
||||
|
||||
// Post the successful run result
|
||||
self.postMessage({
|
||||
type: 'run-done',
|
||||
payload: {
|
||||
state: 'success',
|
||||
stdout,
|
||||
stderr,
|
||||
result: runResult,
|
||||
} as RunResult,
|
||||
});
|
||||
} catch (e) {
|
||||
// Post the error result
|
||||
self.postMessage({
|
||||
type: 'run-done',
|
||||
payload: {
|
||||
state: 'error',
|
||||
stdout,
|
||||
stderr,
|
||||
message: String(e),
|
||||
} as RunResult,
|
||||
});
|
||||
} finally {
|
||||
console.timeEnd('total run time');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown message type:', event.data?.type);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { addFileToJob } from '@hanzo_network/hanzo-message-ts/api/jobs/index';
|
||||
import { type DirectoryContent } from '@hanzo_network/hanzo-message-ts/api/vector-fs/types';
|
||||
import { useGetDownloadFile } from '@hanzo_network/hanzo-node-state/v2/queries/getDownloadFile/useGetDownloadFile';
|
||||
import { useGetJobContents } from '@hanzo_network/hanzo-node-state/v2/queries/getJobContents/useGetJobContents';
|
||||
import { Button } from '@hanzo_network/hanzo-ui';
|
||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { usePyodideInstance } from './hooks/usePyodideInstance';
|
||||
import { OutputRender } from './output-render';
|
||||
import { type RunResult } from './python-code-runner-web-worker';
|
||||
import PythonRunnerWorker from './python-code-runner-web-worker?worker';
|
||||
import { type FileSystemEntry } from './services/file-system-service';
|
||||
import { JobService } from './services/job-service';
|
||||
|
||||
// Utility function to create a delay
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
type PythonCodeRunnerProps = {
|
||||
code: string;
|
||||
jobId: string;
|
||||
nodeAddress: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
// Define more specific message types
|
||||
type PageMessage = {
|
||||
type: 'page';
|
||||
method: 'GET' | 'POST';
|
||||
meta: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
sharedBuffer: SharedArrayBuffer;
|
||||
};
|
||||
|
||||
type RunDoneMessage = {
|
||||
type: 'run-done';
|
||||
payload: RunResult;
|
||||
};
|
||||
|
||||
type WorkerMessage = PageMessage | RunDoneMessage;
|
||||
|
||||
// Type guard functions
|
||||
function isPageMessage(message: WorkerMessage): message is PageMessage {
|
||||
return message.type === 'page';
|
||||
}
|
||||
|
||||
function isRunDoneMessage(message: WorkerMessage): message is RunDoneMessage {
|
||||
return message.type === 'run-done';
|
||||
}
|
||||
|
||||
export const usePythonRunnerRunMutation = (
|
||||
options?: UseMutationOptions<RunResult, Error, { code: string }>,
|
||||
) => {
|
||||
const response = useMutation({
|
||||
mutationFn: async (params: { code: string }): Promise<RunResult> => {
|
||||
const worker = new PythonRunnerWorker();
|
||||
|
||||
return new Promise<RunResult>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('execution timed out'));
|
||||
}, 120000); // 2 minutes
|
||||
|
||||
worker.onmessage = async (event: { data: WorkerMessage }) => {
|
||||
if (isPageMessage(event.data)) {
|
||||
const {
|
||||
method,
|
||||
meta: url,
|
||||
headers,
|
||||
body,
|
||||
sharedBuffer,
|
||||
} = event.data;
|
||||
console.log(`main thread> ${method.toLowerCase()}ing page`, url);
|
||||
console.log('main thread> headers: ', headers);
|
||||
|
||||
const syncArray = new Int32Array(sharedBuffer, 0, 1);
|
||||
const dataArray = new Uint8Array(sharedBuffer, 4);
|
||||
|
||||
const bufferSize = 512 * 1024;
|
||||
const maxBufferSize = 100 * 1024 * 1024;
|
||||
let success = false;
|
||||
|
||||
while (bufferSize <= maxBufferSize && !success) {
|
||||
try {
|
||||
console.log(
|
||||
`main thread> ${method.toLowerCase()}ing page`,
|
||||
url,
|
||||
);
|
||||
const response = await invoke<{
|
||||
status: number;
|
||||
headers: Record<string, string[]>;
|
||||
body: string;
|
||||
}>(method === 'GET' ? 'get_request' : 'post_request', {
|
||||
url,
|
||||
customHeaders: JSON.stringify(headers),
|
||||
...(method === 'POST' && { body: JSON.stringify(body) }),
|
||||
});
|
||||
console.log(
|
||||
`main thread> ${method.toLowerCase()} response`,
|
||||
response,
|
||||
);
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const textEncoder = new TextEncoder();
|
||||
const encodedData = textEncoder.encode(response.body);
|
||||
|
||||
console.log('Required buffer size:', encodedData.length);
|
||||
|
||||
let offset = 0;
|
||||
while (offset < encodedData.length) {
|
||||
const chunkSize = Math.min(
|
||||
dataArray.length,
|
||||
encodedData.length - offset,
|
||||
);
|
||||
dataArray.set(
|
||||
encodedData.subarray(offset, offset + chunkSize),
|
||||
);
|
||||
offset += chunkSize;
|
||||
|
||||
syncArray[0] = 2;
|
||||
console.log(
|
||||
'main thread> Notifying Atomics with chunk ready',
|
||||
);
|
||||
Atomics.notify(syncArray, 0);
|
||||
|
||||
while (syncArray[0] === 2) {
|
||||
await delay(25);
|
||||
}
|
||||
}
|
||||
|
||||
syncArray[0] = 1;
|
||||
console.log('main thread> Notifying Atomics with success');
|
||||
Atomics.notify(syncArray, 0);
|
||||
success = true;
|
||||
} else {
|
||||
throw new Error(`HTTP Error: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
let errorMessage = 'Unknown error';
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
console.error(
|
||||
`main thread> error using ${method.toLowerCase()} with page`,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const encodedError = textEncoder.encode(errorMessage);
|
||||
|
||||
if (encodedError.length <= dataArray.length) {
|
||||
dataArray.set(encodedError);
|
||||
} else {
|
||||
console.warn('Error message too long to fit in buffer');
|
||||
}
|
||||
|
||||
console.log('main thread> Notifying Atomics with error');
|
||||
syncArray[0] = -1;
|
||||
Atomics.notify(syncArray, 0);
|
||||
|
||||
await delay(10);
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to ${method.toLowerCase()} page: ` + errorMessage,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (isRunDoneMessage(event.data)) {
|
||||
clearTimeout(timeout);
|
||||
console.log('main thread> worker event', event);
|
||||
resolve(event.data.payload);
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (error: { message: string }) => {
|
||||
console.log('worker error', error);
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`worker error: ${error.message}`));
|
||||
};
|
||||
|
||||
worker.postMessage({ type: 'run', payload: { code: params.code } });
|
||||
}).finally(() => {
|
||||
worker.terminate();
|
||||
});
|
||||
},
|
||||
...options,
|
||||
onSuccess: (...onSuccessParameters) => {
|
||||
if (options?.onSuccess) {
|
||||
options.onSuccess(...onSuccessParameters);
|
||||
}
|
||||
},
|
||||
});
|
||||
return { ...response };
|
||||
};
|
||||
|
||||
// Define a function to transform DirectoryContent to FileSystemEntry
|
||||
function transformToFileSystemEntry(
|
||||
contents: DirectoryContent[],
|
||||
): FileSystemEntry[] {
|
||||
return contents.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.is_directory ? 'directory' : 'file',
|
||||
content: undefined,
|
||||
contents: entry.is_directory
|
||||
? transformToFileSystemEntry(entry.children || [])
|
||||
: undefined,
|
||||
mtimeMs: new Date(entry.modified_time).getTime(),
|
||||
}));
|
||||
}
|
||||
|
||||
export const PythonCodeRunner = ({
|
||||
code,
|
||||
jobId,
|
||||
nodeAddress,
|
||||
token,
|
||||
}: PythonCodeRunnerProps) => {
|
||||
const i18n = useTranslation();
|
||||
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const [jobContents, setJobContents] = useState<FileSystemEntry[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const {
|
||||
mutateAsync: run,
|
||||
data: runResult,
|
||||
isPending,
|
||||
} = usePythonRunnerRunMutation();
|
||||
|
||||
const { mutateAsync: downloadFile } = useGetDownloadFile();
|
||||
|
||||
const { data: fetchedJobContents, refetch: refetchJobContents } =
|
||||
useGetJobContents(
|
||||
{
|
||||
nodeAddress,
|
||||
token,
|
||||
jobId,
|
||||
},
|
||||
{
|
||||
enabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { pyodide, fileSystemService, initializePyodide } =
|
||||
usePyodideInstance();
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedJobContents) {
|
||||
const transformedContents =
|
||||
transformToFileSystemEntry(fetchedJobContents);
|
||||
setJobContents(transformedContents);
|
||||
}
|
||||
}, [fetchedJobContents]);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="h-8 min-w-[160px] cursor-pointer justify-start rounded-md border border-[#63676c] px-4 text-gray-50 hover:border-white hover:bg-transparent"
|
||||
disabled={isPending}
|
||||
isLoading={isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
// 1. First ensure we have job contents
|
||||
console.log('Fetching job contents...');
|
||||
const jobContentsResult = await refetchJobContents();
|
||||
const transformedContents = jobContentsResult.data
|
||||
? transformToFileSystemEntry(jobContentsResult.data)
|
||||
: null;
|
||||
|
||||
// 2. Initialize Pyodide and get services
|
||||
console.log('Ensuring Pyodide is initialized...');
|
||||
const { fileSystemService } = await initializePyodide();
|
||||
|
||||
// 3. Create JobService instance
|
||||
const jobService = new JobService({
|
||||
fileSystemService,
|
||||
downloadFile: ({ nodeAddress, token, path }) =>
|
||||
downloadFile({ nodeAddress, token, path }),
|
||||
addFileToJob,
|
||||
nodeAddress,
|
||||
token,
|
||||
jobId,
|
||||
});
|
||||
|
||||
// 4. Sync files and run code
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
await jobService.syncJobFilesToIDBFS(
|
||||
jobContentsResult.data || null,
|
||||
);
|
||||
|
||||
const timeBefore = Date.now();
|
||||
console.log('Executing Python code...');
|
||||
await run({ code });
|
||||
console.log('Python code execution completed.');
|
||||
|
||||
if (fileSystemService) {
|
||||
await fileSystemService.syncFromIndexedDB();
|
||||
await jobService.compareAndUploadFiles(timeBefore);
|
||||
}
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to execute Python code:', error);
|
||||
}
|
||||
}}
|
||||
size={'sm'}
|
||||
variant="outline"
|
||||
>
|
||||
{isPending ? null : (
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0"
|
||||
viewBox="0 0 512 512"
|
||||
width="1em"
|
||||
>
|
||||
<path
|
||||
d="M112 111v290c0 17.44 17 28.52 31 20.16l247.9-148.37c12.12-7.25 12.12-26.33 0-33.58L143 90.84c-14-8.36-31 2.72-31 20.16z"
|
||||
fill="none"
|
||||
strokeMiterlimit="10"
|
||||
strokeWidth="32"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-gray-50">
|
||||
{i18n.t('codeRunner.executeCode')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isPending && runResult && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-2"
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<OutputRender result={runResult} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
export interface AddFileToJobRequest {
|
||||
job_id: string;
|
||||
filename: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export interface AddFileToInboxResponse {
|
||||
message: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface DirectoryContent {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
children: DirectoryContent[] | null;
|
||||
created_time: string;
|
||||
modified_time: string;
|
||||
has_embeddings: boolean;
|
||||
size: number;
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
import { type PyodideInterface } from 'pyodide';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
import { PyodideFileSystemService } from '../file-system-service';
|
||||
|
||||
type FSCallback = (error: Error | null) => void;
|
||||
type MockStats = { [key: string]: { mode: number } };
|
||||
|
||||
describe('PyodideFileSystemService', () => {
|
||||
let mockPyodide: PyodideInterface;
|
||||
let service: PyodideFileSystemService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock Pyodide instance
|
||||
mockPyodide = {
|
||||
FS: {
|
||||
mount: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
isDir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
rmdir: vi.fn(),
|
||||
syncfs: vi.fn(),
|
||||
filesystems: {
|
||||
IDBFS: 'IDBFS',
|
||||
},
|
||||
},
|
||||
} as unknown as PyodideInterface;
|
||||
|
||||
service = new PyodideFileSystemService(mockPyodide);
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should mount IDBFS and sync from IndexedDB', async () => {
|
||||
mockPyodide.FS.syncfs.mockImplementation(
|
||||
// @ts-expect-error unused-vars
|
||||
(populate: boolean, callback: FSCallback) => callback(null),
|
||||
);
|
||||
|
||||
await service.initialize();
|
||||
|
||||
expect(mockPyodide.FS.mount).toHaveBeenCalledWith(
|
||||
'IDBFS',
|
||||
{},
|
||||
'/home/pyodide',
|
||||
);
|
||||
expect(mockPyodide.FS.syncfs).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if mounting fails', async () => {
|
||||
mockPyodide.FS.mount.mockImplementation(() => {
|
||||
throw new Error('Mount failed');
|
||||
});
|
||||
|
||||
await expect(service.initialize()).rejects.toThrow('Mount failed');
|
||||
});
|
||||
|
||||
it('should throw error if sync fails', async () => {
|
||||
mockPyodide.FS.syncfs.mockImplementation(
|
||||
// @ts-expect-error unused-vars
|
||||
(populate: boolean, callback: FSCallback) =>
|
||||
callback(new Error('Sync failed')),
|
||||
);
|
||||
|
||||
await expect(service.initialize()).rejects.toThrow('Sync failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readContents', () => {
|
||||
it('should read directory contents correctly', () => {
|
||||
const mockEntries = ['file1.txt', 'dir1', '.', '..', '.matplotlib'];
|
||||
const mockStats: MockStats = {
|
||||
'file1.txt': { mode: 0o100644 }, // regular file
|
||||
dir1: { mode: 0o040000 }, // directory
|
||||
};
|
||||
|
||||
// Mock first readdir call for root directory
|
||||
mockPyodide.FS.readdir.mockImplementation((path: string) => {
|
||||
if (path === '/test') {
|
||||
return mockEntries;
|
||||
}
|
||||
// Return empty directory for recursive calls
|
||||
return ['.', '..'];
|
||||
});
|
||||
|
||||
mockPyodide.FS.stat.mockImplementation((path: string) => {
|
||||
const name = path.split('/').pop() || '';
|
||||
const stat = mockStats[name];
|
||||
if (!stat) {
|
||||
throw new Error(`No mock stat for ${name}`);
|
||||
}
|
||||
return stat;
|
||||
});
|
||||
mockPyodide.FS.isDir.mockImplementation(
|
||||
(mode: number) => mode === 0o040000,
|
||||
);
|
||||
mockPyodide.FS.readFile.mockReturnValue('file content');
|
||||
|
||||
const result = service.readContents('/test');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'file1.txt',
|
||||
type: 'file',
|
||||
content: 'file content',
|
||||
},
|
||||
{
|
||||
name: 'dir1',
|
||||
type: 'directory',
|
||||
contents: [], // Empty array since we mock empty directory for recursive calls
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle errors and return null', () => {
|
||||
mockPyodide.FS.readdir.mockImplementation(() => {
|
||||
throw new Error('Read failed');
|
||||
});
|
||||
|
||||
const result = service.readContents('/test');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeFile', () => {
|
||||
it('should write file content correctly', () => {
|
||||
service.writeFile('/test/file.txt', 'content');
|
||||
|
||||
expect(mockPyodide.FS.writeFile).toHaveBeenCalledWith(
|
||||
'/test/file.txt',
|
||||
'content',
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if write fails', () => {
|
||||
mockPyodide.FS.writeFile.mockImplementation(() => {
|
||||
throw new Error('Write failed');
|
||||
});
|
||||
|
||||
expect(() => service.writeFile('/test/file.txt', 'content')).toThrow(
|
||||
'Write failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureDirectory', () => {
|
||||
it('should create directory structure correctly', () => {
|
||||
mockPyodide.FS.stat.mockImplementation(() => {
|
||||
throw new Error('Not found');
|
||||
});
|
||||
|
||||
service.ensureDirectory('/test/dir1/dir2');
|
||||
|
||||
expect(mockPyodide.FS.mkdir).toHaveBeenCalledWith('/test');
|
||||
expect(mockPyodide.FS.mkdir).toHaveBeenCalledWith('/test/dir1');
|
||||
expect(mockPyodide.FS.mkdir).toHaveBeenCalledWith('/test/dir1/dir2');
|
||||
});
|
||||
|
||||
it('should handle existing directories', () => {
|
||||
mockPyodide.FS.stat.mockReturnValue({ mode: 0o040000 });
|
||||
mockPyodide.FS.isDir.mockReturnValue(true);
|
||||
|
||||
service.ensureDirectory('/test/dir1/dir2');
|
||||
|
||||
expect(mockPyodide.FS.mkdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should replace file with directory if path exists as file', () => {
|
||||
mockPyodide.FS.stat.mockReturnValue({ mode: 0o100644 });
|
||||
mockPyodide.FS.isDir.mockReturnValue(false);
|
||||
|
||||
service.ensureDirectory('/test/dir1');
|
||||
|
||||
expect(mockPyodide.FS.unlink).toHaveBeenCalledWith('/test/dir1');
|
||||
expect(mockPyodide.FS.mkdir).toHaveBeenCalledWith('/test/dir1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeStaleItems', () => {
|
||||
it('should remove files and directories not in validPaths', () => {
|
||||
const validPaths = new Set(['/home/pyodide/keep.txt']);
|
||||
|
||||
// Mock readdir to return different results for different paths
|
||||
mockPyodide.FS.readdir.mockImplementation((path: string) => {
|
||||
if (path === '/home/pyodide') {
|
||||
return ['keep.txt', 'remove.txt', 'dir1'];
|
||||
}
|
||||
// Return empty directory for recursive calls
|
||||
return ['.', '..'];
|
||||
});
|
||||
|
||||
const mockStats: MockStats = {
|
||||
'keep.txt': { mode: 0o100644 },
|
||||
'remove.txt': { mode: 0o100644 },
|
||||
dir1: { mode: 0o040000 },
|
||||
};
|
||||
|
||||
mockPyodide.FS.stat.mockImplementation((path: string) => {
|
||||
const name = path.split('/').pop() || '';
|
||||
const stat = mockStats[name];
|
||||
if (!stat) {
|
||||
// Return a regular file stat for unknown paths to prevent recursion
|
||||
return { mode: 0o100644 };
|
||||
}
|
||||
return stat;
|
||||
});
|
||||
mockPyodide.FS.isDir.mockImplementation(
|
||||
(mode: number) => mode === 0o040000,
|
||||
);
|
||||
|
||||
service.removeStaleItems('/home/pyodide', validPaths);
|
||||
|
||||
expect(mockPyodide.FS.unlink).toHaveBeenCalledWith(
|
||||
'/home/pyodide/remove.txt',
|
||||
);
|
||||
expect(mockPyodide.FS.rmdir).toHaveBeenCalledWith('/home/pyodide/dir1');
|
||||
expect(mockPyodide.FS.unlink).not.toHaveBeenCalledWith(
|
||||
'/home/pyodide/keep.txt',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncToIndexedDB', () => {
|
||||
it('should sync to IndexedDB successfully', async () => {
|
||||
mockPyodide.FS.syncfs.mockImplementation(
|
||||
// @ts-expect-error unused-vars
|
||||
(populate: boolean, callback: FSCallback) => callback(null),
|
||||
);
|
||||
|
||||
await service.syncToIndexedDB();
|
||||
|
||||
expect(mockPyodide.FS.syncfs).toHaveBeenCalledWith(
|
||||
false,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle sync errors', async () => {
|
||||
mockPyodide.FS.syncfs.mockImplementation(
|
||||
// @ts-expect-error unused-vars
|
||||
(populate: boolean, callback: FSCallback) =>
|
||||
callback(new Error('Sync failed')),
|
||||
);
|
||||
|
||||
await expect(service.syncToIndexedDB()).rejects.toThrow('Sync failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFromIndexedDB', () => {
|
||||
it('should sync from IndexedDB successfully', async () => {
|
||||
mockPyodide.FS.syncfs.mockImplementation(
|
||||
// @ts-expect-error unused-vars
|
||||
(populate: boolean, callback: FSCallback) => callback(null),
|
||||
);
|
||||
|
||||
await service.syncFromIndexedDB();
|
||||
|
||||
expect(mockPyodide.FS.syncfs).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle sync errors', async () => {
|
||||
mockPyodide.FS.syncfs.mockImplementation(
|
||||
// @ts-expect-error unused-vars
|
||||
(populate: boolean, callback: FSCallback) =>
|
||||
callback(new Error('Sync failed')),
|
||||
);
|
||||
|
||||
await expect(service.syncFromIndexedDB()).rejects.toThrow('Sync failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
type AddFileToInboxResponse,
|
||||
type AddFileToJobRequest,
|
||||
type DirectoryContent,
|
||||
} from '../__mocks__/hanzo-message-ts';
|
||||
import {
|
||||
type FileSystemEntry,
|
||||
type IFileSystemService,
|
||||
} from '../file-system-service';
|
||||
import { JobService } from '../job-service';
|
||||
|
||||
type DownloadFileFn = (params: {
|
||||
nodeAddress: string;
|
||||
token: string;
|
||||
path: string;
|
||||
}) => Promise<string>;
|
||||
type AddFileToJobFn = (
|
||||
nodeAddress: string,
|
||||
bearerToken: string,
|
||||
payload: AddFileToJobRequest,
|
||||
) => Promise<AddFileToInboxResponse>;
|
||||
|
||||
describe('JobService', () => {
|
||||
let mockFileSystemService: IFileSystemService & {
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
readContents: ReturnType<typeof vi.fn>;
|
||||
readContentsWithMtime: ReturnType<typeof vi.fn>;
|
||||
writeFile: ReturnType<typeof vi.fn>;
|
||||
readFile: ReturnType<typeof vi.fn>;
|
||||
ensureDirectory: ReturnType<typeof vi.fn>;
|
||||
removeStaleItems: ReturnType<typeof vi.fn>;
|
||||
syncToIndexedDB: ReturnType<typeof vi.fn>;
|
||||
syncFromIndexedDB: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockDownloadFile: ReturnType<typeof vi.fn> & DownloadFileFn;
|
||||
let mockAddFileToJob: ReturnType<typeof vi.fn> & AddFileToJobFn;
|
||||
let jobService: JobService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFileSystemService = {
|
||||
initialize: vi.fn(),
|
||||
readContents: vi.fn(),
|
||||
readContentsWithMtime: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
ensureDirectory: vi.fn(),
|
||||
removeStaleItems: vi.fn(),
|
||||
syncToIndexedDB: vi.fn(),
|
||||
syncFromIndexedDB: vi.fn(),
|
||||
} as IFileSystemService & {
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
readContents: ReturnType<typeof vi.fn>;
|
||||
readContentsWithMtime: ReturnType<typeof vi.fn>;
|
||||
writeFile: ReturnType<typeof vi.fn>;
|
||||
readFile: ReturnType<typeof vi.fn>;
|
||||
ensureDirectory: ReturnType<typeof vi.fn>;
|
||||
removeStaleItems: ReturnType<typeof vi.fn>;
|
||||
syncToIndexedDB: ReturnType<typeof vi.fn>;
|
||||
syncFromIndexedDB: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
mockDownloadFile = vi.fn().mockImplementation(async () => '') as ReturnType<
|
||||
typeof vi.fn
|
||||
> &
|
||||
DownloadFileFn;
|
||||
mockAddFileToJob = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () => ({}) as AddFileToInboxResponse,
|
||||
) as ReturnType<typeof vi.fn> & AddFileToJobFn;
|
||||
|
||||
jobService = new JobService({
|
||||
fileSystemService: mockFileSystemService,
|
||||
downloadFile: mockDownloadFile,
|
||||
addFileToJob: mockAddFileToJob,
|
||||
nodeAddress: 'test-node',
|
||||
token: 'test-token',
|
||||
jobId: 'test-job',
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncJobFilesToIDBFS', () => {
|
||||
const mockContents: DirectoryContent[] = [
|
||||
{
|
||||
name: 'file1.txt',
|
||||
path: 'file1.txt',
|
||||
is_directory: false,
|
||||
children: null,
|
||||
created_time: '2024-01-01T00:00:00Z',
|
||||
modified_time: '2024-01-01T00:00:00Z',
|
||||
has_embeddings: false,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
name: 'dir1',
|
||||
path: 'dir1',
|
||||
is_directory: true,
|
||||
children: [
|
||||
{
|
||||
name: 'file2.txt',
|
||||
path: 'dir1/file2.txt',
|
||||
is_directory: false,
|
||||
children: null,
|
||||
created_time: '2024-01-01T00:00:00Z',
|
||||
modified_time: '2024-01-01T00:00:00Z',
|
||||
has_embeddings: false,
|
||||
size: 100,
|
||||
},
|
||||
],
|
||||
created_time: '2024-01-01T00:00:00Z',
|
||||
modified_time: '2024-01-01T00:00:00Z',
|
||||
has_embeddings: false,
|
||||
size: 0,
|
||||
},
|
||||
];
|
||||
|
||||
it('should sync files correctly', async () => {
|
||||
mockFileSystemService.readContentsWithMtime.mockReturnValue([]);
|
||||
mockDownloadFile.mockResolvedValue('new content');
|
||||
|
||||
await jobService.syncJobFilesToIDBFS(mockContents);
|
||||
|
||||
expect(mockFileSystemService.removeStaleItems).toHaveBeenCalled();
|
||||
expect(mockFileSystemService.ensureDirectory).toHaveBeenCalledWith(
|
||||
'/home/pyodide/dir1',
|
||||
);
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith({
|
||||
nodeAddress: 'test-node',
|
||||
token: 'test-token',
|
||||
path: 'file1.txt',
|
||||
});
|
||||
expect(mockFileSystemService.writeFile).toHaveBeenCalled();
|
||||
expect(mockFileSystemService.syncToIndexedDB).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip unchanged files', async () => {
|
||||
mockFileSystemService.readFile.mockReturnValue('same content');
|
||||
mockDownloadFile.mockResolvedValue('same content');
|
||||
|
||||
await jobService.syncJobFilesToIDBFS([
|
||||
{
|
||||
name: 'unchanged.txt',
|
||||
path: 'unchanged.txt',
|
||||
is_directory: false,
|
||||
children: null,
|
||||
created_time: '2024-01-01T00:00:00Z',
|
||||
modified_time: '2024-01-01T00:00:00Z',
|
||||
has_embeddings: false,
|
||||
size: 100,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockFileSystemService.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty contents', async () => {
|
||||
await jobService.syncJobFilesToIDBFS(null);
|
||||
|
||||
expect(mockFileSystemService.removeStaleItems).toHaveBeenCalledWith(
|
||||
'/home/pyodide',
|
||||
new Set(),
|
||||
);
|
||||
expect(mockFileSystemService.syncToIndexedDB).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockFileSystemService.removeStaleItems.mockImplementation(() => {
|
||||
throw new Error('Sync failed');
|
||||
});
|
||||
|
||||
await expect(
|
||||
jobService.syncJobFilesToIDBFS(mockContents),
|
||||
).rejects.toThrow('Sync failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareAndUploadFiles', () => {
|
||||
const mockIDBFSContents: FileSystemEntry[] = [
|
||||
{
|
||||
name: 'file1.txt',
|
||||
type: 'file',
|
||||
content: 'new content',
|
||||
mtimeMs: Date.now() + 1000, // Future time to ensure it's newer
|
||||
},
|
||||
{
|
||||
name: 'dir1',
|
||||
type: 'directory',
|
||||
contents: [
|
||||
{
|
||||
name: 'file2.txt',
|
||||
type: 'file',
|
||||
content: 'old content',
|
||||
mtimeMs: Date.now() - 1000, // Past time to ensure it's older
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('should upload changed files', async () => {
|
||||
mockFileSystemService.readContentsWithMtime.mockReturnValue(
|
||||
mockIDBFSContents,
|
||||
);
|
||||
mockAddFileToJob.mockResolvedValue({
|
||||
message: 'File uploaded successfully',
|
||||
filename: 'file1.txt',
|
||||
} as AddFileToInboxResponse);
|
||||
|
||||
const timeBefore = Date.now();
|
||||
await jobService.compareAndUploadFiles(timeBefore);
|
||||
|
||||
expect(mockAddFileToJob).toHaveBeenCalledWith(
|
||||
'test-node',
|
||||
'test-token',
|
||||
expect.objectContaining({
|
||||
filename: 'file1.txt',
|
||||
job_id: 'test-job',
|
||||
} as AddFileToJobRequest),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip unchanged files', async () => {
|
||||
const oldTime = Date.now() - 1000;
|
||||
mockFileSystemService.readContentsWithMtime.mockReturnValue([
|
||||
{
|
||||
name: 'old.txt',
|
||||
type: 'file',
|
||||
content: 'old content',
|
||||
mtimeMs: oldTime,
|
||||
},
|
||||
]);
|
||||
|
||||
await jobService.compareAndUploadFiles(Date.now());
|
||||
|
||||
expect(mockAddFileToJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty IDBFS', async () => {
|
||||
mockFileSystemService.readContentsWithMtime.mockReturnValue(null);
|
||||
|
||||
await jobService.compareAndUploadFiles(Date.now());
|
||||
|
||||
expect(mockAddFileToJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle upload errors', async () => {
|
||||
mockFileSystemService.readContentsWithMtime.mockReturnValue(
|
||||
mockIDBFSContents,
|
||||
);
|
||||
mockAddFileToJob.mockRejectedValue(new Error('Upload failed'));
|
||||
|
||||
const timeBefore = Date.now();
|
||||
await jobService.compareAndUploadFiles(timeBefore);
|
||||
|
||||
// Should not throw error, just log it
|
||||
expect(mockAddFileToJob).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFileToIDBFS', () => {
|
||||
it('should sync file correctly', async () => {
|
||||
mockDownloadFile.mockResolvedValue('file content');
|
||||
|
||||
await jobService.syncFileToIDBFS({
|
||||
path: 'test.txt',
|
||||
name: 'test.txt',
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith({
|
||||
nodeAddress: 'test-node',
|
||||
token: 'test-token',
|
||||
path: 'test.txt',
|
||||
});
|
||||
expect(mockFileSystemService.writeFile).toHaveBeenCalledWith(
|
||||
'/home/pyodide/test.txt',
|
||||
'file content',
|
||||
);
|
||||
});
|
||||
|
||||
it('should sync file with nested path correctly', async () => {
|
||||
mockDownloadFile.mockResolvedValue('nested content');
|
||||
|
||||
await jobService.syncFileToIDBFS({
|
||||
path: 'subdir/test.txt',
|
||||
name: 'test.txt',
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith({
|
||||
nodeAddress: 'test-node',
|
||||
token: 'test-token',
|
||||
path: 'subdir/test.txt',
|
||||
});
|
||||
expect(mockFileSystemService.ensureDirectory).toHaveBeenCalledWith(
|
||||
'/home/pyodide/subdir'
|
||||
);
|
||||
expect(mockFileSystemService.writeFile).toHaveBeenCalledWith(
|
||||
'/home/pyodide/subdir/test.txt',
|
||||
'nested content'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle download errors', async () => {
|
||||
mockDownloadFile.mockRejectedValue(new Error('Download failed'));
|
||||
|
||||
await expect(
|
||||
jobService.syncFileToIDBFS({
|
||||
path: 'test.txt',
|
||||
name: 'test.txt',
|
||||
}),
|
||||
).rejects.toThrow('Download failed');
|
||||
});
|
||||
|
||||
it('should handle write errors', async () => {
|
||||
mockDownloadFile.mockResolvedValue('file content');
|
||||
mockFileSystemService.writeFile.mockImplementation(() => {
|
||||
throw new Error('Write failed');
|
||||
});
|
||||
|
||||
await expect(
|
||||
jobService.syncFileToIDBFS({
|
||||
path: 'test.txt',
|
||||
name: 'test.txt',
|
||||
}),
|
||||
).rejects.toThrow('Write failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { type PyodideInterface } from 'pyodide';
|
||||
|
||||
export type FileSystemEntry = {
|
||||
name: string;
|
||||
type: 'directory' | 'file';
|
||||
content?: string;
|
||||
contents?: FileSystemEntry[];
|
||||
mtimeMs?: number;
|
||||
};
|
||||
|
||||
export interface IFileSystemService {
|
||||
initialize(): Promise<void>;
|
||||
readContents(path: string): FileSystemEntry[] | null;
|
||||
readContentsWithMtime(path: string): FileSystemEntry[] | null;
|
||||
writeFile(path: string, content: string): void;
|
||||
readFile(path: string): string | undefined;
|
||||
ensureDirectory(dirPath: string): void;
|
||||
removeStaleItems(dirPath: string, validPaths: Set<string>): void;
|
||||
syncToIndexedDB(): Promise<void>;
|
||||
syncFromIndexedDB(): Promise<void>;
|
||||
}
|
||||
|
||||
export class PyodideFileSystemService implements IFileSystemService {
|
||||
private pyodide: PyodideInterface;
|
||||
private rootPath: string;
|
||||
|
||||
constructor(pyodide: PyodideInterface, rootPath = '/home/pyodide') {
|
||||
this.pyodide = pyodide;
|
||||
this.rootPath = rootPath;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
this.pyodide.FS.mount(
|
||||
this.pyodide.FS.filesystems.IDBFS,
|
||||
{},
|
||||
this.rootPath
|
||||
);
|
||||
await this.syncFromIndexedDB();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize file system:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
readContents(path: string): FileSystemEntry[] | null {
|
||||
try {
|
||||
const entries = this.pyodide.FS.readdir(path);
|
||||
const contents = entries.filter((entry: string) =>
|
||||
entry !== '.' && entry !== '..' && entry !== '.matplotlib'
|
||||
);
|
||||
|
||||
return contents.map((entry: string) => {
|
||||
const fullPath = `${path}/${entry}`;
|
||||
const stat = this.pyodide.FS.stat(fullPath);
|
||||
const isDirectory = this.pyodide.FS.isDir(stat.mode);
|
||||
|
||||
if (isDirectory) {
|
||||
return {
|
||||
name: entry,
|
||||
type: 'directory' as const,
|
||||
contents: this.readContents(fullPath)
|
||||
};
|
||||
} else {
|
||||
const content = this.pyodide.FS.readFile(fullPath, { encoding: 'utf8' });
|
||||
return {
|
||||
name: entry,
|
||||
type: 'file' as const,
|
||||
content: content as string
|
||||
};
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${path}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
readContentsWithMtime(path: string): FileSystemEntry[] | null {
|
||||
try {
|
||||
const entries = this.pyodide.FS.readdir(path);
|
||||
const contents = entries.filter((entry: string) =>
|
||||
entry !== '.' && entry !== '..' && entry !== '.matplotlib'
|
||||
);
|
||||
|
||||
return contents.map((entry: string) => {
|
||||
const fullPath = `${path}/${entry}`;
|
||||
const stat = this.pyodide.FS.stat(fullPath);
|
||||
const isDirectory = this.pyodide.FS.isDir(stat.mode);
|
||||
const mtimeMs = stat ? stat.mtime * 1000 : 0;
|
||||
|
||||
if (isDirectory) {
|
||||
return {
|
||||
name: entry,
|
||||
type: 'directory' as const,
|
||||
contents: this.readContentsWithMtime(fullPath)
|
||||
};
|
||||
} else {
|
||||
const content = this.pyodide.FS.readFile(fullPath, { encoding: 'utf8' });
|
||||
return {
|
||||
name: entry,
|
||||
type: 'file' as const,
|
||||
content: content as string,
|
||||
mtimeMs
|
||||
};
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${path}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
writeFile(path: string, content: string): void {
|
||||
try {
|
||||
this.pyodide.FS.writeFile(path, content, { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to write file ${path}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
readFile(path: string): string | undefined {
|
||||
try {
|
||||
return this.pyodide.FS.readFile(path, { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to read file ${path}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
ensureDirectory(dirPath: string): void {
|
||||
if (dirPath === this.rootPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = dirPath.split('/').filter(Boolean);
|
||||
let currentPath = '';
|
||||
|
||||
for (const part of parts) {
|
||||
currentPath += '/' + part;
|
||||
try {
|
||||
const stat = this.pyodide.FS.stat(currentPath);
|
||||
if (!this.pyodide.FS.isDir(stat.mode)) {
|
||||
this.pyodide.FS.unlink(currentPath);
|
||||
this.pyodide.FS.mkdir(currentPath);
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
this.pyodide.FS.mkdir(currentPath);
|
||||
} catch (mkdirErr) {
|
||||
console.error(`Failed to create directory ${currentPath}:`, mkdirErr);
|
||||
throw mkdirErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeStaleItems(dirPath: string, validPaths: Set<string>): void {
|
||||
const entries = this.pyodide.FS.readdir(dirPath);
|
||||
for (const entry of entries) {
|
||||
if (entry === '.' || entry === '..') continue;
|
||||
const fullPath = `${dirPath}/${entry}`;
|
||||
const stat = this.pyodide.FS.stat(fullPath);
|
||||
if (this.pyodide.FS.isDir(stat.mode)) {
|
||||
this.removeStaleItems(fullPath, validPaths);
|
||||
if (!validPaths.has(fullPath)) {
|
||||
try {
|
||||
this.pyodide.FS.rmdir(fullPath);
|
||||
} catch (err) {
|
||||
console.error(`Failed removing directory ${fullPath}:`, err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!validPaths.has(fullPath)) {
|
||||
try {
|
||||
this.pyodide.FS.unlink(fullPath);
|
||||
} catch (err) {
|
||||
console.error(`Failed removing file ${fullPath}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async syncToIndexedDB(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.pyodide.FS.syncfs(false, (err: Error | null) => {
|
||||
if (err) {
|
||||
console.error('Failed to sync to IndexedDB:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('Successfully synced to IndexedDB');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async syncFromIndexedDB(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.pyodide.FS.syncfs(true, (err: Error | null) => {
|
||||
if (err) {
|
||||
console.error('Failed to sync from IndexedDB:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('Successfully synced from IndexedDB');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { type DirectoryContent } from '@hanzo_network/hanzo-message-ts/api/vector-fs/types';
|
||||
|
||||
import { type AddFileToInboxResponse, type AddFileToJobRequest } from './__mocks__/hanzo-message-ts';
|
||||
import { type FileSystemEntry, type IFileSystemService } from './file-system-service';
|
||||
|
||||
export interface IJobService {
|
||||
syncJobFilesToIDBFS(contents: DirectoryContent[] | null): Promise<void>;
|
||||
compareAndUploadFiles(timeBefore: number): Promise<void>;
|
||||
syncFileToIDBFS(item: { path: string; name: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface JobServiceDependencies {
|
||||
fileSystemService: IFileSystemService;
|
||||
downloadFile: (params: { nodeAddress: string; token: string; path: string }) => Promise<string>;
|
||||
addFileToJob: (nodeAddress: string, bearerToken: string, payload: AddFileToJobRequest) => Promise<AddFileToInboxResponse>;
|
||||
nodeAddress: string;
|
||||
token: string;
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
export class JobService implements IJobService {
|
||||
private fileSystemService: IFileSystemService;
|
||||
private downloadFile: (params: { nodeAddress: string; token: string; path: string }) => Promise<string>;
|
||||
private addFileToJob: (nodeAddress: string, bearerToken: string, payload: AddFileToJobRequest) => Promise<AddFileToInboxResponse>;
|
||||
private nodeAddress: string;
|
||||
private token: string;
|
||||
private jobId: string;
|
||||
|
||||
constructor(dependencies: JobServiceDependencies) {
|
||||
this.fileSystemService = dependencies.fileSystemService;
|
||||
this.downloadFile = dependencies.downloadFile;
|
||||
this.addFileToJob = dependencies.addFileToJob;
|
||||
this.nodeAddress = dependencies.nodeAddress;
|
||||
this.token = dependencies.token;
|
||||
this.jobId = dependencies.jobId;
|
||||
}
|
||||
|
||||
async syncJobFilesToIDBFS(contents: DirectoryContent[] | null): Promise<void> {
|
||||
console.time('Sync Job Files to IDBFS');
|
||||
try {
|
||||
// Log current state
|
||||
console.group('Initial State');
|
||||
console.log('Job Contents:', contents ? JSON.stringify(contents, null, 2) : 'empty');
|
||||
const currentIDBFSContents = this.fileSystemService.readContentsWithMtime('/home/pyodide');
|
||||
console.log('Current IDBFS Contents:', JSON.stringify(currentIDBFSContents, null, 2));
|
||||
console.groupEnd();
|
||||
|
||||
// Create empty Set if no job contents
|
||||
const jobPathsSet = new Set<string>();
|
||||
if (contents) {
|
||||
for (const entry of contents) {
|
||||
const fullDirPath = `/home/pyodide/${entry.name}`;
|
||||
jobPathsSet.add(fullDirPath);
|
||||
if (!entry.is_directory) {
|
||||
jobPathsSet.add(fullDirPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove everything not in jobPathsSet
|
||||
console.group('Removing Stale Items');
|
||||
this.fileSystemService.removeStaleItems('/home/pyodide', jobPathsSet);
|
||||
console.groupEnd();
|
||||
|
||||
// Only proceed with syncing if we have job contents
|
||||
if (contents && contents.length > 0) {
|
||||
console.group('Syncing New/Updated Items');
|
||||
for (const entry of contents) {
|
||||
const fullPath = `/home/pyodide/${entry.name}`;
|
||||
|
||||
if (entry.is_directory) {
|
||||
this.fileSystemService.ensureDirectory(fullPath);
|
||||
} else {
|
||||
const dirOnly = fullPath.substring(0, fullPath.lastIndexOf('/'));
|
||||
this.fileSystemService.ensureDirectory(dirOnly);
|
||||
|
||||
const existingContent = this.fileSystemService.readFile(fullPath);
|
||||
|
||||
if (existingContent) {
|
||||
// Compare with new content before syncing
|
||||
const currentContent = await this.downloadFile({
|
||||
nodeAddress: this.nodeAddress,
|
||||
token: this.token,
|
||||
path: entry.path,
|
||||
});
|
||||
|
||||
if (existingContent === currentContent) {
|
||||
console.log(`File ${entry.name} content unchanged, skipping sync`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await this.syncFileToIDBFS({
|
||||
path: entry.path,
|
||||
name: entry.name
|
||||
});
|
||||
}
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// Final sync to IndexedDB
|
||||
await this.fileSystemService.syncToIndexedDB();
|
||||
|
||||
// Log final state
|
||||
console.log('Final IDBFS Contents:', JSON.stringify(this.fileSystemService.readContentsWithMtime('/home/pyodide'), null, 2));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to sync job files:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async traverseAndUpload(
|
||||
entries: FileSystemEntry[],
|
||||
basePath: string,
|
||||
timeBefore: number
|
||||
): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
const fullPath = `${basePath}/${entry.name}`;
|
||||
if (entry.type === 'file' && entry.mtimeMs !== undefined) {
|
||||
const mtimeInMs = entry.mtimeMs;
|
||||
if (mtimeInMs > timeBefore) {
|
||||
console.log(`Uploading changed file: ${fullPath}`);
|
||||
try {
|
||||
const blob = new Blob([entry.content ?? ''], { type: 'text/plain' });
|
||||
const file = new File([blob], entry.name, { type: 'text/plain' });
|
||||
await this.addFileToJob(this.nodeAddress, this.token, {
|
||||
filename: fullPath.replace('/home/pyodide/', ''),
|
||||
job_id: this.jobId,
|
||||
file,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to upload file ${fullPath}:`, error);
|
||||
}
|
||||
}
|
||||
} else if (entry.type === 'directory' && entry.contents) {
|
||||
await this.traverseAndUpload(entry.contents, fullPath, timeBefore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async compareAndUploadFiles(timeBefore: number): Promise<void> {
|
||||
console.time('Compare and Upload Files');
|
||||
try {
|
||||
const idbfsContents = this.fileSystemService.readContentsWithMtime('/home/pyodide');
|
||||
if (!idbfsContents) {
|
||||
console.warn('No contents found in /home/pyodide.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.traverseAndUpload(idbfsContents, '/home/pyodide', timeBefore);
|
||||
} catch (error) {
|
||||
console.error('Failed to compare/upload files:', error);
|
||||
} finally {
|
||||
console.timeEnd('Compare and Upload Files');
|
||||
}
|
||||
}
|
||||
|
||||
async syncFileToIDBFS(item: { path: string; name: string }): Promise<void> {
|
||||
try {
|
||||
const content = await this.downloadFile({
|
||||
nodeAddress: this.nodeAddress,
|
||||
token: this.token,
|
||||
path: item.path,
|
||||
});
|
||||
|
||||
const targetPath = `/home/pyodide/${item.path}`;
|
||||
const dirOnly = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
||||
this.fileSystemService.ensureDirectory(dirOnly);
|
||||
|
||||
this.fileSystemService.writeFile(targetPath, content);
|
||||
console.log(`Synced file ${item.path} to IDBFS`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync file ${item.path}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const StderrRender = ({ stderr }: { stderr: string[] }) => {
|
||||
const i18n = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (stderr.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-red-700">
|
||||
{i18n.t('codeRunner.stderr')}
|
||||
</h3>
|
||||
<button
|
||||
className="text-sm text-red-600 transition-colors duration-200 hover:text-red-800 focus:outline-hidden"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? 'Hide' : 'Show'} ({stderr.length})
|
||||
<span className="ml-1">{isExpanded ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="mt-2 max-h-60 overflow-y-auto">
|
||||
{stderr.map((error: string, index: number) => (
|
||||
<div className="mb-2 text-red-600 last:mb-0" key={index}>
|
||||
<span className="mr-2 rounded-sm bg-red-100 px-1 py-0.5 font-mono">
|
||||
{index + 1}
|
||||
</span>
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const StdoutRender = ({ stdout }: { stdout: string[] }) => {
|
||||
const i18n = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (stdout.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-md border border-gray-200 bg-gray-50">
|
||||
<button
|
||||
className="flex w-full items-center justify-between p-3 text-sm text-gray-700 transition-colors duration-200 hover:bg-gray-100 focus:outline-hidden"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<span
|
||||
className={`mr-2 transition-transform duration-200 ${isExpanded ? 'rotate-90 transform' : ''}`}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{i18n.t('codeRunner.stdout')} ({stdout.length} line
|
||||
{stdout.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
<span className="text-gray-500">{isExpanded ? 'Hide' : 'Show'}</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200">
|
||||
<pre className="text-text-secondary0 max-h-60 overflow-y-auto p-4 text-sm break-words whitespace-pre-wrap">
|
||||
{stdout.map((line, index) => (
|
||||
<div className="mb-1 last:mb-0" key={index}>
|
||||
<span className="mr-2 text-gray-500 select-none">
|
||||
{index + 1}
|
||||
</span>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,557 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { retrieveVectorResource } from '@hanzo_network/hanzo-message-ts/api/vector-fs/index';
|
||||
import { extractJobIdFromInbox } from '@hanzo_network/hanzo-message-ts/utils/inbox_name_handler';
|
||||
import {
|
||||
type SearchVectorFormSchema,
|
||||
searchVectorFormSchema,
|
||||
} from '@hanzo_network/hanzo-node-state/forms/vector-fs/vector-search';
|
||||
import { transformDataToTreeNodes } from '@hanzo_network/hanzo-node-state/lib/utils/files';
|
||||
import { useUpdateJobScope } from '@hanzo_network/hanzo-node-state/v2/mutations/updateJobScope/useUpdateJobScope';
|
||||
import { useUploadVRFiles } from '@hanzo_network/hanzo-node-state/v2/mutations/uploadVRFiles/useUploadVRFiles';
|
||||
import { FunctionKeyV2 } from '@hanzo_network/hanzo-node-state/v2/constants';
|
||||
import { useGetListDirectoryContents } from '@hanzo_network/hanzo-node-state/v2/queries/getDirectoryContents/useGetListDirectoryContents';
|
||||
import { useGetJobFolderName } from '@hanzo_network/hanzo-node-state/v2/queries/getJobFolderName/useGetJobFolderName';
|
||||
import { useGetVRSeachSimplified } from '@hanzo_network/hanzo-node-state/v2/queries/getVRSearchSimplified/useGetSearchVRItems';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
ScrollArea,
|
||||
SearchInput,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { FileTypeIcon } from '@hanzo_network/hanzo-ui/assets';
|
||||
import { SearchIcon, UploadIcon } from 'lucide-react';
|
||||
import { Checkbox } from 'primereact/checkbox';
|
||||
import { Tree, type TreeCheckboxSelectionKeys } from 'primereact/tree';
|
||||
import { type TreeNode } from 'primereact/treenode';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { treeOptions } from '../../lib/constants';
|
||||
import { useAuth } from '../../store/auth';
|
||||
import { useSetJobScope } from './context/set-job-scope-context';
|
||||
|
||||
export const SetJobScopeDrawer = () => {
|
||||
const { t } = useTranslation();
|
||||
const { inboxId: encodedInboxId = '' } = useParams();
|
||||
const inboxId = decodeURIComponent(encodedInboxId);
|
||||
|
||||
const isSetJobScopeOpen = useSetJobScope((state) => state.isSetJobScopeOpen);
|
||||
const setSetJobScopeOpen = useSetJobScope(
|
||||
(state) => state.setSetJobScopeOpen,
|
||||
);
|
||||
const selectedKeys = useSetJobScope((state) => state.selectedKeys);
|
||||
const onSelectedKeysChange = useSetJobScope(
|
||||
(state) => state.onSelectedKeysChange,
|
||||
);
|
||||
|
||||
const selectedFileKeysRef = useSetJobScope(
|
||||
(state) => state.selectedFileKeysRef,
|
||||
);
|
||||
const selectedFolderKeysRef = useSetJobScope(
|
||||
(state) => state.selectedFolderKeysRef,
|
||||
);
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const [nodes, setNodes] = useState<TreeNode[]>([]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { mutateAsync: uploadVRFiles, isPending: isUploadingFiles } =
|
||||
useUploadVRFiles({
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success('Files uploaded', {
|
||||
id: 'ctx-upload',
|
||||
description: `${variables.files.length} file(s) added — select them below, then Save.`,
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [FunctionKeyV2.GET_VR_FILES],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to upload files', {
|
||||
id: 'ctx-upload',
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleUploadFiles = async (fileList: FileList | null) => {
|
||||
if (!fileList || fileList.length === 0 || !auth) return;
|
||||
toast.loading('Uploading files…', {
|
||||
id: 'ctx-upload',
|
||||
description: 'This can take ~1–2 min per file (it gets embedded).',
|
||||
});
|
||||
await uploadVRFiles({
|
||||
nodeAddress: auth.node_address ?? '',
|
||||
destinationPath: '/',
|
||||
files: Array.from(fileList),
|
||||
token: auth.api_v2_key ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to find a TreeNode by its key (path) in the tree
|
||||
const findNodeByKey = (
|
||||
key: string,
|
||||
searchNodes: TreeNode[],
|
||||
): TreeNode | null => {
|
||||
for (const node of searchNodes) {
|
||||
if (String(node.key) === key) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeByKey(key, node.children);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const { data: fileInfoArray, isSuccess: isVRFilesSuccess } =
|
||||
useGetListDirectoryContents({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
path: '/',
|
||||
depth: 6,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateJobScope, isPending: isUpdatingJobScope } =
|
||||
useUpdateJobScope({
|
||||
onSuccess: () => {
|
||||
setSetJobScopeOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to update conversation context', {
|
||||
description: error.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data: jobFolderData } = useGetJobFolderName(
|
||||
{
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
},
|
||||
{
|
||||
enabled: !!inboxId,
|
||||
},
|
||||
);
|
||||
|
||||
const selectedPaths = jobFolderData ? [jobFolderData.folder_name] : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (isVRFilesSuccess) {
|
||||
setNodes(
|
||||
transformDataToTreeNodes(fileInfoArray, undefined, selectedPaths),
|
||||
);
|
||||
}
|
||||
}, [fileInfoArray, isVRFilesSuccess, jobFolderData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSetJobScopeOpen) {
|
||||
const element = document.querySelector('#chat-input') as HTMLDivElement;
|
||||
if (element) {
|
||||
element?.focus?.();
|
||||
}
|
||||
}
|
||||
}, [isSetJobScopeOpen]);
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setSetJobScopeOpen} open={isSetJobScopeOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader className="mb-3">
|
||||
<SheetTitle className="flex h-[40px] items-center gap-4">
|
||||
{t('chat.form.setContext')}
|
||||
{Object.keys(selectedKeys ?? {}).length > 0 && (
|
||||
<Badge className="bg-brand text-sm text-white">
|
||||
{Object.keys(selectedKeys ?? {}).length}
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<p className="text-text-secondary text-sm">
|
||||
{t('chat.form.setContextText')}
|
||||
</p>
|
||||
<input
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
void handleUploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
className="mt-1 w-full gap-2"
|
||||
disabled={isUploadingFiles}
|
||||
isLoading={isUploadingFiles}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
Upload files
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-200px)] flex-1">
|
||||
<Tree
|
||||
onSelect={(e) => {
|
||||
if (e.node.icon === 'icon-folder') {
|
||||
selectedFolderKeysRef.set(String(e.node.key), e.node.data.path);
|
||||
return;
|
||||
}
|
||||
selectedFileKeysRef.set(String(e.node.key), e.node.data.path);
|
||||
}}
|
||||
onSelectionChange={(e) => {
|
||||
onSelectedKeysChange(e.value as TreeCheckboxSelectionKeys);
|
||||
}}
|
||||
onUnselect={(e) => {
|
||||
const nodeKey = String(e.node.key);
|
||||
|
||||
if (e.node.icon === 'icon-folder') {
|
||||
// --- Folder Deselected ---
|
||||
selectedFolderKeysRef.delete(nodeKey);
|
||||
|
||||
// Robustly remove all descendant keys (files and folders) from both refs
|
||||
const clearDescendants = (node: TreeNode) => {
|
||||
node.children?.forEach((child) => {
|
||||
const childKey = String(child.key);
|
||||
selectedFileKeysRef.delete(childKey); // Remove if it exists as a file key
|
||||
selectedFolderKeysRef.delete(childKey); // Remove if it exists as a folder key
|
||||
if (child.children && child.children.length > 0) {
|
||||
clearDescendants(child);
|
||||
}
|
||||
});
|
||||
};
|
||||
clearDescendants(e.node);
|
||||
} else {
|
||||
// --- File Deselected ---
|
||||
const lastSlashIndex = nodeKey.lastIndexOf('/');
|
||||
const parentFolderPath =
|
||||
lastSlashIndex > 0
|
||||
? nodeKey.substring(0, lastSlashIndex)
|
||||
: '/';
|
||||
const isParentFolderSelected =
|
||||
selectedFolderKeysRef.has(parentFolderPath);
|
||||
|
||||
if (isParentFolderSelected) {
|
||||
// Parent folder *was* selected. Transition to selecting individual siblings.
|
||||
|
||||
// 1. Remove the parent folder from selection.
|
||||
selectedFolderKeysRef.delete(parentFolderPath);
|
||||
|
||||
// 2. Find the parent node to access its children.
|
||||
const parentNode = findNodeByKey(parentFolderPath, nodes);
|
||||
|
||||
// 3. Add all *other* sibling *files* to the individual file selection.
|
||||
(parentNode?.children ?? [])
|
||||
.filter(
|
||||
(childNode: TreeNode) =>
|
||||
String(childNode.key) !== nodeKey &&
|
||||
childNode.data?.path,
|
||||
) // Only other files
|
||||
.forEach((childNode: TreeNode) => {
|
||||
if (childNode.icon !== 'icon-folder') {
|
||||
// Ensure path exists and is a file
|
||||
selectedFileKeysRef.set(
|
||||
String(childNode.key),
|
||||
childNode.data.path,
|
||||
);
|
||||
} else {
|
||||
selectedFolderKeysRef.set(
|
||||
String(childNode.key),
|
||||
childNode.data.path,
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Parent folder was NOT selected, so this file was individually selected.
|
||||
selectedFileKeysRef.delete(nodeKey);
|
||||
}
|
||||
}
|
||||
}}
|
||||
propagateSelectionDown={true}
|
||||
propagateSelectionUp={true}
|
||||
pt={treeOptions}
|
||||
selectionKeys={selectedKeys}
|
||||
selectionMode="checkbox"
|
||||
value={nodes}
|
||||
/>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="flex-row items-center gap-3">
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
onSelectedKeysChange(null);
|
||||
selectedFileKeysRef.clear();
|
||||
selectedFolderKeysRef.clear();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t('common.unselectAll')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
isLoading={isUpdatingJobScope}
|
||||
onClick={async () => {
|
||||
if (inboxId) {
|
||||
await updateJobScope({
|
||||
jobId: extractJobIdFromInbox(inboxId),
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
jobScope: {
|
||||
vector_fs_items: Array.from(selectedFileKeysRef.values()),
|
||||
vector_fs_folders: Array.from(
|
||||
selectedFolderKeysRef.values(),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
setSetJobScopeOpen(false);
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{inboxId ? t('common.saveChanges') : t('common.done')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export const KnowledgeSearchDrawer = () => {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const isKnowledgeSearchOpen = useSetJobScope(
|
||||
(state) => state.isKnowledgeSearchOpen,
|
||||
);
|
||||
const setKnowledgeSearchOpen = useSetJobScope(
|
||||
(state) => state.setKnowledgeSearchOpen,
|
||||
);
|
||||
|
||||
const selectedKeys = useSetJobScope((state) => state.selectedKeys);
|
||||
const onSelectedKeysChange = useSetJobScope(
|
||||
(state) => state.onSelectedKeysChange,
|
||||
);
|
||||
const selectedFileKeysRef = useSetJobScope(
|
||||
(state) => state.selectedFileKeysRef,
|
||||
);
|
||||
|
||||
const [isSearchEntered, setIsSearchEntered] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const searchVectorFSForm = useForm<SearchVectorFormSchema>({
|
||||
resolver: zodResolver(searchVectorFormSchema),
|
||||
defaultValues: {
|
||||
searchQuery: '',
|
||||
},
|
||||
});
|
||||
const currentSearchQuery = useWatch({
|
||||
control: searchVectorFSForm.control,
|
||||
name: 'searchQuery',
|
||||
});
|
||||
|
||||
const { isPending, isLoading, isSuccess, data } = useGetVRSeachSimplified(
|
||||
{
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
search: search,
|
||||
token: auth?.api_v2_key ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isSearchEntered || !!search,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = async (data: SearchVectorFormSchema) => {
|
||||
if (!data.searchQuery) return;
|
||||
setIsSearchEntered(true);
|
||||
setSearch(data.searchQuery);
|
||||
};
|
||||
|
||||
const groupedData = data?.reduce<Record<string, string[]>>(
|
||||
(acc, [content, pathList]) => {
|
||||
const generatedFilePath = '/' + pathList.join('/');
|
||||
if (!acc[generatedFilePath]) {
|
||||
acc[generatedFilePath] = [];
|
||||
}
|
||||
acc[generatedFilePath].push(content);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setKnowledgeSearchOpen} open={isKnowledgeSearchOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader className="mb-3">
|
||||
<SheetTitle className="flex h-[40px] items-center gap-4">
|
||||
{t('aiFilesSearch.label')}
|
||||
</SheetTitle>
|
||||
<p className="text-text-secondary text-sm">
|
||||
{t('aiFilesSearch.description')}
|
||||
</p>
|
||||
</SheetHeader>
|
||||
|
||||
<Form {...searchVectorFSForm}>
|
||||
<form
|
||||
className="flex shrink-0 flex-col items-center gap-2 pt-4"
|
||||
onSubmit={searchVectorFSForm.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex w-full flex-1 items-center gap-2">
|
||||
<FormField
|
||||
control={searchVectorFSForm.control}
|
||||
name="searchQuery"
|
||||
render={({ field }) => (
|
||||
<SearchInput
|
||||
classNames={{
|
||||
container: 'mx-0.5 h-9',
|
||||
input: 'bg-transparent',
|
||||
}}
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
className="h-[48px] w-[48px] shrink-0 rounded-xl p-3.5"
|
||||
disabled={isPending && isLoading}
|
||||
isLoading={isPending && isLoading}
|
||||
size="auto"
|
||||
type="submit"
|
||||
>
|
||||
<SearchIcon />
|
||||
<span className="sr-only">{t('common.search')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
<ScrollArea className="h-[calc(100vh-340px)] pr-4 [&>div>div]:!block">
|
||||
{isSearchEntered &&
|
||||
isPending &&
|
||||
Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div
|
||||
className="mb-1 flex h-[69px] items-center justify-between gap-2 rounded-lg bg-gray-400 py-3"
|
||||
key={idx}
|
||||
/>
|
||||
))}
|
||||
{isSearchEntered && isSuccess && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4 p-2">
|
||||
<h2 className="text-text-secondary text-sm font-medium">
|
||||
{t('aiFilesSearch.foundResults', {
|
||||
count: data?.length,
|
||||
})}
|
||||
</h2>
|
||||
{selectedKeys && Object.keys(selectedKeys).length > 0 && (
|
||||
<span className="text-brand text-sm font-medium">
|
||||
{t('aiFilesSearch.filesSelected', {
|
||||
count: Object.keys(selectedKeys).length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(groupedData ?? {}).map(
|
||||
([generatedFilePath, contents]) => (
|
||||
<div
|
||||
className="flex items-start gap-1 px-2 py-3 text-sm"
|
||||
key={generatedFilePath}
|
||||
>
|
||||
<Checkbox
|
||||
checked={generatedFilePath in (selectedKeys || {})}
|
||||
inputId={generatedFilePath}
|
||||
name="files"
|
||||
onChange={async (event) => {
|
||||
const newKeys = { ...selectedKeys };
|
||||
if (event.value in (selectedKeys || {})) {
|
||||
delete newKeys[event.value];
|
||||
} else {
|
||||
newKeys[event.value] = { checked: true };
|
||||
const fileInfo = await retrieveVectorResource(
|
||||
auth?.node_address ?? '',
|
||||
auth?.api_v2_key ?? '',
|
||||
{ path: generatedFilePath },
|
||||
);
|
||||
|
||||
selectedFileKeysRef.set(event.value, {
|
||||
...fileInfo.data,
|
||||
path: event.value,
|
||||
vr_header: {
|
||||
resource_name: fileInfo.data.name,
|
||||
resource_source: fileInfo.data.source,
|
||||
},
|
||||
});
|
||||
}
|
||||
onSelectedKeysChange(newKeys);
|
||||
}}
|
||||
value={generatedFilePath}
|
||||
/>
|
||||
<label
|
||||
className="ml-2 flex-1"
|
||||
htmlFor={generatedFilePath}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileTypeIcon className="h-6 w-6" />
|
||||
<span className="text-sm">
|
||||
{generatedFilePath.split('/').at(-1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-300">
|
||||
{contents?.map((content) => (
|
||||
<p
|
||||
className="text-text-secondary py-3 text-xs"
|
||||
key={content}
|
||||
>
|
||||
{content}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<SheetFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSelectedKeysChange(null);
|
||||
selectedFileKeysRef.clear();
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t('common.unselectAll')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKnowledgeSearchOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{t('common.done')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
import {
|
||||
type WidgetToolType,
|
||||
type WsMessage,
|
||||
} from '@hanzo_network/hanzo-message-ts/api/general/types';
|
||||
import { extractJobIdFromInbox } from '@hanzo_network/hanzo-message-ts/utils/inbox_name_handler';
|
||||
import {
|
||||
FunctionKeyV2,
|
||||
generateOptimisticAssistantMessage,
|
||||
OPTIMISTIC_ASSISTANT_MESSAGE_ID,
|
||||
} from '@hanzo_network/hanzo-node-state/v2/constants';
|
||||
import {
|
||||
type FormattedMessage,
|
||||
type ChatConversationInfiniteData,
|
||||
type ToolCall,
|
||||
} from '@hanzo_network/hanzo-node-state/v2/queries/getChatConversation/types';
|
||||
import { useGetProviderFromJob } from '@hanzo_network/hanzo-node-state/v2/queries/getProviderFromJob/useGetProviderFromJob';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { produce } from 'immer';
|
||||
import { createContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import useWebSocket, { ReadyState } from 'react-use-websocket';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { useAuth } from '../../store/auth';
|
||||
import { useToolsStore } from './context/tools-context';
|
||||
|
||||
type UseWebSocketMessage = {
|
||||
enabled?: boolean;
|
||||
inboxId: string;
|
||||
};
|
||||
|
||||
// Robust reconnect options shared by every node WebSocket consumer.
|
||||
// `react-use-websocket` does NOT reconnect by default (it only retries when
|
||||
// `shouldReconnect` is provided and returns true). Without this, a node
|
||||
// restart / re-pair silently kills the socket and the chat spinner hangs
|
||||
// forever because streamed tokens and the "done" event never arrive.
|
||||
// We always attempt to reconnect (including on error) with an exponential
|
||||
// backoff capped at 10s, effectively forever, so the live stream survives
|
||||
// node restarts and re-auth.
|
||||
const WS_RECONNECT_OPTIONS = {
|
||||
share: true,
|
||||
shouldReconnect: () => true,
|
||||
retryOnError: true,
|
||||
reconnectAttempts: Number.MAX_SAFE_INTEGER,
|
||||
reconnectInterval: (attempt: number) =>
|
||||
Math.min(1000 * 2 ** attempt, 10_000),
|
||||
} as const;
|
||||
|
||||
export const useWebSocketMessage = ({
|
||||
enabled,
|
||||
inboxId: defaultInboxId,
|
||||
}: UseWebSocketMessage) => {
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const nodeAddressUrl = new URL(auth?.node_address ?? 'http://localhost:3690');
|
||||
const socketUrl = ['localhost', '0.0.0.0', '127.0.0.1'].includes(
|
||||
nodeAddressUrl.hostname,
|
||||
)
|
||||
? `ws://${nodeAddressUrl.hostname}:${Number(nodeAddressUrl.port) + 1}/ws`
|
||||
: `ws://${nodeAddressUrl.hostname}${Number(nodeAddressUrl.port) !== 0 ? `:${Number(nodeAddressUrl.port)}` : ''}/ws`;
|
||||
const queryClient = useQueryClient();
|
||||
const isStreamSupported = useRef(false);
|
||||
|
||||
const { sendMessage, lastMessage, readyState } = useWebSocket(
|
||||
socketUrl,
|
||||
WS_RECONNECT_OPTIONS,
|
||||
enabled,
|
||||
);
|
||||
const { inboxId: encodedInboxId = '' } = useParams();
|
||||
const inboxId = defaultInboxId || decodeURIComponent(encodedInboxId);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return [FunctionKeyV2.GET_CHAT_CONVERSATION_PAGINATION, { inboxId }];
|
||||
}, [inboxId]);
|
||||
|
||||
const { data: provider } = useGetProviderFromJob({
|
||||
jobId: inboxId ? extractJobIdFromInbox(inboxId) : '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !auth) return;
|
||||
if (lastMessage?.data) {
|
||||
try {
|
||||
const parseData: WsMessage = JSON.parse(lastMessage.data);
|
||||
if (parseData.inbox !== inboxId) return;
|
||||
|
||||
const isUserMessage =
|
||||
parseData.message_type === 'HanzoMessage' &&
|
||||
parseData.message &&
|
||||
JSON.parse(parseData.message)?.external_metadata.sender ===
|
||||
auth.hanzo_identity &&
|
||||
JSON.parse(parseData.message)?.body.unencrypted.internal_metadata
|
||||
.sender_subidentity === auth.profile;
|
||||
|
||||
const isAssistantMessage =
|
||||
parseData.message_type === 'HanzoMessage' &&
|
||||
parseData.message &&
|
||||
!(
|
||||
JSON.parse(parseData.message)?.external_metadata.sender ===
|
||||
auth.hanzo_identity &&
|
||||
JSON.parse(parseData.message)?.body.unencrypted.internal_metadata
|
||||
.sender_subidentity === auth.profile
|
||||
);
|
||||
|
||||
if (isUserMessage) {
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
produce((draft: ChatConversationInfiniteData) => {
|
||||
if (!draft?.pages?.[0]) return;
|
||||
|
||||
const lastPage = draft.pages[draft.pages.length - 1];
|
||||
const lastMessage = lastPage?.[lastPage.length - 1];
|
||||
|
||||
// validate if optimistic message is already there
|
||||
if (
|
||||
lastMessage &&
|
||||
lastMessage.messageId === OPTIMISTIC_ASSISTANT_MESSAGE_ID &&
|
||||
lastMessage.role === 'assistant' &&
|
||||
lastMessage.status?.type === 'running'
|
||||
) {
|
||||
lastMessage.content = '';
|
||||
} else {
|
||||
const newMessages = [
|
||||
generateOptimisticAssistantMessage(provider),
|
||||
];
|
||||
if (lastPage) {
|
||||
lastPage.push(...newMessages);
|
||||
} else {
|
||||
draft.pages.push(newMessages);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isAssistantMessage && !isStreamSupported.current) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKey });
|
||||
return;
|
||||
}
|
||||
isStreamSupported.current = false;
|
||||
|
||||
// finalize the optimistic assistant message immediately when the final assistant message arrives
|
||||
if (isAssistantMessage) {
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
produce((draft: ChatConversationInfiniteData | undefined) => {
|
||||
if (!draft?.pages?.[0]) return;
|
||||
const lastMessage = draft.pages.at(-1)?.at(-1);
|
||||
if (
|
||||
lastMessage &&
|
||||
lastMessage.messageId === OPTIMISTIC_ASSISTANT_MESSAGE_ID &&
|
||||
lastMessage.role === 'assistant' &&
|
||||
lastMessage.status?.type === 'running'
|
||||
) {
|
||||
// Mark as complete so the UI stops "thinking"
|
||||
lastMessage.status = { type: 'complete', reason: 'unknown' };
|
||||
// Optional: also close reasoning if it's still marked running
|
||||
if (lastMessage.reasoning?.status?.type === 'running') {
|
||||
lastMessage.reasoning.status = {
|
||||
type: 'complete',
|
||||
reason: 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// now fetch the authoritative message to replace optimistic state
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parseData.message_type !== 'Stream') return;
|
||||
isStreamSupported.current = true;
|
||||
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
produce((draft: ChatConversationInfiniteData | undefined) => {
|
||||
if (!draft?.pages?.[0]) return;
|
||||
const lastMessage: FormattedMessage | undefined = draft.pages
|
||||
.at(-1)
|
||||
?.at(-1);
|
||||
if (
|
||||
lastMessage &&
|
||||
lastMessage.messageId === OPTIMISTIC_ASSISTANT_MESSAGE_ID &&
|
||||
lastMessage.role === 'assistant' &&
|
||||
lastMessage.status?.type === 'running'
|
||||
) {
|
||||
if (parseData.metadata?.is_reasoning) {
|
||||
if (!lastMessage.reasoning) {
|
||||
lastMessage.reasoning = {
|
||||
text: '',
|
||||
status: { type: 'running' },
|
||||
};
|
||||
}
|
||||
lastMessage.reasoning.text += parseData.message;
|
||||
lastMessage.reasoning.status = { type: 'running' };
|
||||
} else {
|
||||
if (lastMessage.reasoning) {
|
||||
lastMessage.reasoning.status = {
|
||||
type: 'complete',
|
||||
reason: 'unknown',
|
||||
};
|
||||
}
|
||||
lastMessage.content += parseData.message;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse ws message', error);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
auth?.hanzo_identity,
|
||||
auth?.profile,
|
||||
enabled,
|
||||
inboxId,
|
||||
lastMessage?.data,
|
||||
queryClient,
|
||||
queryKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Only (re)subscribe once the socket is actually OPEN. Re-running this on
|
||||
// `readyState` ensures we re-subscribe after every reconnect (node restart
|
||||
// / re-pair) with the current api_v2_key, instead of subscribing once on a
|
||||
// socket that may not be open yet and never recovering.
|
||||
if (readyState !== ReadyState.OPEN) return;
|
||||
const wsMessage = {
|
||||
bearer_auth: auth?.api_v2_key ?? '',
|
||||
message: {
|
||||
subscriptions: [{ topic: 'inbox', subtopic: inboxId }],
|
||||
unsubscriptions: [],
|
||||
},
|
||||
};
|
||||
const wsMessageString = JSON.stringify(wsMessage);
|
||||
sendMessage(wsMessageString);
|
||||
}, [
|
||||
auth?.api_v2_key,
|
||||
auth?.hanzo_identity,
|
||||
enabled,
|
||||
inboxId,
|
||||
readyState,
|
||||
sendMessage,
|
||||
]);
|
||||
|
||||
return {
|
||||
readyState,
|
||||
};
|
||||
};
|
||||
|
||||
export const useWebSocketTools = ({
|
||||
enabled,
|
||||
inboxId: defaultInboxId,
|
||||
}: UseWebSocketMessage) => {
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const nodeAddressUrl = new URL(auth?.node_address ?? 'http://localhost:3690');
|
||||
const socketUrl = ['localhost', '0.0.0.0', '127.0.0.1'].includes(
|
||||
nodeAddressUrl.hostname,
|
||||
)
|
||||
? `ws://${nodeAddressUrl.hostname}:${Number(nodeAddressUrl.port) + 1}/ws`
|
||||
: `ws://${nodeAddressUrl.hostname}${Number(nodeAddressUrl.port) !== 0 ? `:${Number(nodeAddressUrl.port)}` : ''}/ws`;
|
||||
const { sendMessage, lastMessage, readyState } = useWebSocket(
|
||||
socketUrl,
|
||||
WS_RECONNECT_OPTIONS,
|
||||
enabled,
|
||||
);
|
||||
const { inboxId: encodedInboxId = '' } = useParams();
|
||||
const inboxId = defaultInboxId || decodeURIComponent(encodedInboxId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setWidget = useToolsStore((state) => state.setWidget);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return [FunctionKeyV2.GET_CHAT_CONVERSATION_PAGINATION, { inboxId }];
|
||||
}, [inboxId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (lastMessage?.data) {
|
||||
try {
|
||||
const parseData: WsMessage = JSON.parse(lastMessage.data);
|
||||
if (parseData.inbox !== inboxId) return;
|
||||
|
||||
if (
|
||||
parseData.message_type === 'Widget' &&
|
||||
parseData?.widget?.ToolRequest
|
||||
) {
|
||||
const tool = parseData.widget.ToolRequest;
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
produce((draft: ChatConversationInfiniteData | undefined) => {
|
||||
if (!draft?.pages?.[0]) return;
|
||||
const lastMessage = draft.pages.at(-1)?.at(-1);
|
||||
if (
|
||||
lastMessage &&
|
||||
lastMessage.messageId === OPTIMISTIC_ASSISTANT_MESSAGE_ID &&
|
||||
lastMessage.role === 'assistant' &&
|
||||
lastMessage.status?.type === 'running'
|
||||
) {
|
||||
const existingToolCall: ToolCall | undefined =
|
||||
lastMessage.toolCalls?.[tool.index];
|
||||
|
||||
if (existingToolCall) {
|
||||
lastMessage.toolCalls[tool.index] = {
|
||||
...lastMessage.toolCalls[tool.index],
|
||||
status: tool.status.type_,
|
||||
result: tool.result?.data.message,
|
||||
};
|
||||
} else {
|
||||
lastMessage.toolCalls.push({
|
||||
name: tool.tool_name,
|
||||
// TODO: fix this based on backend
|
||||
args: tool?.args ?? tool?.args?.arguments,
|
||||
status: tool.status.type_,
|
||||
toolRouterKey: tool?.tool_router_key ?? '',
|
||||
result: tool.result?.data.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
parseData.message_type === 'Widget' &&
|
||||
parseData?.widget?.PaymentRequest
|
||||
) {
|
||||
const widgetName = Object.keys(parseData.widget)[0];
|
||||
setWidget({
|
||||
name: widgetName as WidgetToolType,
|
||||
data: parseData.widget[widgetName as WidgetToolType],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse ws message', error);
|
||||
}
|
||||
}
|
||||
}, [enabled, inboxId, lastMessage?.data, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Re-subscribe to the widget topic on every (re)connect once OPEN, with the
|
||||
// current api_v2_key, so tool/widget streams survive node restarts.
|
||||
if (readyState !== ReadyState.OPEN) return;
|
||||
const wsMessage = {
|
||||
bearer_auth: auth?.api_v2_key ?? '',
|
||||
message: {
|
||||
subscriptions: [{ topic: 'widget', subtopic: inboxId }],
|
||||
unsubscriptions: [],
|
||||
},
|
||||
};
|
||||
const wsMessageString = JSON.stringify(wsMessage);
|
||||
sendMessage(wsMessageString);
|
||||
}, [
|
||||
auth?.api_v2_key,
|
||||
auth?.hanzo_identity,
|
||||
enabled,
|
||||
inboxId,
|
||||
readyState,
|
||||
sendMessage,
|
||||
]);
|
||||
|
||||
return { readyState };
|
||||
};
|
||||
|
||||
type ContentPartState = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
part: {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
status: {
|
||||
type: 'complete' | 'running';
|
||||
};
|
||||
};
|
||||
|
||||
export const ContentPartContext = createContext({});
|
||||
const COMPLETE_STATUS = {
|
||||
type: 'complete' as const,
|
||||
};
|
||||
|
||||
const RUNNING_STATUS = {
|
||||
type: 'running' as const,
|
||||
};
|
||||
|
||||
export const TextContentPartProvider = ({
|
||||
isRunning,
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
text: string;
|
||||
isRunning?: boolean | undefined;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [store] = useState(() => {
|
||||
return create<ContentPartState>(() => ({
|
||||
status: isRunning ? RUNNING_STATUS : COMPLETE_STATUS,
|
||||
part: { type: 'text', text },
|
||||
type: 'text',
|
||||
text: '',
|
||||
}));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const state = store.getState() as ContentPartState & {
|
||||
type: 'text';
|
||||
};
|
||||
|
||||
const textUpdated = state.text !== text;
|
||||
const targetStatus = isRunning ? RUNNING_STATUS : COMPLETE_STATUS;
|
||||
const statusUpdated = state.status !== targetStatus;
|
||||
|
||||
if (!textUpdated && !statusUpdated) return;
|
||||
|
||||
store.setState(
|
||||
{
|
||||
type: 'text',
|
||||
text,
|
||||
part: { type: 'text', text },
|
||||
status: targetStatus,
|
||||
} satisfies ContentPartState,
|
||||
true,
|
||||
);
|
||||
}, [store, isRunning, text]);
|
||||
|
||||
return (
|
||||
<ContentPartContext.Provider value={store}>
|
||||
{children}
|
||||
</ContentPartContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,688 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import { type RecurringTask } from '@hanzo_network/hanzo-message-ts/api/recurring-tasks/types';
|
||||
import { DEFAULT_CHAT_CONFIG } from '@hanzo_network/hanzo-node-state/v2/constants';
|
||||
import { useCreateRecurringTask } from '@hanzo_network/hanzo-node-state/v2/mutations/createRecurringTask/useCreateRecurringTask';
|
||||
import { useUpdateRecurringTask } from '@hanzo_network/hanzo-node-state/v2/mutations/updateRecurringTask/useUpdateRecurringTask';
|
||||
import { useGetTools } from '@hanzo_network/hanzo-node-state/v2/queries/getToolsList/useGetToolsList';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
Label,
|
||||
Slider,
|
||||
Switch,
|
||||
Textarea,
|
||||
TextField,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import {
|
||||
ScheduledTasksIcon,
|
||||
ToolsIcon,
|
||||
} from '@hanzo_network/hanzo-ui/assets';
|
||||
import { formatText } from '@hanzo_network/hanzo-ui/helpers';
|
||||
import { cn } from '@hanzo_network/hanzo-ui/utils';
|
||||
import cronstrue from 'cronstrue';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SubpageLayout } from '../../../pages/layout/simple-layout';
|
||||
import { useAuth } from '../../../store/auth';
|
||||
import { useSettings } from '../../../store/settings';
|
||||
import { AIModelSelector } from '../../chat/chat-action-bar/ai-update-selection-action-bar';
|
||||
import { actionButtonClassnames } from '../../chat/conversation-footer';
|
||||
|
||||
const createTaskFormSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
llmOrAgentId: z.string(),
|
||||
cronExpression: z.string().refine(
|
||||
(value) => {
|
||||
try {
|
||||
cronstrue.toString(value, {
|
||||
throwExceptionOnParseError: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Invalid cron expression. Please provide a valid cron expression.',
|
||||
},
|
||||
),
|
||||
jobConfig: z.object({
|
||||
custom_system_prompt: z.string().optional(),
|
||||
custom_prompt: z.string(),
|
||||
temperature: z.number(),
|
||||
max_tokens: z.number().optional(),
|
||||
seed: z.number().optional(),
|
||||
top_k: z.number(),
|
||||
top_p: z.number(),
|
||||
stream: z.boolean().optional(),
|
||||
use_tools: z.boolean().optional(),
|
||||
thinking: z.boolean().optional(),
|
||||
reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
|
||||
web_search_enabled: z.boolean().optional(),
|
||||
}),
|
||||
jobMessage: z.object({
|
||||
content: z.string(),
|
||||
tool_key: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
type CreateTaskForm = z.infer<typeof createTaskFormSchema>;
|
||||
|
||||
type CronTaskProps = {
|
||||
mode: 'create' | 'edit';
|
||||
initialValues?: RecurringTask;
|
||||
};
|
||||
function CronTask({ mode, initialValues }: CronTaskProps) {
|
||||
const { t } = useTranslation();
|
||||
const defaultAgentId = useSettings((state) => state.defaultAgentId);
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth((state) => state.auth);
|
||||
const form = useForm<CreateTaskForm>({
|
||||
resolver: zodResolver(createTaskFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
cronExpression: '',
|
||||
jobConfig: {
|
||||
custom_system_prompt: '',
|
||||
custom_prompt: '',
|
||||
temperature: DEFAULT_CHAT_CONFIG.temperature,
|
||||
top_k: DEFAULT_CHAT_CONFIG.top_k,
|
||||
top_p: DEFAULT_CHAT_CONFIG.top_p,
|
||||
stream: DEFAULT_CHAT_CONFIG.stream ?? false,
|
||||
use_tools: DEFAULT_CHAT_CONFIG.use_tools ?? false,
|
||||
thinking: DEFAULT_CHAT_CONFIG.thinking,
|
||||
reasoning_effort: DEFAULT_CHAT_CONFIG.reasoning_effort,
|
||||
web_search_enabled: DEFAULT_CHAT_CONFIG.web_search_enabled,
|
||||
},
|
||||
llmOrAgentId: defaultAgentId,
|
||||
jobMessage: {
|
||||
content: '',
|
||||
tool_key: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialValues &&
|
||||
'CreateJobWithConfigAndMessage' in initialValues.action
|
||||
) {
|
||||
const initialConfig =
|
||||
initialValues.action.CreateJobWithConfigAndMessage.config;
|
||||
form.reset({
|
||||
cronExpression: initialValues.cron,
|
||||
description: initialValues.description,
|
||||
name: initialValues.name,
|
||||
jobConfig: {
|
||||
custom_system_prompt: initialConfig.custom_system_prompt ?? '',
|
||||
custom_prompt: initialConfig.custom_prompt,
|
||||
temperature: initialConfig.temperature,
|
||||
top_k: initialConfig.top_k,
|
||||
top_p: initialConfig.top_p,
|
||||
stream: initialConfig.stream ?? false,
|
||||
use_tools: initialConfig.use_tools ?? false,
|
||||
thinking: initialConfig.thinking,
|
||||
reasoning_effort: initialConfig.reasoning_effort,
|
||||
web_search_enabled: initialConfig.web_search_enabled,
|
||||
},
|
||||
jobMessage: {
|
||||
content:
|
||||
'CreateJobWithConfigAndMessage' in initialValues.action
|
||||
? initialValues.action.CreateJobWithConfigAndMessage.message
|
||||
.content
|
||||
: '',
|
||||
tool_key:
|
||||
'CreateJobWithConfigAndMessage' in initialValues.action
|
||||
? initialValues.action.CreateJobWithConfigAndMessage.message
|
||||
.tool_key
|
||||
: '',
|
||||
},
|
||||
llmOrAgentId:
|
||||
'CreateJobWithConfigAndMessage' in initialValues.action
|
||||
? initialValues.action.CreateJobWithConfigAndMessage.llm_provider
|
||||
: defaultAgentId,
|
||||
});
|
||||
}
|
||||
}, [form, initialValues, defaultAgentId]);
|
||||
|
||||
const { data: toolsList, isSuccess: isToolListSuccess } = useGetTools({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
});
|
||||
|
||||
const { mutateAsync: createRecurringTask, isPending } =
|
||||
useCreateRecurringTask({
|
||||
onSuccess: () => {
|
||||
void navigate('/tasks');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to create task', {
|
||||
description: error.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
const {
|
||||
mutateAsync: updateRecurringTask,
|
||||
isPending: isUpdateRecurringTaskPending,
|
||||
} = useUpdateRecurringTask({
|
||||
onSuccess: () => {
|
||||
toast.success('Task updated successfully');
|
||||
void navigate('/tasks');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error('Failed to updated task', {
|
||||
description: error.response?.data?.message ?? error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const submit = async (values: CreateTaskForm) => {
|
||||
if (mode === 'create') {
|
||||
await createRecurringTask({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
cronExpression: values.cronExpression,
|
||||
chatConfig: values.jobConfig,
|
||||
message: values.jobMessage.content,
|
||||
toolKey: values.jobMessage.tool_key,
|
||||
llmProvider: values.llmOrAgentId,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mode === 'edit' && initialValues) {
|
||||
await updateRecurringTask({
|
||||
nodeAddress: auth?.node_address ?? '',
|
||||
token: auth?.api_v2_key ?? '',
|
||||
cronExpression: values.cronExpression,
|
||||
chatConfig: values.jobConfig,
|
||||
message: values.jobMessage.content,
|
||||
toolKey: values.jobMessage.tool_key,
|
||||
llmProvider: values.llmOrAgentId,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
taskId: initialValues.task_id.toString() ?? '',
|
||||
jobId:
|
||||
'CreateJobWithConfigAndMessage' in initialValues.action
|
||||
? initialValues?.action.CreateJobWithConfigAndMessage.message.job_id
|
||||
: '',
|
||||
active: !initialValues.paused,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultAgentId) {
|
||||
form.setValue('llmOrAgentId', defaultAgentId);
|
||||
}
|
||||
}, [defaultAgentId]);
|
||||
|
||||
const currentCronExpression = form.watch('cronExpression');
|
||||
|
||||
const readableCronExpression = useMemo(() => {
|
||||
const readableCron = cronstrue.toString(currentCronExpression, {
|
||||
throwExceptionOnParseError: false,
|
||||
});
|
||||
if (readableCron.toLowerCase().includes('error')) {
|
||||
return null;
|
||||
}
|
||||
return readableCron;
|
||||
}, [currentCronExpression, form]);
|
||||
return (
|
||||
<SubpageLayout
|
||||
className="container"
|
||||
title={`${mode === 'create' ? 'Create' : 'Edit'} Scheduled Task`}
|
||||
>
|
||||
<p className="text-text-secondary -mt-8 py-3 pb-6 text-center text-sm">
|
||||
Schedule recurring tasks at a specified time
|
||||
</p>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex grid grid-cols-2 flex-col justify-between gap-8 pt-4"
|
||||
onSubmit={form.handleSubmit(submit, (errors) => {
|
||||
console.error('Form validation errors:', errors);
|
||||
toast.error('Validation failed. Please check the form fields.', {
|
||||
description: Object.values(errors)
|
||||
.map((e) => e.message)
|
||||
.join('\n'),
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<TextField autoFocus field={field} label="Task Name" />
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
field={field}
|
||||
helperMessage={t('cronTask.taskDescriptionHelper')}
|
||||
label={t('cronTask.taskDescription')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="jobMessage.content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('cronTask.taskPrompt')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="!min-h-[130px] text-sm"
|
||||
placeholder={t('cronTask.promptPlaceholder')}
|
||||
resize="vertical"
|
||||
spellCheck={false}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('cronTask.promptExample')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cronExpression"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
field={field}
|
||||
helperMessage={t('cronTask.cronExample')}
|
||||
label={t('cronTask.cronExpression')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{readableCronExpression && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<ScheduledTasksIcon className="size-4" />
|
||||
<span>
|
||||
{t('cronTask.cronWillRun', {
|
||||
schedule: readableCronExpression.toLowerCase(),
|
||||
expression: form.watch('cronExpression'),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{
|
||||
label: t('cronTask.scheduleOptions.every5Min'),
|
||||
cron: '*/5 * * * *',
|
||||
},
|
||||
{
|
||||
label: t('cronTask.scheduleOptions.every5Hours'),
|
||||
cron: '0 */5 * * *',
|
||||
},
|
||||
{
|
||||
label: t('cronTask.scheduleOptions.everyMonday8am'),
|
||||
cron: '0 8 * * 1',
|
||||
},
|
||||
{
|
||||
label: t('cronTask.scheduleOptions.everyJanuary1st12am'),
|
||||
cron: '0 0 1 1 *',
|
||||
},
|
||||
{
|
||||
label: t('cronTask.scheduleOptions.every1stMonth12pm'),
|
||||
cron: '0 12 1 * *',
|
||||
},
|
||||
].map((item) => (
|
||||
<Badge
|
||||
key={item.cron}
|
||||
onClick={() => {
|
||||
form.setValue('cronExpression', item.cron);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<span className="text-xs">{item.label}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-bg-secondary space-y-6 rounded-lg px-4 py-4 pb-7">
|
||||
<span className="text-text-default flex-1 items-center gap-1 truncate py-2 text-left text-xs font-semibold">
|
||||
{t('cronTask.aiModelConfiguration')}
|
||||
</span>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-[1fr_auto] items-center">
|
||||
<span className="text-text-secondary text-xs">
|
||||
{t('cronTask.aiAgent')}
|
||||
</span>
|
||||
<AIModelSelector
|
||||
onValueChange={(value) => {
|
||||
form.setValue('llmOrAgentId', value);
|
||||
}}
|
||||
value={form.watch('llmOrAgentId')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] items-center">
|
||||
<span className="text-text-secondary text-xs">
|
||||
{t('cronTask.forceToolUsage')}
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
actionButtonClassnames,
|
||||
'w-auto max-w-[250px] justify-between truncate [&[data-state=open]>.icon]:rotate-180',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
<ToolsIcon className="mr-1 h-4 w-4" />
|
||||
<span>
|
||||
{form.watch('jobMessage.tool_key')
|
||||
? formatText(
|
||||
form
|
||||
.watch('jobMessage.tool_key')
|
||||
?.split(':::')?.[2] ?? '',
|
||||
)
|
||||
: 'None'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="icon h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="max-h-[400px] min-w-[330px] overflow-y-auto p-1 py-2"
|
||||
side="top"
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => {
|
||||
form.setValue('jobMessage.tool_key', value);
|
||||
}}
|
||||
value={form.watch('jobMessage.tool_key')}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
className="text-text-default flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-2 transition-colors"
|
||||
value=""
|
||||
>
|
||||
<ToolsIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">
|
||||
{t('common.none')}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
{isToolListSuccess &&
|
||||
toolsList.length > 0 &&
|
||||
toolsList?.map((tool) => (
|
||||
<DropdownMenuRadioItem
|
||||
className="text-text-default flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-2 transition-colors"
|
||||
key={tool.tool_router_key}
|
||||
value={tool.tool_router_key}
|
||||
>
|
||||
<ToolsIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">
|
||||
{formatText(tool.name)}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible className="space-y-4">
|
||||
<CollapsibleTrigger className="text-text-secondary hover:text-text-default flex items-center gap-1 text-sm [&[data-state=open]>svg]:rotate-90">
|
||||
{t('common.advanced')}
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="jobConfig.custom_system_prompt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>System Prompt</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="!min-h-[130px] text-sm"
|
||||
resize="vertical"
|
||||
spellCheck={false}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="jobConfig.stream"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex w-full flex-col gap-3">
|
||||
<div className="flex gap-3">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-text-default static space-y-1.5 text-sm">
|
||||
{t('cronTask.enableStream')}
|
||||
</FormLabel>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="jobConfig.use_tools"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex w-full flex-col gap-3">
|
||||
<div className="flex gap-3">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-text-default static space-y-1.5 text-sm">
|
||||
{t('cronTask.enableTools')}
|
||||
</FormLabel>
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="jobConfig.temperature"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex gap-2.5">
|
||||
<FormControl>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="grid w-full gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="temperature">
|
||||
{t('cronTask.temperature')}
|
||||
</Label>
|
||||
<span className="text-text-secondary hover:border-border w-12 rounded-md border border-transparent px-2 py-0.5 text-right text-sm">
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
aria-label="Temperature"
|
||||
className="[&_[role=slider]]:h-4 [&_[role=slider]]:w-4"
|
||||
id="temperature"
|
||||
max={1}
|
||||
onValueChange={(vals) => {
|
||||
field.onChange(vals[0]);
|
||||
}}
|
||||
step={0.1}
|
||||
value={[field.value]}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
className="w-[260px] px-2 py-3 text-xs"
|
||||
side="left"
|
||||
>
|
||||
{t('cronTask.temperatureInfo')}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="jobConfig.top_p"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex gap-2.5">
|
||||
<FormControl>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="grid w-full gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="topP">
|
||||
{t('cronTask.topP')}
|
||||
</Label>
|
||||
<span className="text-text-secondary hover:border-border w-12 rounded-md border border-transparent px-2 py-0.5 text-right text-sm">
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
aria-label="Top P"
|
||||
className="[&_[role=slider]]:h-4 [&_[role=slider]]:w-4"
|
||||
id="topP"
|
||||
max={1}
|
||||
min={0}
|
||||
onValueChange={(vals) => {
|
||||
field.onChange(vals[0]);
|
||||
}}
|
||||
step={0.1}
|
||||
value={[field.value]}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
className="w-[260px] px-2 py-3 text-xs"
|
||||
side="left"
|
||||
>
|
||||
{t('cronTask.topPInfo')}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="jobConfig.top_k"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex gap-2.5">
|
||||
<FormControl>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="grid w-full gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="topK">
|
||||
{t('cronTask.topK')}
|
||||
</Label>
|
||||
<span className="text-text-secondary hover:border-border w-12 rounded-md border border-transparent px-2 py-0.5 text-right text-sm">
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
aria-label="Top K"
|
||||
className="[&_[role=slider]]:h-4 [&_[role=slider]]:w-4"
|
||||
id="topK"
|
||||
max={100}
|
||||
onValueChange={(vals) => {
|
||||
field.onChange(vals[0]);
|
||||
}}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
className="w-[260px] px-2 py-3 text-xs"
|
||||
side="left"
|
||||
>
|
||||
{t('cronTask.topKInfo')}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
className="min-w-[120px]"
|
||||
disabled={isPending || isUpdateRecurringTaskPending}
|
||||
onClick={() => navigate('/tasks')}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="min-w-[120px]"
|
||||
disabled={isPending || isUpdateRecurringTaskPending}
|
||||
isLoading={isPending || isUpdateRecurringTaskPending}
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{mode === 'create' ? t('common.save') : t('common.update')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SubpageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default CronTask;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user