Compare commits

..

1 Commits

Author SHA1 Message Date
Benjamin Lu
195b766f86 Desktop maintenance: unsafe base path warning (#6750)
Surface unsafe base path validation in the desktop maintenance view and
add an installation-fix auto-refresh after successful tasks.

<img width="1080" height="870" alt="image"
src="https://github.com/user-attachments/assets/26fe61be-fed8-47c0-a921-604f0af018f8"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6750-Desktop-maintenance-unsafe-base-path-warning-2b06d73d36508147aeb4d19d02bbf0f0)
by [Unito](https://www.unito.io)
2025-11-20 02:13:54 +00:00
274 changed files with 24982 additions and 31388 deletions

View File

@@ -25,6 +25,3 @@ e3bb29ceb8174b8bbca9e48ec7d42cd540f40efa
# [refactor] Improve updates/notifications domain organization (#5590)
27ab355f9c73415dc39f4d3f512b02308f847801
# Migrate Tailwind styles to design-system package
9f19d8fb4bd22518879343b49c05634dca777df0

View File

@@ -1,234 +0,0 @@
# Manual API Changelog Generation
This workflow allows you to generate API changelogs by comparing any two versions of the ComfyUI Frontend package.
## Usage
### Via GitHub Actions UI
1. Go to **Actions** tab in the repository
2. Select **Manual API Changelog Generation** from the workflows list
3. Click **Run workflow** button
4. Fill in the inputs:
- **Previous version**: The earlier version (e.g., `1.29.0` or `v1.29.0`)
- **Current version**: The later version (e.g., `1.30.2` or `v1.30.2`)
- **Create PR**: Check this to automatically create a pull request with the changelog
### Via GitHub CLI
```bash
# Basic usage - just generate changelog
gh workflow run manual-api-changelog.yaml \
-f from_version=1.29.0 \
-f to_version=1.30.2 \
-f create_pr=false
# Generate changelog and create PR
gh workflow run manual-api-changelog.yaml \
-f from_version=1.29.0 \
-f to_version=1.30.2 \
-f create_pr=true
```
## What It Does
1. **Validates Inputs**: Checks that version formats are valid (X.Y.Z) and tags exist
2. **Builds Both Versions**: Checks out each version tag, installs dependencies, and builds TypeScript types
3. **Generates Snapshots**: Creates structured JSON snapshots of the public API surface for each version
4. **Compares APIs**: Analyzes differences and categorizes as:
- ⚠️ **Breaking changes** (removals, signature changes)
-**Additions** (new interfaces, methods, properties)
- 🔄 **Modifications** (non-breaking changes)
5. **Uploads Artifact**: Saves the changelog and snapshots as a workflow artifact (90-day retention)
6. **Creates PR** (optional): Generates a draft PR to update `docs/API-CHANGELOG.md`
## Output
### Workflow Artifacts
Every run produces an artifact containing:
- `CHANGELOG-{from}-to-{to}.md` - Human-readable changelog
- `from.json` - API snapshot of the earlier version
- `to.json` - API snapshot of the later version
**Retention**: 90 days
### Pull Request (Optional)
If `create_pr` is enabled and changes are detected:
- Creates a draft PR with title: `[docs] API Changelog: v{from} → v{to}`
- Updates `docs/API-CHANGELOG.md` with the new changelog entry
- Includes detailed metadata and review instructions
- Labeled with `documentation`
## Example Changelog Output
```markdown
## v1.30.2 (2025-11-04)
Comparing v1.29.0 → v1.30.2. This changelog documents changes to the public API surface.
### ✨ Additions
**Type Aliases**
- `WorkflowId`
**Interfaces**
- `ExtensionMetadata`
- Members: `id`, `name`, `version`, `description`
### 🔄 Modifications
> **Note**: Some modifications may be breaking changes.
**Interfaces**
- `ComfyApi`
- ✨ Added member: `queuePromptAsync`
- ✨ Added member: `cancelPrompt`
- ⚠️ **Breaking**: Removed member: `queuePrompt`
**Enums**
- `NodeStatus`
- ✨ Added enum value: `ERROR`
- ✨ Added enum value: `COMPLETED`
```
## Use Cases
### 1. Generate Changelog for Missed Releases
If the automatic workflow failed or was skipped for a release:
```bash
gh workflow run manual-api-changelog.yaml \
-f from_version=1.28.0 \
-f to_version=1.29.0 \
-f create_pr=true
```
### 2. Compare Non-Adjacent Versions
To see cumulative changes across multiple releases:
```bash
gh workflow run manual-api-changelog.yaml \
-f from_version=1.25.0 \
-f to_version=1.30.2 \
-f create_pr=false
```
### 3. Test Upcoming Changes
Compare current `main` branch against the latest release (requires creating a temporary tag):
```bash
# Create temporary tag for current main
git tag v1.31.0-preview
git push origin v1.31.0-preview
# Run comparison
gh workflow run manual-api-changelog.yaml \
-f from_version=1.30.2 \
-f to_version=1.31.0-preview \
-f create_pr=false
# Clean up temporary tag
git tag -d v1.31.0-preview
git push origin :refs/tags/v1.31.0-preview
```
### 4. Audit Historical Changes
Generate changelogs for documentation purposes:
```bash
# Compare multiple version pairs
for from in 1.26.0 1.27.0 1.28.0 1.29.0; do
to=$(echo "$from" | awk -F. '{print $1"."$2+1".0"}')
gh workflow run manual-api-changelog.yaml \
-f from_version=$from \
-f to_version=$to \
-f create_pr=false
done
```
## Validation
The workflow validates:
- ✅ Version format matches semantic versioning (X.Y.Z)
- ✅ Both version tags exist in the repository
- ✅ Tags reference valid commits with buildable code
If validation fails, the workflow exits early with a clear error message.
## Limitations
- **Tag requirement**: Both versions must have corresponding `vX.Y.Z` git tags
- **Build requirement**: Both versions must have functional build processes
- **Type files**: Requires `dist/index.d.ts` to exist after building
- **Scripts**: Requires `scripts/snapshot-api.js` and `scripts/compare-api-snapshots.js` to be present
## Related Workflows
- **[Release API Changelogs](.github/workflows/release-api-changelogs.yaml)**: Automatic changelog generation triggered by NPM releases
- **[Release NPM Types](.github/workflows/release-npm-types.yaml)**: Publishes type definitions and triggers automatic changelog
## Troubleshooting
### "Tag does not exist" error
Ensure the version exists as a git tag:
```bash
git tag -l 'v*' | grep 1.29.0
```
If missing, the version may not have been released yet.
### "Build failed" error
Check that the version can be built successfully:
```bash
git checkout v1.29.0
pnpm install
pnpm build:types
```
### No changes detected
If the workflow reports no changes but you expect some:
1. Check the artifact snapshots to verify they're different
2. Ensure you're comparing the correct versions
3. Review the comparison script logic in `scripts/compare-api-snapshots.js`
### PR not created
PR creation requires:
- `create_pr` input set to `true`
- Significant changes detected (more than just headers)
- `PR_GH_TOKEN` secret configured with appropriate permissions
## Security
- **Permissions**: Workflow requires `contents: write` and `pull-requests: write`
- **Token**: Uses `secrets.PR_GH_TOKEN` for PR creation
- **Isolation**: Each workflow run uses a unique concurrency group
- **Artifacts**: Retained for 90 days, accessible to repository collaborators
## Monitoring
View workflow runs:
```bash
gh run list --workflow=manual-api-changelog.yaml
```
View specific run details:
```bash
gh run view <run-id>
```
Download artifacts:
```bash
gh run download <run-id>
```

View File

@@ -1,255 +0,0 @@
name: Manual API Changelog Generation
on:
workflow_dispatch:
inputs:
from_version:
description: 'Previous version (e.g., 1.29.0 or v1.29.0)'
required: true
type: string
to_version:
description: 'Current version (e.g., 1.30.2 or v1.30.2)'
required: true
type: string
create_pr:
description: 'Create a pull request with the changelog'
required: false
type: boolean
default: false
concurrency:
group: manual-api-changelog-${{ github.run_id }}
cancel-in-progress: false
jobs:
generate_changelog:
name: Generate API Changelog
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0 # Fetch all history for comparing versions
- name: Validate version inputs
id: validate_versions
run: |
# Normalize version strings (remove 'v' prefix if present)
FROM_VERSION="${{ github.event.inputs.from_version }}"
TO_VERSION="${{ github.event.inputs.to_version }}"
FROM_VERSION=${FROM_VERSION#v}
TO_VERSION=${TO_VERSION#v}
# Validate version format (semantic versioning)
if ! [[ "$FROM_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Invalid from_version format: $FROM_VERSION"
echo "Expected format: X.Y.Z (e.g., 1.29.0)"
exit 1
fi
if ! [[ "$TO_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Invalid to_version format: $TO_VERSION"
echo "Expected format: X.Y.Z (e.g., 1.30.2)"
exit 1
fi
# Check if tags exist
if ! git rev-parse "v$FROM_VERSION" >/dev/null 2>&1; then
echo "Error: Tag v$FROM_VERSION does not exist"
exit 1
fi
if ! git rev-parse "v$TO_VERSION" >/dev/null 2>&1; then
echo "Error: Tag v$TO_VERSION does not exist"
exit 1
fi
echo "from_version=$FROM_VERSION" >> $GITHUB_OUTPUT
echo "to_version=$TO_VERSION" >> $GITHUB_OUTPUT
echo "from_tag=v$FROM_VERSION" >> $GITHUB_OUTPUT
echo "to_tag=v$TO_VERSION" >> $GITHUB_OUTPUT
echo "✅ Validated versions: v$FROM_VERSION → v$TO_VERSION"
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Create snapshots directory
run: mkdir -p .api-snapshots
- name: Preserve scripts
run: |
# Copy scripts to temporary location
mkdir -p /tmp/api-changelog-scripts
cp scripts/snapshot-api.js scripts/compare-api-snapshots.js /tmp/api-changelog-scripts/
- name: Build and snapshot TO version
run: |
echo "Building types for v${{ steps.validate_versions.outputs.to_version }}"
git checkout ${{ steps.validate_versions.outputs.to_tag }}
# Restore scripts
mkdir -p scripts
cp /tmp/api-changelog-scripts/*.js scripts/
pnpm install --frozen-lockfile
pnpm build:types
# Generate snapshot
node scripts/snapshot-api.js dist/index.d.ts > /tmp/api-snapshots-to.json
echo "✅ Created snapshot for v${{ steps.validate_versions.outputs.to_version }}"
- name: Build and snapshot FROM version
run: |
echo "Building types for v${{ steps.validate_versions.outputs.from_version }}"
git checkout ${{ steps.validate_versions.outputs.from_tag }}
# Restore scripts
mkdir -p scripts
cp /tmp/api-changelog-scripts/*.js scripts/
pnpm install --frozen-lockfile
pnpm build:types
# Generate snapshot
node scripts/snapshot-api.js dist/index.d.ts > /tmp/api-snapshots-from.json
echo "✅ Created snapshot for v${{ steps.validate_versions.outputs.from_version }}"
- name: Return to original branch
run: |
git checkout ${{ github.ref_name }}
# Restore scripts
mkdir -p scripts
cp /tmp/api-changelog-scripts/*.js scripts/
# Copy snapshots to working directory
cp /tmp/api-snapshots-from.json .api-snapshots/from.json
cp /tmp/api-snapshots-to.json .api-snapshots/to.json
- name: Compare API snapshots and generate changelog
id: generate_changelog
run: |
# Get git ref for TO version
GIT_REF=$(git rev-parse ${{ steps.validate_versions.outputs.to_tag }})
# Run the comparison script
CHANGELOG_OUTPUT=$(node scripts/compare-api-snapshots.js \
.api-snapshots/from.json \
.api-snapshots/to.json \
${{ steps.validate_versions.outputs.from_version }} \
${{ steps.validate_versions.outputs.to_version }} \
Comfy-Org \
ComfyUI_frontend \
"$GIT_REF")
# Save changelog to file for artifact
echo "$CHANGELOG_OUTPUT" > .api-snapshots/CHANGELOG-${{ steps.validate_versions.outputs.from_version }}-to-${{ steps.validate_versions.outputs.to_version }}.md
# Also output to step summary
echo "## 📊 Generated API Changelog" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "$CHANGELOG_OUTPUT" >> $GITHUB_STEP_SUMMARY
# Check if changelog is empty or just header
if [ $(echo "$CHANGELOG_OUTPUT" | wc -l) -lt 5 ]; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "⚠️ No significant API changes detected" >> $GITHUB_STEP_SUMMARY
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "✅ API changes detected and documented" >> $GITHUB_STEP_SUMMARY
fi
echo "✅ Changelog generated successfully"
- name: Upload changelog artifact
uses: actions/upload-artifact@v4
with:
name: api-changelog-v${{ steps.validate_versions.outputs.from_version }}-to-v${{ steps.validate_versions.outputs.to_version }}
path: |
.api-snapshots/CHANGELOG-*.md
.api-snapshots/from.json
.api-snapshots/to.json
retention-days: 90
- name: Create Pull Request
if: github.event.inputs.create_pr == 'true' && steps.generate_changelog.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[docs] Update API changelog for v${{ steps.validate_versions.outputs.from_version }} → v${{ steps.validate_versions.outputs.to_version }}'
title: '[docs] API Changelog: v${{ steps.validate_versions.outputs.from_version }} → v${{ steps.validate_versions.outputs.to_version }}'
body: |
## API Changelog Update (Manual)
This PR documents public API changes between v${{ steps.validate_versions.outputs.from_version }} and v${{ steps.validate_versions.outputs.to_version }}.
The changelog has been manually generated by comparing TypeScript type definitions between versions.
### Version Comparison
- **From:** v${{ steps.validate_versions.outputs.from_version }}
- **To:** v${{ steps.validate_versions.outputs.to_version }}
- **Requested by:** @${{ github.actor }}
- **Workflow run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
### Review Instructions
- Review the changes in `docs/API-CHANGELOG.md`
- Verify accuracy of breaking changes
- Add any additional context or migration notes if needed
- Merge when ready to publish changelog
### Artifacts
The full changelog and snapshots are available as workflow artifacts for 90 days.
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
branch: api-changelog-manual-${{ steps.validate_versions.outputs.from_version }}-to-${{ steps.validate_versions.outputs.to_version }}
base: ${{ github.ref_name }}
labels: documentation
delete-branch: true
draft: true
add-paths: |
docs/API-CHANGELOG.md
- name: Summary
run: |
echo "## 🎉 Workflow Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **From version:** v${{ steps.validate_versions.outputs.from_version }}" >> $GITHUB_STEP_SUMMARY
echo "- **To version:** v${{ steps.validate_versions.outputs.to_version }}" >> $GITHUB_STEP_SUMMARY
echo "- **Changes detected:** ${{ steps.generate_changelog.outputs.has_changes }}" >> $GITHUB_STEP_SUMMARY
echo "- **Create PR:** ${{ github.event.inputs.create_pr }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Artifact" >> $GITHUB_STEP_SUMMARY
echo "The generated changelog and API snapshots have been uploaded as artifacts." >> $GITHUB_STEP_SUMMARY
echo "Retention: 90 days" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ github.event.inputs.create_pr }}" == "true" ] && [ "${{ steps.generate_changelog.outputs.has_changes }}" == "true" ]; then
echo "### 🔀 Pull Request" >> $GITHUB_STEP_SUMMARY
echo "A draft pull request has been created with the changelog updates." >> $GITHUB_STEP_SUMMARY
elif [ "${{ github.event.inputs.create_pr }}" == "true" ]; then
echo "### No PR Created" >> $GITHUB_STEP_SUMMARY
echo "No significant changes were detected, so no PR was created." >> $GITHUB_STEP_SUMMARY
else
echo "### PR Creation Skipped" >> $GITHUB_STEP_SUMMARY
echo "Pull request creation was not requested. Enable 'Create PR' option to automatically create a PR." >> $GITHUB_STEP_SUMMARY
fi

View File

@@ -17,9 +17,40 @@ concurrency:
cancel-in-progress: true
jobs:
claude-review:
wait-for-ci:
runs-on: ubuntu-latest
if: github.event.label.name == 'claude-review'
outputs:
should-proceed: ${{ steps.check-status.outputs.proceed }}
steps:
- name: Wait for other CI checks
uses: lewagon/wait-on-check-action@e106e5c43e8ca1edea6383a39a01c5ca495fd812
with:
ref: ${{ github.event.pull_request.head.sha }}
check-regexp: '^(lint-and-format|test|playwright-tests)'
allowed-conclusions: success,skipped,failure,cancelled,neutral,action_required,timed_out,stale
wait-interval: 30
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if we should proceed
id: check-status
run: |
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("lint-and-format")) | {name, conclusion}')
if echo "$CHECK_RUNS" | grep -Eq '"conclusion": "(failure|cancelled|timed_out|action_required)"'; then
echo "Some CI checks failed - skipping Claude review"
echo "proceed=false" >> $GITHUB_OUTPUT
else
echo "All CI checks passed - proceeding with Claude review"
echo "proceed=true" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
claude-review:
needs: wait-for-ci
if: needs.wait-for-ci.outputs.should-proceed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository

View File

@@ -1,203 +0,0 @@
name: Release API Changelogs
on:
workflow_run:
workflows: ['Release NPM Types']
types:
- completed
push:
branches:
- sno-api-changelog
concurrency:
group: release-api-changelogs-${{ github.workflow }}
cancel-in-progress: false
jobs:
generate_changelog:
name: Generate API Changelog
runs-on: ubuntu-latest
# Only run on successful completion of the Release NPM Types workflow or on push to sno-api-changelog
if: ${{ github.event_name == 'push' || github.event.workflow_run.conclusion == 'success' }}
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0 # Fetch all history for comparing versions
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Get current version
id: current_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Current version: $VERSION"
- name: Get previous version
id: previous_version
run: |
# Get the two most recent version tags sorted
CURRENT_VERSION="${{ steps.current_version.outputs.version }}"
TAGS=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -2)
# Find the previous version tag (skip current if it exists)
PREVIOUS_TAG=""
for tag in $TAGS; do
TAG_VERSION=${tag#v}
if [ "$TAG_VERSION" != "$CURRENT_VERSION" ]; then
PREVIOUS_TAG=$tag
break
fi
done
if [ -z "$PREVIOUS_TAG" ]; then
echo "No previous version found, this may be the first release"
echo "version=" >> $GITHUB_OUTPUT
echo "tag=" >> $GITHUB_OUTPUT
else
echo "version=${PREVIOUS_TAG#v}" >> $GITHUB_OUTPUT
echo "tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
echo "Previous version: ${PREVIOUS_TAG#v}"
fi
- name: Build current types
run: pnpm build:types
- name: Snapshot current API
id: current_snapshot
run: |
# Create snapshots directory
mkdir -p .api-snapshots
# Generate snapshot of current types
node scripts/snapshot-api.js dist/index.d.ts > .api-snapshots/current.json
echo "Current API snapshot created"
- name: Preserve scripts for previous version
if: steps.previous_version.outputs.tag != ''
run: |
# Copy scripts to temporary location to use with previous version
mkdir -p /tmp/api-changelog-scripts
cp scripts/snapshot-api.js scripts/compare-api-snapshots.js /tmp/api-changelog-scripts/
- name: Checkout previous version
if: steps.previous_version.outputs.tag != ''
run: |
# Stash current changes
git stash
# Checkout previous version
git checkout ${{ steps.previous_version.outputs.tag }}
# Restore scripts
mkdir -p scripts
cp /tmp/api-changelog-scripts/*.js scripts/
- name: Build previous types
if: steps.previous_version.outputs.tag != ''
run: |
pnpm install --frozen-lockfile
pnpm build:types
- name: Snapshot previous API
if: steps.previous_version.outputs.tag != ''
run: |
# Generate snapshot of previous types
node scripts/snapshot-api.js dist/index.d.ts > .api-snapshots/previous.json
echo "Previous API snapshot created"
- name: Return to current version
if: steps.previous_version.outputs.tag != ''
run: |
# Remove copied scripts to avoid conflicts
rm -f scripts/snapshot-api.js scripts/compare-api-snapshots.js
git checkout -
git stash pop || true
- name: Compare API snapshots and generate changelog
id: generate_changelog
run: |
# Create docs directory if it doesn't exist
mkdir -p docs
# Get current git ref (commit SHA)
GIT_REF=$(git rev-parse HEAD)
# Run the comparison script
if [ -f .api-snapshots/previous.json ]; then
node scripts/compare-api-snapshots.js \
.api-snapshots/previous.json \
.api-snapshots/current.json \
${{ steps.previous_version.outputs.version }} \
${{ steps.current_version.outputs.version }} \
Comfy-Org \
ComfyUI_frontend \
"$GIT_REF" \
>> docs/API-CHANGELOG.md
else
# First release - just document the initial API surface
echo "## v${{ steps.current_version.outputs.version }} ($(date +%Y-%m-%d))" >> docs/API-CHANGELOG.md
echo "" >> docs/API-CHANGELOG.md
echo "Initial API release." >> docs/API-CHANGELOG.md
echo "" >> docs/API-CHANGELOG.md
fi
# Check if there are any changes
if git diff --quiet docs/API-CHANGELOG.md; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No API changes detected"
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "API changes detected"
fi
- name: Create Pull Request
if: steps.generate_changelog.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[docs] Update API changelog for v${{ steps.current_version.outputs.version }}'
title: '[docs] API Changelog for v${{ steps.current_version.outputs.version }}'
body: |
## API Changelog Update
This PR documents public API changes between v${{ steps.previous_version.outputs.version }} and v${{ steps.current_version.outputs.version }}.
The changelog has been automatically generated by comparing TypeScript type definitions between versions.
### Review Instructions
- Review the changes in `docs/API-CHANGELOG.md`
- Verify accuracy of breaking changes
- Add any additional context or migration notes if needed
- Merge when ready to publish changelog
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
branch: api-changelog-v${{ steps.current_version.outputs.version }}
base: ${{ github.event_name == 'push' && github.ref_name || 'main' }}
labels: documentation
delete-branch: true
draft: true
add-paths: |
docs/API-CHANGELOG.md

4
.gitignore vendored
View File

@@ -18,7 +18,6 @@ yarn.lock
.stylelintcache
node_modules
.pnpm-store
dist
dist-ssr
*.local
@@ -93,6 +92,3 @@ storybook-static
.github/instructions/nx.instructions.md
vite.config.*.timestamp*
vitest.config.*.timestamp*
# Weekly docs check output
/output.txt

View File

@@ -16,7 +16,8 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
execute: async () => await electron.setBasePath(),
name: 'Base path',
shortDescription: 'Change the application base path.',
errorDescription: 'Unable to open the base path. Please select a new one.',
errorDescription:
'The current base path is invalid or unsafe. Please select a new location.',
description:
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
isInstallationFix: true,

View File

@@ -85,6 +85,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
const electron = electronAPI()
// Reactive state
const lastUpdate = ref<InstallValidation | null>(null)
const isRefreshing = ref(false)
const isRunningTerminalCommand = computed(() =>
tasks.value
@@ -97,6 +98,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
.some((task) => getRunner(task)?.executing)
)
const unsafeBasePath = computed(
() => lastUpdate.value?.unsafeBasePath === true
)
const unsafeBasePathReason = computed(
() => lastUpdate.value?.unsafeBasePathReason
)
// Task list
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
@@ -123,6 +131,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
* @param validationUpdate Update details passed in by electron
*/
const processUpdate = (validationUpdate: InstallValidation) => {
lastUpdate.value = validationUpdate
const update = validationUpdate as IndexedUpdate
isRefreshing.value = true
@@ -155,7 +164,11 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
}
const execute = async (task: MaintenanceTask) => {
return getRunner(task).execute(task)
const success = await getRunner(task).execute(task)
if (success && task.isInstallationFix) {
await refreshDesktopTasks()
}
return success
}
return {
@@ -163,6 +176,8 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
isRefreshing,
isRunningTerminalCommand,
isRunningInstallationFix,
unsafeBasePath,
unsafeBasePathReason,
execute,
getRunner,
processUpdate,

View File

@@ -0,0 +1,159 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { defineAsyncComponent } from 'vue'
type UnsafeReason = 'appInstallDir' | 'updaterCache' | 'oneDrive' | null
type ValidationIssueState = 'OK' | 'warning' | 'error' | 'skipped'
type ValidationState = {
inProgress: boolean
installState: string
basePath?: ValidationIssueState
unsafeBasePath: boolean
unsafeBasePathReason: UnsafeReason
venvDirectory?: ValidationIssueState
pythonInterpreter?: ValidationIssueState
pythonPackages?: ValidationIssueState
uv?: ValidationIssueState
git?: ValidationIssueState
vcRedist?: ValidationIssueState
upgradePackages?: ValidationIssueState
}
const validationState: ValidationState = {
inProgress: false,
installState: 'installed',
basePath: 'OK',
unsafeBasePath: false,
unsafeBasePathReason: null,
venvDirectory: 'OK',
pythonInterpreter: 'OK',
pythonPackages: 'OK',
uv: 'OK',
git: 'OK',
vcRedist: 'OK',
upgradePackages: 'OK'
}
const createMockElectronAPI = () => {
const logListeners: Array<(message: string) => void> = []
const getValidationUpdate = () => ({
...validationState
})
return {
getPlatform: () => 'darwin',
changeTheme: (_theme: unknown) => {},
onLogMessage: (listener: (message: string) => void) => {
logListeners.push(listener)
},
showContextMenu: (_options: unknown) => {},
Events: {
trackEvent: (_eventName: string, _data?: unknown) => {}
},
Validation: {
onUpdate: (_callback: (update: unknown) => void) => {},
async getStatus() {
return getValidationUpdate()
},
async validateInstallation(callback: (update: unknown) => void) {
callback(getValidationUpdate())
},
async complete() {
// Only allow completion when the base path is safe
return !validationState.unsafeBasePath
},
dispose: () => {}
},
setBasePath: () => Promise.resolve(true),
reinstall: () => Promise.resolve(),
uv: {
installRequirements: () => Promise.resolve(),
clearCache: () => Promise.resolve(),
resetVenv: () => Promise.resolve()
}
}
}
const ensureElectronAPI = () => {
const globalWindow = window as unknown as { electronAPI?: unknown }
if (!globalWindow.electronAPI) {
globalWindow.electronAPI = createMockElectronAPI()
}
return globalWindow.electronAPI
}
const MaintenanceView = defineAsyncComponent(async () => {
ensureElectronAPI()
const module = await import('./MaintenanceView.vue')
return module.default
})
const meta: Meta<typeof MaintenanceView> = {
title: 'Desktop/Views/MaintenanceView',
component: MaintenanceView,
parameters: {
layout: 'fullscreen',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
name: 'All tasks OK',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'OK'
validationState.unsafeBasePath = false
validationState.unsafeBasePathReason = null
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}
export const UnsafeBasePathOneDrive: Story = {
name: 'Unsafe base path (OneDrive)',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'error'
validationState.unsafeBasePath = true
validationState.unsafeBasePathReason = 'oneDrive'
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}

View File

@@ -47,6 +47,28 @@
</div>
</div>
<!-- Unsafe migration warning -->
<div v-if="taskStore.unsafeBasePath" class="my-4">
<p class="flex items-start gap-3 text-neutral-300">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
<span>
<strong class="block mb-1">
{{ t('maintenance.unsafeMigration.title') }}
</strong>
<span class="block mb-1">
{{ unsafeReasonText }}
</span>
<span class="block text-sm text-neutral-400">
{{ t('maintenance.unsafeMigration.action') }}
</span>
</span>
</p>
</div>
<!-- Tasks -->
<TaskListPanel
class="border-neutral-700 border-solid border-x-0 border-y"
@@ -89,10 +111,10 @@
import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue'
@@ -139,6 +161,27 @@ const filterOptions = ref([
/** Filter binding; can be set to show all tasks, or only errors. */
const filter = ref<MaintenanceFilter>(filterOptions.value[0])
const unsafeReasonText = computed(() => {
const reason = taskStore.unsafeBasePathReason
if (!reason) {
return t('maintenance.unsafeMigration.generic')
}
if (reason === 'appInstallDir') {
return t('maintenance.unsafeMigration.appInstallDir')
}
if (reason === 'updaterCache') {
return t('maintenance.unsafeMigration.updaterCache')
}
if (reason === 'oneDrive') {
return t('maintenance.unsafeMigration.oneDrive')
}
return t('maintenance.unsafeMigration.generic')
})
/** If valid, leave the validation window. */
const completeValidation = async () => {
const isValid = await electron.Validation.complete()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -1,49 +0,0 @@
## v1.30.2 (2025-11-01)
Comparing v1.29.0 → v1.30.2. This changelog documents changes to the public API surface that third-party extensions and custom nodes depend on.
### ✨ Additions
**Type Aliases**
- `WorkflowId`
**Interfaces**
- `ExtensionMetadata`
- Members: `id`, `name`, `version`, `description`
### 🔄 Modifications
> **Note**: Some modifications may be breaking changes.
**Interfaces**
- `ComfyApi`
- ✨ Added member: `queuePromptAsync`
- ✨ Added member: `cancelPrompt`
- ✨ Added member: `getQueueStatus`
- ⚠️ **Breaking**: Removed member: `queuePrompt`
- `NodeDef`
- ✨ Added member: `input`
- ✨ Added member: `output`
- ✨ Added member: `output_name`
- `WorkflowMetadata`
- ✨ Added member: `tags`
- ✨ Added member: `thumbnail`
**Enums**
- `NodeStatus`
- ✨ Added enum value: `ERROR`
- ✨ Added enum value: `COMPLETED`
**Classes**
- `WorkflowManager`
- ✨ Added member: `cache`
- ✨ Added method: `deleteWorkflow()`
- ✨ Added method: `searchWorkflows()`
---

View File

@@ -1,188 +0,0 @@
# API Changelog Generation Demo
This demo showcases the automated API changelog generation system comparing two versions of the ComfyUI Frontend public API.
## Overview
The demo compares **v1.29.0****v1.30.2** to demonstrate:
- Breaking change detection
- API additions tracking
- Non-breaking modifications
- Human-readable changelog generation
## Demo Files
### Input Files
- **`v1.29.0.d.ts`** - TypeScript definitions representing the v1.29.0 API surface
- **`v1.30.2.d.ts`** - TypeScript definitions representing the v1.30.2 API surface
### Generated Files
- **`v1.29.0.json`** - Structured API snapshot from v1.29.0
- **`v1.30.2.json`** - Structured API snapshot from v1.30.2
- **`CHANGELOG-DEMO.md`** - Generated changelog comparing the two versions
## Running the Demo
```bash
# Generate API snapshots
node scripts/snapshot-api.js demo-snapshots/v1.29.0.d.ts > demo-snapshots/v1.29.0.json
node scripts/snapshot-api.js demo-snapshots/v1.30.2.d.ts > demo-snapshots/v1.30.2.json
# Compare snapshots and generate changelog
node scripts/compare-api-snapshots.js \
demo-snapshots/v1.29.0.json \
demo-snapshots/v1.30.2.json \
1.29.0 \
1.30.2 \
> demo-snapshots/CHANGELOG-DEMO.md
```
## Key Changes Detected
### ⚠️ Breaking Changes
1. **`ComfyApi.queuePrompt()` removed**
- Replaced with `queuePromptAsync()` which includes additional options
- Extension developers need to update their code to use the new async method
### ✨ New Additions
1. **New Interface: `ExtensionMetadata`**
- Provides metadata for extensions
- Fields: `id`, `name`, `version`, `description`
2. **New Type: `WorkflowId`**
- Type alias for workflow identifiers
3. **Enhanced `ComfyApi` Interface**
- `queuePromptAsync()` - Async queue with priority support
- `cancelPrompt()` - Cancel queued prompts
- `getQueueStatus()` - Query queue state
4. **Extended `NodeDef` Interface**
- `input` - Input specification
- `output` - Output types
- `output_name` - Output names
5. **Enhanced `NodeStatus` Enum**
- Added `ERROR` state
- Added `COMPLETED` state
6. **Extended `WorkflowManager` Class**
- `cache` property for workflow caching
- `deleteWorkflow()` method
- `searchWorkflows()` method
### 🔄 Non-Breaking Modifications
1. **`WorkflowMetadata` enhancements**
- Added optional `tags` field
- Added optional `thumbnail` field
## Real-World Usage
In production, this system will:
1. **Automatic Triggering**: Run after each NPM types release
2. **Version Detection**: Automatically detect current and previous versions from git tags
3. **Build Integration**: Build actual TypeScript types from the repository
4. **PR Creation**: Generate draft pull requests with the changelog
5. **Human Review**: Allow maintainers to review and enhance before merging
## Benefits for Extension Developers
### Clear Breaking Change Visibility
Extension developers can immediately see:
- What APIs were removed
- What signatures changed
- How to migrate their code
### Migration Planning
With clear documentation of additions and changes, developers can:
- Plan updates around breaking changes
- Adopt new features when ready
- Understand version compatibility
### Historical Reference
The cumulative `docs/API-CHANGELOG.md` provides:
- Complete API evolution history
- Context for design decisions
- Migration guides for major versions
## Example Extension Migration
### Before (v1.29.0)
```typescript
// Old code using queuePrompt
const result = await api.queuePrompt(workflow);
console.log('Queued:', result.prompt_id);
```
### After (v1.30.2)
```typescript
// New code using queuePromptAsync with priority
const result = await api.queuePromptAsync(workflow, { priority: 1 });
console.log('Queued:', result.prompt_id, 'Position:', result.number);
```
## Snapshot Structure
The JSON snapshots contain structured representations of:
```json
{
"types": { /* Type aliases */ },
"interfaces": { /* Interface definitions with members */ },
"enums": { /* Enum values */ },
"functions": { /* Exported functions */ },
"classes": { /* Class definitions with methods */ },
"constants": { /* Exported constants */ }
}
```
Each entry includes:
- **Name**: Identifier
- **Kind**: Type of declaration
- **Members/Methods**: Properties and functions
- **Types**: Parameter and return types
- **Visibility**: Public/private/protected modifiers
- **Optional**: Whether parameters/properties are optional
## Comparison Algorithm
The comparison script:
1. **Categorizes changes** into breaking, additions, and modifications
2. **Detects breaking changes**:
- Removed interfaces, classes, functions
- Removed methods or properties
- Changed method signatures
- Changed return types
- Removed enum values
3. **Tracks additions**:
- New interfaces, classes, types
- New methods and properties
- New enum values
4. **Identifies modifications**:
- Type changes
- Optionality changes
- Signature changes
## Future Enhancements
Planned improvements include:
- **LLM Enhancement**: Use AI to generate better descriptions and migration guides
- **Email Notifications**: Alert developers on mailing list for major changes
- **Release Notes Integration**: Auto-include in GitHub releases
- **Deprecation Tracking**: Mark APIs as deprecated before removal
- **Example Code**: Generate migration code snippets automatically
## Conclusion
This automated system ensures:
- ✅ Zero manual effort for changelog generation
- ✅ Consistent documentation format
- ✅ Clear breaking change visibility
- ✅ Historical API evolution tracking
- ✅ Better extension developer experience

View File

@@ -1,58 +0,0 @@
/**
* Mock TypeScript definitions representing v1.29.0 API surface
* This represents the public API as it existed in version 1.29.0
*/
export interface ComfyApi {
/**
* Get API URL for backend calls
*/
apiURL(path: string): string
/**
* Get file URL for static resources
*/
fileURL(path: string): string
/**
* Queue a prompt for execution
*/
queuePrompt(prompt: object): Promise<{ prompt_id: string }>
/**
* Interrupt current execution
*/
interrupt(): Promise<void>
}
export interface NodeDef {
name: string
category: string
display_name?: string
description?: string
python_module: string
}
export enum NodeStatus {
IDLE = 'idle',
QUEUED = 'queued',
RUNNING = 'running'
}
export interface WorkflowMetadata {
title?: string
description?: string
author?: string
version?: string
}
export class WorkflowManager {
workflows: Map<string, object>
constructor()
loadWorkflow(id: string): Promise<object>
saveWorkflow(id: string, data: object): Promise<void>
}
export type NodeId = string

View File

@@ -1,192 +0,0 @@
{
"types": {
"NodeId": {
"kind": "type",
"name": "NodeId",
"text": "export type NodeId = string;",
"exported": true
}
},
"interfaces": {
"ComfyApi": {
"kind": "interface",
"name": "ComfyApi",
"members": [
{
"name": "apiURL",
"kind": "method",
"parameters": [
{
"name": "path",
"type": "string",
"optional": false
}
],
"returnType": "string"
},
{
"name": "fileURL",
"kind": "method",
"parameters": [
{
"name": "path",
"type": "string",
"optional": false
}
],
"returnType": "string"
},
{
"name": "queuePrompt",
"kind": "method",
"parameters": [
{
"name": "prompt",
"type": "object",
"optional": false
}
],
"returnType": "Promise<{ prompt_id: string }>"
},
{
"name": "interrupt",
"kind": "method",
"parameters": [],
"returnType": "Promise<void>"
}
],
"exported": true,
"heritage": []
},
"NodeDef": {
"kind": "interface",
"name": "NodeDef",
"members": [
{
"name": "name",
"type": "string",
"optional": false
},
{
"name": "category",
"type": "string",
"optional": false
},
{
"name": "display_name",
"type": "string",
"optional": true
},
{
"name": "description",
"type": "string",
"optional": true
},
{
"name": "python_module",
"type": "string",
"optional": false
}
],
"exported": true,
"heritage": []
},
"WorkflowMetadata": {
"kind": "interface",
"name": "WorkflowMetadata",
"members": [
{
"name": "title",
"type": "string",
"optional": true
},
{
"name": "description",
"type": "string",
"optional": true
},
{
"name": "author",
"type": "string",
"optional": true
},
{
"name": "version",
"type": "string",
"optional": true
}
],
"exported": true,
"heritage": []
}
},
"enums": {
"NodeStatus": {
"kind": "enum",
"name": "NodeStatus",
"members": [
{
"name": "IDLE",
"value": "\"idle\""
},
{
"name": "QUEUED",
"value": "\"queued\""
},
{
"name": "RUNNING",
"value": "\"running\""
}
],
"exported": true
}
},
"functions": {},
"classes": {
"WorkflowManager": {
"kind": "class",
"name": "WorkflowManager",
"members": [
{
"name": "workflows",
"type": "Map<string, object>",
"visibility": "public"
}
],
"methods": [
{
"name": "loadWorkflow",
"parameters": [
{
"name": "id",
"type": "string",
"optional": false
}
],
"returnType": "Promise<object>",
"visibility": "public"
},
{
"name": "saveWorkflow",
"parameters": [
{
"name": "id",
"type": "string",
"optional": false
},
{
"name": "data",
"type": "object",
"optional": false
}
],
"returnType": "Promise<void>",
"visibility": "public"
}
],
"exported": true,
"heritage": []
}
},
"constants": {}
}

View File

@@ -1,92 +0,0 @@
/**
* Mock TypeScript definitions representing v1.30.2 API surface
* This represents the public API with several breaking changes and additions
*/
export interface ComfyApi {
/**
* Get API URL for backend calls
*/
apiURL(path: string): string
/**
* Get file URL for static resources
*/
fileURL(path: string): string
/**
* Queue a prompt for execution (async version)
*/
queuePromptAsync(
prompt: object,
options?: { priority?: number }
): Promise<{ prompt_id: string; number: number }>
/**
* Cancel a queued prompt
*/
cancelPrompt(prompt_id: string): Promise<void>
/**
* Interrupt current execution
*/
interrupt(): Promise<void>
/**
* Get queue status
*/
getQueueStatus(): Promise<{ queue_running: any[]; queue_pending: any[] }>
}
export interface NodeDef {
name: string
category: string
display_name?: string
description?: string
python_module: string
input: {
required?: Record<string, any>
optional?: Record<string, any>
}
output: string[]
output_name: string[]
}
export enum NodeStatus {
IDLE = 'idle',
QUEUED = 'queued',
RUNNING = 'running',
ERROR = 'error',
COMPLETED = 'completed'
}
export interface WorkflowMetadata {
title?: string
description?: string
author?: string
version?: string
tags?: string[]
thumbnail?: string
}
export interface ExtensionMetadata {
id: string
name: string
version: string
description?: string
}
export class WorkflowManager {
workflows: Map<string, object>
cache: Map<string, object>
constructor()
loadWorkflow(id: string): Promise<object>
saveWorkflow(id: string, data: object): Promise<void>
deleteWorkflow(id: string): Promise<void>
searchWorkflows(query: string): Promise<object[]>
}
export type NodeId = string
export type WorkflowId = string

View File

@@ -1,311 +0,0 @@
{
"types": {
"NodeId": {
"kind": "type",
"name": "NodeId",
"text": "export type NodeId = string;",
"exported": true
},
"WorkflowId": {
"kind": "type",
"name": "WorkflowId",
"text": "export type WorkflowId = string;",
"exported": true
}
},
"interfaces": {
"ComfyApi": {
"kind": "interface",
"name": "ComfyApi",
"members": [
{
"name": "apiURL",
"kind": "method",
"parameters": [
{
"name": "path",
"type": "string",
"optional": false
}
],
"returnType": "string"
},
{
"name": "fileURL",
"kind": "method",
"parameters": [
{
"name": "path",
"type": "string",
"optional": false
}
],
"returnType": "string"
},
{
"name": "queuePromptAsync",
"kind": "method",
"parameters": [
{
"name": "prompt",
"type": "object",
"optional": false
},
{
"name": "options",
"type": "{ priority?: number }",
"optional": true
}
],
"returnType": "Promise<{ prompt_id: string; number: number }>"
},
{
"name": "cancelPrompt",
"kind": "method",
"parameters": [
{
"name": "prompt_id",
"type": "string",
"optional": false
}
],
"returnType": "Promise<void>"
},
{
"name": "interrupt",
"kind": "method",
"parameters": [],
"returnType": "Promise<void>"
},
{
"name": "getQueueStatus",
"kind": "method",
"parameters": [],
"returnType": "Promise<{ queue_running: any[]; queue_pending: any[] }>"
}
],
"exported": true,
"heritage": []
},
"NodeDef": {
"kind": "interface",
"name": "NodeDef",
"members": [
{
"name": "name",
"type": "string",
"optional": false
},
{
"name": "category",
"type": "string",
"optional": false
},
{
"name": "display_name",
"type": "string",
"optional": true
},
{
"name": "description",
"type": "string",
"optional": true
},
{
"name": "python_module",
"type": "string",
"optional": false
},
{
"name": "input",
"type": "{\n required?: Record<string, any>;\n optional?: Record<string, any>;\n }",
"optional": false
},
{
"name": "output",
"type": "string[]",
"optional": false
},
{
"name": "output_name",
"type": "string[]",
"optional": false
}
],
"exported": true,
"heritage": []
},
"WorkflowMetadata": {
"kind": "interface",
"name": "WorkflowMetadata",
"members": [
{
"name": "title",
"type": "string",
"optional": true
},
{
"name": "description",
"type": "string",
"optional": true
},
{
"name": "author",
"type": "string",
"optional": true
},
{
"name": "version",
"type": "string",
"optional": true
},
{
"name": "tags",
"type": "string[]",
"optional": true
},
{
"name": "thumbnail",
"type": "string",
"optional": true
}
],
"exported": true,
"heritage": []
},
"ExtensionMetadata": {
"kind": "interface",
"name": "ExtensionMetadata",
"members": [
{
"name": "id",
"type": "string",
"optional": false
},
{
"name": "name",
"type": "string",
"optional": false
},
{
"name": "version",
"type": "string",
"optional": false
},
{
"name": "description",
"type": "string",
"optional": true
}
],
"exported": true,
"heritage": []
}
},
"enums": {
"NodeStatus": {
"kind": "enum",
"name": "NodeStatus",
"members": [
{
"name": "IDLE",
"value": "\"idle\""
},
{
"name": "QUEUED",
"value": "\"queued\""
},
{
"name": "RUNNING",
"value": "\"running\""
},
{
"name": "ERROR",
"value": "\"error\""
},
{
"name": "COMPLETED",
"value": "\"completed\""
}
],
"exported": true
}
},
"functions": {},
"classes": {
"WorkflowManager": {
"kind": "class",
"name": "WorkflowManager",
"members": [
{
"name": "workflows",
"type": "Map<string, object>",
"visibility": "public"
},
{
"name": "cache",
"type": "Map<string, object>",
"visibility": "public"
}
],
"methods": [
{
"name": "loadWorkflow",
"parameters": [
{
"name": "id",
"type": "string",
"optional": false
}
],
"returnType": "Promise<object>",
"visibility": "public"
},
{
"name": "saveWorkflow",
"parameters": [
{
"name": "id",
"type": "string",
"optional": false
},
{
"name": "data",
"type": "object",
"optional": false
}
],
"returnType": "Promise<void>",
"visibility": "public"
},
{
"name": "deleteWorkflow",
"parameters": [
{
"name": "id",
"type": "string",
"optional": false
}
],
"returnType": "Promise<void>",
"visibility": "public"
},
{
"name": "searchWorkflows",
"parameters": [
{
"name": "query",
"type": "string",
"optional": false
}
],
"returnType": "Promise<object[]>",
"visibility": "public"
}
],
"exported": true,
"heritage": []
}
},
"constants": {}
}

View File

@@ -1,52 +0,0 @@
# Public API Changelog
This changelog documents changes to the ComfyUI Frontend public API surface across versions. The public API surface includes types, interfaces, and objects used by third-party extensions and custom nodes.
**Important**: This is an automatically generated changelog based on TypeScript type definitions. Breaking changes are marked with ⚠️.
## What is tracked
This changelog tracks changes to the following public API components exported from `@comfyorg/comfyui-frontend-types`:
- **Type Aliases**: Type definitions used by extensions
- **Interfaces**: Object shapes and contracts
- **Enums**: Enumerated values
- **Functions**: Public utility functions
- **Classes**: Exported classes and their public members
- **Constants**: Public constant values
## Migration Guide
When breaking changes occur, refer to the specific version section below for:
- What changed
- Why it changed (if applicable)
- How to migrate your code
---
<!-- Automated changelog entries will be added below -->
## v1.32.1 (2025-11-05)
Comparing v1.32.0 → v1.32.1. This changelog documents changes to the public API surface that third-party extensions and custom nodes depend on.
### 🔄 Modifications
> **Note**: Some modifications may be breaking changes.
**Interfaces**
- [`ComfyCommand`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/f844d3e95b52501b308aa399e3765f9ed79918cc/src/stores/commandStore.ts#L10)
- ⚠️ **Breaking**: Member `function` type changed: `() => void | Promise<void>``(metadata?: Record<string, unknown>) => void | Promise<void>`
- [`TemplateInfo`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/f844d3e95b52501b308aa399e3765f9ed79918cc/src/platform/workflow/templates/types/template.ts#L1)
- ✨ Added member: `openSource`
**Classes**
- [`ComfyApp`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/f844d3e95b52501b308aa399e3765f9ed79918cc/src/scripts/app.ts#L123)
- ⚠️ **Breaking**: Method `loadGraphData()` signature changed
- ⚠️ **Breaking**: Method `handleFile()` signature changed
- [`LGraphCanvas`](https://github.com/Comfy-Org/ComfyUI_frontend/blob/f844d3e95b52501b308aa399e3765f9ed79918cc/src/lib/litegraph/src/LGraphCanvas.ts#L250)
- ⚠️ **Breaking**: Method `processMouseDown()` signature changed
---

View File

@@ -58,8 +58,7 @@ export default defineConfig([
'src/extensions/core/*',
'src/scripts/*',
'src/types/generatedManagerTypes.ts',
'src/types/vue-shim.d.ts',
'demo-snapshots/*'
'src/types/vue-shim.d.ts'
]
},
{

View File

@@ -41,9 +41,7 @@ const config: KnipConfig = {
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Demo snapshots for API changelog system
'demo-snapshots/**'
'src/scripts/ui/components/splitButton.ts'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.32.1",
"version": "1.31.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -73,7 +73,6 @@
--color-jade-400: #47e469;
--color-jade-600: #00cd72;
--color-graphite-400: #9C9EAB;
--color-gold-400: #fcbf64;
--color-gold-500: #fdab34;
@@ -158,8 +157,6 @@
--button-surface: var(--color-white);
--button-surface-contrast: var(--color-black);
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
--modal-card-button-surface: var(--color-smoke-300);
/* Code styling colors for help menu*/
@@ -228,7 +225,7 @@
--brand-yellow: var(--color-electric-400);
--brand-blue: var(--color-sapphire-700);
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-selected: var(--color-smoke-600);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);
@@ -242,29 +239,6 @@
--border-subtle: var(--color-smoke-400);
--muted-background: var(--color-smoke-700);
--accent-background: var(--color-smoke-800);
/* Component/Node tokens from design system light */
--component-node-background: var(--color-white);
--component-node-border: var(--color-border-default);
--component-node-foreground: var(--base-foreground);
--component-node-foreground-secondary: var(--color-muted-foreground);
--component-node-widget-background: var(--secondary-background);
--component-node-widget-background-hovered: var(--secondary-background-hover);
--component-node-widget-background-selected: var(--secondary-background-selected);
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
--component-node-widget-background-highlighted: var(--color-ash-500);
/* Default UI element color palette variables */
--palette-contrast-mix-color: #fff;
--palette-interface-panel-surface: var(--comfy-menu-bg);
--palette-interface-stroke: color-mix(in srgb, var(--interface-panel-surface) 75.5%, var(--contrast-mix-color));
--palette-interface-panel-box-shadow: 1px 1px 8px 0 rgb(0 0 0 / 0.4);
--palette-interface-panel-drop-shadow: 1px 1px 4px rgb(0 0 0 / 0.4);
--palette-interface-panel-hover-surface: color-mix(in srgb, var(--interface-panel-surface) 92.5%, var(--contrast-mix-color));
--palette-interface-panel-selected-surface: color-mix(in srgb, var(--interface-panel-surface) 87.5%, var(--contrast-mix-color));
--palette-interface-button-hover-surface: color-mix(in srgb, var(--interface-panel-surface) 82%, var(--contrast-mix-color));
}
.dark-theme {
@@ -284,8 +258,6 @@
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-smoke-800);
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
--modal-card-button-surface: var(--color-charcoal-300);
--dialog-surface: var(--color-neutral-700);
@@ -313,7 +285,7 @@
--node-component-surface-highlight: var(--color-slate-100);
--node-component-surface-hovered: var(--color-charcoal-600);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-charcoal-600);
--node-component-surface: var(--color-charcoal-800);
--node-component-tooltip: var(--color-white);
--node-component-tooltip-border: var(--color-slate-300);
--node-component-tooltip-surface: var(--color-charcoal-800);
@@ -351,17 +323,6 @@
--border-subtle: var(--color-charcoal-300);
--muted-background: var(--color-charcoal-100);
--accent-background: var(--color-charcoal-100);
/* Component/Node tokens from design dark system */
--component-node-background: var(--color-charcoal-600);
--component-node-border: var(--color-charcoal-100);
--component-node-foreground: var(--base-foreground);
--component-node-foreground-secondary: var(--color-muted-foreground);
--component-node-widget-background: var(--secondary-background-hover);
--component-node-widget-background-hovered: var(--secondary-background-selected);
--component-node-widget-background-selected: var(--color-charcoal-100);
--component-node-widget-background-disabled: var(--color-alpha-charcoal-600-30);
--component-node-widget-background-highlighted: var(--color-graphite-400);
}
@theme inline {
@@ -371,7 +332,6 @@
--color-button-icon: var(--button-icon);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-subscription-button-gradient: var(--subscription-button-gradient);
--color-modal-card-button-surface: var(--modal-card-button-surface);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(
@@ -429,17 +389,6 @@
--color-text-primary: var(--text-primary);
--color-input-surface: var(--input-surface);
/* Component/Node design tokens */
--color-component-node-background: var(--component-node-background);
--color-component-node-border: var(--component-node-border);
--color-component-node-foreground: var(--component-node-foreground);
--color-component-node-foreground-secondary: var(--component-node-foreground-secondary);
--color-component-node-widget-background: var(--component-node-widget-background);
--color-component-node-widget-background-hovered: var(--component-node-widget-background-hovered);
--color-component-node-widget-background-selected: var(--component-node-widget-background-selected);
--color-component-node-widget-background-disabled: var(--component-node-widget-background-disabled);
--color-component-node-widget-background-highlighted: var(--component-node-widget-background-highlighted);
/* Semantic tokens */
--color-base-foreground: var(--base-foreground);
--color-muted-foreground: var(--muted-foreground);
@@ -543,40 +492,36 @@ body {
.comfy-markdown {
/* We assign the textarea and the Tiptap editor to the same CSS grid area to stack them on top of one another. */
display: grid;
& > textarea,
.tiptap {
grid-area: 1 / 1 / 2 / 2;
}
}
& > textarea {
opacity: 0;
pointer-events: none;
}
.comfy-markdown > textarea {
grid-area: 1 / 1 / 2 / 2;
}
&.editing {
& > textarea {
opacity: 1;
pointer-events: all;
}
.comfy-markdown .tiptap {
grid-area: 1 / 1 / 2 / 2;
background-color: var(--comfy-input-bg);
color: var(--input-text);
overflow: hidden;
overflow-y: auto;
resize: none;
border: none;
box-sizing: border-box;
font-size: var(--comfy-textarea-font-size);
height: 100%;
padding: 0.5em;
}
.tiptap {
opacity: 0;
pointer-events: none;
}
}
.comfy-markdown.editing .tiptap {
display: none;
}
.tiptap {
overflow-y: auto;
font-size: var(--comfy-textarea-font-size);
.comfy-markdown .tiptap :first-child {
margin-top: 0;
}
:first-child {
margin-top: 0;
}
:last-child {
margin-bottom: 0;
}
}
.comfy-markdown .tiptap :last-child {
margin-bottom: 0;
}
.comfy-markdown .tiptap blockquote {

View File

@@ -1,4 +0,0 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.13727 11.2201L6.27454 14.4398" stroke="var(--pin-color, var(--color-smoke-800, #8a8a8a))" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
<path d="M6.28085 6.68385C6.21652 6.92342 6.08664 7.1403 5.9058 7.31009C5.72497 7.47989 5.50035 7.59587 5.25721 7.645L3.95569 7.91742C3.71254 7.96655 3.48793 8.08253 3.30709 8.25233C3.12626 8.42212 2.99637 8.639 2.93204 8.87857L2.80091 9.36797C2.75515 9.53876 2.7791 9.72073 2.86751 9.87385C2.95591 10.027 3.10153 10.1387 3.27231 10.1845L10.9997 12.255C11.1705 12.3008 11.3525 12.2768 11.5056 12.1884C11.6587 12.1 11.7705 11.9544 11.8162 11.7836L11.9474 11.2942C12.0114 11.0546 12.0074 10.8018 11.9357 10.5643C11.864 10.3269 11.7274 10.1141 11.5414 9.95001L10.5505 9.06333C10.3645 8.89921 10.2279 8.68646 10.1562 8.44899C10.0845 8.21153 10.0805 7.95877 10.1446 7.71913L10.7933 5.29787C10.8391 5.12709 10.9508 4.98148 11.1039 4.89307C11.2571 4.80466 11.439 4.78071 11.6098 4.82647C11.9514 4.91799 12.3153 4.87008 12.6216 4.69327C12.9278 4.51646 13.1513 4.22523 13.2428 3.88366C13.3343 3.54209 13.2864 3.17815 13.1096 2.8719C12.9328 2.56566 12.6416 2.34219 12.3 2.25067L7.1484 0.870299C6.80683 0.778775 6.44289 0.826689 6.13665 1.0035C5.8304 1.18031 5.60694 1.47154 5.51541 1.81311C5.42389 2.15468 5.4718 2.51862 5.64861 2.82487C5.82542 3.13111 6.11665 3.35458 6.45822 3.4461C6.62901 3.49186 6.77462 3.6036 6.86302 3.75672C6.95143 3.90984 6.97539 4.09181 6.92962 4.2626L6.28085 6.68385Z" fill="var(--handle-color, var(--color-gold-600, #fd9903))" />
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -4,13 +4,6 @@ import { addDynamicIconSelectors } from '@iconify/tailwind'
import { iconCollection } from './src/iconCollection'
export default {
theme: {
extend: {
boxShadow: {
interface: 'var(--interface-panel-box-shadow)'
}
}
},
plugins: [
addDynamicIconSelectors({
iconSets: {

File diff suppressed because it is too large Load Diff

6
pnpm-lock.yaml generated
View File

@@ -12,9 +12,15 @@ catalogs:
'@eslint/js':
specifier: ^9.35.0
version: 9.35.0
'@iconify-json/lucide':
specifier: ^1.1.178
version: 1.2.66
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
'@iconify/tailwind':
specifier: ^1.1.3
version: 1.2.0
'@intlify/eslint-plugin-vue-i18n':
specifier: ^4.1.0
version: 4.1.0

View File

@@ -112,7 +112,6 @@ onlyBuiltDependencies:
- '@playwright/browser-chromium'
- '@playwright/browser-firefox'
- '@playwright/browser-webkit'
- '@sentry/cli'
- '@tailwindcss/oxide'
- esbuild
- nx

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

View File

@@ -1,444 +0,0 @@
#!/usr/bin/env node
/**
* Compares two API snapshots and generates a human-readable changelog
* documenting additions, removals, and modifications to the public API.
*/
import * as fs from 'fs'
const args = process.argv.slice(2)
if (args.length < 4) {
console.error(
'Usage: compare-api-snapshots.js <previous.json> <current.json> <previous-version> <current-version> [repo-owner] [repo-name] [git-ref]'
)
process.exit(1)
}
const [
previousPath,
currentPath,
previousVersion,
currentVersion,
repoOwner = 'Comfy-Org',
repoName = 'ComfyUI_frontend',
gitRef = 'main'
] = args
if (!fs.existsSync(previousPath)) {
console.error(`Previous snapshot not found: ${previousPath}`)
process.exit(1)
}
if (!fs.existsSync(currentPath)) {
console.error(`Current snapshot not found: ${currentPath}`)
process.exit(1)
}
const previousApi = JSON.parse(fs.readFileSync(previousPath, 'utf-8'))
const currentApi = JSON.parse(fs.readFileSync(currentPath, 'utf-8'))
/**
* Generate GitHub permalink to source code
* Prefers source file location over dist .d.ts location
*/
function generateGitHubLink(name, item) {
// If we have source file information, use that
if (item?.sourceFile && item?.sourceLine) {
return `[\`${name}\`](https://github.com/${repoOwner}/${repoName}/blob/${gitRef}/${item.sourceFile}#L${item.sourceLine})`
}
// Fallback to .d.ts location if available
if (item?.line) {
return `[\`${name}\`](https://github.com/${repoOwner}/${repoName}/blob/${gitRef}/dist/index.d.ts#L${item.line})`
}
// No location info available
return `\`${name}\``
}
/**
* Compare two API snapshots and generate changelog
*/
function compareApis(previous, current) {
const changes = {
breaking: [],
additions: [],
modifications: [],
deprecations: []
}
const categories = [
'types',
'interfaces',
'enums',
'functions',
'classes',
'constants'
]
for (const category of categories) {
const prevItems = previous[category] || {}
const currItems = current[category] || {}
// Find additions
for (const name in currItems) {
if (!prevItems[name]) {
changes.additions.push({
category,
name,
item: currItems[name]
})
}
}
// Find removals and modifications
for (const name in prevItems) {
if (!currItems[name]) {
changes.breaking.push({
category,
name,
type: 'removed',
item: prevItems[name]
})
} else {
// Check for modifications
const diff = compareItems(prevItems[name], currItems[name], category)
if (diff.length > 0) {
changes.modifications.push({
category,
name,
changes: diff
})
}
}
}
}
return changes
}
/**
* Compare two items and return differences
*/
function compareItems(prev, curr, category) {
const differences = []
if (category === 'interfaces' || category === 'classes') {
// Compare members
const prevMembers = new Map(prev.members?.map((m) => [m.name, m]) || [])
const currMembers = new Map(curr.members?.map((m) => [m.name, m]) || [])
// Find added members
for (const [name, member] of currMembers) {
if (!prevMembers.has(name)) {
differences.push({
type: 'member_added',
name,
member
})
}
}
// Find removed members
for (const [name, member] of prevMembers) {
if (!currMembers.has(name)) {
differences.push({
type: 'member_removed',
name,
member
})
} else {
// Check if member type changed
const prevMember = prevMembers.get(name)
const currMember = currMembers.get(name)
if (prevMember.type !== currMember.type) {
differences.push({
type: 'member_type_changed',
name,
from: prevMember.type,
to: currMember.type
})
}
if (prevMember.optional !== currMember.optional) {
differences.push({
type: 'member_optionality_changed',
name,
from: prevMember.optional ? 'optional' : 'required',
to: currMember.optional ? 'optional' : 'required'
})
}
}
}
// Compare methods (for classes and interfaces)
if (category === 'classes') {
const prevMethods = new Map(prev.methods?.map((m) => [m.name, m]) || [])
const currMethods = new Map(curr.methods?.map((m) => [m.name, m]) || [])
for (const [name, method] of currMethods) {
if (!prevMethods.has(name)) {
differences.push({
type: 'method_added',
name,
method
})
}
}
for (const [name, method] of prevMethods) {
if (!currMethods.has(name)) {
differences.push({
type: 'method_removed',
name,
method
})
} else {
const prevMethod = prevMethods.get(name)
const currMethod = currMethods.get(name)
if (prevMethod.returnType !== currMethod.returnType) {
differences.push({
type: 'method_return_type_changed',
name,
from: prevMethod.returnType,
to: currMethod.returnType
})
}
// Compare parameters
if (
JSON.stringify(prevMethod.parameters) !==
JSON.stringify(currMethod.parameters)
) {
differences.push({
type: 'method_signature_changed',
name,
from: prevMethod.parameters,
to: currMethod.parameters
})
}
}
}
}
} else if (category === 'functions') {
// Compare function signatures
if (prev.returnType !== curr.returnType) {
differences.push({
type: 'return_type_changed',
from: prev.returnType,
to: curr.returnType
})
}
if (JSON.stringify(prev.parameters) !== JSON.stringify(curr.parameters)) {
differences.push({
type: 'parameters_changed',
from: prev.parameters,
to: curr.parameters
})
}
} else if (category === 'enums') {
// Compare enum members
const prevMembers = new Set(prev.members?.map((m) => m.name) || [])
const currMembers = new Set(curr.members?.map((m) => m.name) || [])
for (const member of currMembers) {
if (!prevMembers.has(member)) {
differences.push({
type: 'enum_member_added',
name: member
})
}
}
for (const member of prevMembers) {
if (!currMembers.has(member)) {
differences.push({
type: 'enum_member_removed',
name: member
})
}
}
}
return differences
}
/**
* Format changelog as markdown
*/
function formatChangelog(changes, prevVersion, currVersion) {
const lines = []
lines.push(`## v${currVersion} (${new Date().toISOString().split('T')[0]})`)
lines.push('')
lines.push(
`Comparing v${prevVersion} → v${currVersion}. This changelog documents changes to the public API surface that third-party extensions and custom nodes depend on.`
)
lines.push('')
// Breaking changes
if (changes.breaking.length > 0) {
lines.push('### ⚠️ Breaking Changes')
lines.push('')
const grouped = groupByCategory(changes.breaking)
for (const [category, items] of Object.entries(grouped)) {
lines.push(`**${categoryToTitle(category)}**`)
lines.push('')
for (const item of items) {
const displayName = generateGitHubLink(item.name, item.item)
lines.push(`- **Removed**: ${displayName}`)
}
lines.push('')
}
}
// Additions - commented out as per feedback
// if (changes.additions.length > 0) {
// lines.push('### ✨ Additions')
// lines.push('')
//
// const grouped = groupByCategory(changes.additions)
// for (const [category, items] of Object.entries(grouped)) {
// lines.push(`**${categoryToTitle(category)}**`)
// lines.push('')
// for (const item of items) {
// lines.push(`- \`${item.name}\``)
// if (item.item.members && item.item.members.length > 0) {
// const publicMembers = item.item.members.filter(
// (m) => !m.visibility || m.visibility === 'public'
// )
// if (publicMembers.length > 0 && publicMembers.length <= 5) {
// lines.push(
// ` - Members: ${publicMembers.map((m) => `\`${m.name}\``).join(', ')}`
// )
// }
// }
// }
// lines.push('')
// }
// }
// Modifications
if (changes.modifications.length > 0) {
lines.push('### 🔄 Modifications')
lines.push('')
const hasBreakingMods = changes.modifications.some((mod) =>
mod.changes.some((c) => isBreakingChange(c))
)
if (hasBreakingMods) {
lines.push('> **Note**: Some modifications may be breaking changes.')
lines.push('')
}
const grouped = groupByCategory(changes.modifications)
for (const [category, items] of Object.entries(grouped)) {
lines.push(`**${categoryToTitle(category)}**`)
lines.push('')
for (const item of items) {
// Get the current item to access source location
const currItem =
currentApi[item.category] && currentApi[item.category][item.name]
const displayName = generateGitHubLink(item.name, currItem)
lines.push(`- ${displayName}`)
for (const change of item.changes) {
const formatted = formatChange(change)
if (formatted) {
lines.push(` ${formatted}`)
}
}
}
lines.push('')
}
}
if (changes.breaking.length === 0 && changes.modifications.length === 0) {
lines.push('_No API changes detected._')
lines.push('')
}
lines.push('---')
lines.push('')
return lines.join('\n')
}
function groupByCategory(items) {
const grouped = {}
for (const item of items) {
if (!grouped[item.category]) {
grouped[item.category] = []
}
grouped[item.category].push(item)
}
return grouped
}
function categoryToTitle(category) {
const titles = {
types: 'Type Aliases',
interfaces: 'Interfaces',
enums: 'Enums',
functions: 'Functions',
classes: 'Classes',
constants: 'Constants'
}
return titles[category] || category
}
function isBreakingChange(change) {
const breakingTypes = [
'member_removed',
'method_removed',
'member_type_changed',
'method_return_type_changed',
'method_signature_changed',
'return_type_changed',
'parameters_changed',
'enum_member_removed'
]
return breakingTypes.includes(change.type)
}
function formatChange(change) {
switch (change.type) {
case 'member_added':
return `- ✨ Added member: \`${change.name}\``
case 'member_removed':
return `- ⚠️ **Breaking**: Removed member: \`${change.name}\``
case 'member_type_changed':
return `- ⚠️ **Breaking**: Member \`${change.name}\` type changed: \`${change.from}\`\`${change.to}\``
case 'member_optionality_changed':
return `- ${change.to === 'required' ? '⚠️ **Breaking**' : '✨'}: Member \`${change.name}\` is now ${change.to}`
case 'method_added':
return `- ✨ Added method: \`${change.name}()\``
case 'method_removed':
return `- ⚠️ **Breaking**: Removed method: \`${change.name}()\``
case 'method_return_type_changed':
return `- ⚠️ **Breaking**: Method \`${change.name}()\` return type changed: \`${change.from}\`\`${change.to}\``
case 'method_signature_changed':
return `- ⚠️ **Breaking**: Method \`${change.name}()\` signature changed`
case 'return_type_changed':
return `- ⚠️ **Breaking**: Return type changed: \`${change.from}\`\`${change.to}\``
case 'parameters_changed':
return `- ⚠️ **Breaking**: Function parameters changed`
case 'enum_member_added':
return `- ✨ Added enum value: \`${change.name}\``
case 'enum_member_removed':
return `- ⚠️ **Breaking**: Removed enum value: \`${change.name}\``
default:
return null
}
}
// Main execution
const changes = compareApis(previousApi, currentApi)
const changelog = formatChangelog(changes, previousVersion, currentVersion)
console.log(changelog)

View File

@@ -1,313 +0,0 @@
#!/usr/bin/env node
/**
* Generates a JSON snapshot of the public API surface from TypeScript definitions.
* This snapshot is used to track API changes between versions.
*/
import { execSync } from 'child_process'
import * as fs from 'fs'
import * as path from 'path'
import * as ts from 'typescript'
const args = process.argv.slice(2)
if (args.length === 0) {
console.error('Usage: snapshot-api.js <path-to-index.d.ts>')
process.exit(1)
}
const filePath = args[0]
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`)
process.exit(1)
}
/**
* Search for the declaration in source files
* Returns {file, line} or null if not found
*/
function findInSourceFiles(declarationName, kind, sourceRoot = 'src') {
const searchPattern = getSearchPattern(declarationName, kind)
if (!searchPattern) return null
try {
// Search for the declaration pattern in source files
const result = execSync(
`grep -rn "${searchPattern}" ${sourceRoot} --include="*.ts" --include="*.tsx" | head -1`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
).trim()
if (result) {
// Parse grep output: filepath:line:content
const match = result.match(/^([^:]+):(\d+):/)
if (match) {
return {
file: match[1],
line: parseInt(match[2], 10)
}
}
}
} catch (error) {
// grep returns non-zero exit code if no match found
}
return null
}
/**
* Generate search pattern for finding declaration in source
*/
function getSearchPattern(name, kind) {
switch (kind) {
case 'interface':
return `export interface ${name}`
case 'class':
return `export class ${name}`
case 'type':
return `export type ${name}`
case 'enum':
return `export enum ${name}`
case 'function':
return `export function ${name}`
case 'constant':
return `export const ${name}`
default:
return null
}
}
/**
* Extract API surface from TypeScript definitions
*/
function extractApiSurface(sourceFile) {
const api = {
types: {},
interfaces: {},
enums: {},
functions: {},
classes: {},
constants: {}
}
function visit(node) {
// Extract type aliases
if (ts.isTypeAliasDeclaration(node) && node.name) {
const name = node.name.text
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
const sourceLocation = findInSourceFiles(name, 'type')
api.types[name] = {
kind: 'type',
name,
text: node.getText(sourceFile),
exported: hasExportModifier(node),
line: line + 1, // Convert to 1-indexed
sourceFile: sourceLocation?.file,
sourceLine: sourceLocation?.line
}
}
// Extract interfaces
if (ts.isInterfaceDeclaration(node) && node.name) {
const name = node.name.text
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
const members = []
node.members.forEach((member) => {
if (ts.isPropertySignature(member) && member.name) {
members.push({
name: member.name.getText(sourceFile),
type: member.type ? member.type.getText(sourceFile) : 'any',
optional: !!member.questionToken
})
} else if (ts.isMethodSignature(member) && member.name) {
members.push({
name: member.name.getText(sourceFile),
kind: 'method',
parameters: member.parameters.map((p) => ({
name: p.name.getText(sourceFile),
type: p.type ? p.type.getText(sourceFile) : 'any',
optional: !!p.questionToken
})),
returnType: member.type ? member.type.getText(sourceFile) : 'void'
})
}
})
const sourceLocation = findInSourceFiles(name, 'interface')
api.interfaces[name] = {
kind: 'interface',
name,
members,
exported: hasExportModifier(node),
heritage: node.heritageClauses
? node.heritageClauses
.map((clause) =>
clause.types.map((type) => type.getText(sourceFile))
)
.flat()
: [],
line: line + 1, // Convert to 1-indexed
sourceFile: sourceLocation?.file,
sourceLine: sourceLocation?.line
}
}
// Extract enums
if (ts.isEnumDeclaration(node) && node.name) {
const name = node.name.text
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
const members = node.members.map((member) => ({
name: member.name.getText(sourceFile),
value: member.initializer
? member.initializer.getText(sourceFile)
: undefined
}))
const sourceLocation = findInSourceFiles(name, 'enum')
api.enums[name] = {
kind: 'enum',
name,
members,
exported: hasExportModifier(node),
line: line + 1, // Convert to 1-indexed
sourceFile: sourceLocation?.file,
sourceLine: sourceLocation?.line
}
}
// Extract functions
if (ts.isFunctionDeclaration(node) && node.name) {
const name = node.name.text
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
const sourceLocation = findInSourceFiles(name, 'function')
api.functions[name] = {
kind: 'function',
name,
parameters: node.parameters.map((p) => ({
name: p.name.getText(sourceFile),
type: p.type ? p.type.getText(sourceFile) : 'any',
optional: !!p.questionToken
})),
returnType: node.type ? node.type.getText(sourceFile) : 'any',
exported: hasExportModifier(node),
line: line + 1, // Convert to 1-indexed
sourceFile: sourceLocation?.file,
sourceLine: sourceLocation?.line
}
}
// Extract classes
if (ts.isClassDeclaration(node) && node.name) {
const name = node.name.text
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
const members = []
const methods = []
node.members.forEach((member) => {
if (ts.isPropertyDeclaration(member) && member.name) {
members.push({
name: member.name.getText(sourceFile),
type: member.type ? member.type.getText(sourceFile) : 'any',
static: hasStaticModifier(member),
visibility: getVisibility(member)
})
} else if (ts.isMethodDeclaration(member) && member.name) {
methods.push({
name: member.name.getText(sourceFile),
parameters: member.parameters.map((p) => ({
name: p.name.getText(sourceFile),
type: p.type ? p.type.getText(sourceFile) : 'any',
optional: !!p.questionToken
})),
returnType: member.type ? member.type.getText(sourceFile) : 'any',
static: hasStaticModifier(member),
visibility: getVisibility(member)
})
}
})
const sourceLocation = findInSourceFiles(name, 'class')
api.classes[name] = {
kind: 'class',
name,
members,
methods,
exported: hasExportModifier(node),
heritage: node.heritageClauses
? node.heritageClauses
.map((clause) =>
clause.types.map((type) => type.getText(sourceFile))
)
.flat()
: [],
line: line + 1, // Convert to 1-indexed
sourceFile: sourceLocation?.file,
sourceLine: sourceLocation?.line
}
}
// Extract variable declarations (constants)
if (ts.isVariableStatement(node)) {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart())
node.declarationList.declarations.forEach((decl) => {
if (decl.name && ts.isIdentifier(decl.name)) {
const name = decl.name.text
const sourceLocation = findInSourceFiles(name, 'constant')
api.constants[name] = {
kind: 'constant',
name,
type: decl.type ? decl.type.getText(sourceFile) : 'unknown',
exported: hasExportModifier(node),
line: line + 1, // Convert to 1-indexed
sourceFile: sourceLocation?.file,
sourceLine: sourceLocation?.line
}
}
})
}
ts.forEachChild(node, visit)
}
function hasExportModifier(node) {
return (
node.modifiers &&
node.modifiers.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)
)
}
function hasStaticModifier(node) {
return (
node.modifiers &&
node.modifiers.some((mod) => mod.kind === ts.SyntaxKind.StaticKeyword)
)
}
function getVisibility(node) {
if (!node.modifiers) return 'public'
if (node.modifiers.some((mod) => mod.kind === ts.SyntaxKind.PrivateKeyword))
return 'private'
if (
node.modifiers.some((mod) => mod.kind === ts.SyntaxKind.ProtectedKeyword)
)
return 'protected'
return 'public'
}
visit(sourceFile)
return api
}
// Read and parse the file
const sourceCode = fs.readFileSync(filePath, 'utf-8')
const sourceFile = ts.createSourceFile(
path.basename(filePath),
sourceCode,
ts.ScriptTarget.Latest,
true
)
const apiSurface = extractApiSurface(sourceFile)
// Output as JSON
console.log(JSON.stringify(apiSurface, null, 2))

View File

@@ -52,7 +52,7 @@
"comfy_base": {
"fg-color": "#fff",
"bg-color": "#202020",
"comfy-menu-bg": "#11141a",
"comfy-menu-bg": "#353535",
"comfy-menu-secondary-bg": "#303030",
"comfy-input-bg": "#222",
"input-text": "#ddd",

View File

@@ -68,12 +68,7 @@
"content-fg": "#222",
"content-hover-bg": "#adadad",
"content-hover-fg": "#222",
"bar-shadow": "rgba(16, 16, 16, 0.25) 0 0 0.5rem",
"interface-panel-box-shadow": "1px 1px 8px 0 rgba(0, 0, 0, 0.2)",
"interface-panel-drop-shadow": "1px 1px 4px rgba(0, 0, 0, 0.4)",
"interface-panel-hover-surface": "var(--color-gray-200)",
"interface-panel-selected-surface": "color-mix(in srgb, var(--interface-panel-surface) 78%, var(--contrast-mix-color))",
"contrast-mix-color": "#000"
"bar-shadow": "rgba(16, 16, 16, 0.25) 0 0 0.5rem"
}
}
}

View File

@@ -5,7 +5,7 @@
</div>
<div
class="actionbar-container pointer-events-auto mx-1 flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
class="actionbar-container pointer-events-auto mx-1 flex h-12 items-center rounded-lg px-2 shadow-md"
>
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -48,5 +48,6 @@ onMounted(() => {
<style scoped>
.actionbar-container {
background-color: var(--comfy-menu-bg);
border: 1px solid var(--p-panel-border-color);
}
</style>

View File

@@ -48,7 +48,6 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
@@ -133,15 +132,6 @@ watch(visible, async (newVisible) => {
}
})
/**
* Track run button handle drag start using mousedown on the drag handle.
*/
useEventListener(dragHandleRef, 'mousedown', () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'actionbar_run_handle_drag_start'
})
})
const lastDragState = ref({
x: x.value,
y: y.value,
@@ -268,9 +258,7 @@ const panelClass = computed(() =>
cn(
'actionbar pointer-events-auto z1000',
isDragging.value && 'select-none pointer-events-none',
isDocked.value
? 'p-0 static mr-2 border-none bg-transparent'
: 'fixed shadow-interface'
isDocked.value ? 'p-0 static mr-2 border-none bg-transparent' : 'fixed'
)
)
</script>

View File

@@ -100,7 +100,7 @@ import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const { mode: queueMode } = storeToRefs(useQueueSettingsStore())
const { t } = useI18n()
const queueModeMenuItemLookup = computed(() => {
@@ -118,9 +118,6 @@ const queueModeMenuItemLookup = computed(() => {
label: `${t('menu.run')} (${t('menu.onChange')})`,
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected'
})
queueMode.value = 'change'
}
}
@@ -131,9 +128,6 @@ const queueModeMenuItemLookup = computed(() => {
label: `${t('menu.run')} (${t('menu.instant')})`,
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
})
queueMode.value = 'instant'
}
}
@@ -164,18 +158,11 @@ const queuePrompt = async (e: Event) => {
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
})
if (isCloud) {
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
}
await commandStore.execute(commandId, {
metadata: {
subscribe_to_run: false,
trigger_source: 'button'
}
})
await commandStore.execute(commandId)
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="subgraph-breadcrumb w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
class="subgraph-breadcrumb w-auto drop-shadow-md"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
@@ -40,7 +40,6 @@ import { computed, onUpdated, ref, watch } from 'vue'
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
@@ -74,9 +73,6 @@ const items = computed(() => {
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -101,9 +97,6 @@ const home = computed(() => ({
key: 'root',
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -208,8 +201,8 @@ onUpdated(() => {
:deep(.p-breadcrumb-separator),
:deep(.p-breadcrumb-item) {
@apply h-12;
border-top: 1px solid var(--interface-stroke);
border-bottom: 1px solid var(--interface-stroke);
border-top: 1px solid var(--p-panel-border-color);
border-bottom: 1px solid var(--p-panel-border-color);
background-color: var(--comfy-menu-bg);
}
@@ -221,7 +214,7 @@ onUpdated(() => {
@apply rounded-l-lg;
/* Then collapse the root workflow */
flex-shrink: 5000;
border-left: 1px solid var(--interface-stroke);
border-left: 1px solid var(--p-panel-border-color);
.p-breadcrumb-item-link {
padding-left: var(--p-breadcrumb-item-padding);
@@ -232,7 +225,7 @@ onUpdated(() => {
@apply rounded-r-lg;
/* Then collapse the active item */
flex-shrink: 1;
border-right: 1px solid var(--interface-stroke);
border-right: 1px solid var(--p-panel-border-color);
}
:deep(.p-breadcrumb-item-link:hover),

View File

@@ -56,7 +56,7 @@ describe('UserAvatar', () => {
const avatar = wrapper.findComponent(Avatar)
expect(avatar.exists()).toBe(true)
expect(avatar.props('image')).toBeNull()
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
expect(avatar.props('icon')).toBe('pi pi-user')
})
it('renders with default icon when provided photo Url is null', () => {
@@ -67,7 +67,7 @@ describe('UserAvatar', () => {
const avatar = wrapper.findComponent(Avatar)
expect(avatar.exists()).toBe(true)
expect(avatar.props('image')).toBeNull()
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
expect(avatar.props('icon')).toBe('pi pi-user')
})
it('falls back to icon when image fails to load', async () => {
@@ -82,7 +82,7 @@ describe('UserAvatar', () => {
avatar.vm.$emit('error')
await nextTick()
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
expect(avatar.props('icon')).toBe('pi pi-user')
})
it('uses provided ariaLabel', () => {

View File

@@ -1,9 +1,7 @@
<template>
<Avatar
class="bg-gray-200 dark-theme:bg-[var(--interface-panel-selected-surface)]"
:image="photoUrl ?? undefined"
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
:pt:icon:class="{ 'size-4': !hasAvatar }"
:icon="hasAvatar ? undefined : 'pi pi-user'"
shape="circle"
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
@error="handleImageError"

View File

@@ -68,17 +68,17 @@
</template>
</MultiSelect>
<!-- Runs On Filter -->
<!-- License Filter -->
<MultiSelect
v-model="selectedRunsOnObjects"
:label="runsOnFilterLabel"
:options="runsOnOptions"
v-model="selectedLicenseObjects"
:label="licenseFilterLabel"
:options="licenseOptions"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
>
<template #icon>
<i class="icon-[lucide--server]" />
<i class="icon-[lucide--file-text]" />
</template>
</MultiSelect>
</div>
@@ -382,7 +382,7 @@
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
@@ -403,8 +403,6 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
@@ -414,34 +412,10 @@ import { createGridStyle } from '@/utils/gridUtil'
const { t } = useI18n()
const { onClose: originalOnClose } = defineProps<{
const { onClose } = defineProps<{
onClose: () => void
}>()
// Track session time for telemetry
const sessionStartTime = ref<number>(0)
const templateWasSelected = ref(false)
onMounted(() => {
sessionStartTime.value = Date.now()
})
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
const timeSpentSeconds = Math.floor(
(Date.now() - sessionStartTime.value) / 1000
)
useTelemetry()?.trackTemplateLibraryClosed({
template_selected: templateWasSelected.value,
time_spent_seconds: timeSpentSeconds
})
}
originalOnClose()
}
provide(OnCloseKey, onClose)
// Workflow templates store and composable
@@ -528,12 +502,12 @@ const {
searchQuery,
selectedModels,
selectedUseCases,
selectedRunsOn,
selectedLicenses,
sortBy,
filteredTemplates,
availableModels,
availableUseCases,
availableRunsOn,
availableLicenses,
filteredCount,
totalCount,
resetFilters
@@ -561,15 +535,15 @@ const selectedUseCaseObjects = computed({
}
})
const selectedRunsOnObjects = computed({
const selectedLicenseObjects = computed({
get() {
return selectedRunsOn.value.map((runsOn) => ({
name: runsOn,
value: runsOn
return selectedLicenses.value.map((license) => ({
name: license,
value: license
}))
},
set(value: { name: string; value: string }[]) {
selectedRunsOn.value = value.map((item) => item.value)
selectedLicenses.value = value.map((item) => item.value)
}
})
@@ -602,10 +576,10 @@ const useCaseOptions = computed(() =>
}))
)
const runsOnOptions = computed(() =>
availableRunsOn.value.map((runsOn) => ({
name: runsOn,
value: runsOn
const licenseOptions = computed(() =>
availableLicenses.value.map((license) => ({
name: license,
value: license
}))
)
@@ -634,14 +608,14 @@ const useCaseFilterLabel = computed(() => {
}
})
const runsOnFilterLabel = computed(() => {
if (selectedRunsOnObjects.value.length === 0) {
return t('templateWorkflows.runsOnFilter', 'Runs On')
} else if (selectedRunsOnObjects.value.length === 1) {
return selectedRunsOnObjects.value[0].name
const licenseFilterLabel = computed(() => {
if (selectedLicenseObjects.value.length === 0) {
return t('templateWorkflows.licenseFilter', 'License')
} else if (selectedLicenseObjects.value.length === 1) {
return selectedLicenseObjects.value[0].name
} else {
return t('templateWorkflows.runsOnSelected', {
count: selectedRunsOnObjects.value.length
return t('templateWorkflows.licensesSelected', {
count: selectedLicenseObjects.value.length
})
}
})
@@ -708,7 +682,7 @@ watch(
sortBy,
selectedModels,
selectedUseCases,
selectedRunsOn
selectedLicenses
],
() => {
resetPagination()
@@ -726,7 +700,6 @@ const onLoadWorkflow = async (template: any) => {
template.name,
getEffectiveSourceModule(template)
)
templateWasSelected.value = true
onClose()
} finally {
loadingTemplate.value = null

View File

@@ -61,7 +61,6 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useTelemetry } from '@/platform/telemetry'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
@@ -87,33 +86,18 @@ const repoOwner = 'comfyanonymous'
const repoName = 'ComfyUI'
const reportContent = ref('')
const reportOpen = ref(false)
/**
* Open the error report content and track telemetry.
*/
const showReport = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_show_report_clicked'
})
reportOpen.value = true
}
const toast = useToast()
const { t } = useI18n()
const systemStatsStore = useSystemStatsStore()
const telemetry = useTelemetry()
const title = computed<string>(
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
)
/**
* Open contact support flow from error dialog and track telemetry.
*/
const showContactSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
await useCommandStore().execute('Comfy.ContactSupport')
}

View File

@@ -11,8 +11,6 @@
import Button from 'primevue/button'
import { computed } from 'vue'
import { useTelemetry } from '@/platform/telemetry'
const props = defineProps<{
errorMessage: string
repoOwner: string
@@ -21,13 +19,7 @@ const props = defineProps<{
const queryString = computed(() => props.errorMessage + ' is:issue')
/**
* Open GitHub issues search and track telemetry.
*/
const openGitHubIssues = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`
window.open(url, '_blank')

View File

@@ -15,7 +15,7 @@
<UserCredit text-class="text-3xl font-bold" />
<Skeleton v-if="loading" width="2rem" height="2rem" />
<Button
v-else-if="isActiveSubscription"
v-else
:label="$t('credits.purchaseCredits')"
:loading="loading"
@click="handlePurchaseCreditsClick"
@@ -92,13 +92,6 @@
icon="pi pi-question-circle"
@click="handleFaqClick"
/>
<Button
:label="$t('subscription.partnerNodesCredits')"
text
severity="secondary"
icon="pi pi-question-circle"
@click="handleOpenPartnerNodesInfo"
/>
<Button
:label="$t('credits.messageSupport')"
text
@@ -123,8 +116,6 @@ import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -141,8 +132,6 @@ const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useSubscription()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)
@@ -164,8 +153,6 @@ watch(
)
const handlePurchaseCreditsClick = () => {
// Track purchase credits entry from Settings > Credits panel
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
}
@@ -174,11 +161,6 @@ const handleCreditsHistoryClick = async () => {
}
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
}
@@ -186,12 +168,5 @@ const handleFaqClick = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
}
const handleOpenPartnerNodesInfo = () => {
window.open(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
'_blank'
)
}
const creditHistory = ref<CreditHistoryItemData[]>([])
</script>

View File

@@ -96,7 +96,6 @@ import Message from 'primevue/message'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
import { useTelemetry } from '@/platform/telemetry'
import type { AuditLog } from '@/services/customerEventsService'
import {
EventType,
@@ -160,9 +159,6 @@ const loadEvents = async () => {
if (response.totalPages) {
pagination.value.totalPages = response.totalPages
}
// Check if a pending top-up has completed
useTelemetry()?.checkForCompletedTopup(response.events)
} else {
error.value = customerEventService.error.value || 'Failed to load events'
}

View File

@@ -10,10 +10,8 @@
></div>
<ButtonGroup
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-[var(--interface-stroke)] bg-interface-panel-surface p-2"
:style="{
...stringifiedMinimapStyles.buttonGroupStyles
}"
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
:style="stringifiedMinimapStyles.buttonGroupStyles"
@wheel="canvasInteractions.handleWheel"
>
<CanvasModeSelector
@@ -63,7 +61,7 @@
data-testid="toggle-minimap-button"
:style="stringifiedMinimapStyles.buttonStyles"
:class="minimapButtonClass"
@click="onMinimapToggleClick"
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
>
<template #icon>
<i class="icon-[lucide--map] h-4 w-4" />
@@ -84,7 +82,7 @@
:aria-label="linkVisibilityAriaLabel"
data-testid="toggle-link-visibility-button"
:style="stringifiedMinimapStyles.buttonStyles"
@click="onLinkVisibilityToggleClick"
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
>
<template #icon>
<i class="icon-[lucide--route-off] h-4 w-4" />
@@ -103,7 +101,6 @@ import { useI18n } from 'vue-i18n'
import { useZoomControls } from '@/composables/useZoomControls'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
@@ -221,26 +218,6 @@ onMounted(() => {
canvasStore.initScaleSync()
})
/**
* Track minimap toggle button click and execute the command.
*/
const onMinimapToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_minimap_toggle_clicked'
})
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
}
/**
* Track hide/show links button click and execute the command.
*/
const onLinkVisibilityToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_hide_links_toggle_clicked'
})
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
}
onBeforeUnmount(() => {
canvasStore.cleanupScaleSync()
})

View File

@@ -11,7 +11,7 @@
@click="toggleBypass"
>
<template #icon>
<i class="icon-[lucide--redo-dot] h-4 w-4" />
<i class="icon-[lucide--ban] h-4 w-4" />
</template>
</Button>
</template>

View File

@@ -7,7 +7,7 @@
data-testid="info-button"
text
severity="secondary"
@click="onInfoClick"
@click="toggleHelp"
>
<i class="icon-[lucide--info] h-4 w-4" />
</Button>
@@ -17,17 +17,6 @@
import Button from 'primevue/button'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useTelemetry } from '@/platform/telemetry'
const { showNodeHelp: toggleHelp } = useSelectionState()
/**
* Track node info button click and toggle node help.
*/
const onInfoClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
})
toggleHelp()
}
</script>

View File

@@ -27,33 +27,7 @@ const props = defineProps<{
const executionStore = useExecutionStore()
const isParentNodeExecuting = ref(true)
const formattedText = computed(() => {
const src = modelValue.value
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
const tokens: { label: string; url: string }[] = []
const holed = src.replace(
/\[\[([^|\]]+)\|([^\]]+)\]\]/g,
(_m, label, url) => {
tokens.push({ label: String(label), url: String(url) })
return `__LNK${tokens.length - 1}__`
}
)
// Keep current behavior (auto-link bare URLs + \n -> <br>)
let html = nl2br(linkifyHtml(holed))
// Restore placeholders as <a>...</a> (minimal escaping + http default)
html = html.replace(/__LNK(\d+)__/g, (_m, i) => {
const { label, url } = tokens[+i]
const safeHref = url.replace(/"/g, '&quot;')
const safeLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;')
return /^https?:\/\//i.test(url)
? `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`
: safeLabel
})
return html
})
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
let parentNodeId: NodeId | null = null
onMounted(() => {

View File

@@ -138,14 +138,13 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import type { CSSProperties, Component } from 'vue'
import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -197,7 +196,6 @@ const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const telemetry = useTelemetry()
// Emits
const emit = defineEmits<{
@@ -209,7 +207,6 @@ const isSubmenuVisible = ref(false)
const submenuRef = ref<HTMLElement | null>(null)
const submenuStyle = ref<CSSProperties>({})
let hoverTimeout: number | null = null
const openedAt = ref<number>(Date.now())
// Computed
const hasReleases = computed(() => releaseStore.releases.length > 0)
@@ -229,7 +226,6 @@ const moreItems = computed<MenuItem[]>(() => {
label: t('helpCenter.desktopUserGuide'),
visible: isElectron(),
action: () => {
trackResourceClick('docs', true)
const docsUrl =
electronAPI().getPlatform() === 'darwin'
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
@@ -285,7 +281,6 @@ const menuItems = computed<MenuItem[]>(() => {
icon: 'pi pi-book',
label: t('helpCenter.docs'),
action: () => {
trackResourceClick('docs', true)
openExternalLink(EXTERNAL_LINKS.DOCS)
emit('close')
}
@@ -296,7 +291,6 @@ const menuItems = computed<MenuItem[]>(() => {
icon: 'pi pi-discord',
label: 'Discord',
action: () => {
trackResourceClick('discord', true)
openExternalLink(EXTERNAL_LINKS.DISCORD)
emit('close')
}
@@ -307,7 +301,6 @@ const menuItems = computed<MenuItem[]>(() => {
icon: 'pi pi-github',
label: t('helpCenter.github'),
action: () => {
trackResourceClick('github', true)
openExternalLink(EXTERNAL_LINKS.GITHUB)
emit('close')
}
@@ -318,7 +311,6 @@ const menuItems = computed<MenuItem[]>(() => {
icon: 'pi pi-question-circle',
label: t('helpCenter.helpFeedback'),
action: () => {
trackResourceClick('help_feedback', false)
void commandStore.execute('Comfy.ContactSupport')
emit('close')
}
@@ -334,7 +326,6 @@ const menuItems = computed<MenuItem[]>(() => {
label: t('helpCenter.managerExtension'),
showRedDot: shouldShowManagerRedDot.value,
action: async () => {
trackResourceClick('manager', false)
await useManagerState().openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
@@ -358,23 +349,6 @@ const menuItems = computed<MenuItem[]>(() => {
})
// Utility Functions
const trackResourceClick = (
resourceType:
| 'docs'
| 'discord'
| 'github'
| 'help_feedback'
| 'manager'
| 'release_notes',
isExternal: boolean
): void => {
telemetry?.trackHelpResourceClicked({
resource_type: resourceType,
is_external: isExternal,
source: 'help_center'
})
}
const openExternalLink = (url: string): void => {
window.open(url, '_blank', 'noopener,noreferrer')
}
@@ -530,7 +504,6 @@ const onReinstall = (): void => {
}
const onReleaseClick = (release: ReleaseNote): void => {
trackResourceClick('release_notes', true)
void releaseStore.handleShowChangelog(release.version)
const versionAnchor = formatVersionAnchor(release.version)
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
@@ -539,7 +512,6 @@ const onReleaseClick = (release: ReleaseNote): void => {
}
const onUpdate = (_: ReleaseNote): void => {
trackResourceClick('docs', true)
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
emit('close')
}
@@ -554,16 +526,10 @@ const getChangelogUrl = (): string => {
// Lifecycle
onMounted(async () => {
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
if (!hasReleases.value) {
await releaseStore.fetchReleases()
}
})
onBeforeUnmount(() => {
const timeSpentSeconds = Math.round((Date.now() - openedAt.value) / 1000)
telemetry?.trackHelpCenterClosed({ time_spent_seconds: timeSpentSeconds })
})
</script>
<style scoped>

View File

@@ -4,11 +4,13 @@
:width="size"
:height="size"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.8193 0.600586C15.1248 0.600586 15.3296 0.70893 15.459 0.881836C15.5914 1.05888 15.6471 1.33774 15.5527 1.66895L14.8037 4.30176C14.7063 4.64386 14.4729 4.97024 14.1641 5.21191C13.8544 5.45415 13.496 5.58984 13.1699 5.58984H13.1689L9.5791 5.59668H7.90625C7.52654 5.59668 7.19496 5.84986 7.09082 6.21289L5.69434 11.0889C5.63007 11.3133 5.66134 11.5534 5.77734 11.7529L5.83203 11.8359C5.99177 12.0491 6.24252 12.1758 6.50977 12.1758H6.51074L8.88281 12.1709H11.4971C11.7643 12.171 11.9541 12.254 12.084 12.3906L12.1357 12.4521C12.2685 12.6295 12.3249 12.9089 12.2305 13.2402L11.4805 15.8721C11.383 16.2144 11.1498 16.5415 10.8408 16.7832C10.5314 17.0252 10.1736 17.161 9.84766 17.1611H9.84668L6.25684 17.168H3.64258C3.33762 17.1679 3.13349 17.0588 3.00391 16.8857C2.87135 16.7087 2.81482 16.43 2.90918 16.0986L3.39551 14.3887C3.46841 14.1327 3.41794 13.8576 3.25879 13.6445V13.6436C3.09901 13.4303 2.84745 13.3037 2.58008 13.3037H1.18066C0.875088 13.3037 0.670398 13.1953 0.541016 13.0225C0.408483 12.8451 0.351891 12.5655 0.446289 12.2344L2.11914 6.38965L2.30371 5.74707V5.74609C2.40139 5.40341 2.63456 5.07671 2.94336 4.83496C3.25302 4.59258 3.61143 4.45705 3.9375 4.45703H5.6123C5.94484 4.45703 6.24083 4.26316 6.37891 3.9707L6.42773 3.83984L6.98145 1.89551C7.07894 1.55317 7.31212 1.22614 7.62109 0.984375C7.93074 0.742127 8.2892 0.606445 8.61523 0.606445H8.61621L12.1982 0.600586H14.8193Z"
v-bind="attributes"
:stroke="color"
stroke-width="1"
/>
</svg>
</template>
@@ -20,18 +22,11 @@ interface Props {
size?: number | string
color?: string
class?: string
mode?: 'outline' | 'fill'
}
const {
size = 16,
color = 'currentColor',
mode = 'outline',
class: className
} = defineProps<Props>()
const iconClass = computed(() => className || '')
const attributes = computed(() => ({
stroke: mode === 'outline' ? color : undefined,
strokeWidth: mode === 'outline' ? 1 : undefined,
fill: mode === 'fill' ? color : 'none'
}))
</script>

View File

@@ -1,48 +1,71 @@
<template>
<div
class="widget-expands relative h-full w-full"
class="relative h-full w-full"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<Load3DScene
v-if="node"
ref="load3DSceneRef"
:initialize-load3d="initializeLoad3d"
:cleanup="cleanup"
:loading="loading"
:loading-message="loadingMessage"
:on-model-drop="isPreview ? undefined : handleModelDrop"
:is-preview="isPreview"
:node="node"
:input-spec="inputSpec"
:background-color="backgroundColor"
:show-grid="showGrid"
:light-intensity="lightIntensity"
:fov="fov"
:camera-type="cameraType"
:show-preview="showPreview"
:background-image="backgroundImage"
:up-direction="upDirection"
:material-mode="materialMode"
:edge-threshold="edgeThreshold"
@material-mode-change="listenMaterialModeChange"
@background-color-change="listenBackgroundColorChange"
@light-intensity-change="listenLightIntensityChange"
@fov-change="listenFOVChange"
@camera-type-change="listenCameraTypeChange"
@show-grid-change="listenShowGridChange"
@show-preview-change="listenShowPreviewChange"
@background-image-change="listenBackgroundImageChange"
@up-direction-change="listenUpDirectionChange"
@edge-threshold-change="listenEdgeThresholdChange"
@recording-status-change="listenRecordingStatusChange"
/>
<Load3DControls
:input-spec="inputSpec"
:background-color="backgroundColor"
:show-grid="showGrid"
:show-preview="showPreview"
:light-intensity="lightIntensity"
:show-light-intensity-button="showLightIntensityButton"
:fov="fov"
:show-f-o-v-button="showFOVButton"
:show-preview-button="showPreviewButton"
:camera-type="cameraType"
:has-background-image="hasBackgroundImage"
:up-direction="upDirection"
:material-mode="materialMode"
:edge-threshold="edgeThreshold"
@update-background-image="handleBackgroundImageUpdate"
@switch-camera="switchCamera"
@toggle-grid="toggleGrid"
@update-background-color="handleBackgroundColorChange"
@update-light-intensity="handleUpdateLightIntensity"
@toggle-preview="togglePreview"
@update-f-o-v="handleUpdateFOV"
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
@export-model="handleExportModel"
/>
<div class="pointer-events-none absolute top-0 left-0 h-full w-full">
<Load3DControls
v-model:scene-config="sceneConfig"
v-model:model-config="modelConfig"
v-model:camera-config="cameraConfig"
v-model:light-config="lightConfig"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
/>
<AnimationControls
v-if="animations && animations.length > 0"
v-model:animations="animations"
v-model:playing="playing"
v-model:selected-speed="selectedSpeed"
v-model:selected-animation="selectedAnimation"
/>
</div>
<div
v-if="enable3DViewer && node"
v-if="enable3DViewer"
class="pointer-events-auto absolute top-12 right-2 z-20"
>
<ViewerControls :node="node as LGraphNode" />
<ViewerControls :node="node" />
</div>
<div
v-if="!isPreview"
v-if="showRecordingControls"
class="pointer-events-auto absolute right-2 z-20"
:class="{
'top-12': !enable3DViewer,
@@ -50,9 +73,10 @@
}"
>
<RecordingControls
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
:node="node"
:is-recording="isRecording"
:has-recording="hasRecording"
:recording-duration="recordingDuration"
@start-recording="handleStartRecording"
@stop-recording="handleStopRecording"
@export-recording="handleExportRecording"
@@ -63,79 +87,250 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
CameraType,
Load3DNodeType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { app } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
const { t } = useI18n()
const { widget } = defineProps<{
widget: ComponentWidget<string[]>
}>()
function isComponentWidget(
widget: ComponentWidget<string[]> | SimplifiedWidget
): widget is ComponentWidget<string[]> {
return 'node' in widget && widget.node !== undefined
}
const inputSpec = widget.inputSpec as CustomInputSpec
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
})
}
const node = widget.node
const type = inputSpec.type as Load3DNodeType
const backgroundColor = ref('#000000')
const showGrid = ref(true)
const showPreview = ref(false)
const lightIntensity = ref(5)
const showLightIntensityButton = ref(true)
const fov = ref(75)
const showFOVButton = ref(true)
const cameraType = ref<CameraType>('perspective')
const hasBackgroundImage = ref(false)
const backgroundImage = ref('')
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const edgeThreshold = ref(85)
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
const {
// configs
sceneConfig,
modelConfig,
cameraConfig,
lightConfig,
// other state
isRecording,
isPreview,
hasRecording,
recordingDuration,
animations,
playing,
selectedSpeed,
selectedAnimation,
loading,
loadingMessage,
// Methods
initializeLoad3d,
handleMouseEnter,
handleMouseLeave,
handleStartRecording,
handleStopRecording,
handleExportRecording,
handleClearRecording,
handleBackgroundImageUpdate,
handleExportModel,
handleModelDrop,
cleanup
} = useLoad3d(node as Ref<LGraphNode | null>)
const isRecording = ref(false)
const hasRecording = ref(false)
const recordingDuration = ref(0)
const showRecordingControls = ref(!inputSpec.isPreview)
const enable3DViewer = computed(() =>
useSettingStore().get('Comfy.Load3D.3DViewerEnable')
)
const showPreviewButton = computed(() => {
return !type.includes('Preview')
})
const handleMouseEnter = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.updateStatusMouseOnScene(true)
}
}
const handleMouseLeave = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.updateStatusMouseOnScene(false)
}
}
const handleStartRecording = async () => {
if (load3DSceneRef.value?.load3d) {
await load3DSceneRef.value.load3d.startRecording()
isRecording.value = true
}
}
const handleStopRecording = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.stopRecording()
isRecording.value = false
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
const handleExportRecording = () => {
if (load3DSceneRef.value?.load3d) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${timestamp}-scene-recording.mp4`
load3DSceneRef.value.load3d.exportRecording(filename)
}
}
const handleClearRecording = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.clearRecording()
hasRecording.value = false
recordingDuration.value = 0
}
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
showFOVButton.value = cameraType.value === 'perspective'
node.properties['Camera Type'] = cameraType.value
}
const togglePreview = (value: boolean) => {
showPreview.value = value
node.properties['Show Preview'] = showPreview.value
}
const toggleGrid = (value: boolean) => {
showGrid.value = value
node.properties['Show Grid'] = showGrid.value
}
const handleUpdateLightIntensity = (value: number) => {
lightIntensity.value = value
node.properties['Light Intensity'] = lightIntensity.value
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
hasBackgroundImage.value = false
backgroundImage.value = ''
node.properties['Background Image'] = ''
return
}
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d'
backgroundImage.value = await Load3dUtils.uploadFile(file, subfolder)
node.properties['Background Image'] = backgroundImage.value
}
const handleUpdateFOV = (value: number) => {
fov.value = value
node.properties['FOV'] = fov.value
}
const handleUpdateEdgeThreshold = (value: number) => {
edgeThreshold.value = value
node.properties['Edge Threshold'] = edgeThreshold.value
}
const handleBackgroundColorChange = (value: string) => {
backgroundColor.value = value
node.properties['Background Color'] = value
}
const handleUpdateUpDirection = (value: UpDirection) => {
upDirection.value = value
node.properties['Up Direction'] = value
}
const handleUpdateMaterialMode = (value: MaterialMode) => {
materialMode.value = value
node.properties['Material Mode'] = value
}
const handleExportModel = async (format: string) => {
if (!load3DSceneRef.value?.load3d) {
useToastStore().addAlert(t('toastMessages.no3dSceneToExport'))
return
}
try {
await load3DSceneRef.value.load3d.exportModel(format)
} catch (error) {
console.error('Error exporting model:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', {
format: format.toUpperCase()
})
)
}
}
const listenMaterialModeChange = (mode: MaterialMode) => {
materialMode.value = mode
showLightIntensityButton.value = mode === 'original'
}
const listenUpDirectionChange = (value: UpDirection) => {
upDirection.value = value
}
const listenEdgeThresholdChange = (value: number) => {
edgeThreshold.value = value
}
const listenRecordingStatusChange = (value: boolean) => {
isRecording.value = value
if (!value && load3DSceneRef.value?.load3d) {
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
const listenBackgroundColorChange = (value: string) => {
backgroundColor.value = value
}
const listenLightIntensityChange = (value: number) => {
lightIntensity.value = value
}
const listenFOVChange = (value: number) => {
fov.value = value
}
const listenCameraTypeChange = (value: CameraType) => {
cameraType.value = value
showFOVButton.value = cameraType.value === 'perspective'
}
const listenShowGridChange = (value: boolean) => {
showGrid.value = value
}
const listenShowPreviewChange = (value: boolean) => {
showPreview.value = value
}
const listenBackgroundImageChange = (value: string) => {
backgroundImage.value = value
if (backgroundImage.value && backgroundImage.value !== '') {
hasBackgroundImage.value = true
}
}
</script>

View File

@@ -0,0 +1,336 @@
<template>
<div
class="relative h-full w-full"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<Load3DAnimationScene
ref="load3DAnimationSceneRef"
:node="node"
:input-spec="inputSpec"
:background-color="backgroundColor"
:show-grid="showGrid"
:light-intensity="lightIntensity"
:fov="fov"
:camera-type="cameraType"
:show-preview="showPreview"
:show-f-o-v-button="showFOVButton"
:show-light-intensity-button="showLightIntensityButton"
:playing="playing"
:selected-speed="selectedSpeed"
:selected-animation="selectedAnimation"
:background-image="backgroundImage"
:up-direction="upDirection"
:material-mode="materialMode"
@material-mode-change="listenMaterialModeChange"
@background-color-change="listenBackgroundColorChange"
@light-intensity-change="listenLightIntensityChange"
@fov-change="listenFOVChange"
@camera-type-change="listenCameraTypeChange"
@show-grid-change="listenShowGridChange"
@show-preview-change="listenShowPreviewChange"
@background-image-change="listenBackgroundImageChange"
@animation-list-change="animationListChange"
@up-direction-change="listenUpDirectionChange"
@recording-status-change="listenRecordingStatusChange"
/>
<div class="pointer-events-none absolute top-0 left-0 h-full w-full">
<Load3DControls
:input-spec="inputSpec"
:background-color="backgroundColor"
:show-grid="showGrid"
:show-preview="showPreview"
:light-intensity="lightIntensity"
:show-light-intensity-button="showLightIntensityButton"
:fov="fov"
:show-f-o-v-button="showFOVButton"
:show-preview-button="showPreviewButton"
:camera-type="cameraType"
:has-background-image="hasBackgroundImage"
:up-direction="upDirection"
:material-mode="materialMode"
@update-background-image="handleBackgroundImageUpdate"
@switch-camera="switchCamera"
@toggle-grid="toggleGrid"
@update-background-color="handleBackgroundColorChange"
@update-light-intensity="handleUpdateLightIntensity"
@toggle-preview="togglePreview"
@update-f-o-v="handleUpdateFOV"
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
/>
<Load3DAnimationControls
:animations="animations"
:playing="playing"
@toggle-play="togglePlay"
@speed-change="speedChange"
@animation-change="animationChange"
/>
</div>
<div
v-if="showRecordingControls"
class="pointer-events-auto absolute top-12 right-2 z-20"
>
<RecordingControls
:node="node"
:is-recording="isRecording"
:has-recording="hasRecording"
:recording-duration="recordingDuration"
@start-recording="handleStartRecording"
@stop-recording="handleStopRecording"
@export-recording="handleExportRecording"
@clear-recording="handleClearRecording"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Load3DAnimationControls from '@/components/load3d/Load3DAnimationControls.vue'
import Load3DAnimationScene from '@/components/load3d/Load3DAnimationScene.vue'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
AnimationItem,
CameraType,
Load3DAnimationNodeType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
const { widget } = defineProps<{
widget: ComponentWidget<string[]>
}>()
const inputSpec = widget.inputSpec as CustomInputSpec
const node = widget.node
const type = inputSpec.type as Load3DAnimationNodeType
const backgroundColor = ref('#000000')
const showGrid = ref(true)
const showPreview = ref(false)
const lightIntensity = ref(5)
const showLightIntensityButton = ref(true)
const fov = ref(75)
const showFOVButton = ref(true)
const cameraType = ref<'perspective' | 'orthographic'>('perspective')
const hasBackgroundImage = ref(false)
const animations = ref<AnimationItem[]>([])
const playing = ref(false)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const backgroundImage = ref('')
const isRecording = ref(false)
const hasRecording = ref(false)
const recordingDuration = ref(0)
const showRecordingControls = ref(!inputSpec.isPreview)
const showPreviewButton = computed(() => {
return !type.includes('Preview')
})
const load3DAnimationSceneRef = ref<InstanceType<
typeof Load3DAnimationScene
> | null>(null)
const handleMouseEnter = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.updateStatusMouseOnScene(true)
}
}
const handleMouseLeave = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.updateStatusMouseOnScene(false)
}
}
const handleStartRecording = async () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
await sceneRef.load3d.startRecording()
isRecording.value = true
}
}
const handleStopRecording = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.stopRecording()
isRecording.value = false
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
const handleExportRecording = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `${timestamp}-animation-recording.mp4`
sceneRef.load3d.exportRecording(filename)
}
}
const handleClearRecording = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.clearRecording()
hasRecording.value = false
recordingDuration.value = 0
}
}
const listenRecordingStatusChange = (value: boolean) => {
isRecording.value = value
if (!value) {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
showFOVButton.value = cameraType.value === 'perspective'
node.properties['Camera Type'] = cameraType.value
}
const togglePreview = (value: boolean) => {
showPreview.value = value
node.properties['Show Preview'] = showPreview.value
}
const toggleGrid = (value: boolean) => {
showGrid.value = value
node.properties['Show Grid'] = showGrid.value
}
const handleUpdateLightIntensity = (value: number) => {
lightIntensity.value = value
node.properties['Light Intensity'] = lightIntensity.value
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
hasBackgroundImage.value = false
backgroundImage.value = ''
node.properties['Background Image'] = ''
return
}
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d'
backgroundImage.value = await Load3dUtils.uploadFile(file, subfolder)
node.properties['Background Image'] = backgroundImage.value
}
const handleUpdateFOV = (value: number) => {
fov.value = value
node.properties['FOV'] = fov.value
}
const materialMode = ref<MaterialMode>('original')
const upDirection = ref<UpDirection>('original')
const handleUpdateUpDirection = (value: UpDirection) => {
upDirection.value = value
node.properties['Up Direction'] = value
}
const handleUpdateMaterialMode = (value: MaterialMode) => {
materialMode.value = value
node.properties['Material Mode'] = value
}
const handleBackgroundColorChange = (value: string) => {
backgroundColor.value = value
node.properties['Background Color'] = value
}
const togglePlay = (value: boolean) => {
playing.value = value
}
const speedChange = (value: number) => {
selectedSpeed.value = value
}
const animationChange = (value: number) => {
selectedAnimation.value = value
}
const animationListChange = (value: any) => {
animations.value = value
}
const listenMaterialModeChange = (mode: MaterialMode) => {
materialMode.value = mode
showLightIntensityButton.value = mode === 'original'
}
const listenUpDirectionChange = (value: UpDirection) => {
upDirection.value = value
}
const listenBackgroundColorChange = (value: string) => {
backgroundColor.value = value
}
const listenLightIntensityChange = (value: number) => {
lightIntensity.value = value
}
const listenFOVChange = (value: number) => {
fov.value = value
}
const listenCameraTypeChange = (value: CameraType) => {
cameraType.value = value
showFOVButton.value = cameraType.value === 'perspective'
}
const listenShowGridChange = (value: boolean) => {
showGrid.value = value
}
const listenShowPreviewChange = (value: boolean) => {
showPreview.value = value
}
const listenBackgroundImageChange = (value: string) => {
backgroundImage.value = value
if (backgroundImage.value && backgroundImage.value !== '') {
hasBackgroundImage.value = true
}
}
</script>

View File

@@ -15,6 +15,7 @@
option-label="name"
option-value="value"
class="w-24"
@change="speedChange"
/>
<Select
@@ -23,6 +24,7 @@
option-label="name"
option-value="index"
class="w-32"
@change="animationChange"
/>
</div>
</template>
@@ -30,13 +32,23 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { ref, watch } from 'vue'
type Animation = { name: string; index: number }
const props = defineProps<{
animations: Array<{ name: string; index: number }>
playing: boolean
}>()
const animations = defineModel<Animation[]>('animations')
const playing = defineModel<boolean>('playing')
const selectedSpeed = defineModel<number>('selectedSpeed')
const selectedAnimation = defineModel<number>('selectedAnimation')
const emit = defineEmits<{
(e: 'togglePlay', value: boolean): void
(e: 'speedChange', value: number): void
(e: 'animationChange', value: number): void
}>()
const animations = ref(props.animations)
const playing = ref(props.playing)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const speedOptions = [
{ name: '0.1x', value: 0.1 },
@@ -46,7 +58,24 @@ const speedOptions = [
{ name: '2x', value: 2 }
]
watch(
() => props.animations,
(newVal) => {
animations.value = newVal
}
)
const togglePlay = () => {
playing.value = !playing.value
emit('togglePlay', playing.value)
}
const speedChange = () => {
emit('speedChange', selectedSpeed.value)
}
const animationChange = () => {
emit('animationChange', selectedAnimation.value)
}
</script>

View File

@@ -0,0 +1,208 @@
<template>
<Load3DScene
ref="load3DSceneRef"
:node="node"
:input-spec="inputSpec"
:background-color="backgroundColor"
:show-grid="showGrid"
:light-intensity="lightIntensity"
:fov="fov"
:camera-type="cameraType"
:show-preview="showPreview"
:extra-listeners="animationListeners"
:background-image="backgroundImage"
:up-direction="upDirection"
:material-mode="materialMode"
@material-mode-change="listenMaterialModeChange"
@background-color-change="listenBackgroundColorChange"
@light-intensity-change="listenLightIntensityChange"
@fov-change="listenFOVChange"
@camera-type-change="listenCameraTypeChange"
@show-grid-change="listenShowGridChange"
@show-preview-change="listenShowPreviewChange"
@recording-status-change="listenRecordingStatusChange"
/>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
import type Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import type {
CameraType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const props = defineProps<{
node: any
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
lightIntensity: number
fov: number
cameraType: CameraType
showPreview: boolean
materialMode: MaterialMode
upDirection: UpDirection
showFOVButton: boolean
showLightIntensityButton: boolean
playing: boolean
selectedSpeed: number
selectedAnimation: number
backgroundImage: string
}>()
const node = ref(props.node)
const backgroundColor = ref(props.backgroundColor)
const showPreview = ref(props.showPreview)
const fov = ref(props.fov)
const lightIntensity = ref(props.lightIntensity)
const cameraType = ref(props.cameraType)
const showGrid = ref(props.showGrid)
const upDirection = ref(props.upDirection)
const materialMode = ref(props.materialMode)
const showFOVButton = ref(props.showFOVButton)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
watch(
() => props.cameraType,
(newValue) => {
cameraType.value = newValue
}
)
watch(
() => props.showGrid,
(newValue) => {
showGrid.value = newValue
}
)
watch(
() => props.backgroundColor,
(newValue) => {
backgroundColor.value = newValue
}
)
watch(
() => props.lightIntensity,
(newValue) => {
lightIntensity.value = newValue
}
)
watch(
() => props.fov,
(newValue) => {
fov.value = newValue
}
)
watch(
() => props.upDirection,
(newValue) => {
upDirection.value = newValue
}
)
watch(
() => props.materialMode,
(newValue) => {
materialMode.value = newValue
}
)
watch(
() => props.showPreview,
(newValue) => {
showPreview.value = newValue
}
)
watch(
() => props.playing,
(newValue) => {
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.toggleAnimation(newValue)
}
)
watch(
() => props.selectedSpeed,
(newValue) => {
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.setAnimationSpeed(newValue)
}
)
watch(
() => props.selectedAnimation,
(newValue) => {
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.updateSelectedAnimation(newValue)
}
)
const emit = defineEmits<{
(e: 'animationListChange', animationList: string): void
(e: 'materialModeChange', materialMode: MaterialMode): void
(e: 'backgroundColorChange', color: string): void
(e: 'lightIntensityChange', lightIntensity: number): void
(e: 'fovChange', fov: number): void
(e: 'cameraTypeChange', cameraType: CameraType): void
(e: 'showGridChange', showGrid: boolean): void
(e: 'showPreviewChange', showPreview: boolean): void
(e: 'upDirectionChange', direction: UpDirection): void
(e: 'recording-status-change', status: boolean): void
}>()
const listenMaterialModeChange = (mode: MaterialMode) => {
materialMode.value = mode
showLightIntensityButton.value = mode === 'original'
}
const listenBackgroundColorChange = (value: string) => {
backgroundColor.value = value
}
const listenLightIntensityChange = (value: number) => {
lightIntensity.value = value
}
const listenFOVChange = (value: number) => {
fov.value = value
}
const listenCameraTypeChange = (value: CameraType) => {
cameraType.value = value
showFOVButton.value = cameraType.value === 'perspective'
}
const listenShowGridChange = (value: boolean) => {
showGrid.value = value
}
const listenShowPreviewChange = (value: boolean) => {
showPreview.value = value
}
const listenRecordingStatusChange = (value: boolean) => {
emit('recording-status-change', value)
}
const animationListeners = {
animationListChange: (newValue: any) => {
emit('animationListChange', newValue)
}
}
defineExpose({
load3DSceneRef
})
</script>

View File

@@ -1,10 +1,6 @@
<template>
<div
class="pointer-events-auto absolute top-12 left-2 z-20 flex flex-col rounded-lg bg-smoke-700/30"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
@wheel.stop
>
<div class="show-menu relative">
<Button class="p-button-rounded p-button-text" @click="toggleMenu">
@@ -24,9 +20,7 @@
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)" />
<span class="whitespace-nowrap text-white">{{
$t(categoryLabels[category])
}}</span>
<span class="text-white">{{ t(categoryLabels[category]) }}</span>
</Button>
</div>
</div>
@@ -34,47 +28,71 @@
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
<SceneControls
v-if="showSceneControls"
v-if="activeCategory === 'scene'"
ref="sceneControlsRef"
v-model:show-grid="sceneConfig!.showGrid"
v-model:background-color="sceneConfig!.backgroundColor"
v-model:background-image="sceneConfig!.backgroundImage"
:background-color="backgroundColor"
:show-grid="showGrid"
:has-background-image="hasBackgroundImage"
@toggle-grid="handleToggleGrid"
@update-background-color="handleBackgroundColorChange"
@update-background-image="handleBackgroundImageUpdate"
/>
<ModelControls
v-if="showModelControls"
v-if="activeCategory === 'model'"
ref="modelControlsRef"
v-model:material-mode="modelConfig!.materialMode"
v-model:up-direction="modelConfig!.upDirection"
:input-spec="inputSpec"
:up-direction="upDirection"
:material-mode="materialMode"
:edge-threshold="edgeThreshold"
@update-up-direction="handleUpdateUpDirection"
@update-material-mode="handleUpdateMaterialMode"
@update-edge-threshold="handleUpdateEdgeThreshold"
/>
<CameraControls
v-if="showCameraControls"
v-if="activeCategory === 'camera'"
ref="cameraControlsRef"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
:camera-type="cameraType"
:fov="fov"
:show-f-o-v-button="showFOVButton"
@switch-camera="switchCamera"
@update-f-o-v="handleUpdateFOV"
/>
<LightControls
v-if="showLightControls"
v-if="activeCategory === 'light'"
ref="lightControlsRef"
v-model:light-intensity="lightConfig!.intensity"
v-model:material-mode="modelConfig!.materialMode"
:light-intensity="lightIntensity"
:show-light-intensity-button="showLightIntensityButton"
@update-light-intensity="handleUpdateLightIntensity"
/>
<ExportControls
v-if="showExportControls"
v-if="activeCategory === 'export'"
ref="exportControlsRef"
@export-model="handleExportModel"
/>
</div>
<div v-if="showPreviewButton">
<Button class="p-button-rounded p-button-text" @click="togglePreview">
<i
v-tooltip.right="{ value: t('load3d.previewOutput'), showDelay: 300 }"
:class="[
'pi',
showPreview ? 'pi-eye' : 'pi-eye-slash',
'text-lg text-white'
]"
/>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
@@ -82,16 +100,31 @@ import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
import type {
CameraConfig,
LightConfig,
ModelConfig,
SceneConfig
CameraType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
const modelConfig = defineModel<ModelConfig>('modelConfig')
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
const lightConfig = defineModel<LightConfig>('lightConfig')
const vTooltip = Tooltip
const props = defineProps<{
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
showPreview: boolean
lightIntensity: number
showLightIntensityButton: boolean
fov: number
showFOVButton: boolean
showPreviewButton: boolean
cameraType: CameraType
hasBackgroundImage?: boolean
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold?: number
}>()
const isMenuOpen = ref(false)
const activeCategory = ref<string>('scene')
@@ -104,25 +137,14 @@ const categoryLabels: Record<string, string> = {
}
const availableCategories = computed(() => {
return ['scene', 'model', 'camera', 'light', 'export']
})
const baseCategories = ['scene', 'model', 'camera', 'light']
const showSceneControls = computed(
() => activeCategory.value === 'scene' && !!sceneConfig.value
)
const showModelControls = computed(
() => activeCategory.value === 'model' && !!modelConfig.value
)
const showCameraControls = computed(
() => activeCategory.value === 'camera' && !!cameraConfig.value
)
const showLightControls = computed(
() =>
activeCategory.value === 'light' &&
!!lightConfig.value &&
!!modelConfig.value
)
const showExportControls = computed(() => activeCategory.value === 'export')
if (!props.inputSpec.isAnimation) {
return [...baseCategories, 'export']
}
return baseCategories
})
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
@@ -146,14 +168,73 @@ const getCategoryIcon = (category: string) => {
}
const emit = defineEmits<{
(e: 'switchCamera'): void
(e: 'toggleGrid', value: boolean): void
(e: 'updateBackgroundColor', color: string): void
(e: 'updateLightIntensity', value: number): void
(e: 'updateFOV', value: number): void
(e: 'togglePreview', value: boolean): void
(e: 'updateBackgroundImage', file: File | null): void
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'exportModel', format: string): void
}>()
const backgroundColor = ref(props.backgroundColor)
const showGrid = ref(props.showGrid)
const showPreview = ref(props.showPreview)
const lightIntensity = ref(props.lightIntensity)
const upDirection = ref(props.upDirection || 'original')
const materialMode = ref(props.materialMode || 'original')
const showLightIntensityButton = ref(props.showLightIntensityButton)
const fov = ref(props.fov)
const showFOVButton = ref(props.showFOVButton)
const showPreviewButton = ref(props.showPreviewButton)
const hasBackgroundImage = ref(props.hasBackgroundImage)
const edgeThreshold = ref(props.edgeThreshold)
const switchCamera = () => {
emit('switchCamera')
}
const togglePreview = () => {
showPreview.value = !showPreview.value
emit('togglePreview', showPreview.value)
}
const handleToggleGrid = (value: boolean) => {
emit('toggleGrid', value)
}
const handleBackgroundColorChange = (value: string) => {
emit('updateBackgroundColor', value)
}
const handleBackgroundImageUpdate = (file: File | null) => {
emit('updateBackgroundImage', file)
}
const handleUpdateUpDirection = (direction: UpDirection) => {
emit('updateUpDirection', direction)
}
const handleUpdateMaterialMode = (mode: MaterialMode) => {
emit('updateMaterialMode', mode)
}
const handleUpdateEdgeThreshold = (value: number) => {
emit('updateEdgeThreshold', value)
}
const handleUpdateLightIntensity = (value: number) => {
emit('updateLightIntensity', value)
}
const handleUpdateFOV = (value: number) => {
emit('updateFOV', value)
}
const handleExportModel = (format: string) => {
emit('exportModel', format)
}
@@ -166,6 +247,101 @@ const closeSlider = (e: MouseEvent) => {
}
}
watch(
() => props.upDirection,
(newValue) => {
if (newValue) {
upDirection.value = newValue
}
}
)
watch(
() => props.backgroundColor,
(newValue) => {
backgroundColor.value = newValue
}
)
watch(
() => props.fov,
(newValue) => {
fov.value = newValue
}
)
watch(
() => props.lightIntensity,
(newValue) => {
lightIntensity.value = newValue
}
)
watch(
() => props.showFOVButton,
(newValue) => {
showFOVButton.value = newValue
}
)
watch(
() => props.showLightIntensityButton,
(newValue) => {
showLightIntensityButton.value = newValue
}
)
watch(
() => props.upDirection,
(newValue) => {
upDirection.value = newValue
}
)
watch(
() => props.materialMode,
(newValue) => {
materialMode.value = newValue
}
)
watch(
() => props.showPreviewButton,
(newValue) => {
showPreviewButton.value = newValue
}
)
watch(
() => props.showPreview,
(newValue) => {
showPreview.value = newValue
}
)
watch(
() => props.hasBackgroundImage,
(newValue) => {
hasBackgroundImage.value = newValue
}
)
watch(
() => props.materialMode,
(newValue) => {
if (newValue) {
materialMode.value = newValue
}
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
edgeThreshold.value = newValue
}
)
onMounted(() => {
document.addEventListener('click', closeSlider)
})

View File

@@ -1,72 +1,238 @@
<template>
<div
ref="container"
class="relative h-full w-full"
data-capture-wheel="true"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
@mousedown.stop
@mousemove.stop
@mouseup.stop
@contextmenu.stop.prevent
@dragover.prevent.stop="handleDragOver"
@dragleave.stop="handleDragLeave"
@drop.prevent.stop="handleDrop"
>
<LoadingOverlay
ref="loadingOverlayRef"
:loading="loading"
:loading-message="loadingMessage"
/>
<div
v-if="!isPreview && isDragging"
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div
class="rounded-lg border-2 border-dashed border-blue-400 bg-blue-500/20 px-6 py-4 text-lg font-medium text-blue-100"
>
{{ dragMessage }}
</div>
</div>
<div ref="container" class="relative h-full w-full">
<LoadingOverlay ref="loadingOverlayRef" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, toRaw, watch } from 'vue'
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import type Load3d from '@/extensions/core/load3d/Load3d'
import type Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import type {
CameraType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useLoad3dService } from '@/services/load3dService'
const props = defineProps<{
initializeLoad3d: (containerRef: HTMLElement) => Promise<void>
cleanup: () => void
loading: boolean
loadingMessage: string
onModelDrop?: (file: File) => void | Promise<void>
isPreview: boolean
node: LGraphNode
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
lightIntensity: number
fov: number
cameraType: CameraType
showPreview: boolean
backgroundImage: string
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold?: number
extraListeners?: Record<string, (value: any) => void>
}>()
const container = ref<HTMLElement | null>(null)
const node = ref(props.node)
const load3d = ref<Load3d | Load3dAnimation | null>(null)
const loadingOverlayRef = ref<InstanceType<typeof LoadingOverlay> | null>(null)
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({
onModelDrop: async (file) => {
if (props.onModelDrop) {
await props.onModelDrop(file)
}
},
disabled: computed(() => props.isPreview)
const eventConfig = {
materialModeChange: (value: string) =>
emit('materialModeChange', value as MaterialMode),
backgroundColorChange: (value: string) =>
emit('backgroundColorChange', value),
lightIntensityChange: (value: number) => emit('lightIntensityChange', value),
fovChange: (value: number) => emit('fovChange', value),
cameraTypeChange: (value: string) =>
emit('cameraTypeChange', value as CameraType),
showGridChange: (value: boolean) => emit('showGridChange', value),
showPreviewChange: (value: boolean) => emit('showPreviewChange', value),
backgroundImageChange: (value: string) =>
emit('backgroundImageChange', value),
backgroundImageLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.loadingBackgroundImage')),
backgroundImageLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
upDirectionChange: (value: string) =>
emit('upDirectionChange', value as UpDirection),
edgeThresholdChange: (value: number) => emit('edgeThresholdChange', value),
modelLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.loadingModel')),
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
materialLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.switchingMaterialMode')),
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
exportLoadingStart: (message: string) => {
loadingOverlayRef.value?.startLoading(message || t('load3d.exportingModel'))
},
exportLoadingEnd: () => {
loadingOverlayRef.value?.endLoading()
},
recordingStatusChange: (value: boolean) =>
emit('recordingStatusChange', value)
} as const
watch(
() => props.showPreview,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.togglePreview(newValue)
}
}
)
watch(
() => props.cameraType,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.toggleCamera(newValue)
}
}
)
watch(
() => props.fov,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setFOV(newValue)
}
}
)
watch(
() => props.lightIntensity,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setLightIntensity(newValue)
}
}
)
watch(
() => props.showGrid,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.toggleGrid(newValue)
}
}
)
watch(
() => props.backgroundColor,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setBackgroundColor(newValue)
}
}
)
watch(
() => props.backgroundImage,
async (newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
await rawLoad3d.setBackgroundImage(newValue)
}
}
)
watch(
() => props.upDirection,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setUpDirection(newValue)
}
}
)
watch(
() => props.materialMode,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setMaterialMode(newValue)
}
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
if (load3d.value && newValue) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setEdgeThreshold(newValue)
}
}
)
const emit = defineEmits<{
(e: 'materialModeChange', materialMode: MaterialMode): void
(e: 'backgroundColorChange', color: string): void
(e: 'lightIntensityChange', lightIntensity: number): void
(e: 'fovChange', fov: number): void
(e: 'cameraTypeChange', cameraType: CameraType): void
(e: 'showGridChange', showGrid: boolean): void
(e: 'showPreviewChange', showPreview: boolean): void
(e: 'backgroundImageChange', backgroundImage: string): void
(e: 'upDirectionChange', upDirection: UpDirection): void
(e: 'edgeThresholdChange', threshold: number): void
(e: 'recordingStatusChange', status: boolean): void
}>()
const handleEvents = (action: 'add' | 'remove') => {
if (!load3d.value) return
Object.entries(eventConfig).forEach(([event, handler]) => {
const method = `${action}EventListener` as const
load3d.value?.[method](event, handler)
})
if (props.extraListeners) {
Object.entries(props.extraListeners).forEach(([event, handler]) => {
const method = `${action}EventListener` as const
load3d.value?.[method](event, handler)
})
}
}
onMounted(() => {
if (container.value) {
void props.initializeLoad3d(container.value)
load3d.value = useLoad3dService().registerLoad3d(
node.value as LGraphNode,
container.value,
props.inputSpec
)
}
handleEvents('add')
})
onUnmounted(() => {
props.cleanup()
handleEvents('remove')
useLoad3dService().removeLoad3d(node.value as LGraphNode)
})
defineExpose({
load3d
})
</script>

View File

@@ -11,20 +11,7 @@
ref="containerRef"
class="absolute h-full w-full"
@resize="viewer.handleResize"
@dragover.prevent.stop="handleDragOver"
@dragleave.stop="handleDragLeave"
@drop.prevent.stop="handleDrop"
/>
<div
v-if="isDragging"
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div
class="rounded-lg border-2 border-dashed border-blue-400 bg-blue-500/20 px-6 py-4 text-lg font-medium text-blue-100"
>
{{ dragMessage }}
</div>
</div>
</div>
<div class="flex w-72 flex-col">
@@ -88,7 +75,6 @@ import ExportControls from '@/components/load3d/controls/viewer/ViewerExportCont
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
@@ -106,14 +92,6 @@ const mutationObserver = ref<MutationObserver | null>(null)
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({
onModelDrop: async (file) => {
await viewer.handleModelDrop(file)
},
disabled: viewer.isPreview
})
onMounted(async () => {
const source = useLoad3dService().getLoad3d(props.node)
if (source && containerRef.value) {

View File

@@ -1,8 +1,8 @@
<template>
<Transition name="fade">
<div
v-if="loading"
class="bg-opacity-50 absolute inset-0 z-50 flex items-center justify-center bg-black"
v-if="modelLoading"
class="absolute inset-0 z-50 flex items-center justify-center bg-black/50"
>
<div class="flex flex-col items-center">
<div class="spinner" />
@@ -15,10 +15,29 @@
</template>
<script setup lang="ts">
defineProps<{
loading: boolean
loadingMessage: string
}>()
import { nextTick, ref } from 'vue'
import { t } from '@/i18n'
const modelLoading = ref(false)
const loadingMessage = ref('')
const startLoading = async (message?: string) => {
loadingMessage.value = message || t('load3d.loadingModel')
modelLoading.value = true
await nextTick()
}
const endLoading = async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
modelLoading.value = false
}
defineExpose({
startLoading,
endLoading
})
</script>
<style scoped>

View File

@@ -3,7 +3,7 @@
<Button class="p-button-rounded p-button-text" @click="switchCamera">
<i
v-tooltip.right="{
value: $t('load3d.switchCamera'),
value: t('load3d.switchCamera'),
showDelay: 300
}"
:class="['pi', getCameraIcon, 'text-lg text-white']"
@@ -12,7 +12,7 @@
<div v-if="showFOVButton" class="show-fov relative">
<Button class="p-button-rounded p-button-text" @click="toggleFOV">
<i
v-tooltip.right="{ value: $t('load3d.fov'), showDelay: 300 }"
v-tooltip.right="{ value: t('load3d.fov'), showDelay: 300 }"
class="pi pi-expand text-lg text-white"
/>
</Button>
@@ -21,37 +21,83 @@
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
style="width: 150px"
>
<Slider v-model="fov" class="w-full" :min="10" :max="150" :step="1" />
<Slider
v-model="fov"
class="w-full"
:min="10"
:max="150"
:step="1"
@change="updateFOV"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
cameraType: CameraType
fov: number
showFOVButton: boolean
}>()
const emit = defineEmits<{
(e: 'switchCamera'): void
(e: 'updateFOV', value: number): void
}>()
const cameraType = ref(props.cameraType)
const fov = ref(props.fov)
const showFOVButton = ref(props.showFOVButton)
const showFOV = ref(false)
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const showFOVButton = computed(() => cameraType.value === 'perspective')
const getCameraIcon = computed(() => {
return cameraType.value === 'perspective' ? 'pi-camera' : 'pi-camera'
})
watch(
() => props.fov,
(newValue) => {
fov.value = newValue
}
)
watch(
() => props.showFOVButton,
(newValue) => {
showFOVButton.value = newValue
}
)
watch(
() => props.cameraType,
(newValue) => {
cameraType.value = newValue
}
)
const switchCamera = () => {
emit('switchCamera')
}
const toggleFOV = () => {
showFOV.value = !showFOV.value
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
const updateFOV = () => {
emit('updateFOV', fov.value)
}
const getCameraIcon = computed(() => {
return props.cameraType === 'perspective' ? 'pi-camera' : 'pi-camera'
})
const closeCameraSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -7,7 +7,7 @@
>
<i
v-tooltip.right="{
value: $t('load3d.exportModel'),
value: t('load3d.exportModel'),
showDelay: 300
}"
class="pi pi-download text-lg text-white"
@@ -33,9 +33,14 @@
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()

View File

@@ -7,7 +7,7 @@
>
<i
v-tooltip.right="{
value: $t('load3d.lightIntensity'),
value: t('load3d.lightIntensity'),
showDelay: 300
}"
class="pi pi-sun text-lg text-white"
@@ -24,6 +24,7 @@
:min="lightIntensityMinimum"
:max="lightIntensityMaximum"
:step="lightAdjustmentIncrement"
@change="updateLightIntensity"
/>
</div>
</div>
@@ -31,19 +32,27 @@
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import type { MaterialMode } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')
const materialMode = defineModel<MaterialMode>('materialMode')
const vTooltip = Tooltip
const showLightIntensityButton = computed(
() => materialMode.value === 'original'
)
const props = defineProps<{
lightIntensity: number
showLightIntensityButton: boolean
}>()
const emit = defineEmits<{
(e: 'updateLightIntensity', value: number): void
}>()
const lightIntensity = ref(props.lightIntensity)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const showLightIntensity = ref(false)
const lightIntensityMaximum = useSettingStore().get(
@@ -56,10 +65,28 @@ const lightAdjustmentIncrement = useSettingStore().get(
'Comfy.Load3D.LightAdjustmentIncrement'
)
watch(
() => props.lightIntensity,
(newValue) => {
lightIntensity.value = newValue
}
)
watch(
() => props.showLightIntensityButton,
(newValue) => {
showLightIntensityButton.value = newValue
}
)
const toggleLightIntensity = () => {
showLightIntensity.value = !showLightIntensity.value
}
const updateLightIntensity = () => {
emit('updateLightIntensity', lightIntensity.value)
}
const closeLightSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -22,7 +22,7 @@
:class="{ 'bg-blue-500': upDirection === direction }"
@click="selectUpDirection(direction)"
>
{{ direction.toUpperCase() }}
{{ formatOption(direction) }}
</Button>
</div>
</div>
@@ -49,7 +49,7 @@
<Button
v-for="mode in materialModes"
:key="mode"
class="p-button-text whitespace-nowrap text-white"
class="p-button-text text-white"
:class="{ 'bg-blue-500': materialMode === mode }"
@click="selectMaterialMode(mode)"
>
@@ -58,24 +58,75 @@
</div>
</div>
</div>
<div v-if="materialMode === 'lineart'" class="show-edge-threshold relative">
<Button
class="p-button-rounded p-button-text"
@click="toggleEdgeThreshold"
>
<i
v-tooltip.right="{
value: t('load3d.edgeThreshold'),
showDelay: 300
}"
class="pi pi-sliders-h text-lg text-white"
/>
</Button>
<div
v-show="showEdgeThreshold"
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
style="width: 150px"
>
<label class="mb-1 block text-xs text-white"
>{{ t('load3d.edgeThreshold') }}: {{ edgeThreshold }}°</label
>
<Slider
v-model="edgeThreshold"
class="w-full"
:min="0"
:max="120"
:step="1"
@change="updateEdgeThreshold"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type {
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const materialMode = defineModel<MaterialMode>('materialMode')
const upDirection = defineModel<UpDirection>('upDirection')
const vTooltip = Tooltip
const props = defineProps<{
inputSpec: CustomInputSpec
upDirection: UpDirection
materialMode: MaterialMode
edgeThreshold?: number
}>()
const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
}>()
const upDirection = ref(props.upDirection || 'original')
const materialMode = ref(props.materialMode || 'original')
const edgeThreshold = ref(props.edgeThreshold || 85)
const showUpDirection = ref(false)
const showMaterialMode = ref(false)
const showEdgeThreshold = ref(false)
const upDirections: UpDirection[] = [
'original',
@@ -95,26 +146,65 @@ const materialModes = computed(() => {
//'depth' disable for now
]
if (!props.inputSpec.isAnimation && !props.inputSpec.isPreview) {
modes.push('lineart')
}
return modes
})
watch(
() => props.upDirection,
(newValue) => {
if (newValue) {
upDirection.value = newValue
}
}
)
watch(
() => props.materialMode,
(newValue) => {
if (newValue) {
materialMode.value = newValue
}
}
)
watch(
() => props.edgeThreshold,
(newValue) => {
// @ts-expect-error fixme ts strict error
edgeThreshold.value = newValue
}
)
const toggleUpDirection = () => {
showUpDirection.value = !showUpDirection.value
showMaterialMode.value = false
showEdgeThreshold.value = false
}
const selectUpDirection = (direction: UpDirection) => {
upDirection.value = direction
emit('updateUpDirection', direction)
showUpDirection.value = false
}
const formatOption = (option: string) => {
if (option === 'original') return 'Original'
return option.toUpperCase()
}
const toggleMaterialMode = () => {
showMaterialMode.value = !showMaterialMode.value
showUpDirection.value = false
showEdgeThreshold.value = false
}
const selectMaterialMode = (mode: MaterialMode) => {
materialMode.value = mode
emit('updateMaterialMode', mode)
showMaterialMode.value = false
}
@@ -122,6 +212,16 @@ const formatMaterialMode = (mode: MaterialMode) => {
return t(`load3d.materialModes.${mode}`)
}
const toggleEdgeThreshold = () => {
showEdgeThreshold.value = !showEdgeThreshold.value
showUpDirection.value = false
showMaterialMode.value = false
}
const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const closeSceneSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement
@@ -132,6 +232,10 @@ const closeSceneSlider = (e: MouseEvent) => {
if (!target.closest('.show-material-mode')) {
showMaterialMode.value = false
}
if (!target.closest('.show-edge-threshold')) {
showEdgeThreshold.value = false
}
}
onMounted(() => {

View File

@@ -1,6 +1,18 @@
<template>
<div class="relative rounded-lg bg-smoke-700/30">
<div class="flex flex-col gap-2">
<Button
class="p-button-rounded p-button-text"
@click="resizeNodeMatchOutput"
>
<i
v-tooltip.right="{
value: t('load3d.resizeNodeMatchOutput'),
showDelay: 300
}"
class="pi pi-window-maximize text-lg text-white"
/>
</Button>
<Button
class="p-button-rounded p-button-text"
:class="{
@@ -12,8 +24,8 @@
<i
v-tooltip.right="{
value: isRecording
? $t('load3d.stopRecording')
: $t('load3d.startRecording'),
? t('load3d.stopRecording')
: t('load3d.startRecording'),
showDelay: 300
}"
:class="[
@@ -27,11 +39,11 @@
<Button
v-if="hasRecording && !isRecording"
class="p-button-rounded p-button-text"
@click="handleExportRecording"
@click="exportRecording"
>
<i
v-tooltip.right="{
value: $t('load3d.exportRecording'),
value: t('load3d.exportRecording'),
showDelay: 300
}"
class="pi pi-download text-lg text-white"
@@ -41,11 +53,11 @@
<Button
v-if="hasRecording && !isRecording"
class="p-button-rounded p-button-text"
@click="handleClearRecording"
@click="clearRecording"
>
<i
v-tooltip.right="{
value: $t('load3d.clearRecording'),
value: t('load3d.clearRecording'),
showDelay: 300
}"
class="pi pi-trash text-lg text-white"
@@ -53,7 +65,7 @@
</Button>
<div
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
v-if="recordingDuration > 0 && !isRecording"
class="mt-1 text-center text-xs text-white"
>
{{ formatDuration(recordingDuration) }}
@@ -63,11 +75,21 @@
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
const hasRecording = defineModel<boolean>('hasRecording')
const isRecording = defineModel<boolean>('isRecording')
const recordingDuration = defineModel<number>('recordingDuration')
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLoad3dService } from '@/services/load3dService'
const vTooltip = Tooltip
const { hasRecording, isRecording, node, recordingDuration } = defineProps<{
hasRecording: boolean
isRecording: boolean
node: LGraphNode
recordingDuration: number
}>()
const emit = defineEmits<{
(e: 'startRecording'): void
@@ -76,19 +98,49 @@ const emit = defineEmits<{
(e: 'clearRecording'): void
}>()
const resizeNodeMatchOutput = () => {
const outputWidth = node.widgets?.find((w) => w.name === 'width')
const outputHeight = node.widgets?.find((w) => w.name === 'height')
if (outputWidth && outputHeight && outputHeight.value && outputWidth.value) {
const [oldWidth, oldHeight] = node.size
const scene = node.widgets?.find((w) => w.name === 'image')
const sceneHeight = scene?.computedHeight
if (sceneHeight) {
const sceneWidth = oldWidth - 20
const outputRatio = Number(outputHeight.value) / Number(outputWidth.value)
const expectSceneHeight = sceneWidth * outputRatio
node.setSize([oldWidth, oldHeight + (expectSceneHeight - sceneHeight)])
node.graph?.setDirtyCanvas(true, true)
const load3d = useLoad3dService().getLoad3d(node as LGraphNode)
if (load3d) {
load3d.refreshViewport()
}
}
}
}
const toggleRecording = () => {
if (isRecording.value) {
if (isRecording) {
emit('stopRecording')
} else {
emit('startRecording')
}
}
const handleExportRecording = () => {
const exportRecording = () => {
emit('exportRecording')
}
const handleClearRecording = () => {
const clearRecording = () => {
emit('clearRecording')
}

View File

@@ -6,7 +6,7 @@
@click="toggleGrid"
>
<i
v-tooltip.right="{ value: $t('load3d.showGrid'), showDelay: 300 }"
v-tooltip.right="{ value: t('load3d.showGrid'), showDelay: 300 }"
class="pi pi-table text-lg text-white"
/>
</Button>
@@ -15,7 +15,7 @@
<Button class="p-button-rounded p-button-text" @click="openColorPicker">
<i
v-tooltip.right="{
value: $t('load3d.backgroundColor'),
value: t('load3d.backgroundColor'),
showDelay: 300
}"
class="pi pi-palette text-lg text-white"
@@ -36,7 +36,7 @@
<Button class="p-button-rounded p-button-text" @click="openImagePicker">
<i
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
value: t('load3d.uploadBackgroundImage'),
showDelay: 300
}"
class="pi pi-image text-lg text-white"
@@ -58,7 +58,7 @@
>
<i
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
value: t('load3d.removeBackgroundImage'),
showDelay: 300
}"
class="pi pi-times text-lg text-white"
@@ -69,29 +69,60 @@
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { ref, watch } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const props = defineProps<{
backgroundColor: string
showGrid: boolean
hasBackgroundImage?: boolean
}>()
const emit = defineEmits<{
(e: 'toggleGrid', value: boolean): void
(e: 'updateBackgroundColor', color: string): void
(e: 'updateBackgroundImage', file: File | null): void
}>()
const showGrid = defineModel<boolean>('showGrid')
const backgroundColor = defineModel<string>('backgroundColor')
const backgroundImage = defineModel<string>('backgroundImage')
const hasBackgroundImage = computed(
() => backgroundImage.value && backgroundImage.value !== ''
)
const backgroundColor = ref(props.backgroundColor)
const showGrid = ref(props.showGrid)
const hasBackgroundImage = ref(props.hasBackgroundImage)
const colorPickerRef = ref<HTMLInputElement | null>(null)
const imagePickerRef = ref<HTMLInputElement | null>(null)
watch(
() => props.backgroundColor,
(newValue) => {
backgroundColor.value = newValue
}
)
watch(
() => props.showGrid,
(newValue) => {
showGrid.value = newValue
}
)
watch(
() => props.hasBackgroundImage,
(newValue) => {
hasBackgroundImage.value = newValue
}
)
const toggleGrid = () => {
showGrid.value = !showGrid.value
emit('toggleGrid', showGrid.value)
}
const updateBackgroundColor = (color: string) => {
backgroundColor.value = color
emit('updateBackgroundColor', color)
}
const openColorPicker = () => {

View File

@@ -15,6 +15,7 @@
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
@@ -23,6 +24,8 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const vTooltip = Tooltip
const { node } = defineProps<{
node: LGraphNode
}>()

View File

@@ -8,7 +8,7 @@
</Select>
<Button severity="secondary" text rounded @click="exportModel(exportFormat)">
{{ $t('load3d.export') }}
{{ t('load3d.export') }}
</Button>
</template>
@@ -17,6 +17,8 @@ import Button from 'primevue/button'
import Select from 'primevue/select'
import { ref } from 'vue'
import { t } from '@/i18n'
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()

View File

@@ -1,5 +1,5 @@
<template>
<label>{{ $t('load3d.lightIntensity') }}</label>
<label>{{ t('load3d.lightIntensity') }}</label>
<Slider
v-model="lightIntensity"
@@ -13,6 +13,7 @@
<script setup lang="ts">
import Slider from 'primevue/slider'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')

View File

@@ -1,7 +1,7 @@
<template>
<div class="space-y-4">
<div>
<label>{{ $t('load3d.upDirection') }}</label>
<label>{{ t('load3d.upDirection') }}</label>
<Select
v-model="upDirection"
:options="upDirectionOptions"
@@ -11,7 +11,7 @@
</div>
<div>
<label>{{ $t('load3d.materialMode') }}</label>
<label>{{ t('load3d.materialMode') }}</label>
<Select
v-model="materialMode"
:options="materialModeOptions"

View File

@@ -2,7 +2,7 @@
<div class="space-y-4">
<div v-if="!hasBackgroundImage">
<label>
{{ $t('load3d.backgroundColor') }}
{{ t('load3d.backgroundColor') }}
</label>
<input v-model="backgroundColor" type="color" class="w-full" />
</div>
@@ -10,14 +10,14 @@
<div>
<Checkbox v-model="showGrid" input-id="showGrid" binary name="showGrid" />
<label for="showGrid" class="pl-2">
{{ $t('load3d.showGrid') }}
{{ t('load3d.showGrid') }}
</label>
</div>
<div v-if="!hasBackgroundImage">
<Button
severity="secondary"
:label="$t('load3d.uploadBackgroundImage')"
:label="t('load3d.uploadBackgroundImage')"
icon="pi pi-image"
class="w-full"
@click="openImagePicker"
@@ -34,7 +34,7 @@
<div v-if="hasBackgroundImage" class="space-y-2">
<Button
severity="secondary"
:label="$t('load3d.removeBackgroundImage')"
:label="t('load3d.removeBackgroundImage')"
icon="pi pi-times"
class="w-full"
@click="removeBackgroundImage"
@@ -48,6 +48,8 @@ import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import { ref } from 'vue'
import { t } from '@/i18n'
const backgroundColor = defineModel<string>('backgroundColor')
const showGrid = defineModel<boolean>('showGrid')

View File

@@ -1,23 +1,21 @@
<template>
<div
v-tooltip="{
value: t('sideToolbar.labels.menu'),
showDelay: 300,
hideDelay: 300
}"
class="comfy-menu-button-wrapper flex shrink-0 cursor-pointer flex-col items-center justify-center p-2 transition-colors"
class="comfy-menu-button-wrapper flex shrink-0 cursor-pointer flex-col items-center justify-center rounded-t-md p-2 transition-colors"
:class="{
'comfy-menu-button-active': menuRef?.visible
}"
@click="onLogoMenuClick($event)"
@click="menuRef?.toggle($event)"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-black">
<ComfyLogo
alt="ComfyUI Logo"
class="comfyui-logo h-[18px] w-[18px] text-white"
mode="fill"
/>
</div>
<ComfyLogoTransparent
alt="ComfyUI Logo"
class="comfyui-logo h-[18px] w-[18px]"
/>
<span
v-if="!isSmall"
class="side-bar-button-label mt-1 text-center text-[10px]"
>{{ t('sideToolbar.labels.menu') }}</span
>
</div>
<TieredMenu
@@ -77,10 +75,8 @@ import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import ComfyLogoTransparent from '@/components/icons/ComfyLogoTransparent.vue'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -99,19 +95,14 @@ const colorPaletteService = useColorPaletteService()
const dialogStore = useDialogStore()
const managerState = useManagerState()
const { isSmall = false } = defineProps<{
isSmall?: boolean
}>()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
const telemetry = useTelemetry()
function onLogoMenuClick(event: MouseEvent) {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_comfy_menu_opened'
})
menuRef.value?.toggle(event)
}
const translateMenuItem = (item: MenuItem): MenuItem => {
const label = typeof item.label === 'function' ? item.label() : item.label
const translatedLabel = label
@@ -169,18 +160,13 @@ const extraMenuItems = computed(() => [
key: 'browse-templates',
label: t('menuLabels.Browse Templates'),
icon: 'icon-[comfy--template]',
command: () => useWorkflowTemplateSelectorDialog().show('menu')
command: () => commandStore.execute('Comfy.BrowseTemplates')
},
{
key: 'settings',
label: t('g.settings'),
icon: 'mdi mdi-cog-outline',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened'
})
showSettings()
}
command: () => showSettings()
},
{
key: 'manage-extensions',
@@ -290,12 +276,12 @@ const hasActiveStateSiblings = (item: MenuItem): boolean => {
}
.comfy-menu-button-wrapper:hover {
background: var(--interface-panel-hover-surface);
background: var(--p-button-text-secondary-hover-background);
}
.comfy-menu-button-active,
.comfy-menu-button-active:hover {
background: var(--interface-panel-selected-surface);
background-color: var(--content-hover-bg);
}
.keybinding-tag {

View File

@@ -6,8 +6,7 @@
'small-sidebar': isSmall,
'connected-sidebar': isConnected,
'floating-sidebar': !isConnected,
'overflowing-sidebar': isOverflowing,
'border-r border-[var(--interface-stroke)] shadow-interface': isConnected
'overflowing-sidebar': isOverflowing
}"
>
<div
@@ -19,7 +18,7 @@
"
>
<div ref="topToolbarRef" :class="groupClasses">
<ComfyMenuButton />
<ComfyMenuButton :is-small="isSmall" />
<SidebarIcon
v-for="tab in tabs"
:key="tab.id"
@@ -58,7 +57,6 @@ import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
@@ -99,40 +97,10 @@ const isConnected = computed(
const tabs = computed(() => workspaceStore.getSidebarTabs())
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
/**
* Handle sidebar tab icon click.
* - Emits UI button telemetry for known tabs
* - Delegates to the corresponding toggle command
*/
const onTabClick = async (item: SidebarTabExtension) => {
const telemetry = useTelemetry()
const isNodeLibraryTab = item.id === 'node-library'
const isModelLibraryTab = item.id === 'model-library'
const isWorkflowsTab = item.id === 'workflows'
const isAssetsTab = item.id === 'assets'
if (isNodeLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_node_library_selected'
})
else if (isModelLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_model_library_selected'
})
else if (isWorkflowsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_workflows_selected'
})
else if (isAssetsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_assets_media_selected'
})
const onTabClick = async (item: SidebarTabExtension) =>
await commandStore.commands
.find((cmd) => cmd.id === `Workspace.ToggleSidebarTab.${item.id}`)
?.function?.()
}
const keybindingStore = useKeybindingStore()
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
@@ -146,7 +114,7 @@ const isOverflowing = ref(false)
const groupClasses = computed(() =>
cn(
'sidebar-item-group pointer-events-auto flex flex-col items-center overflow-hidden flex-shrink-0' +
(isConnected.value ? '' : ' rounded-lg shadow-interface')
(isConnected.value ? '' : ' rounded-lg shadow-md')
)
)
@@ -215,7 +183,7 @@ onMounted(() => {
--sidebar-padding: 4px;
--sidebar-icon-size: 1rem;
--sidebar-default-floating-width: 48px;
--sidebar-default-floating-width: 56px;
--sidebar-default-connected-width: calc(
var(--sidebar-default-floating-width) + var(--sidebar-padding) * 2
);

View File

@@ -3,7 +3,7 @@
:label="$t('sideToolbar.labels.console')"
:tooltip="$t('menu.toggleBottomPanel')"
:selected="bottomPanelStore.activePanel == 'terminal'"
@click="toggleConsole"
@click="bottomPanelStore.toggleBottomPanel"
>
<template #icon>
<i-ph:terminal-bold />
@@ -12,20 +12,9 @@
</template>
<script setup lang="ts">
import { useTelemetry } from '@/platform/telemetry'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import SidebarIcon from './SidebarIcon.vue'
const bottomPanelStore = useBottomPanelStore()
/**
* Toggle console bottom panel and track UI button click.
*/
const toggleConsole = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_bottom_panel_console_toggled'
})
bottomPanelStore.toggleBottomPanel()
}
</script>

View File

@@ -65,7 +65,6 @@ import { computed, onMounted, toRefs } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import ReleaseNotificationToast from '@/platform/updates/components/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
@@ -105,13 +104,7 @@ const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
/**
* Toggle Help Center and track UI button click.
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_help_center_toggled'
})
helpCenterStore.toggle()
}

View File

@@ -86,11 +86,7 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
}
.side-bar-button-selected {
background-color: var(--interface-panel-selected-surface);
color: var(--content-hover-fg);
}
.side-bar-button:hover {
background-color: var(--interface-panel-hover-surface);
background-color: var(--content-hover-bg);
color: var(--content-hover-fg);
}

View File

@@ -15,7 +15,6 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -35,13 +34,7 @@ const tooltipText = computed(
() => `${t('shortcuts.keyboardShortcuts')} (${formatKeySequence(command)})`
)
/**
* Toggle keyboard shortcuts panel and track UI button click.
*/
const toggleShortcutsPanel = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_shortcuts_panel_toggled'
})
bottomPanelStore.togglePanel('shortcuts')
}
</script>

Some files were not shown because too many files have changed in this diff Show More