mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-07 15:47:53 +00:00
Compare commits
27 Commits
feature/ec
...
uy/agent-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18dec0094a | ||
|
|
6f5a6d773d | ||
|
|
0aedb6360f | ||
|
|
a7b89a4ad8 | ||
|
|
63f5c71a9e | ||
|
|
5654c4edab | ||
|
|
ef57ee29ea | ||
|
|
b751e717b3 | ||
|
|
80b1c3cd71 | ||
|
|
462029b004 | ||
|
|
65021e2b8a | ||
|
|
e8d8ab412c | ||
|
|
7f8e7f7fb2 | ||
|
|
9182ef4948 | ||
|
|
a94b3d541b | ||
|
|
8ce6f6e234 | ||
|
|
b571db1897 | ||
|
|
c1b5a5166c | ||
|
|
11e0446bb8 | ||
|
|
e45a1bed17 | ||
|
|
ddb0a181ea | ||
|
|
927ba00e91 | ||
|
|
8a61e9aa72 | ||
|
|
636608664d | ||
|
|
499a706081 | ||
|
|
fb40f2fdb9 | ||
|
|
2c9cce86d7 |
173
.agents/skills/add-model-page/SKILL.md
Normal file
173
.agents/skills/add-model-page/SKILL.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: add-model-page
|
||||
description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.'
|
||||
---
|
||||
|
||||
# add-model-page
|
||||
|
||||
add, update, or remove model pages in the ComfyUI website.
|
||||
|
||||
## Trigger phrases
|
||||
|
||||
- `Add a model page for <model-name>`
|
||||
- `Update the model page for <model-name>`
|
||||
- `Remove <model-name> from model pages`
|
||||
|
||||
## Phase 1 — Parse the request
|
||||
|
||||
Extract:
|
||||
|
||||
- **action**: `add` | `update` | `remove`
|
||||
- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`)
|
||||
|
||||
Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions.
|
||||
Example: `flux1_dev.safetensors` → `flux1-dev`
|
||||
|
||||
## Architecture overview
|
||||
|
||||
Models come from two sources merged at build time:
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) |
|
||||
| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides |
|
||||
| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` |
|
||||
|
||||
To regenerate the JSON from workflow_templates:
|
||||
|
||||
```bash
|
||||
pnpm tsx apps/website/scripts/generate-models.ts
|
||||
```
|
||||
|
||||
This writes `apps/website/src/config/generated-models.json` directly.
|
||||
Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Gather model data (ADD / UPDATE)
|
||||
|
||||
Run the generator to get fresh data, then find the model:
|
||||
|
||||
```bash
|
||||
pnpm tsx apps/website/scripts/generate-models.ts
|
||||
jq '.[] | select(.slug | contains("MODEL_SLUG"))' \
|
||||
apps/website/src/config/generated-models.json
|
||||
```
|
||||
|
||||
The JSON fields are:
|
||||
|
||||
- `slug` — URL slug
|
||||
- `name` — exact filename or display name for partner nodes
|
||||
- `huggingFaceUrl` — download URL (empty for partner nodes)
|
||||
- `directory` — `diffusion_models` | `loras` | … | `partner_nodes`
|
||||
- `workflowCount` — integer
|
||||
- `displayName` — human-readable name
|
||||
|
||||
If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in
|
||||
`generate-models.ts` and re-run. Otherwise tell the user.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Check for existing entry
|
||||
|
||||
```bash
|
||||
jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \
|
||||
apps/website/src/config/generated-models.json
|
||||
```
|
||||
|
||||
- Match found + action is `add` → switch to UPDATE flow automatically
|
||||
- No match + action is `update` → stop and tell the user
|
||||
|
||||
---
|
||||
|
||||
## Phase 4A — ADD: new partner/API model not in workflow_templates
|
||||
|
||||
For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in
|
||||
`apps/website/scripts/generate-models.ts`:
|
||||
|
||||
```typescript
|
||||
mymodel: { name: 'My Model', slug: 'my-model' },
|
||||
```
|
||||
|
||||
Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear
|
||||
in `generated-models.json` automatically.
|
||||
|
||||
If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`:
|
||||
|
||||
```typescript
|
||||
'my-model': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/...',
|
||||
blogUrl: 'https://blog.comfy.org/...',
|
||||
hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200)
|
||||
featured: true
|
||||
}
|
||||
```
|
||||
|
||||
No changes to `models.ts` or `translations.ts` are needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4B — UPDATE: edit existing entry
|
||||
|
||||
Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl,
|
||||
featured). For `displayName` or `directory` changes, edit the entry directly in
|
||||
`generated-models.json` (until the next generator run would overwrite it — then
|
||||
fix the source in `generate-models.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4C — REMOVE: delete entry
|
||||
|
||||
Remove the entry from `generated-models.json` (or mark it with `canonicalSlug`
|
||||
pointing to the replacement). No translation file changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Verify TypeScript
|
||||
|
||||
```bash
|
||||
pnpm typecheck 2>&1 | grep -E "error|warning" | head -20
|
||||
```
|
||||
|
||||
Fix any type errors before proceeding. Common issues:
|
||||
|
||||
- `ModelDirectory` type not matching a new `directory` value — add it to the union
|
||||
- JSON import shape mismatch — `generated-models.json` must match `OutputModel`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Create PR
|
||||
|
||||
```bash
|
||||
BRANCH="add-model-page-MODEL-SLUG" # or update- / remove-
|
||||
git checkout -b $BRANCH
|
||||
git add apps/website/src/config/generated-models.json \
|
||||
apps/website/scripts/generate-models.ts \
|
||||
apps/website/src/config/model-metadata.ts
|
||||
git commit -m "feat(models): add model page for MODEL-SLUG"
|
||||
git push -u origin $BRANCH
|
||||
gh pr create \
|
||||
--title "Add model page: MODEL-SLUG" \
|
||||
--body "$(cat <<'EOF'
|
||||
Adds a new model page entry for MODEL-SLUG.
|
||||
|
||||
## Changes
|
||||
- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY)
|
||||
- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
For UPDATE use branch `update-model-page-MODEL-SLUG`.
|
||||
For REMOVE use `remove-model-page-MODEL-SLUG`.
|
||||
|
||||
---
|
||||
|
||||
## Error states
|
||||
|
||||
| Situation | Response |
|
||||
| ------------------------------- | ---------------------------------------------------------------- |
|
||||
| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node |
|
||||
| Slug already exists (add) | Switch to update flow automatically |
|
||||
| Slug not found (update/remove) | Stop and ask user to confirm |
|
||||
| Typecheck fails | Fix the error before pushing |
|
||||
84
.agents/skills/adding-deprecation-warnings/SKILL.md
Normal file
84
.agents/skills/adding-deprecation-warnings/SKILL.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: adding-deprecation-warnings
|
||||
description: 'Adds deprecation warnings for renamed or removed properties/APIs. Searches custom node ecosystem for usage, applies defineDeprecatedProperty helper, adds JSDoc. Triggers on: deprecate, deprecation warning, rename property, backward compatibility.'
|
||||
---
|
||||
|
||||
# Adding Deprecation Warnings
|
||||
|
||||
Adds backward-compatible deprecation warnings for renamed or removed
|
||||
properties using the `defineDeprecatedProperty` helper in
|
||||
`src/lib/litegraph/src/utils/feedback.ts`.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A property or API has been renamed and custom nodes still use the old name
|
||||
- A property is being removed but needs a grace period
|
||||
- Backward compatibility must be preserved while nudging adoption
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Search the Custom Node Ecosystem
|
||||
|
||||
Before implementing, assess impact by searching for usage of the
|
||||
deprecated property across ComfyUI custom nodes:
|
||||
|
||||
```text
|
||||
Use the comfy_codesearch tool to search for the old property name.
|
||||
Search for both `widget.oldProp` and just `oldProp` to catch all patterns.
|
||||
```
|
||||
|
||||
Document the usage patterns found (property access, truthiness checks,
|
||||
caching to local vars, style mutation, etc.) — these all must continue
|
||||
working.
|
||||
|
||||
### 2. Apply the Deprecation
|
||||
|
||||
Use `defineDeprecatedProperty` from `src/lib/litegraph/src/utils/feedback.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
|
||||
|
||||
/** @deprecated Use {@link obj.newProp} instead. */
|
||||
defineDeprecatedProperty(
|
||||
obj,
|
||||
'oldProp',
|
||||
'newProp',
|
||||
'obj.oldProp is deprecated. Use obj.newProp instead.'
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Checklist
|
||||
|
||||
- [ ] Ecosystem search completed — all usage patterns are compatible
|
||||
- [ ] `defineDeprecatedProperty` call added after the new property is assigned
|
||||
- [ ] JSDoc `@deprecated` tag added above the call for IDE support
|
||||
- [ ] Warning message names both old and new property clearly
|
||||
- [ ] `pnpm typecheck` passes
|
||||
- [ ] `pnpm lint` passes
|
||||
|
||||
### 4. PR Comment
|
||||
|
||||
Add a PR comment summarizing the ecosystem search results: which repos
|
||||
use the deprecated property, what access patterns were found, and
|
||||
confirmation that all patterns are compatible with the ODP getter/setter.
|
||||
|
||||
## How `defineDeprecatedProperty` Works
|
||||
|
||||
- Creates an `Object.defineProperty` getter/setter on the target object
|
||||
- Getter returns `this[currentKey]`, setter assigns `this[currentKey]`
|
||||
- Both log via `warnDeprecated`, which deduplicates (once per unique
|
||||
message per session via a `Set`)
|
||||
- `enumerable: false` keeps the alias out of `Object.keys()` / `for...in`
|
||||
/ `JSON.stringify`
|
||||
- `configurable: true` allows further redefinition if needed
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Truthiness checks** (`if (widget.oldProp)`) — works, getter fires
|
||||
- **Caching to local var** (`const el = widget.oldProp`) — works, warns
|
||||
once then the cached ref is used directly
|
||||
- **Style/property mutation** (`widget.oldProp.style.color = 'red'`) —
|
||||
works, getter returns the real object
|
||||
- **Serialization** (`JSON.stringify`) — `enumerable: false` excludes it
|
||||
- **Heavy access in loops** — `warnDeprecated` deduplicates, only warns
|
||||
once per session regardless of call count
|
||||
390
.agents/skills/backport-management/SKILL.md
Normal file
390
.agents/skills/backport-management/SKILL.md
Normal file
@@ -0,0 +1,390 @@
|
||||
---
|
||||
name: backport-management
|
||||
description: Manages cherry-pick backports across stable release branches. Discovers candidates from Slack/git, analyzes dependencies, resolves conflicts via worktree, and logs results. Use when asked to backport, cherry-pick to stable, manage release branches, do stable branch maintenance, or run a backport session.
|
||||
---
|
||||
|
||||
# Backport Management
|
||||
|
||||
Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release branches.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap, then **reconcile both lists** (`reference/discovery.md`)
|
||||
2. **Pre-filter by path** — Auto-skip PRs whose changed files are entirely under `apps/website/`, `browser_tests/`, `.github/`, `packages/design-system/`, `packages/{cloud,registry}-types/`, `.Codex/`, `docs/`. Don't read PR bodies for these — they don't ship to core ComfyUI users (`reference/analysis.md`)
|
||||
3. **Verify target file existence** — For each surviving candidate, run `git cat-file -e origin/$TARGET:$path` for primary changed files. If they don't exist on the target, auto-mark SKIP with reason `feature-not-on-branch`
|
||||
4. **Tiered triage** — Bucket into **Tier 1 (core editor must-haves)**, **Tier 2 (cloud-distribution only)**, **Tier 3 (skip)** before reviewing individually (`reference/analysis.md`)
|
||||
5. **Analyze** — Categorize remaining MUST/SHOULD, check deps (`reference/analysis.md`)
|
||||
6. **Human Review** — Present candidates in batches for interactive approval, with tier context attached (see Interactive Approval Flow)
|
||||
7. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
8. **Test-then-resolve dry-run** — Classify clean vs conflict before committing time (`reference/execution.md`)
|
||||
9. **Execute** — Label-driven automation for clean PRs → worktree fallback for conflicts (`reference/execution.md`)
|
||||
10. **Public-API conflict review** — If conflict resolution touches a public LiteGraph callback, extension API, or `node.*` method, consult oracle for compat-regression review BEFORE pushing (`reference/execution.md`)
|
||||
11. **Verify** — Per-PR validation (typecheck + targeted tests + lint on changed files) AND per-wave verification (full typecheck + test:unit on branch HEAD)
|
||||
12. **Log & Report** — Generate session report + author accountability report + Slack status update (`reference/logging.md`)
|
||||
|
||||
## System Context
|
||||
|
||||
| Item | Value |
|
||||
| -------------- | --------------------------------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## CI Safety Rules
|
||||
|
||||
**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs.
|
||||
|
||||
- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails.
|
||||
- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass.
|
||||
- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step.
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
**Critical: Match PRs to the correct target branches.**
|
||||
|
||||
| Branch prefix | Scope | Example |
|
||||
| ------------- | ------------------------------ | ------------------------------------------------- |
|
||||
| `cloud/*` | Cloud-hosted ComfyUI only | Team workspaces, cloud queue, cloud-only login |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
| Both | Shared infrastructure | App mode, Firebase auth (API nodes), payment URLs |
|
||||
|
||||
### What Goes Where
|
||||
|
||||
**Both core + cloud:**
|
||||
|
||||
- **App mode** PRs — app mode is NOT cloud-only
|
||||
- **Firebase auth** PRs — Firebase auth is on core for API nodes
|
||||
- **Payment redirect** PRs — payment infrastructure shared
|
||||
- **Bug fixes** touching shared components
|
||||
|
||||
**Cloud-only (skip for core):**
|
||||
|
||||
- Team workspaces
|
||||
- Cloud queue virtualization
|
||||
- Hide API key login
|
||||
- Cloud-specific UI behind cloud feature flags
|
||||
|
||||
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** But do NOT assume "app mode" or "Firebase" = cloud-only. Check the actual files changed.
|
||||
|
||||
## ⚠️ Gotchas (Learn from Past Sessions)
|
||||
|
||||
### Use `gh api` for Labels — NOT `gh pr edit`
|
||||
|
||||
`gh pr edit --add-label` triggers Projects Classic deprecation errors. Always use:
|
||||
|
||||
```bash
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
|
||||
```
|
||||
|
||||
### Automation Over-Reports Conflicts
|
||||
|
||||
The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-pick -m 1` with git auto-merge handles many cases the automation can't. Always attempt manual cherry-pick before skipping.
|
||||
|
||||
### Never Skip Based on Conflict File Count
|
||||
|
||||
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
|
||||
|
||||
### Accept-Theirs Can Produce Broken Hybrids
|
||||
|
||||
When a PR **rewrites a component** (e.g., PrimeVue → Reka UI), the accept-theirs regex produces a broken mix of old and new code. The template may reference new APIs while the script still has old imports, or vice versa.
|
||||
|
||||
**Detection:** Content conflicts with 4+ conflict markers in a single `.vue` file, especially when imports change between component libraries.
|
||||
|
||||
**Fix:** Instead of accept-theirs regex, use `git show MERGE_SHA:path/to/file > path/to/file` to get the complete correct version from the merge commit on main. This bypasses the conflict entirely.
|
||||
|
||||
### Cherry-Picks Can Reference Missing Dependencies
|
||||
|
||||
When PR A on main depends on code introduced by PR B (which was merged before A), cherry-picking A brings in code that references B's additions. The cherry-pick succeeds but the branch is broken.
|
||||
|
||||
**Common pattern:** Composables, component files, or type definitions introduced by an earlier PR and used by the cherry-picked PR.
|
||||
|
||||
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "is not defined" errors after cherry-pick.
|
||||
|
||||
**Fix:** Use `git show MERGE_SHA:path/to/missing/file > path/to/missing/file` to bring the missing files from main. Always verify with typecheck.
|
||||
|
||||
### Use `--no-verify` for Worktree Pushes
|
||||
|
||||
Husky hooks fail in worktrees (can't find lint-staged config). Always use `git push --no-verify` and `git commit --no-verify` when working in `/tmp/` worktrees.
|
||||
|
||||
### Automation Success Varies Wildly by Branch
|
||||
|
||||
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
|
||||
|
||||
### Cherry-Picked Tests Can Reference Files Added By Earlier Unbackported PRs
|
||||
|
||||
A common conflict: PR A on main modifies a test file that was _added_ on main by an earlier PR B (not backported to the target). The cherry-pick of A reports "modify/delete" on B's test file because the file doesn't exist on the target. Adding the new file would smuggle in B's test scaffolding without B's runtime changes.
|
||||
|
||||
**Detection:** Conflict says `deleted in HEAD and modified in <PR>`. Verify with:
|
||||
|
||||
```bash
|
||||
git log --diff-filter=A --oneline origin/main -- path/to/test.ts
|
||||
```
|
||||
|
||||
If the introducing commit is **not** on the target branch, the test file isn't a real prerequisite for the runtime fix.
|
||||
|
||||
**Fix:** `git rm` the test file (drop it from the backport). Document in the commit body which PR introduced it on main and why dropping it is safe. The runtime fix itself usually doesn't depend on these tests — coverage exists at the integration layer.
|
||||
|
||||
### Backport-Only Compatibility Shims
|
||||
|
||||
When a PR's _mechanism_ relies on changes upstream that aren't on the older branch, a literal cherry-pick can recreate the original bug for any consumer still using the old contract. This is most dangerous for **public LiteGraph callbacks, extension APIs, and `node.*` methods** that custom-node packages depend on.
|
||||
|
||||
**Real example (#11541, core/1.43 backport):** The PR removed `LGraphNode.vue`'s legacy `handled === true` sync-return check from `handleDrop`, replacing it with `await node.onDragDrop(event, true)`. Safe on `main` because all in-repo `onDragDrop` handlers had migrated to participate in the new `claimEvent` flag. On `core/1.43`, `onDragDrop` is a public callback — custom-node packages with synchronous `onDragDrop` returning `true` would no longer have their event claimed, recreating the duplicate-node-creation bug the PR was fixing.
|
||||
|
||||
**Detection:** The PR's diff modifies a file that is part of a public extension API surface. Look for:
|
||||
|
||||
- `node.onXxx` callback assignments
|
||||
- Methods on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
|
||||
- Public exports from `src/lib/litegraph/`
|
||||
- Type changes affecting `litegraph-augmentation.d.ts`
|
||||
|
||||
**Fix:** Add a backport-only compatibility shim that preserves the old contract while keeping the new fix. Document it explicitly in the commit body under a `## Backport-only compatibility fix` heading. Consult oracle for review before pushing — a bad shim is worse than no fix.
|
||||
|
||||
## Conflict Triage
|
||||
|
||||
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
|
||||
|
||||
| Type | Symptom | Resolution |
|
||||
| ---------------------------- | ------------------------------------ | --------------------------------------------------------------- |
|
||||
| **Binary snapshots (PNGs)** | `.png` files in conflict list | `git checkout --theirs $FILE && git add $FILE` — always trivial |
|
||||
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
|
||||
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
|
||||
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
|
||||
| **Component rewrites** | 4+ markers in `.vue`, library change | Use `git show SHA:path > path` — do NOT accept-theirs |
|
||||
| **Import-only conflicts** | Only import lines differ | Keep both imports if both used; remove unused after |
|
||||
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
|
||||
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
|
||||
|
||||
```python
|
||||
# Accept theirs for content conflicts
|
||||
import re
|
||||
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
|
||||
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
|
||||
```
|
||||
|
||||
### Escalation Triggers (Flag for Human)
|
||||
|
||||
- **Package.json/lockfile changes** → skip on stable (transitive dep regression risk)
|
||||
- **Core type definition changes** → requires human judgment
|
||||
- **Business logic conflicts** (not just imports/exports) → requires domain knowledge
|
||||
- **Admin-merged conflict resolutions** → get human review of the resolution before continuing the wave
|
||||
|
||||
## Auto-Skip Categories
|
||||
|
||||
Skip these without discussion:
|
||||
|
||||
- **Dep refresh PRs** — Risk of transitive dep regressions on stable. Cherry-pick individual CVE fixes instead.
|
||||
- **CI/tooling changes** — Not user-facing
|
||||
- **Test-only / lint rule changes** — Not user-facing
|
||||
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
|
||||
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
|
||||
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
|
||||
|
||||
### Path Pre-Filter (run BEFORE reading PR bodies)
|
||||
|
||||
For 50+ candidate PRs, classify by changed paths first to skip the unproductive ones without spending time on triage. Run `git show --stat $SHA` (or `gh pr view --json files`) and bucket:
|
||||
|
||||
| Path prefix | Bucket | Reason |
|
||||
| ---------------------------------------------- | ---------------------- | ------------------------------------------------ |
|
||||
| `apps/website/` | SKIP | Marketing/platform site, not core ComfyUI bundle |
|
||||
| `apps/desktop-ui/` | SKIP for `core/*` | Desktop app, separate release cadence |
|
||||
| `browser_tests/` only (no `src/`) | SKIP | Test-only |
|
||||
| `.github/workflows/` only | SKIP | CI/release infra |
|
||||
| `packages/design-system/` only | SKIP | Design tokens, not core |
|
||||
| `packages/{cloud,registry,ingest}-types/` only | SKIP | Generated types |
|
||||
| `.Codex/`, `.agents/`, `docs/` | SKIP | Agent / documentation |
|
||||
| `*.stories.ts` only | SKIP | Storybook only |
|
||||
| `src/` (core editor) | KEEP — analyze further | Runtime/editor code that requires full triage |
|
||||
|
||||
A PR touches multiple paths? Keep it if **any** changed file is under `src/` (or other core paths) and run normal analysis. Auto-skip is conservative — only skip when _all_ paths match the SKIP buckets.
|
||||
|
||||
This filter alone removes ~30-50% of candidates in a typical session, leaving only the PRs that need real triage.
|
||||
|
||||
## Wave Verification
|
||||
|
||||
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:
|
||||
|
||||
```bash
|
||||
# Fetch latest state of target branch
|
||||
git fetch origin TARGET_BRANCH
|
||||
|
||||
# Quick smoke check: does the branch build?
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
### Fix PRs Are Normal
|
||||
|
||||
Expect to create 1 fix PR per branch after verification. Common issues:
|
||||
|
||||
1. **Component rewrite hybrids** — accept-theirs produced broken `.vue` files. Fix: overwrite with correct version from merge commit via `git show SHA:path > path`
|
||||
2. **Missing dependency files** — cherry-pick brought in code referencing composables/components not on the branch. Fix: add missing files from merge commit
|
||||
3. **Missing type properties** — cherry-picked code uses interface properties not yet on the branch (e.g., `key` on `ConfirmDialogOptions`). Fix: add the property to the interface
|
||||
4. **Unused imports** — conflict resolution kept imports that the branch doesn't use. Fix: remove unused imports
|
||||
5. **Wrong types from conflict resolution** — e.g., `{ top: number; right: number }` vs `{ top: number; left: number }`. Fix: match the return type of the actual function
|
||||
|
||||
Create a fix PR on a branch from the target, verify typecheck passes, then merge with `--squash --admin`.
|
||||
|
||||
### Never Admin-Merge Without CI
|
||||
|
||||
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuous backporting:
|
||||
|
||||
- Backport bug fixes as they merge to main (same day or next day)
|
||||
- Use the automation labels immediately after merge
|
||||
- Reserve session-style bulk backporting for catching up after gaps
|
||||
- When a release branch is created, immediately start the continuous process
|
||||
|
||||
## Interactive Approval Flow
|
||||
|
||||
After analysis, present ALL candidates (MUST, SHOULD, and borderline) to the human for interactive review before execution. Do not write a static decisions.md — collect approvals in conversation.
|
||||
|
||||
### Batch Presentation
|
||||
|
||||
Present PRs in batches of 5-10, grouped by theme (visual bugs, interaction bugs, cloud/auth, data correctness, etc.). Use this table format:
|
||||
|
||||
```
|
||||
# | PR | Title | Target | Rec | Context
|
||||
----+--------+------------------------------------------+---------------+------+--------
|
||||
1 | #12345 | fix: broken thing | core+cloud/42 | Y | Description here. Why it matters. Agent reasoning.
|
||||
2 | #12346 | fix: another issue | core/42 | N | Only affects removed feature. Not on target branch.
|
||||
```
|
||||
|
||||
Each row includes:
|
||||
|
||||
- PR number and title
|
||||
- Target branches
|
||||
- Agent recommendation: `Rec: Y` or `Rec: N` with brief reasoning
|
||||
- 2-3 sentence context: what the PR does, why it matters (or doesn't)
|
||||
|
||||
### Human Response Format
|
||||
|
||||
- `Y` — approve for backport
|
||||
- `N` — skip
|
||||
- `?` — investigate (agent shows PR description, files changed, detailed take, then re-asks)
|
||||
- Any freeform question or comment triggers discussion before moving on
|
||||
- Bulk responses accepted (e.g. `1 Y, 2 Y, 3 N, 4 ?`)
|
||||
|
||||
### Rules
|
||||
|
||||
- ALL candidates are reviewed, not just MUST items
|
||||
- When human responds `?`, show the PR description, files changed, and agent's detailed analysis, then re-ask for their decision
|
||||
- When human asks a question about a PR, answer with context and recommendation, then wait for their decision
|
||||
- Do not proceed to execution until all batches are reviewed and every candidate has a Y or N
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Label-Driven Automation (default path)
|
||||
|
||||
```bash
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
|
||||
# Wait 3 min, check: gh pr list --base TARGET_BRANCH --state open
|
||||
```
|
||||
|
||||
### Manual Worktree Cherry-Pick (conflict fallback)
|
||||
|
||||
```bash
|
||||
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
|
||||
cd /tmp/backport-$BRANCH
|
||||
|
||||
# For each PR:
|
||||
git fetch origin $BRANCH
|
||||
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
# Resolve conflicts (see Conflict Triage)
|
||||
git push origin backport-$PR-to-$BRANCH --no-verify
|
||||
gh pr create --base $BRANCH --head backport-$PR-to-$BRANCH \
|
||||
--title "[backport $BRANCH] $TITLE (#$PR)" \
|
||||
--body "Backport of #$PR. [conflict notes]"
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 25
|
||||
```
|
||||
|
||||
### Efficient Batch: Test-Then-Resolve Pattern
|
||||
|
||||
When many PRs need manual cherry-pick (e.g., cloud branches), test all first:
|
||||
|
||||
```bash
|
||||
cd /tmp/backport-$BRANCH
|
||||
for pr in "${ORDER[@]}"; do
|
||||
git checkout -b test-$pr origin/$BRANCH
|
||||
if git cherry-pick -m 1 $SHA 2>/dev/null; then
|
||||
echo "CLEAN: $pr"
|
||||
else
|
||||
echo "CONFLICT: $pr"
|
||||
git cherry-pick --abort
|
||||
fi
|
||||
git checkout --detach HEAD
|
||||
git branch -D test-$pr
|
||||
done
|
||||
```
|
||||
|
||||
Then process clean PRs in a batch loop, conflicts individually.
|
||||
|
||||
### PR Title Convention
|
||||
|
||||
```
|
||||
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
|
||||
```
|
||||
|
||||
## Final Deliverables (Slack-Compatible)
|
||||
|
||||
After execution completes, generate two files in `~/temp/backport-session/`. Both must be **Slack-compatible plain text** — no emojis, no markdown tables, no headers (`#`), no bold (`**`), no inline code. Use plain dashes, indentation, and line breaks only.
|
||||
|
||||
### 1. Author Accountability Report
|
||||
|
||||
File: `backport-author-accountability.md`
|
||||
|
||||
Lists all backported PRs grouped by original author (via `gh pr view $PR --json author`). Surfaces who should be self-labeling.
|
||||
|
||||
```
|
||||
Backport Session YYYY-MM-DD -- PRs that should have been labeled by authors
|
||||
|
||||
- author-login
|
||||
- #1234 fix: short title
|
||||
- #5678 fix: another title
|
||||
- other-author
|
||||
- #9012 fix: some other fix
|
||||
```
|
||||
|
||||
Authors sorted alphabetically, 4-space indent for nested items.
|
||||
|
||||
### 2. Slack Status Update
|
||||
|
||||
File: `slack-status-update.md`
|
||||
|
||||
A shareable summary of the session. Structure:
|
||||
|
||||
```
|
||||
Backport session complete -- YYYY-MM-DD
|
||||
|
||||
[1-sentence summary: N PRs backported to which branches. All pass typecheck.]
|
||||
|
||||
Branches updated:
|
||||
- core/X.XX: N PRs + N fix PRs (N auto, N manual)
|
||||
- cloud/X.XX: N PRs + N fix PRs (N auto, N manual)
|
||||
- ...
|
||||
|
||||
N total PRs created and merged (N backports + N fix PRs).
|
||||
|
||||
Notable fixes included:
|
||||
- [category]: [list of fixes]
|
||||
- ...
|
||||
|
||||
Conflict patterns encountered:
|
||||
- [pattern and how it was resolved]
|
||||
- ...
|
||||
|
||||
N authors had PRs backported. See author accountability list for details.
|
||||
```
|
||||
|
||||
No emojis, no tables, no bold, no headers. Plain text that pastes cleanly into Slack.
|
||||
149
.agents/skills/backport-management/reference/analysis.md
Normal file
149
.agents/skills/backport-management/reference/analysis.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Analysis & Decision Framework
|
||||
|
||||
## Categorization
|
||||
|
||||
| Category | Criteria | Action |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| **MUST** | User-facing bug, crash, data corruption, security. Clear breakage that users will hit. | Backport (with deps if needed) |
|
||||
| **SHOULD** | UX improvement, minor bug, small dep chain. No user-visible breakage if skipped, but improves experience. | Backport if clean cherry-pick; defer if conflict resolution is non-trivial |
|
||||
| **SKIP** | CI/tooling, test-only, lint rules, cosmetic, dep refresh | Skip with documented reason |
|
||||
| **NEEDS DISCUSSION** | Large dep chain, unclear risk/benefit, touches core types | Flag for human |
|
||||
|
||||
### MUST vs SHOULD Decision Guide
|
||||
|
||||
When unsure, ask: "If a user on this stable branch reports this issue, would we consider it a bug?"
|
||||
|
||||
- **Yes** → MUST. The fix addresses broken behavior.
|
||||
- **No, but it's noticeably better** → SHOULD. The fix is a quality-of-life improvement.
|
||||
- **No, and it's cosmetic or internal** → SKIP.
|
||||
|
||||
For SHOULD items with conflicts: if conflict resolution requires more than trivial accept-theirs patterns (content conflicts in business logic, not just imports), downgrade to SKIP or escalate to NEEDS DISCUSSION.
|
||||
|
||||
## Branch Scope Filtering
|
||||
|
||||
**Before categorizing, filter by branch scope:**
|
||||
|
||||
| Target branch | Skip if PR is... |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `core/*` | Cloud-only (team workspaces, cloud queue, cloud-only login). Note: app mode and Firebase auth are NOT cloud-only. |
|
||||
| `cloud/*` | Local-only features not present on cloud branch |
|
||||
|
||||
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
|
||||
|
||||
## Features Not on Stable Branches
|
||||
|
||||
Check before backporting — these don't exist on older branches:
|
||||
|
||||
- **Painter** (`src/extensions/core/painter.ts`) — not on core/1.40
|
||||
- **GLSLShader** — not on core/1.40
|
||||
- **App builder** — check per branch
|
||||
- **appModeStore.ts** — not on core/1.40
|
||||
|
||||
### Verify Target File Existence (Run Before Cherry-Pick)
|
||||
|
||||
Before cherry-picking any PR, confirm the files it modifies actually exist on the target branch. If they don't, the PR's runtime fix is for a feature that hasn't been added yet — skip cleanly without attempting cherry-pick:
|
||||
|
||||
```bash
|
||||
# For each file the PR changes
|
||||
for f in $(gh pr view $PR --json files --jq '.files[].path' | grep -v "^browser_tests/\|\.test\." ); do
|
||||
if ! git cat-file -e origin/$TARGET:$f 2>/dev/null; then
|
||||
echo "MISSING on $TARGET: $f"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
If the _primary_ changed files (the runtime ones, not tests) are missing, mark the PR `SKIP / feature-not-on-branch`. This is faster than letting cherry-pick fail with modify/delete conflicts and gives a clean signal.
|
||||
|
||||
This check is the first thing that runs after the path pre-filter and BEFORE you spend time reading PR descriptions.
|
||||
|
||||
## Tiered Triage (Recommended for 30+ Candidates)
|
||||
|
||||
Before the interactive Y/N approval flow, bucket all surviving candidates into three tiers. This surfaces release-engineering decisions that a flat MUST/SHOULD list obscures:
|
||||
|
||||
### Tier 1 — Core Editor Must-Haves
|
||||
|
||||
User-facing bugs, crashes, data corruption, or security issues in code paths that exist on the target branch. These are the strongest backport candidates.
|
||||
|
||||
Indicators:
|
||||
|
||||
- `fix:` prefix and the bug is reproducible on the target branch
|
||||
- Crash guards, runtime null checks, race-condition fixes
|
||||
- Data-loss bugs (state not persisted, duplicates, drops)
|
||||
- Security hardening (CSRF, XSS, auth)
|
||||
- Vue Nodes 2.0 regression cluster (if the target ships Vue Nodes 2.0)
|
||||
- Subgraph correctness fixes
|
||||
- Public-API extension callback fixes
|
||||
|
||||
Recommend `Y` to user.
|
||||
|
||||
### Tier 2 — Cloud-Distribution Only
|
||||
|
||||
Bugs that only manifest on cloud-hosted distributions (Secrets panel, subscription flows, cloud signup, workspace tracking, etc.). Whether to backport depends on whether cloud ships from the target `core/*` branch in your release matrix.
|
||||
|
||||
Indicators:
|
||||
|
||||
- Files under `src/platform/secrets/`, `src/platform/subscription/`, signup flows
|
||||
- PR description mentions cloud staging issues
|
||||
- Fix gated behind cloud feature flags
|
||||
|
||||
Default: ask the cloud release rotation owner. If unsure, defer.
|
||||
|
||||
### Tier 3 — Skip
|
||||
|
||||
Path pre-filter caught most of these. The rest are PRs where the diff _touches_ `src/` but the practical impact is non-user-facing or scoped to features the target doesn't ship.
|
||||
|
||||
Indicators:
|
||||
|
||||
- All changes in test files even if the PR touched `src/` test files
|
||||
- Storybook stories only
|
||||
- Lint config / lint rule additions
|
||||
- Documentation comments
|
||||
- Internal refactors with no behavior change
|
||||
|
||||
### Presentation Format
|
||||
|
||||
When showing tier results to the user, format as:
|
||||
|
||||
```text
|
||||
Tier 1 (N PRs) — strong backport candidates
|
||||
- #11541 fix: stop duplicate node creation when dropping image on Vue nodes
|
||||
Why: Vue Nodes 2.0 regression — async onDragDrop bypassed handled-check, drops bubble to document, spawns extra LoadImage nodes
|
||||
- #10849 fix: store promoted widget values per SubgraphNode instance
|
||||
Why: Multiple instances overwriting each other's promoted widget values — data loss
|
||||
|
||||
Tier 2 (N PRs) — cloud-distribution release rotation should decide
|
||||
- #11636 fix: enable Chrome password autofill on signup form
|
||||
- ...
|
||||
|
||||
Tier 3 (N PRs) — skip recommended
|
||||
- #11586 fix: website polish (apps/website/ only)
|
||||
- ...
|
||||
```
|
||||
|
||||
Then run interactive Y/N over Tier 1 and Tier 2; Tier 3 gets confirmed-skip without per-PR review.
|
||||
|
||||
## Dep Refresh PRs
|
||||
|
||||
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.
|
||||
|
||||
## Revert Pairs
|
||||
|
||||
If PR A is reverted by PR B:
|
||||
|
||||
- Skip BOTH A and B
|
||||
- If a fixed version exists (PR C), backport only C
|
||||
|
||||
## Dependency Analysis
|
||||
|
||||
```bash
|
||||
# Find other PRs that touched the same files
|
||||
gh pr view $PR --json files --jq '.files[].path' | while read f; do
|
||||
git log --oneline origin/TARGET..$MERGE_SHA -- "$f"
|
||||
done
|
||||
```
|
||||
|
||||
## Human Review Checkpoint
|
||||
|
||||
Use the Interactive Approval Flow (see SKILL.md) to review all candidates interactively. Do not write a static decisions.md for the human to edit — instead, present batches of 5-10 PRs with context and recommendations, and collect Y/N/? responses in conversation.
|
||||
|
||||
All candidates must be reviewed (MUST, SHOULD, and borderline items), not just a subset.
|
||||
84
.agents/skills/backport-management/reference/discovery.md
Normal file
84
.agents/skills/backport-management/reference/discovery.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Discovery — Candidate Collection
|
||||
|
||||
**Run all sources, then reconcile.** No single source is authoritative:
|
||||
|
||||
- Slack bot may flag PRs that have already been backported (false positive)
|
||||
- Git gap may include PRs that don't need backport (test-only, design-system, website)
|
||||
- Bot can also miss PRs that landed without the right labels
|
||||
|
||||
## Source 1: Slack Backport-Checker Bot
|
||||
|
||||
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
|
||||
|
||||
```bash
|
||||
slackdump export -o ~/slack-exports/frontend-releases.zip C09K9TPU2G7
|
||||
```
|
||||
|
||||
Parse bot messages for PRs flagged "Might need backport" per release version.
|
||||
|
||||
## Source 2: Git Log Gap Analysis
|
||||
|
||||
```bash
|
||||
# Count gap
|
||||
git log --oneline origin/TARGET..origin/main | wc -l
|
||||
|
||||
# List gap commits
|
||||
git log --oneline origin/TARGET..origin/main
|
||||
|
||||
# Check if a PR is already on target
|
||||
git log --oneline origin/TARGET --grep="#PR_NUMBER"
|
||||
|
||||
# Check for existing backport PRs
|
||||
gh pr list --base TARGET --state all --search "backport PR_NUMBER"
|
||||
```
|
||||
|
||||
## Source 3: GitHub PR Details
|
||||
|
||||
```bash
|
||||
# Get merge commit SHA
|
||||
gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.mergeCommit.oid)"'
|
||||
|
||||
# Get files changed
|
||||
gh pr view $PR --json files --jq '.files[].path'
|
||||
```
|
||||
|
||||
## Source 4: Already-Backported PRs (cross-reference)
|
||||
|
||||
When the target branch already has some cherry-picks on it (e.g., partway through a release window), extract the originals to avoid re-backporting:
|
||||
|
||||
```bash
|
||||
# Get all original PR numbers already backported to TARGET since the last release tag
|
||||
git log --format="%H%n%B" $LAST_TAG..origin/$TARGET \
|
||||
| grep -oiE "(backport of|cherry.picked) #?[0-9]+" \
|
||||
| grep -oE "[0-9]+" \
|
||||
| sort -un > /tmp/already-backported.txt
|
||||
```
|
||||
|
||||
Subtract this list from your candidates.
|
||||
|
||||
## Reconciliation Workflow
|
||||
|
||||
```bash
|
||||
# 1. Slack bot list (parse from export)
|
||||
# /tmp/bot-flagged.txt — one PR# per line, sorted
|
||||
|
||||
# 2. Git gap fix/perf only
|
||||
MB=$(git merge-base origin/main origin/$TARGET)
|
||||
git log --format="%h|%s" $MB..origin/main \
|
||||
| grep -iE "^[a-f0-9]+\|(fix|perf)" \
|
||||
| grep -oE "#[0-9]+\)" | grep -oE "[0-9]+" \
|
||||
| sort -un > /tmp/gap-fixes.txt
|
||||
|
||||
# 3. Already backported (Source 4 above)
|
||||
|
||||
# 4. Candidates = (gap-fixes ∪ bot-flagged) − already-backported
|
||||
sort -u /tmp/gap-fixes.txt /tmp/bot-flagged.txt > /tmp/union.txt
|
||||
comm -23 /tmp/union.txt /tmp/already-backported.txt > /tmp/candidates.txt
|
||||
```
|
||||
|
||||
The result is the input to the path pre-filter (`SKILL.md` Quick Start step 2).
|
||||
|
||||
## Output: candidate_list.md
|
||||
|
||||
Table per target branch:
|
||||
| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision |
|
||||
380
.agents/skills/backport-management/reference/execution.md
Normal file
380
.agents/skills/backport-management/reference/execution.md
Normal file
@@ -0,0 +1,380 @@
|
||||
# Execution Workflow
|
||||
|
||||
## Per-Branch Execution Order
|
||||
|
||||
1. Smallest gap first (validation run)
|
||||
2. Medium gap next (quick win)
|
||||
3. Largest gap last (main effort)
|
||||
|
||||
## Step 0: Test-Then-Resolve Pre-Pass (Recommended)
|
||||
|
||||
Before triggering label-driven automation, run a dry-run cherry-pick loop to classify candidates. This is much faster than discovering conflicts after-the-fact across automation, manual cherry-picks, and CI failures.
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/dryrun-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/dryrun-TARGET
|
||||
|
||||
CLEAN=()
|
||||
CONFLICT=()
|
||||
for pr in "${CANDIDATES[@]}"; do
|
||||
SHA=$(gh pr view $pr --json mergeCommit --jq '.mergeCommit.oid')
|
||||
git checkout -b dryrun-$pr origin/TARGET_BRANCH 2>/dev/null
|
||||
if git cherry-pick -m 1 $SHA 2>/dev/null; then
|
||||
CLEAN+=($pr)
|
||||
else
|
||||
CONFLICT+=($pr)
|
||||
git cherry-pick --abort
|
||||
fi
|
||||
git checkout --detach HEAD 2>/dev/null
|
||||
git branch -D dryrun-$pr 2>/dev/null
|
||||
done
|
||||
|
||||
echo "CLEAN (${#CLEAN[@]}): ${CLEAN[*]}"
|
||||
echo "CONFLICT (${#CONFLICT[@]}): ${CONFLICT[*]}"
|
||||
|
||||
cd -
|
||||
git worktree remove /tmp/dryrun-TARGET --force
|
||||
```
|
||||
|
||||
Use the result to:
|
||||
|
||||
- Send CLEAN PRs through label-driven automation (Step 1) — they'll typically self-merge
|
||||
- Reserve manual worktree time (Step 3) for CONFLICT PRs only
|
||||
- Surface PRs likely to need backport-only compat shims (CONFLICT files in `src/lib/litegraph/` or `src/scripts/app.ts`)
|
||||
|
||||
## Step 1: Label-Driven Automation (Batch)
|
||||
|
||||
```bash
|
||||
# Add labels to all candidates for a target branch
|
||||
for pr in $PR_LIST; do
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$pr/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH" --silent
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those.
|
||||
|
||||
## Step 2: Wait for CI & Merge Clean Auto-PRs
|
||||
|
||||
Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures:
|
||||
|
||||
```bash
|
||||
# Wait for CI to complete (~45 minutes for full suite)
|
||||
sleep 2700
|
||||
|
||||
# Check which PRs are still open (CI may have failed, or auto-merge succeeded)
|
||||
STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number')
|
||||
RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt)
|
||||
|
||||
# For PRs still open, check CI status
|
||||
for pr in $STILL_OPEN_PRS; do
|
||||
CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length')
|
||||
CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length')
|
||||
if [ "$CI_FAILED" != "0" ]; then
|
||||
# CI failed — collect details for triage
|
||||
echo "PR #$pr — CI FAILED:"
|
||||
gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"'
|
||||
elif [ "$CI_PENDING" != "0" ]; then
|
||||
echo "PR #$pr — CI still running ($CI_PENDING checks pending)"
|
||||
else
|
||||
# All checks passed but didn't auto-merge (race condition or label issue)
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/backport-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/backport-TARGET
|
||||
|
||||
for PR in ${CONFLICT_PRS[@]}; do
|
||||
# Refresh target ref so each branch is based on current HEAD
|
||||
git fetch origin TARGET_BRANCH
|
||||
git checkout origin/TARGET_BRANCH
|
||||
|
||||
git checkout -b backport-$PR-to-TARGET origin/TARGET_BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
|
||||
# If conflict — NEVER skip based on file count alone!
|
||||
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add, component rewrites
|
||||
# See SKILL.md Conflict Triage table for resolution per type.
|
||||
|
||||
# For component rewrites (4+ markers in a .vue file, library migration):
|
||||
# DO NOT use accept-theirs regex — it produces broken hybrids.
|
||||
# Instead, use the complete file from the merge commit:
|
||||
# git show $MERGE_SHA:path/to/file > path/to/file
|
||||
|
||||
# For simple content conflicts, accept theirs:
|
||||
# python3 -c "import re; ..."
|
||||
|
||||
# Resolve all conflicts, then:
|
||||
git add .
|
||||
GIT_EDITOR=true git cherry-pick --continue
|
||||
|
||||
# ── Public-API conflict review (REQUIRED for extension-API surfaces) ──
|
||||
# If the conflict resolution touched any of these surfaces, consult oracle
|
||||
# BEFORE pushing. A bad shim is worse than no fix:
|
||||
# - node.onXxx callback assignments (onDragDrop, onConnectionsChange, onRemoved, onConfigure, etc.)
|
||||
# - Methods on LGraphNode, LGraphCanvas, LGraph, Subgraph
|
||||
# - Public exports from src/lib/litegraph/
|
||||
# - Type changes in litegraph-augmentation.d.ts
|
||||
# If a public callback's signature/contract changed: add a backport-only
|
||||
# compatibility shim that preserves the OLD contract while keeping the
|
||||
# new fix. Document it in the commit body under
|
||||
# "## Backport-only compatibility fix". See SKILL.md gotcha section.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit`
|
||||
# with no path filter would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
pnpm typecheck
|
||||
|
||||
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
pnpm test:unit "${TEST_FILES[@]}"
|
||||
else
|
||||
echo "No changed test files — skipping targeted unit tests"
|
||||
fi
|
||||
|
||||
mapfile -t CODE_FILES < <(git diff --name-only HEAD~1 | grep -E '\.(ts|vue)$' || true)
|
||||
if [ ${#CODE_FILES[@]} -gt 0 ]; then
|
||||
pnpm exec eslint "${CODE_FILES[@]}"
|
||||
pnpm exec oxfmt --check "${CODE_FILES[@]}"
|
||||
else
|
||||
echo "No changed ts/vue files — skipping targeted lint/format"
|
||||
fi
|
||||
|
||||
git push origin backport-$PR-to-TARGET --no-verify
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
|
||||
# Wait for CI before merging — NEVER admin-merge without CI passing
|
||||
echo "Waiting for CI on PR #$NEW_PR..."
|
||||
gh pr checks $NEW_PR --watch --fail-fast || {
|
||||
echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage"
|
||||
continue
|
||||
}
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
cd -
|
||||
git worktree remove /tmp/backport-TARGET --force
|
||||
```
|
||||
|
||||
**⚠️ Human review for conflict resolutions:** When admin-merging a PR where you manually resolved conflicts (especially content conflicts beyond trivial accept-theirs), pause and present the resolution diff to the human for review before merging. Trivial resolutions (binary snapshots, modify/delete, locale key additions) can proceed without review.
|
||||
|
||||
## Step 4: Wave Verification
|
||||
|
||||
After completing all PRs in a wave for a target branch:
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If verification fails, **do not skip** — create a fix PR:
|
||||
|
||||
```bash
|
||||
# Stay in the verify worktree
|
||||
git checkout -b fix-backport-TARGET origin/TARGET_BRANCH
|
||||
|
||||
# Common fixes:
|
||||
# 1. Component rewrite hybrids: overwrite with merge commit version
|
||||
git show MERGE_SHA:path/to/Component.vue > path/to/Component.vue
|
||||
|
||||
# 2. Missing dependency files
|
||||
git show MERGE_SHA:path/to/missing.ts > path/to/missing.ts
|
||||
|
||||
# 3. Missing type properties: edit the interface
|
||||
# 4. Unused imports: delete the import lines
|
||||
|
||||
git add -A
|
||||
git commit --no-verify -m "fix: resolve backport typecheck issues on TARGET"
|
||||
git push origin fix-backport-TARGET --no-verify
|
||||
gh pr create --base TARGET --head fix-backport-TARGET --title "fix: resolve backport typecheck issues on TARGET" --body "..."
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
Do not proceed to the next branch until typecheck passes.
|
||||
|
||||
## Conflict Resolution Patterns
|
||||
|
||||
### 1. Content Conflicts (accept theirs)
|
||||
|
||||
```python
|
||||
import re
|
||||
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
|
||||
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
|
||||
```
|
||||
|
||||
### 2. Modify/Delete (two cases!)
|
||||
|
||||
```bash
|
||||
# Case A: PR introduces NEW files not on target → keep them
|
||||
git add $FILE
|
||||
|
||||
# Case B: Target REMOVED files the PR modifies → drop them
|
||||
git rm $FILE
|
||||
```
|
||||
|
||||
### 3. Binary Files (snapshots)
|
||||
|
||||
```bash
|
||||
git checkout --theirs $FILE && git add $FILE
|
||||
```
|
||||
|
||||
### 4. Component Rewrites (DO NOT accept-theirs)
|
||||
|
||||
When a PR completely rewrites a component (e.g., PrimeVue → Reka UI), accept-theirs produces
|
||||
a broken hybrid with mismatched template/script sections.
|
||||
|
||||
```bash
|
||||
# Use the complete correct file from the merge commit instead:
|
||||
git show $MERGE_SHA:src/components/input/MultiSelect.vue > src/components/input/MultiSelect.vue
|
||||
git show $MERGE_SHA:src/components/input/SingleSelect.vue > src/components/input/SingleSelect.vue
|
||||
git add src/components/input/MultiSelect.vue src/components/input/SingleSelect.vue
|
||||
```
|
||||
|
||||
**Detection:** 4+ conflict markers in a single `.vue` file, imports changing between component
|
||||
libraries (PrimeVue → Reka UI, etc.), template structure completely different on each side.
|
||||
|
||||
### 5. Missing Dependencies After Cherry-Pick
|
||||
|
||||
Cherry-picks can succeed but leave the branch broken because the PR's code on main
|
||||
references composables/components introduced by an earlier PR.
|
||||
|
||||
```bash
|
||||
# Add the missing file from the merge commit:
|
||||
git show $MERGE_SHA:src/composables/queue/useJobDetailsHover.ts > src/composables/queue/useJobDetailsHover.ts
|
||||
git show $MERGE_SHA:src/components/builder/BuilderSaveDialogContent.vue > src/components/builder/BuilderSaveDialogContent.vue
|
||||
```
|
||||
|
||||
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "X is not defined" after cherry-pick succeeds cleanly.
|
||||
|
||||
### 6. Locale Files
|
||||
|
||||
Usually adding new i18n keys — accept theirs, validate JSON:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('src/locales/en/main.json'))" && echo "Valid"
|
||||
```
|
||||
|
||||
## Merge Conflicts After Other Merges
|
||||
|
||||
When merging multiple PRs to the same branch, later PRs may conflict with earlier merges:
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git rebase origin/TARGET_BRANCH
|
||||
# Resolve new conflicts
|
||||
git push --force origin backport-$PR-to-TARGET
|
||||
sleep 20 # Wait for GitHub to recompute merge state
|
||||
# Wait for CI after rebase before merging
|
||||
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **Automation reports more conflicts than reality** — `cherry-pick -m 1` with git auto-merge handles many "conflicts" the automation can't
|
||||
2. **Never skip based on conflict file count** — 12 or 27 conflicts can be trivial (snapshots, new files). Categorize first: binary PNGs, modify/delete, content, add/add.
|
||||
3. **Modify/delete goes BOTH ways** — if the PR introduces new files (not on target), `git add` them. If target deleted files the PR modifies, `git rm`.
|
||||
4. **Binary snapshot PNGs** — always `git checkout --theirs && git add`. Never skip a PR just because it has many snapshot conflicts.
|
||||
5. **Batch label additions need 2s delay** between API calls to avoid rate limits
|
||||
6. **Merging 6+ PRs rapidly** can cause later PRs to become unmergeable — wait 20-30s for GitHub to recompute merge state
|
||||
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
|
||||
8. **Always validate JSON** after resolving locale file conflicts
|
||||
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
|
||||
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **App mode and Firebase auth are NOT cloud-only** — they go to both core and cloud branches. Only team workspaces, cloud queue, and cloud-specific login are cloud-only.
|
||||
12. **Never admin-merge without CI** — `--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
|
||||
13. **Accept-theirs regex breaks component rewrites** — when a PR migrates between component libraries (PrimeVue → Reka UI), the regex produces a broken hybrid. Use `git show SHA:path > path` to get the complete correct version instead.
|
||||
14. **Cherry-picks can silently bring in missing-dependency code** — if PR A references a composable introduced by PR B, cherry-picking A succeeds but typecheck fails. Always run typecheck after each wave and add missing files from the merge commit.
|
||||
15. **Fix PRs are expected** — plan for 1 fix PR per branch to resolve typecheck issues from conflict resolutions. This is normal, not a failure.
|
||||
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
|
||||
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
|
||||
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
|
||||
19. **Public-API conflict resolutions need oracle review** — when a conflict touches `node.onXxx` callbacks, `LGraphNode`/`LGraphCanvas`/`LGraph`/`Subgraph` methods, or types in `litegraph-augmentation.d.ts`, consult oracle BEFORE pushing. Custom-node packages depend on these contracts. A literal cherry-pick of a refactor-style fix can silently break extensions still using the old contract — sometimes recreating the very bug the PR was fixing. Document any backport-only compatibility shim explicitly in the commit body.
|
||||
20. **Cherry-picked tests can require unbackported test scaffolding** — when a PR modifies a test file that was _added_ on main by an earlier unbackported PR, the cherry-pick reports modify/delete on that file. Drop it from the backport (`git rm`) and document which PR introduced it. Don't smuggle in test infrastructure without its runtime prerequisites.
|
||||
21. **Per-PR validation catches issues earlier than wave verification** — for high-stakes branches, run `pnpm typecheck && pnpm exec eslint <changed files> && pnpm exec oxfmt --check` per PR before pushing. Wave verification still matters (it catches cross-PR interactions), but per-PR makes attribution trivial when something fails.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
When CI fails on a backport PR, present failures to the user using this template:
|
||||
|
||||
```markdown
|
||||
### PR #XXXX — CI Failed
|
||||
|
||||
- **Failing check:** test / lint / typecheck
|
||||
- **Error:** (summary of the failure message)
|
||||
- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch
|
||||
- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites
|
||||
```
|
||||
|
||||
Common failure categories:
|
||||
|
||||
| Category | Example | Resolution |
|
||||
| --------------------------- | ---------------------------------------- | ----------------------------------------- |
|
||||
| Test without implementation | Test references function not on branch | Backport the implementation PR first |
|
||||
| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip |
|
||||
| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch |
|
||||
| Flaky test | Passes on retry | Re-run CI, merge if green on retry |
|
||||
| Type error | Interface changed on main but not branch | May need manual adaptation |
|
||||
|
||||
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
|
||||
|
||||
## PR Body Template (Manual Cherry-Picks)
|
||||
|
||||
Manual cherry-pick PRs need detail beyond the automation's terse default. Use this template — reviewers will look here before re-deriving conflict-resolution logic from the diff.
|
||||
|
||||
```markdown
|
||||
Manual backport of #ORIG to `TARGET` for inclusion in `vX.Y.Z`.
|
||||
|
||||
Cherry-picked from upstream merge commit `SHORT_SHA`.
|
||||
|
||||
## Why
|
||||
|
||||
[1-2 sentences from the original PR's "Summary" — what bug, what fix mechanism]
|
||||
|
||||
## Conflict resolution
|
||||
|
||||
- **`path/to/file`** — [what conflicted on this branch] → [resolution chosen + why]
|
||||
- **`path/to/dropped-test.test.ts`** — added on main by unrelated PR #XXXX (not backported). Dropped from this backport; runtime fix intact.
|
||||
- [...]
|
||||
|
||||
## Backport-only compatibility fix (if applicable)
|
||||
|
||||
[If you added a shim that wasn't in the upstream PR, document it here — what extension surface, what contract, what the shim preserves, why the upstream version would have regressed it]
|
||||
|
||||
## Validation
|
||||
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm test:unit <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
[If manual e2e was skipped, explain why — e.g., requires live backend, headless not feasible. State that source is byte-identical to upstream + how long it's been baking on main.]
|
||||
|
||||
Original PR: #ORIG / Original commit: `FULL_SHA`
|
||||
```
|
||||
|
||||
The conflict-resolution section is non-negotiable — every conflict you resolved by hand needs a one-liner. This makes archaeology trivial six months later when someone asks "why does this look slightly different from main?"
|
||||
103
.agents/skills/backport-management/reference/logging.md
Normal file
103
.agents/skills/backport-management/reference/logging.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Logging & Session Reports
|
||||
|
||||
## During Execution
|
||||
|
||||
Maintain `execution-log.md` with per-branch tables (this is internal, markdown tables are fine here):
|
||||
|
||||
```markdown
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------ | ----------- | ------- |
|
||||
| #XXXX | Title | merged | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
|
||||
Track verification results per wave within execution-log.md:
|
||||
|
||||
```markdown
|
||||
Wave N Verification -- TARGET_BRANCH
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: pass / fail
|
||||
- Fix PR: #YYYY (if needed)
|
||||
- Issues found: (if any)
|
||||
```
|
||||
|
||||
## Session Report Template
|
||||
|
||||
```markdown
|
||||
# Backport Session Report
|
||||
|
||||
## Summary
|
||||
|
||||
| Branch | Candidates | Merged | Skipped | Deferred | Rate |
|
||||
| ------ | ---------- | ------ | ------- | -------- | ---- |
|
||||
|
||||
## Deferred Items (Needs Human)
|
||||
|
||||
| PR# | Title | Branch | Issue |
|
||||
|
||||
## Conflict Resolutions Requiring Review
|
||||
|
||||
| PR# | Branch | Conflict Type | Resolution Summary |
|
||||
|
||||
## CI Failure Report
|
||||
|
||||
| PR# | Branch | Failing Check | Error Summary | Cause | Resolution |
|
||||
| --- | ------ | ------------- | ------------- | ----- | ---------- |
|
||||
|
||||
## Automation Performance
|
||||
|
||||
| Metric | Value |
|
||||
| --------------------------- | ----- |
|
||||
| Auto success rate | X% |
|
||||
| Manual resolution rate | X% |
|
||||
| Overall clean rate | X% |
|
||||
| Wave verification pass rate | X% |
|
||||
|
||||
## Process Recommendations
|
||||
|
||||
- Were there clusters of related PRs that should have been backported together?
|
||||
- Any PRs that should have been backported sooner (continuous backporting candidates)?
|
||||
- Feature branches that need tracking for future sessions?
|
||||
```
|
||||
|
||||
## Final Deliverables
|
||||
|
||||
After all branches are complete and verified, generate these files in `~/temp/backport-session/`:
|
||||
|
||||
### 1. execution-log.md (internal)
|
||||
|
||||
Per-branch tables with PR#, title, status, backport PR#, notes. Markdown tables are fine — this is for internal tracking, not Slack.
|
||||
|
||||
### 2. backport-author-accountability.md (Slack-compatible)
|
||||
|
||||
See SKILL.md "Final Deliverables" section. Plain text, no emojis/tables/headers/bold. Authors sorted alphabetically with PRs nested under each.
|
||||
|
||||
### 3. slack-status-update.md (Slack-compatible)
|
||||
|
||||
See SKILL.md "Final Deliverables" section. Plain text summary that pastes cleanly into Slack. Includes branch counts, notable fixes, conflict patterns, author count.
|
||||
|
||||
## Slack Formatting Rules
|
||||
|
||||
Both shareable files (author accountability + status update) must follow these rules:
|
||||
|
||||
- No emojis (no checkmarks, no arrows, no icons)
|
||||
- No markdown tables (use plain lists with dashes)
|
||||
- No headers (no # or ##)
|
||||
- No bold (\*_) or italic (_)
|
||||
- No inline code backticks
|
||||
- Use -- instead of em dash
|
||||
- Use plain dashes (-) for lists with 4-space indent for nesting
|
||||
- Line breaks between sections for readability
|
||||
|
||||
These files should paste directly into a Slack message and look clean.
|
||||
|
||||
## Files to Track
|
||||
|
||||
All in `~/temp/backport-session/`:
|
||||
|
||||
- `execution-plan.md` -- approved PRs with merge SHAs (input)
|
||||
- `execution-log.md` -- real-time status with per-branch tables (internal)
|
||||
- `backport-author-accountability.md` -- PRs grouped by author (Slack-compatible)
|
||||
- `slack-status-update.md` -- session summary (Slack-compatible)
|
||||
99
.agents/skills/contain-audit/SKILL.md
Normal file
99
.agents/skills/contain-audit/SKILL.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: contain-audit
|
||||
description: 'Detect DOM elements where CSS contain:layout+style would improve rendering performance. Runs a Playwright-based audit on a large workflow, scores candidates by subtree size and sizing constraints, measures performance impact, and generates a ranked report.'
|
||||
---
|
||||
|
||||
# CSS Containment Audit
|
||||
|
||||
Automatically finds DOM elements where adding `contain: layout style` would reduce browser recalculation overhead.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Loads a large workflow (245 nodes) in a real browser
|
||||
2. Walks the DOM tree and scores every element as a containment candidate
|
||||
3. For each high-scoring candidate, applies `contain: layout style` via JavaScript
|
||||
4. Measures rendering performance (style recalcs, layouts, task duration) before and after
|
||||
5. Takes before/after screenshots to detect visual breakage
|
||||
6. Generates a ranked report with actionable recommendations
|
||||
|
||||
## When to Use
|
||||
|
||||
- After adding new Vue components to the node rendering pipeline
|
||||
- When investigating rendering performance on large workflows
|
||||
- Before and after refactoring node DOM structure
|
||||
- As part of periodic performance audits
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Start the dev server first
|
||||
pnpm dev &
|
||||
|
||||
# Run the audit (uses the @audit tag, not included in normal CI runs)
|
||||
pnpm exec playwright test browser_tests/tests/containAudit.spec.ts --project=audit
|
||||
|
||||
# View the HTML report
|
||||
pnpm exec playwright show-report
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
The audit outputs a table to the console:
|
||||
|
||||
```text
|
||||
CSS Containment Audit Results
|
||||
=======================================================
|
||||
Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual
|
||||
1 | [data-testid="node-inner-wrap"] | 18 | 72 | -34% | -12% | OK
|
||||
2 | .node-body | 12 | 48 | -8% | -3% | OK
|
||||
3 | .node-header | 4 | 16 | +1% | 0% | OK
|
||||
```
|
||||
|
||||
- **Subtree**: Number of descendant elements (higher = more to skip)
|
||||
- **Score**: Composite heuristic score (subtree size x sizing constraint bonus)
|
||||
- **DRecalcs / DLayouts**: Change in style recalcs / layout counts vs baseline (negative = improvement)
|
||||
- **Visual**: OK if no pixel change, DIFF if screenshot differs (may include subpixel noise — verify manually)
|
||||
|
||||
## Candidate Scoring
|
||||
|
||||
An element is a good containment candidate when:
|
||||
|
||||
1. **Large subtree** -- many descendants that the browser can skip recalculating
|
||||
2. **Externally constrained size** -- width/height determined by CSS variables, flex, or explicit values (not by content)
|
||||
3. **No existing containment** -- `contain` is not already applied
|
||||
4. **Not a leaf** -- has at least a few child elements
|
||||
|
||||
Elements that should NOT get containment:
|
||||
|
||||
- Elements whose children overflow visually beyond bounds (e.g., absolute-positioned overlays with negative inset)
|
||||
- Elements whose height is determined by content and affects sibling layout
|
||||
- Very small subtrees (overhead of containment context outweighs benefit)
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cannot fully guarantee `contain` safety -- visual review of screenshots is required
|
||||
- Performance measurements have natural variance; run multiple times for confidence
|
||||
- Only tests idle and pan scenarios; widget interactions may differ
|
||||
- The audit modifies styles at runtime via JS, which doesn't account for Tailwind purging or build-time optimizations
|
||||
|
||||
## Example PR
|
||||
|
||||
[#9946 — fix: add CSS contain:layout contain:style to node inner wrapper](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946)
|
||||
|
||||
This PR added `contain-layout contain-style` to the node inner wrapper div in `LGraphNode.vue`. The audit tool would have flagged this element as a high-scoring candidate because:
|
||||
|
||||
- **Large subtree** (18+ descendants: header, slots, widgets, content, badges)
|
||||
- **Externally constrained size** (`w-(--node-width)`, `flex-1` — dimensions set by CSS variables and flex parent)
|
||||
- **Natural isolation boundary** between frequently-changing content (widgets) and infrequently-changing overlays (selection outlines, borders)
|
||||
|
||||
The actual change was a single line: adding `'contain-layout contain-style'` to the inner wrapper's class list at `src/renderer/extensions/vueNodes/components/LGraphNode.vue:79`.
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| Audit test | `browser_tests/tests/containAudit.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf tests | `browser_tests/tests/performance.spec.ts` |
|
||||
| Large workflow | `browser_tests/assets/large-graph-workflow.json` |
|
||||
| Example PR | https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946 |
|
||||
246
.agents/skills/hardening-flaky-e2e-tests/SKILL.md
Normal file
246
.agents/skills/hardening-flaky-e2e-tests/SKILL.md
Normal file
@@ -0,0 +1,246 @@
|
||||
---
|
||||
name: hardening-flaky-e2e-tests
|
||||
description: 'Diagnoses and fixes flaky Playwright e2e tests by replacing race-prone patterns with retry-safe alternatives. Use when triaging CI flakes, hardening spec files, fixing timing races, or asked to stabilize browser tests. Triggers on: flaky, flake, harden, stabilize, race condition in e2e, intermittent failure.'
|
||||
---
|
||||
|
||||
# Hardening Flaky E2E Tests
|
||||
|
||||
Fix flaky Playwright specs by identifying race-prone patterns and replacing them with retry-safe alternatives. This skill covers diagnosis, pattern matching, and mechanical transforms — not writing new tests (see `writing-playwright-tests` for that).
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Gather CI Evidence
|
||||
|
||||
```bash
|
||||
gh run list --workflow=ci-test.yaml --limit=5
|
||||
gh run download <run-id> -n playwright-report
|
||||
```
|
||||
|
||||
- Open `report.json` and search for `"status": "flaky"` entries.
|
||||
- Collect file paths, test titles, and error messages.
|
||||
- Do NOT trust green checks alone — flaky tests that passed on retry still need fixing.
|
||||
- Use `error-context.md`, traces, and page snapshots before editing code.
|
||||
- Pull the newest run after each push instead of assuming the flaky set is unchanged.
|
||||
|
||||
### 2. Classify the Flake
|
||||
|
||||
Read the failing assertion and match it against the pattern table. Most flakes fall into one of these categories:
|
||||
|
||||
| # | Pattern | Signature in Code | Fix |
|
||||
| --- | ------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| 1 | **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| 2 | **Immediate count** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
|
||||
| 3 | **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
|
||||
| 4 | **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000 ms; prefer default 5000 ms |
|
||||
| 5 | **Immediate evaluate after mutation** | `setSetting(k, v); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| 6 | **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
|
||||
| 7 | **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
|
||||
| 8 | **Fake readiness helper** | Helper clicks but doesn't assert state | Remove; poll the actual value |
|
||||
| 9 | **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
|
||||
| 10 | **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width))` |
|
||||
|
||||
### 3. Apply the Transform
|
||||
|
||||
#### Rule: Choose the Smallest Correct Assertion
|
||||
|
||||
- **Locator state** → use built-in retrying assertions: `toBeVisible()`, `toHaveText()`, `toHaveCount()`, `toHaveClass()`
|
||||
- **Single async value** → `expect.poll(() => asyncFn()).toBe(expected)`
|
||||
- **Multiple assertions that must settle together** → `expect(async () => { ... }).toPass()`
|
||||
- **Never** use `waitForTimeout()` to hide a race.
|
||||
|
||||
```typescript
|
||||
// ✅ Single value — use expect.poll
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.graph.links.length))
|
||||
.toBe(3)
|
||||
|
||||
// ✅ Locator count — use toHaveCount
|
||||
await expect(comfyPage.page.locator('.dom-widget')).toHaveCount(2)
|
||||
|
||||
// ✅ Multiple conditions — use toPass
|
||||
await expect(async () => {
|
||||
expect(await node1.getValue()).toBe('foo')
|
||||
expect(await node2.getValue()).toBe('bar')
|
||||
}).toPass({ timeout: 5000 })
|
||||
```
|
||||
|
||||
#### Rule: Wait for the Real Readiness Boundary
|
||||
|
||||
Visible is not always ready. Prefer user-facing assertions when possible; poll internal state only when there is no UI surface to assert on.
|
||||
|
||||
Common readiness boundaries:
|
||||
|
||||
| After this action... | Wait for... |
|
||||
| -------------------------------------- | ------------------------------------------------------------ |
|
||||
| Canvas interaction (drag, click node) | `await comfyPage.nextFrame()` |
|
||||
| Menu item click | `await contextMenu.waitForHidden()` |
|
||||
| Workflow load | `await comfyPage.workflow.loadWorkflow(...)` (built-in wait) |
|
||||
| Settings write | Poll the setting value with `expect.poll()` |
|
||||
| Node pin/bypass/collapse toggle | `await expect.poll(() => nodeRef.isPinned()).toBe(true)` |
|
||||
| Graph mutation (add/remove node, link) | Poll link/node count |
|
||||
| Clipboard write | Poll pasted value |
|
||||
| Screenshot | Ensure nodes are rendered: `waitForNodes()` or poll state |
|
||||
|
||||
#### Rule: Expose Locators for Retrying Assertions
|
||||
|
||||
When a helper returns a count via `await loc.count()`, callers can't use `toHaveCount()`. Expose the underlying `Locator` as a getter so callers choose between:
|
||||
|
||||
```typescript
|
||||
// Helper exposes locator
|
||||
get domWidgets(): Locator {
|
||||
return this.page.locator('.dom-widget')
|
||||
}
|
||||
|
||||
// Caller uses retrying assertion
|
||||
await expect(comfyPage.domWidgets).toHaveCount(2)
|
||||
```
|
||||
|
||||
Replace count methods with locator getters so callers can use retrying assertions directly.
|
||||
|
||||
#### Rule: Fix Check-then-Act Races in Helpers
|
||||
|
||||
```typescript
|
||||
// ❌ Race: count can change between check and waitFor
|
||||
const count = await locator.count()
|
||||
if (count > 0) {
|
||||
await locator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
// ✅ Direct: waitFor handles both cases
|
||||
await locator.waitFor({ state: 'hidden' })
|
||||
```
|
||||
|
||||
#### Rule: Remove force:true from Clicks
|
||||
|
||||
`force: true` bypasses actionability checks, hiding real animation/visibility races. Remove it and fix the underlying timing issue.
|
||||
|
||||
```typescript
|
||||
// ❌ Hides the race
|
||||
await closeButton.click({ force: true })
|
||||
|
||||
// ✅ Surfaces the real issue — fix with proper wait
|
||||
await closeButton.click()
|
||||
await dialog.waitForHidden()
|
||||
```
|
||||
|
||||
#### Rule: Handle Non-deterministic Element Order
|
||||
|
||||
When `getNodeRefsByType` returns multiple nodes, the order is not guaranteed. Don't use index `[0]` blindly.
|
||||
|
||||
```typescript
|
||||
// ❌ Assumes order
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
|
||||
// ✅ Find by ID or proximity
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
let target = nodes[0]
|
||||
for (const n of nodes) {
|
||||
const pos = await n.getPosition()
|
||||
if (Math.abs(pos.y - expectedY) < minDist) target = n
|
||||
}
|
||||
```
|
||||
|
||||
Or guard the assumption:
|
||||
|
||||
```typescript
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(nodes).toHaveLength(1)
|
||||
const node = nodes[0]
|
||||
```
|
||||
|
||||
#### Rule: Use toPass for Timing-sensitive Dismiss Guards
|
||||
|
||||
Some UI elements (e.g. LiteGraph's graphdialog) have built-in dismiss delays. Retry the entire dismiss action:
|
||||
|
||||
```typescript
|
||||
// ✅ Retry click+assert together
|
||||
await expect(async () => {
|
||||
await comfyPage.canvas.click({ position: { x: 10, y: 10 } })
|
||||
await expect(dialog).toBeHidden({ timeout: 500 })
|
||||
}).toPass({ timeout: 5000 })
|
||||
```
|
||||
|
||||
### 4. Keep Changes Narrow
|
||||
|
||||
- Shared helpers should drive setup to a stable boundary.
|
||||
- Do not encode one-spec timing assumptions into generic helpers.
|
||||
- If a race only matters to one spec, prefer a local wait in that spec.
|
||||
- If a helper fails before the real test begins, remove or relax the brittle precondition and let downstream UI interaction prove readiness.
|
||||
|
||||
### 5. Verify Narrowly
|
||||
|
||||
```bash
|
||||
# Targeted rerun with repetition
|
||||
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts --repeat-each 10
|
||||
|
||||
# Single test by line number (avoids grep quoting issues on Windows)
|
||||
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts:42
|
||||
```
|
||||
|
||||
- Use `--repeat-each 10` for targeted flake verification (use 20 for single test cases).
|
||||
- Verify with the smallest command that exercises the flaky path.
|
||||
|
||||
### 6. Watch CI E2E Runs
|
||||
|
||||
After pushing, use `gh` to monitor the E2E workflow:
|
||||
|
||||
```bash
|
||||
# Find the run for the current branch
|
||||
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1
|
||||
|
||||
# Watch it live (blocks until complete, streams logs)
|
||||
gh run watch <run-id>
|
||||
|
||||
# One-liner: find and watch the latest E2E run for the current branch
|
||||
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId" | xargs gh run watch
|
||||
```
|
||||
|
||||
On Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
# One-liner equivalent
|
||||
gh run watch (gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId")
|
||||
```
|
||||
|
||||
After the run completes:
|
||||
|
||||
```bash
|
||||
# Download the Playwright report artifact
|
||||
gh run download <run-id> -n playwright-report
|
||||
|
||||
# View the run summary in browser
|
||||
gh run view <run-id> --web
|
||||
```
|
||||
|
||||
Also watch the unit test workflow in parallel if you changed helpers:
|
||||
|
||||
```bash
|
||||
gh run list --workflow="CI: Tests Unit" --branch=$(git branch --show-current) --limit=1
|
||||
```
|
||||
|
||||
### 7. Pre-merge Checklist
|
||||
|
||||
Before merging a flaky-test fix, confirm:
|
||||
|
||||
- [ ] The latest CI artifact was inspected directly
|
||||
- [ ] The root cause is stated as a race or readiness mismatch
|
||||
- [ ] The fix waits on the real readiness boundary
|
||||
- [ ] The assertion primitive matches the job (poll vs toHaveCount vs toPass)
|
||||
- [ ] The fix stays local unless a shared helper truly owns the race
|
||||
- [ ] Local verification uses a targeted rerun
|
||||
- [ ] No behavioral changes to the test — only timing/retry strategy updated
|
||||
|
||||
## Local Noise — Do Not Fix
|
||||
|
||||
These are local distractions, not CI root causes:
|
||||
|
||||
- Missing local input fixture files required by the test path
|
||||
- Missing local models directory
|
||||
- Teardown `EPERM` while restoring the local browser-test user data directory
|
||||
- Local screenshot baseline differences on Windows
|
||||
|
||||
Rules:
|
||||
|
||||
- First confirm whether it blocks the exact flaky path under investigation.
|
||||
- Do not commit temporary local assets used only for verification.
|
||||
- Do not commit local screenshot baselines.
|
||||
82
.agents/skills/layer-audit/SKILL.md
Normal file
82
.agents/skills/layer-audit/SKILL.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: layer-audit
|
||||
description: 'Detect violations of the layered architecture import rules (base -> platform -> workbench -> renderer). Runs ESLint with the import-x/no-restricted-paths rule and generates a grouped report.'
|
||||
---
|
||||
|
||||
# Layer Architecture Audit
|
||||
|
||||
Finds imports that violate the layered architecture boundary rules enforced by `import-x/no-restricted-paths` in `eslint.config.ts`.
|
||||
|
||||
## Layer Hierarchy (bottom to top)
|
||||
|
||||
```
|
||||
renderer (top -- can import from all lower layers)
|
||||
^
|
||||
workbench
|
||||
^
|
||||
platform
|
||||
^
|
||||
base (bottom -- cannot import from any upper layer)
|
||||
```
|
||||
|
||||
Each layer may only import from layers below it.
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Run ESLint filtering for just the layer boundary rule violations
|
||||
pnpm lint 2>&1 | grep 'import-x/no-restricted-paths' -B1 | head -200
|
||||
```
|
||||
|
||||
To get a full structured report, run:
|
||||
|
||||
```bash
|
||||
# Collect all violations from base/, platform/, workbench/ layers
|
||||
pnpm eslint src/base/ src/platform/ src/workbench/ --no-error-on-unmatched-pattern --rule '{"import-x/no-restricted-paths": "warn"}' --format compact 2>&1 | grep 'no-restricted-paths' | sort
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
Each violation line shows:
|
||||
|
||||
- The **file** containing the bad import
|
||||
- The **import path** crossing the boundary
|
||||
- The **message** identifying which layer pair is violated
|
||||
|
||||
### Grouping by Layer Pair
|
||||
|
||||
After collecting violations, group them by the layer pair pattern:
|
||||
|
||||
| Layer pair | Meaning |
|
||||
| --------------------- | ----------------------------------- |
|
||||
| base -> platform | base/ importing from platform/ |
|
||||
| base -> workbench | base/ importing from workbench/ |
|
||||
| base -> renderer | base/ importing from renderer/ |
|
||||
| platform -> workbench | platform/ importing from workbench/ |
|
||||
| platform -> renderer | platform/ importing from renderer/ |
|
||||
| workbench -> renderer | workbench/ importing from renderer/ |
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before creating a PR that adds imports between `src/base/`, `src/platform/`, `src/workbench/`, or `src/renderer/`
|
||||
- When auditing the codebase to find and plan migration of existing violations
|
||||
- After moving files between layers to verify no new violations were introduced
|
||||
|
||||
## Fixing Violations
|
||||
|
||||
Common strategies to resolve a layer violation:
|
||||
|
||||
1. **Move the import target down** -- if the imported module doesn't depend on upper-layer concepts, move it to a lower layer
|
||||
2. **Introduce an interface** -- define an interface/type in the lower layer and implement it in the upper layer via dependency injection or a registration pattern
|
||||
3. **Move the importing file up** -- if the file logically belongs in a higher layer, relocate it
|
||||
4. **Extract shared logic** -- pull the shared functionality into `base/` or a shared utility
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ------------------------------- | ------------------ |
|
||||
| ESLint config (rule definition) | `eslint.config.ts` |
|
||||
| Base layer | `src/base/` |
|
||||
| Platform layer | `src/platform/` |
|
||||
| Workbench layer | `src/workbench/` |
|
||||
| Renderer layer | `src/renderer/` |
|
||||
179
.agents/skills/perf-fix-with-proof/SKILL.md
Normal file
179
.agents/skills/perf-fix-with-proof/SKILL.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
name: perf-fix-with-proof
|
||||
description: 'Ships performance fixes with CI-proven improvement using stacked PRs. PR1 adds a @perf test (establishes baseline on main), PR2 adds the fix (CI shows delta). Use when implementing a perf optimization and wanting to prove it in CI.'
|
||||
---
|
||||
|
||||
# Performance Fix with Proof
|
||||
|
||||
Ships perf fixes as two stacked PRs so CI automatically proves the improvement.
|
||||
|
||||
## Why Two PRs
|
||||
|
||||
The `ci-perf-report.yaml` workflow compares PR metrics against the **base branch baseline**. If you add a new `@perf` test in the same PR as the fix, that test doesn't exist on main yet — no baseline, no delta, no proof. Stacking solves this:
|
||||
|
||||
1. **PR1 (test-only)** — adds the `@perf` test that exercises the bottleneck. Merges to main. CI runs it on main → baseline established.
|
||||
2. **PR2 (fix)** — adds the optimization. CI runs the same test → compares against PR1's baseline → delta shows improvement.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Create the test branch
|
||||
|
||||
```bash
|
||||
git worktree add <worktree-path> -b perf/test-<name> origin/main
|
||||
```
|
||||
|
||||
### Step 2: Write the `@perf` test
|
||||
|
||||
Add a test to `browser_tests/tests/performance.spec.ts` (or a new file with `@perf` tag). The test should stress the specific bottleneck.
|
||||
|
||||
**Test structure:**
|
||||
|
||||
```typescript
|
||||
test('<descriptive name>', async ({ comfyPage }) => {
|
||||
// 1. Load a workflow that exercises the bottleneck
|
||||
await comfyPage.workflow.loadWorkflow('<workflow>')
|
||||
|
||||
// 2. Start measuring
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// 3. Perform the action that triggers the bottleneck (at scale)
|
||||
for (let i = 0; i < N; i++) {
|
||||
// ... stress the hot path ...
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// 4. Stop measuring and record
|
||||
const m = await comfyPage.perf.stopMeasuring('<metric-name>')
|
||||
recordMeasurement(m)
|
||||
console.log(`<name>: ${m.styleRecalcs} recalcs, ${m.layouts} layouts`)
|
||||
})
|
||||
```
|
||||
|
||||
**Available metrics** (from `PerformanceHelper`):
|
||||
|
||||
- `m.styleRecalcs` / `m.styleRecalcDurationMs` — style recalculation count and time
|
||||
- `m.layouts` / `m.layoutDurationMs` — forced layout count and time
|
||||
- `m.taskDurationMs` — total main-thread JS execution time
|
||||
- `m.heapDeltaBytes` — memory pressure delta
|
||||
|
||||
**Key helpers** (from `ComfyPage`):
|
||||
|
||||
- `comfyPage.perf.startMeasuring()` / `.stopMeasuring(name)` — CDP metrics capture
|
||||
- `comfyPage.nextFrame()` — wait one animation frame
|
||||
- `comfyPage.workflow.loadWorkflow(name)` — load a test workflow from `browser_tests/assets/`
|
||||
- `comfyPage.canvas` — the canvas locator
|
||||
- `comfyPage.page.mouse.move(x, y)` — mouse interaction
|
||||
|
||||
### Step 3: Add test workflow asset (if needed)
|
||||
|
||||
If the bottleneck needs a specific workflow (e.g., 50+ nodes, many DOM widgets), add it to `browser_tests/assets/`. Keep it minimal — only the structure needed to trigger the bottleneck.
|
||||
|
||||
### Step 4: Verify locally
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test --project=performance --grep "<test name>"
|
||||
```
|
||||
|
||||
Confirm the test runs and produces reasonable metric values.
|
||||
|
||||
### Step 5: Create PR1 (test-only)
|
||||
|
||||
```bash
|
||||
pnpm typecheck:browser
|
||||
pnpm lint
|
||||
git add browser_tests/
|
||||
git commit -m "test: add perf test for <bottleneck description>"
|
||||
git push -u origin perf/test-<name>
|
||||
gh pr create --title "test: add perf test for <bottleneck>" \
|
||||
--body "Adds a @perf test to establish a baseline for <bottleneck>.
|
||||
|
||||
This is PR 1 of 2. The fix will follow in a separate PR once this baseline is established on main.
|
||||
|
||||
## What
|
||||
Adds \`<test-name>\` to the performance test suite measuring <metric> during <action>.
|
||||
|
||||
## Why
|
||||
Needed to prove the improvement from the upcoming fix for backlog item #<N>." \
|
||||
--base main
|
||||
```
|
||||
|
||||
### Step 6: Get PR1 merged
|
||||
|
||||
Once PR1 merges, CI runs the test on main → baseline artifact saved.
|
||||
|
||||
### Step 7: Create PR2 (fix) on top of main
|
||||
|
||||
```bash
|
||||
git worktree add <worktree-path> -b perf/fix-<name> origin/main
|
||||
```
|
||||
|
||||
Implement the fix. The `@perf` test from PR1 is now on main and will run automatically. CI will:
|
||||
|
||||
1. Run the test on the PR branch
|
||||
2. Download the baseline from main (which includes PR1's test results)
|
||||
3. Post a PR comment showing the delta
|
||||
|
||||
### Step 8: Verify the improvement shows in CI
|
||||
|
||||
The `ci-perf-report.yaml` posts a comment like:
|
||||
|
||||
```markdown
|
||||
## ⚡ Performance Report
|
||||
|
||||
| Metric | Baseline | PR (n=3) | Δ | Sig |
|
||||
| --------------------- | -------- | -------- | ---- | --- |
|
||||
| <name>: style recalcs | 450 | 12 | -97% | 🟢 |
|
||||
```
|
||||
|
||||
If Δ is negative for the target metric, the fix is proven.
|
||||
|
||||
## Test Design Guidelines
|
||||
|
||||
1. **Stress the specific bottleneck** — don't measure everything, isolate the hot path
|
||||
2. **Use enough iterations** — the test should run long enough that the metric difference is clear (100+ frames for idle tests, 50+ interactions for event tests)
|
||||
3. **Keep it deterministic** — avoid timing-dependent assertions; measure counts not durations when possible
|
||||
4. **Match the backlog entry** — reference the backlog item number in the test name or PR description
|
||||
|
||||
## Examples
|
||||
|
||||
**Testing DOM widget reactive mutations (backlog #8):**
|
||||
|
||||
```typescript
|
||||
test('DOM widget positioning recalculations', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
// Idle for 120 frames — DOM widgets update position every frame
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const m = await comfyPage.perf.stopMeasuring('dom-widget-idle')
|
||||
recordMeasurement(m)
|
||||
})
|
||||
```
|
||||
|
||||
**Testing measureText caching (backlog #4):**
|
||||
|
||||
```typescript
|
||||
test('canvas text rendering with many nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('large-workflow-50-nodes')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const m = await comfyPage.perf.stopMeasuring('text-rendering-50-nodes')
|
||||
recordMeasurement(m)
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| Perf test file | `browser_tests/tests/performance.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
|
||||
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
|
||||
| Report generator | `scripts/perf-report.ts` |
|
||||
| Stats utilities | `scripts/perf-stats.ts` |
|
||||
| Backlog | `docs/perf/BACKLOG.md` (local only, not committed) |
|
||||
| Playbook | `docs/perf/PLAYBOOK.md` (local only, not committed) |
|
||||
221
.agents/skills/red-green-fix/SKILL.md
Normal file
221
.agents/skills/red-green-fix/SKILL.md
Normal file
@@ -0,0 +1,221 @@
|
||||
---
|
||||
name: red-green-fix
|
||||
description: 'Bug fix workflow that proves test validity with a red-then-green CI sequence. Commits a failing test first (CI red), then the minimal fix (CI green). Use when fixing a bug, writing a regression test, or when asked to prove a fix works.'
|
||||
---
|
||||
|
||||
# Red-Green Fix
|
||||
|
||||
Fixes bugs as two commits so CI automatically proves the test catches the bug.
|
||||
|
||||
## Why Two Commits
|
||||
|
||||
If you commit the test and fix together, the test always passes — reviewers cannot tell whether the test actually detects the bug or is a no-op. Splitting into two commits creates a verifiable CI trail:
|
||||
|
||||
1. **Commit 1 (test-only)** — adds a test that exercises the bug. CI runs it → test fails → red X.
|
||||
2. **Commit 2 (fix)** — adds the minimal fix. CI runs the same test → test passes → green check.
|
||||
|
||||
The red-then-green sequence in the commit history proves the test is valid.
|
||||
|
||||
## Input
|
||||
|
||||
The user provides a bug description as an argument. If no description is given, ask the user to describe the bug before proceeding.
|
||||
|
||||
Bug description: $ARGUMENTS
|
||||
|
||||
## Step 0 — Setup
|
||||
|
||||
Create an isolated branch from main:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git checkout -b fix/<bug-name> origin/main
|
||||
```
|
||||
|
||||
## Step 1 — Red: Failing Test Only
|
||||
|
||||
Write a test that reproduces the bug. **Do NOT write any fix code.**
|
||||
|
||||
### Choosing the Test Framework
|
||||
|
||||
| Bug type | Framework | File location |
|
||||
| --------------------------------- | ---------- | ------------------------------- |
|
||||
| Logic, utils, stores, composables | Vitest | `src/**/*.test.ts` (colocated) |
|
||||
| UI interaction, canvas, workflows | Playwright | `browser_tests/tests/*.spec.ts` |
|
||||
|
||||
For Playwright tests, follow the `/writing-playwright-tests` skill for patterns, fixtures, and tags.
|
||||
|
||||
### Rules
|
||||
|
||||
- The test MUST fail against the current codebase (this is the whole point)
|
||||
- Do NOT modify any source code outside of test files
|
||||
- Do NOT include any fix, workaround, or behavioral change
|
||||
- Do NOT add unrelated tests or refactor existing tests
|
||||
- Keep the test minimal — only what is needed to reproduce the bug
|
||||
- Avoid common anti-patterns — see `reference/testing-anti-patterns.md`
|
||||
|
||||
### Vitest Example
|
||||
|
||||
```typescript
|
||||
// src/utils/pathUtil.test.ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveModelPath } from './pathUtil'
|
||||
|
||||
describe('resolveModelPath', () => {
|
||||
it('handles absolute paths from folder_paths API', () => {
|
||||
const result = resolveModelPath(
|
||||
'/absolute/models',
|
||||
'/absolute/models/checkpoints'
|
||||
)
|
||||
expect(result).toBe('/absolute/models/checkpoints')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Playwright Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Model Download', { tag: ['@smoke'] }, () => {
|
||||
test('downloads model when path is absolute', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing-model')
|
||||
const downloadBtn = comfyPage.page.getByTestId('download-model-button')
|
||||
await downloadBtn.click()
|
||||
await expect(comfyPage.page.getByText('Download complete')).toBeVisible()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Verify Locally First
|
||||
|
||||
Run the test locally before pushing to confirm it fails for the right reason:
|
||||
|
||||
```bash
|
||||
# Vitest
|
||||
pnpm test:unit <test-file>
|
||||
|
||||
# Playwright
|
||||
pnpm test:browser:local -- --grep "<test name>"
|
||||
```
|
||||
|
||||
If the test passes locally, it does not reproduce the bug — revisit your test before pushing.
|
||||
|
||||
### Quality Checks and Commit
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm format:check
|
||||
|
||||
git add <test-files-only>
|
||||
git commit -m "test: add failing test for <concise bug description>"
|
||||
git push -u origin HEAD
|
||||
```
|
||||
|
||||
### Verify CI Failure
|
||||
|
||||
```bash
|
||||
gh run list --branch $(git branch --show-current) --limit 1
|
||||
```
|
||||
|
||||
**STOP HERE.** Inform the user of the CI status and wait for confirmation before proceeding to Step 2.
|
||||
|
||||
- If CI passes: the test does not catch the bug. Revisit the test.
|
||||
- If CI fails for unrelated reasons: investigate and fix the test setup, not the bug.
|
||||
- If CI fails because the test correctly catches the bug: proceed to Step 2.
|
||||
|
||||
## Step 2 — Green: Minimal Fix
|
||||
|
||||
Write the minimum code change needed to make the failing test pass.
|
||||
|
||||
### Rules
|
||||
|
||||
- Do NOT modify, weaken, or delete the test from Step 1 — it is immutable. If the test needs changes, restart from Step 1 and re-prove the red.
|
||||
- Do NOT add new tests (tests were finalized in Step 1)
|
||||
- Do NOT refactor, clean up, or make "drive-by" improvements
|
||||
- Do NOT modify code unrelated to the bug
|
||||
- The fix should be the smallest correct change
|
||||
|
||||
### Quality Checks and Commit
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm format
|
||||
|
||||
git add <fix-files-only>
|
||||
git commit -m "fix: <concise bug description>"
|
||||
git push
|
||||
```
|
||||
|
||||
### Verify CI Pass
|
||||
|
||||
```bash
|
||||
gh run list --branch $(git branch --show-current) --limit 1
|
||||
```
|
||||
|
||||
- If CI passes: the fix is verified. Proceed to PR creation.
|
||||
- If CI fails: investigate and fix. Do NOT change the test from Step 1.
|
||||
|
||||
## Step 3 — Open Pull Request
|
||||
|
||||
```bash
|
||||
gh pr create --title "fix: <description>" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
<Brief explanation of the bug and root cause>
|
||||
|
||||
- Fixes #<issue-number>
|
||||
|
||||
## Red-Green Verification
|
||||
|
||||
| Commit | CI Status | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `test: ...` | :red_circle: Red | Proves the test catches the bug |
|
||||
| `fix: ...` | :green_circle: Green | Proves the fix resolves the bug |
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] CI red on test-only commit
|
||||
- [ ] CI green on fix commit
|
||||
- [ ] Added/updated E2E regression under `browser_tests/` or explained why not applicable
|
||||
- [ ] Manual verification (if applicable)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### CI fails on test commit for unrelated reasons
|
||||
|
||||
Lint, typecheck, or other tests may fail — not just your new test. Check the CI logs carefully. If the failure is unrelated, fix it in a separate commit before the `test:` commit so the red X is clearly attributable to your test.
|
||||
|
||||
### Test passes when it should fail
|
||||
|
||||
The bug may only manifest under specific conditions (e.g., Windows paths, external model directories, certain workflow structures). Make sure your test setup matches the actual bug scenario. Check that you're not accidentally testing the happy path.
|
||||
|
||||
### Flaky Playwright tests
|
||||
|
||||
If your e2e test is intermittent, it doesn't prove anything. Use retrying assertions (`toBeVisible`, `toHaveText`) instead of `waitForTimeout`. See the `/writing-playwright-tests` skill for anti-patterns.
|
||||
|
||||
### Pre-existing CI failures on main
|
||||
|
||||
If main itself is red, branch from the last green commit or fix the pre-existing failure first. A red-green proof is meaningless if the baseline is already red.
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| --------------------- | ------------------------------------------------- |
|
||||
| Unit test framework | Vitest (`src/**/*.test.ts`) |
|
||||
| E2E test framework | Playwright (`browser_tests/tests/*.spec.ts`) |
|
||||
| E2E fixtures | `browser_tests/fixtures/` |
|
||||
| E2E assets | `browser_tests/assets/` |
|
||||
| Playwright skill | `.Codex/skills/writing-playwright-tests/SKILL.md` |
|
||||
| Unit CI | `.github/workflows/ci-tests-unit.yaml` |
|
||||
| E2E CI | `.github/workflows/ci-tests-e2e.yaml` |
|
||||
| Lint CI | `.github/workflows/ci-lint-format.yaml` |
|
||||
| Testing anti-patterns | `reference/testing-anti-patterns.md` |
|
||||
| Related skill | `.Codex/skills/perf-fix-with-proof/SKILL.md` |
|
||||
214
.agents/skills/red-green-fix/reference/testing-anti-patterns.md
Normal file
214
.agents/skills/red-green-fix/reference/testing-anti-patterns.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Testing Anti-Patterns for Red-Green Fixes
|
||||
|
||||
Common mistakes that undermine the red-green proof. Avoid these when writing the test commit (Step 1).
|
||||
|
||||
## Testing Implementation Details
|
||||
|
||||
Test observable behavior, not internal state.
|
||||
|
||||
**Bad** — coupling to internals:
|
||||
|
||||
```typescript
|
||||
it('uses cache internally', () => {
|
||||
const service = new UserService()
|
||||
service.getUser(1)
|
||||
expect(service._cache.has(1)).toBe(true) // Implementation detail
|
||||
})
|
||||
```
|
||||
|
||||
**Good** — testing through the public interface:
|
||||
|
||||
```typescript
|
||||
it('returns same user on repeated calls', async () => {
|
||||
const service = new UserService()
|
||||
const user1 = await service.getUser(1)
|
||||
const user2 = await service.getUser(1)
|
||||
expect(user1).toBe(user2) // Behavior, not implementation
|
||||
})
|
||||
```
|
||||
|
||||
Why this matters for red-green: if your test is coupled to internals, a valid fix that changes the implementation may break the test — even though the bug is fixed. The green commit should only require changing source code, not rewriting the test.
|
||||
|
||||
## Assertion-Free Tests
|
||||
|
||||
Every test must assert something meaningful. A test without assertions always passes — it cannot produce the red X needed in Step 1.
|
||||
|
||||
**Bad**:
|
||||
|
||||
```typescript
|
||||
it('processes the download', () => {
|
||||
processDownload('/models/checkpoints', 'model.safetensors')
|
||||
// No expect()!
|
||||
})
|
||||
```
|
||||
|
||||
**Good**:
|
||||
|
||||
```typescript
|
||||
it('processes the download to correct path', () => {
|
||||
const result = processDownload('/models/checkpoints', 'model.safetensors')
|
||||
expect(result.savePath).toBe('/models/checkpoints/model.safetensors')
|
||||
})
|
||||
```
|
||||
|
||||
## Over-Mocking
|
||||
|
||||
Mock only system boundaries (network, filesystem, Electron APIs). If you mock the module under test, you are testing your mocks — the test will not detect the real bug.
|
||||
|
||||
**Bad** — mocking everything:
|
||||
|
||||
```typescript
|
||||
vi.mock('./pathResolver')
|
||||
vi.mock('./validator')
|
||||
vi.mock('./downloader')
|
||||
|
||||
it('downloads model', () => {
|
||||
// This only tests that mocks were called, not that the bug exists
|
||||
})
|
||||
```
|
||||
|
||||
**Good** — mock only the boundary:
|
||||
|
||||
```typescript
|
||||
vi.mock('./electronAPI') // Boundary: Electron IPC
|
||||
|
||||
it('resolves absolute path correctly', () => {
|
||||
const result = resolveModelPath('/root/models', '/root/models/checkpoints')
|
||||
expect(result).toBe('/root/models/checkpoints')
|
||||
})
|
||||
```
|
||||
|
||||
See also: [Don't Mock What You Don't Own](https://hynek.me/articles/what-to-mock-in-5-mins/)
|
||||
|
||||
## Giant Tests
|
||||
|
||||
A test that covers the entire flow makes it hard to pinpoint which part catches the bug. Keep it focused — one concept per test.
|
||||
|
||||
**Bad**:
|
||||
|
||||
```typescript
|
||||
it('full model download flow', async () => {
|
||||
// 80 lines: load workflow, open dialog, select model,
|
||||
// click download, verify path, check progress, confirm completion
|
||||
})
|
||||
```
|
||||
|
||||
**Good**:
|
||||
|
||||
```typescript
|
||||
it('resolves absolute savePath without nesting under modelsDirectory', () => {
|
||||
const result = getLocalSavePath(
|
||||
'/models',
|
||||
'/models/checkpoints',
|
||||
'file.safetensors'
|
||||
)
|
||||
expect(result).toBe('/models/checkpoints/file.safetensors')
|
||||
})
|
||||
```
|
||||
|
||||
If the bug is in path resolution, test path resolution — not the entire download flow.
|
||||
|
||||
## Test Duplication
|
||||
|
||||
Duplicated test code hides what actually differs between cases. Use parameterized tests.
|
||||
|
||||
**Bad**:
|
||||
|
||||
```typescript
|
||||
it('resolves checkpoints path', () => {
|
||||
expect(resolve('/models', '/models/checkpoints', 'a.safetensors')).toBe(
|
||||
'/models/checkpoints/a.safetensors'
|
||||
)
|
||||
})
|
||||
it('resolves loras path', () => {
|
||||
expect(resolve('/models', '/models/loras', 'b.safetensors')).toBe(
|
||||
'/models/loras/b.safetensors'
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
**Good**:
|
||||
|
||||
```typescript
|
||||
it.each([
|
||||
['/models/checkpoints', 'a.safetensors', '/models/checkpoints/a.safetensors'],
|
||||
['/models/loras', 'b.safetensors', '/models/loras/b.safetensors']
|
||||
])('resolves %s/%s to %s', (dir, file, expected) => {
|
||||
expect(resolve('/models', dir, file)).toBe(expected)
|
||||
})
|
||||
```
|
||||
|
||||
## Flaky Tests
|
||||
|
||||
A flaky test cannot prove anything — it may show red for reasons unrelated to the bug, or green despite the bug still existing.
|
||||
|
||||
**Common causes in this codebase:**
|
||||
|
||||
| Cause | Fix |
|
||||
| -------------------------------------- | --------------------------------------- |
|
||||
| Missing `nextFrame()` after canvas ops | Add `await comfyPage.nextFrame()` |
|
||||
| `waitForTimeout` instead of assertions | Use `toBeVisible()`, `toHaveText()` |
|
||||
| Shared state between tests | Isolate with `afterEach` / `beforeEach` |
|
||||
| Timing-dependent logic | Use `expect.poll()` or `toPass()` |
|
||||
|
||||
## Gaming the Red-Green Process
|
||||
|
||||
These are ways the red-green proof gets invalidated during Step 2 (the fix commit). The test from Step 1 is immutable — if any of these happen, restart from Step 1.
|
||||
|
||||
**Weakening the assertion to make it pass:**
|
||||
|
||||
```typescript
|
||||
// Step 1 (red) — strict assertion
|
||||
expect(result).toBe('/external/drive/models/checkpoints/file.safetensors')
|
||||
|
||||
// Step 2 (green) — weakened to pass without a real fix
|
||||
expect(result).toBeDefined() // This proves nothing
|
||||
```
|
||||
|
||||
**Updating snapshots to bless the bug:**
|
||||
|
||||
```bash
|
||||
# Instead of fixing the code, just updating the snapshot to match buggy output
|
||||
pnpm test:unit --update
|
||||
```
|
||||
|
||||
If a snapshot needs updating, the fix should change the code behavior, not the expected output.
|
||||
|
||||
**Adding mocks in Step 2 that hide the failure:**
|
||||
|
||||
```typescript
|
||||
// Step 2 adds a mock that didn't exist in Step 1
|
||||
vi.mock('./pathResolver', () => ({
|
||||
resolve: () => '/expected/path' // Hardcoded to pass
|
||||
}))
|
||||
```
|
||||
|
||||
Step 2 should only change source code — not test infrastructure.
|
||||
|
||||
## Testing the Happy Path Only
|
||||
|
||||
The red-green pattern specifically requires the test to exercise the **broken path**. If you only test the case that already works, the test will pass (green) on Step 1 — defeating the purpose.
|
||||
|
||||
**Bad** — testing the default case that works:
|
||||
|
||||
```typescript
|
||||
it('downloads to default models directory', () => {
|
||||
// This already works — it won't produce a red X
|
||||
const result = resolve('/models', 'checkpoints', 'file.safetensors')
|
||||
expect(result).toBe('/models/checkpoints/file.safetensors')
|
||||
})
|
||||
```
|
||||
|
||||
**Good** — testing the case that is actually broken:
|
||||
|
||||
```typescript
|
||||
it('downloads to external models directory configured via extra_model_paths', () => {
|
||||
// This is the broken case — absolute path from folder_paths API
|
||||
const result = resolve(
|
||||
'/models',
|
||||
'/external/drive/models/checkpoints',
|
||||
'file.safetensors'
|
||||
)
|
||||
expect(result).toBe('/external/drive/models/checkpoints/file.safetensors')
|
||||
})
|
||||
```
|
||||
83
.agents/skills/regenerating-screenshots/SKILL.md
Normal file
83
.agents/skills/regenerating-screenshots/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: regenerating-screenshots
|
||||
description: 'Creates a PR to regenerate Playwright screenshot expectations. Use when screenshot tests are failing on main or PRs due to stale golden images. Triggers on: regen screenshots, regenerate screenshots, update expectations, fix screenshot tests.'
|
||||
---
|
||||
|
||||
# Regenerating Playwright Screenshot Expectations
|
||||
|
||||
Automates the process of triggering the `PR: Update Playwright Expectations`
|
||||
GitHub Action by creating a labeled PR from `origin/main`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Fetch latest main**
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
```
|
||||
|
||||
2. **Create a timestamped branch** from `origin/main`
|
||||
|
||||
Format: `regen-screenshots/YYYY-MM-DDTHH` (hour resolution, local time)
|
||||
|
||||
```bash
|
||||
git checkout -b regen-screenshots/<datetime> origin/main
|
||||
```
|
||||
|
||||
3. **Create an empty commit**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "test: regenerate screenshot expectations"
|
||||
```
|
||||
|
||||
4. **Push the branch**
|
||||
|
||||
```bash
|
||||
git push origin regen-screenshots/<datetime>
|
||||
```
|
||||
|
||||
5. **Generate a poem** about regenerating screenshots. Be creative — a
|
||||
new, unique poem every time. Short (4–8 lines). Can be funny, wistful,
|
||||
epic, haiku-style, limerick, sonnet fragment — vary the form.
|
||||
|
||||
6. **Create the PR** with the poem as the body (no label yet).
|
||||
|
||||
Write the poem to a temp file and use `--body-file`:
|
||||
|
||||
```bash
|
||||
# Write poem to temp file
|
||||
# Create PR:
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head regen-screenshots/<datetime> \
|
||||
--title "test: regenerate screenshot expectations" \
|
||||
--body-file <temp-file>
|
||||
```
|
||||
|
||||
7. **Add the label** as a separate step to trigger the GitHub Action.
|
||||
|
||||
The `labeled` event only fires when a label is added after PR
|
||||
creation, not when applied during creation via `--label`.
|
||||
|
||||
Use the GitHub API directly (`gh pr edit --add-label` fails due to
|
||||
deprecated Projects Classic GraphQL errors):
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/issues/<pr-number>/labels \
|
||||
-f "labels[]=New Browser Test Expectations" --method POST
|
||||
```
|
||||
|
||||
8. **Report the result** to the user:
|
||||
- PR URL
|
||||
- Branch name
|
||||
- Note that the GitHub Action will run automatically and commit
|
||||
updated screenshots to the branch.
|
||||
|
||||
## Notes
|
||||
|
||||
- The `New Browser Test Expectations` label triggers the
|
||||
`pr-update-playwright-expectations.yaml` workflow.
|
||||
- The workflow runs Playwright with `--update-snapshots`, commits results
|
||||
back to the PR branch, then removes the label.
|
||||
- This is fire-and-forget — no need to wait for or monitor the Action.
|
||||
- Always return to the original branch/worktree state after pushing.
|
||||
156
.agents/skills/reviewing-unit-tests/SKILL.md
Normal file
156
.agents/skills/reviewing-unit-tests/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: reviewing-unit-tests
|
||||
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
|
||||
---
|
||||
|
||||
# Reviewing Unit Tests for ComfyUI_frontend
|
||||
|
||||
## Overview
|
||||
|
||||
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
|
||||
|
||||
## Review Workflow
|
||||
|
||||
1. Identify the test type: component, store, composable, util, or bugfix regression.
|
||||
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
|
||||
3. Open the authoritative doc section before judging structure.
|
||||
4. Scan the red flags below.
|
||||
5. State the verdict first. Name the failure mode. Cite the doc or rule.
|
||||
|
||||
## Source of Truth / Precedence
|
||||
|
||||
When docs and examples conflict, use this order:
|
||||
|
||||
1. Explicit repo rules, lint rules, and note blocks.
|
||||
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
|
||||
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
|
||||
4. Example snippets
|
||||
5. Prior diffs
|
||||
|
||||
Apply these repo-specific clarifications:
|
||||
|
||||
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
|
||||
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
|
||||
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
|
||||
- Motion != fix.
|
||||
|
||||
## 30-Second Red Flags
|
||||
|
||||
| If you see... | Failure mode | Default action |
|
||||
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
|
||||
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
|
||||
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
|
||||
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
|
||||
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
|
||||
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
|
||||
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
|
||||
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
|
||||
|
||||
## Rationalization Table
|
||||
|
||||
| Excuse | Reality |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
|
||||
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
|
||||
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
|
||||
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
|
||||
| "It is only a nit" | Explicit repo-rule violations are never nits. |
|
||||
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
|
||||
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
|
||||
|
||||
## Mocking Rules
|
||||
|
||||
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
|
||||
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
|
||||
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
|
||||
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
|
||||
|
||||
### Alias-by-Renaming
|
||||
|
||||
```ts
|
||||
// Before
|
||||
const mockAdd = vi.hoisted(() => vi.fn())
|
||||
|
||||
// After: same indirection, new name
|
||||
function getToastAddMock() {
|
||||
return useToast().add
|
||||
}
|
||||
```
|
||||
|
||||
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
|
||||
|
||||
### `vi.mocked()` Scope
|
||||
|
||||
| Use case | `vi.mocked()` required? |
|
||||
| --------------------------------------------------------------- | ----------------------- |
|
||||
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
|
||||
| `.mock.calls`, `.mock.results` | Yes |
|
||||
| `expect(fn).toHaveBeenCalled()` | No |
|
||||
| `expect(fn).toHaveBeenCalledWith(...)` | No |
|
||||
|
||||
- Flag casts whenever `vi.mocked()` would narrow correctly.
|
||||
- Do not add `vi.mocked()` around assertion-only references just for style.
|
||||
|
||||
### Reset Hygiene
|
||||
|
||||
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
|
||||
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
|
||||
|
||||
### Third-Party Seams
|
||||
|
||||
- Distinguish trivial hooks from behavior-rich APIs.
|
||||
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
|
||||
- That exception does not justify mocking behavior-rich third-party modules.
|
||||
|
||||
### `vue-i18n`
|
||||
|
||||
- Never mock `vue-i18n` in component tests.
|
||||
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
|
||||
|
||||
## Test-Body Rules
|
||||
|
||||
| Smell | Review bar |
|
||||
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Change-detector test | Reject. Default values alone prove nothing. |
|
||||
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
|
||||
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
|
||||
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
|
||||
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
|
||||
|
||||
## Bugfix Regression Proof
|
||||
|
||||
For `fix:` PRs or bugfix diffs:
|
||||
|
||||
1. Identify the production change that fixes the bug.
|
||||
2. Verify the new test fails on pre-fix code, or ask the author to show it.
|
||||
3. If the test passes on broken code, request changes.
|
||||
|
||||
A regression test that never proves red does not pin the bug.
|
||||
|
||||
## Review Output Rules
|
||||
|
||||
- State verdict before procedural questions.
|
||||
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
|
||||
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
|
||||
- Link the authoritative doc section in the review comment.
|
||||
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| When you see... | Read this |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
|
||||
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
|
||||
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
|
||||
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
|
||||
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
|
||||
|
||||
## Key Files to Read
|
||||
|
||||
| Purpose | Path |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
|
||||
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
|
||||
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
|
||||
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
|
||||
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
|
||||
203
.agents/skills/writing-playwright-tests/SKILL.md
Normal file
203
.agents/skills/writing-playwright-tests/SKILL.md
Normal file
@@ -0,0 +1,203 @@
|
||||
---
|
||||
name: writing-playwright-tests
|
||||
description: 'Writes Playwright e2e tests for ComfyUI_frontend. Use when creating, modifying, or debugging browser tests. Triggers on: playwright, e2e test, browser test, spec file.'
|
||||
---
|
||||
|
||||
# Writing Playwright Tests for ComfyUI_frontend
|
||||
|
||||
## Golden Rules
|
||||
|
||||
1. **ALWAYS look at existing tests first.** Search `browser_tests/tests/` for similar patterns before writing new tests.
|
||||
|
||||
2. **ALWAYS read the fixture code.** The APIs are in `browser_tests/fixtures/` - read them directly instead of guessing.
|
||||
|
||||
3. **Use premade JSON workflow assets** instead of building workflows programmatically.
|
||||
- Assets live in `browser_tests/assets/`
|
||||
- Load with `await comfyPage.workflow.loadWorkflow('feature/my_workflow')`
|
||||
- Create new assets by starting with `browser_tests/assets/default.json` and manually editing the JSON to match your desired graph state
|
||||
|
||||
## Vue Nodes vs LiteGraph: Decision Guide
|
||||
|
||||
Choose based on **what you're testing**, not personal preference:
|
||||
|
||||
| Testing... | Use | Why |
|
||||
| ---------------------------------------------- | -------------------------------- | ---------------------------------------- |
|
||||
| Vue-rendered node UI, DOM widgets, CSS states | `comfyPage.vueNodes.*` | Nodes are DOM elements, use locators |
|
||||
| Canvas interactions, connections, legacy nodes | `comfyPage.nodeOps.*` | Canvas-based, use coordinates/references |
|
||||
| Both in same test | Pick primary, minimize switching | Avoid confusion |
|
||||
|
||||
**Vue Nodes requires explicit opt-in:**
|
||||
|
||||
```typescript
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
```
|
||||
|
||||
**Vue Node state uses CSS classes:**
|
||||
|
||||
```typescript
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
await expect(node).toHaveClass(BYPASS_CLASS)
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
|
||||
|
||||
| Symptom | Common Cause | Typical Fix |
|
||||
| ----------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
|
||||
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
|
||||
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
|
||||
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
|
||||
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
|
||||
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
|
||||
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
|
||||
| `subtree intercepts pointer events` | Canvas overlay (z-999) | Use `dispatchEvent` on the DOM element to bypass overlay |
|
||||
| Context menu empty / wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
|
||||
| `navigateIntoSubgraph` timeout | Node too small in asset | Use node size `[400, 200]` minimum in test asset JSON |
|
||||
|
||||
## Test Tags
|
||||
|
||||
Add appropriate tags to every test:
|
||||
|
||||
| Tag | When to Use |
|
||||
| ------------- | ----------------------------------------- |
|
||||
| `@smoke` | Quick essential tests |
|
||||
| `@slow` | Tests > 10 seconds |
|
||||
| `@screenshot` | Visual regression tests |
|
||||
| `@canvas` | Canvas interactions |
|
||||
| `@node` | Node-related |
|
||||
| `@widget` | Widget-related |
|
||||
| `@mobile` | Mobile viewport (runs on Pixel 5 project) |
|
||||
| `@2x` | HiDPI tests (runs on 2x scale project) |
|
||||
|
||||
```typescript
|
||||
test.describe('Feature', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
```
|
||||
|
||||
## Retry Patterns
|
||||
|
||||
**Never use `waitForTimeout`** - it's always wrong.
|
||||
|
||||
| Pattern | Use Case |
|
||||
| ------------------------ | ---------------------------------------------------- |
|
||||
| Auto-retrying assertions | `toBeVisible()`, `toHaveText()`, etc. (prefer these) |
|
||||
| `expect.poll()` | Single value polling |
|
||||
| `expect().toPass()` | Multiple assertions that must all pass |
|
||||
|
||||
```typescript
|
||||
// Prefer auto-retrying assertions when possible
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
// Single value polling
|
||||
await expect.poll(() => widget.getValue(), { timeout: 2000 }).toBe(100)
|
||||
|
||||
// Multiple conditions
|
||||
await expect(async () => {
|
||||
expect(await node1.getValue()).toBe('foo')
|
||||
expect(await node2.getValue()).toBe('bar')
|
||||
}).toPass({ timeout: 2000 })
|
||||
```
|
||||
|
||||
## Screenshot Baselines
|
||||
|
||||
- **Screenshots are Linux-only.** Don't commit local screenshots.
|
||||
- **To update baselines:** Add PR label `New Browser Test Expectations`
|
||||
- **Mask dynamic content:**
|
||||
```typescript
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('page.png', {
|
||||
mask: [page.locator('.timestamp')]
|
||||
})
|
||||
```
|
||||
|
||||
## CI Debugging
|
||||
|
||||
1. Download artifacts from failed CI run
|
||||
2. Extract and view trace: `pnpm dlx playwright show-trace trace.zip`
|
||||
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
|
||||
4. Reproduce CI: `CI=true pnpm test:browser`
|
||||
5. Local runs: `pnpm test:browser:local`
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
Avoid these common mistakes:
|
||||
|
||||
1. **Arbitrary waits** - Use retrying assertions instead
|
||||
|
||||
```typescript
|
||||
// ❌ await page.waitForTimeout(500)
|
||||
// ✅ await expect(element).toBeVisible()
|
||||
```
|
||||
|
||||
2. **Implementation-tied selectors** - Use test IDs or semantic selectors
|
||||
|
||||
```typescript
|
||||
// ❌ page.locator('div.container > button.btn-primary')
|
||||
// ✅ page.getByTestId('submit-button')
|
||||
```
|
||||
|
||||
3. **Missing nextFrame after canvas ops** - Canvas needs sync time
|
||||
|
||||
```typescript
|
||||
await node.drag({ x: 50, y: 50 })
|
||||
await comfyPage.nextFrame() // Required
|
||||
```
|
||||
|
||||
4. **Shared state between tests** - Tests must be independent
|
||||
```typescript
|
||||
// ❌ let sharedData // Outside test
|
||||
// ✅ Define state inside each test
|
||||
```
|
||||
|
||||
## Quick Start Template
|
||||
|
||||
```typescript
|
||||
// Path depends on test file location - adjust '../' segments accordingly
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('FeatureName', { tag: ['@canvas'] }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('should do something', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('myWorkflow')
|
||||
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
// ... test logic
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('expected.png')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Finding Patterns
|
||||
|
||||
```bash
|
||||
# Find similar tests
|
||||
grep -r "KSampler" browser_tests/tests/
|
||||
|
||||
# Find usage of a fixture method
|
||||
grep -r "loadWorkflow" browser_tests/tests/
|
||||
|
||||
# Find tests with specific tag
|
||||
grep -r '@screenshot' browser_tests/tests/
|
||||
```
|
||||
|
||||
## Key Files to Read
|
||||
|
||||
| Purpose | Path |
|
||||
| ----------------- | ------------------------------------------ |
|
||||
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
|
||||
| Helper classes | `browser_tests/fixtures/helpers/` |
|
||||
| Component objects | `browser_tests/fixtures/components/` |
|
||||
| Test selectors | `browser_tests/fixtures/selectors.ts` |
|
||||
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
|
||||
| Test assets | `browser_tests/assets/` |
|
||||
| Existing tests | `browser_tests/tests/` |
|
||||
|
||||
**Read the fixture code directly** - it's the source of truth for available methods.
|
||||
143
.agents/skills/writing-storybook-stories/SKILL.md
Normal file
143
.agents/skills/writing-storybook-stories/SKILL.md
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: writing-storybook-stories
|
||||
description: 'Write or update Storybook stories for Vue components in ComfyUI_frontend. Use when adding, modifying, reviewing, or debugging `.stories.ts` files, Storybook docs, component demos, or visual catalog entries in `src/` or `apps/desktop-ui/`.'
|
||||
---
|
||||
|
||||
# Write Storybook Stories for ComfyUI_frontend
|
||||
|
||||
## Workflow
|
||||
|
||||
1. !!!!IMPORTANT Confirm the worktree is on a `feat/*` or `fix/*` branch. Base PRs on the local `main`, not a fork branch.
|
||||
2. Read the component source first. Understand props, emits, slots, exposed methods, and any supporting types or composables.
|
||||
3. Read nearby stories before writing anything.
|
||||
- Search stories: `rg --files src apps | rg '\.stories\.ts$'`
|
||||
- Inspect title patterns: `rg -n "title:\\s*'" src apps --glob '*.stories.ts'`
|
||||
4. If a Figma link is provided, list the states you need to cover before writing stories.
|
||||
5. Co-locate the story file with the component: `ComponentName.stories.ts`.
|
||||
6. Add each variation on separate stories, except hover state. this should be automatically applied by the implementation and not require a separate story.
|
||||
7. Run Storybook and validation checks before handing off.
|
||||
|
||||
## Match Local Conventions
|
||||
|
||||
- Copy the closest neighboring story instead of forcing one universal template.
|
||||
- Most repo stories use `@storybook/vue3-vite`. Some stories under `apps/desktop-ui` still use `@storybook/vue3`; keep the local convention for that area.
|
||||
- Add `tags: ['autodocs']` unless the surrounding stories in that area intentionally omit it.
|
||||
- Use `ComponentPropsAndSlots<typeof Component>` when it helps with prop and slot typing.
|
||||
- Keep `render` functions stateful when needed. Use `ref()`, `computed()`, and `toRefs(args)` instead of mutating Storybook args directly.
|
||||
- Use `args.default` or other slot-shaped args when the component content is provided through slots.
|
||||
- Use `ComponentExposed` only when a component's exposed API breaks the normal Storybook typing.
|
||||
- Add decorators for realistic width or background context when the component needs it.
|
||||
|
||||
## Title Patterns
|
||||
|
||||
Do not invent titles from scratch when a close sibling story already exists. Match the nearest domain pattern.
|
||||
|
||||
| Component area | Typical title pattern |
|
||||
| ------------------------------------------------------- | ------------------------------------ |
|
||||
| `src/components/ui/button/Button.vue` | `Components/Button/Button` |
|
||||
| `src/components/ui/input/Input.vue` | `Components/Input` |
|
||||
| `src/components/ui/search-input/SearchInput.vue` | `Components/Input/SearchInput` |
|
||||
| `src/components/common/SearchBox.vue` | `Components/Input/SearchBox` |
|
||||
| `src/renderer/extensions/vueNodes/widgets/components/*` | `Widgets/<WidgetName>` |
|
||||
| `src/platform/assets/components/*` | `Platform/Assets/<ComponentName>` |
|
||||
| `apps/desktop-ui/src/components/*` | `Desktop/Components/<ComponentName>` |
|
||||
| `apps/desktop-ui/src/views/*` | `Desktop/Views/<ViewName>` |
|
||||
|
||||
If multiple patterns seem plausible, follow the closest sibling story in the same folder tree.
|
||||
|
||||
## Common Story Shapes
|
||||
|
||||
### Stateful input or `v-model`
|
||||
|
||||
```typescript
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { MyComponent },
|
||||
setup() {
|
||||
const { disabled, size } = toRefs(args)
|
||||
const value = ref('Hello world')
|
||||
return { value, disabled, size }
|
||||
},
|
||||
template:
|
||||
'<MyComponent v-model="value" :disabled="disabled" :size="size" />'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Slot-driven content
|
||||
|
||||
```typescript
|
||||
const meta: Meta<ComponentPropsAndSlots<typeof Button>> = {
|
||||
argTypes: {
|
||||
default: { control: 'text' }
|
||||
},
|
||||
args: {
|
||||
default: 'Button'
|
||||
}
|
||||
}
|
||||
|
||||
export const SingleButton: Story = {
|
||||
render: (args) => ({
|
||||
components: { Button },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<Button v-bind="args">{{ args.default }}</Button>'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Variants or edge cases grid
|
||||
|
||||
```typescript
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { MyComponent },
|
||||
template: `
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<MyComponent />
|
||||
<MyComponent disabled />
|
||||
<MyComponent loading />
|
||||
<MyComponent invalid />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Figma Mapping
|
||||
|
||||
- Extract the named states from the design first.
|
||||
- Prefer explicit prop-driven stories such as `Disabled`, `Loading`, `Invalid`, `WithPlaceholder`, `AllSizes`, or `EdgeCases`.
|
||||
- Add an aggregate story such as `AllVariants`, `AllSizes`, or `EdgeCases` when side-by-side comparison is useful.
|
||||
- Use pseudo-state parameters only if the addon is already configured in this repo.
|
||||
- If a Figma state cannot be represented exactly, capture the closest prop-driven version and explain the gap in the story docs.
|
||||
|
||||
## Component-Specific Notes
|
||||
|
||||
- Widget components often need a minimal `SimplifiedWidget` object. Build it in `setup()` and use `computed()` when `args` change `widget.options`.
|
||||
- Input and search components often need a width-constrained wrapper so they render at realistic sizes.
|
||||
- Asset and platform cards often need background decorators such as `bg-base-background` and fixed-width containers.
|
||||
- Desktop installer stories may need custom `backgrounds` parameters and may intentionally keep the older Storybook import style used by neighboring files.
|
||||
- Use semantic tokens such as `bg-base-background` and `bg-node-component-surface` instead of `dark:` variants or hardcoded theme assumptions.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Read the component source and any supporting types or composables
|
||||
- [ ] Match the nearest local title pattern and story style
|
||||
- [ ] Include a baseline story; name it `Default` only when that matches nearby conventions
|
||||
- [ ] Add focused stories for meaningful states
|
||||
- [ ] Add `tags: ['autodocs']`
|
||||
- [ ] Keep the story co-located with the component
|
||||
- [ ] Run `pnpm storybook`
|
||||
- [ ] Run `pnpm typecheck`
|
||||
- [ ] Run `pnpm lint`
|
||||
|
||||
## Avoid
|
||||
|
||||
- Do not guess props, emits, slots, or exposed methods.
|
||||
- Do not force one generic title convention across the repo.
|
||||
- Do not mutate Storybook args directly for `v-model` components.
|
||||
- Do not introduce `dark:` Tailwind variants in story wrappers.
|
||||
- Do not create barrel files.
|
||||
- Do not assume every story needs `layout: 'centered'` or a `Default` export; follow the nearest existing pattern.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: 'ComfyUI Storybook Stories'
|
||||
short_description: 'Write Vue Storybook stories for ComfyUI'
|
||||
default_prompt: 'Use $writing-storybook-stories to add or update a Storybook story for this ComfyUI_frontend component.'
|
||||
30
.cursor/rules/agent-panel-layout.md
Normal file
30
.cursor/rules/agent-panel-layout.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Agent chat panel layout rule — always full viewport height, never nested under the header bar
|
||||
globs:
|
||||
- src/components/LiteGraphCanvasSplitterOverlay.vue
|
||||
- src/platform/agent/**
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent Panel Layout
|
||||
|
||||
The Comfy Agent chat panel must always span the **full viewport height** — from the very top of the screen to the bottom, alongside the header bar and canvas, not below them.
|
||||
|
||||
## Correct structure
|
||||
|
||||
`LiteGraphCanvasSplitterOverlay` uses a top-level **`flex-row`** so the agent panel is a sibling of the entire left column (tabs + canvas), not a child inside it:
|
||||
|
||||
```
|
||||
div.flex-row (viewport)
|
||||
├── div.flex-col.flex-1 ← left side: everything else
|
||||
│ ├── slot#workflow-tabs ← header bar
|
||||
│ └── div.flex-1 ← canvas + sidebar panels
|
||||
└── div.shrink-0 (agent panel) ← RIGHT: full viewport height
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never** place the agent panel inside the `div` that sits below `slot#workflow-tabs`. That causes the panel to start below the header bar.
|
||||
- The agent panel div must be a **direct child** of the outermost `div.flex-row` container in `LiteGraphCanvasSplitterOverlay.vue`.
|
||||
- The left side (`flex-1 flex-col`) wraps both `slot#workflow-tabs` AND the canvas/splitter row.
|
||||
- The agent panel has `h-full` and `shrink-0` so it fills the full height and does not flex-shrink.
|
||||
34
.cursor/rules/icon-button-tooltip.mdc
Normal file
34
.cursor/rules/icon-button-tooltip.mdc
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
description: Icon buttons must always have a tooltip
|
||||
globs: src/**/*.vue
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Icon Button Tooltip Requirement
|
||||
|
||||
Every icon-only button (`size="icon"` or any button containing only an icon with no visible label) **must** be wrapped in a `Tooltip` so users can discover what it does.
|
||||
|
||||
## Required Pattern
|
||||
|
||||
```vue
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button size="icon" border-interface-stroke" :aria-label="$t('...')">
|
||||
<i class="icon-[lucide--some-icon] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('...') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```ts
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
```
|
||||
|
||||
- Always use `side="top"` unless a different direction is needed for layout reasons.
|
||||
- The `aria-label` on the button and the tooltip text should be the same translated string.
|
||||
- Use `vue-i18n` (`$t(...)`) for the label — never hardcode strings.
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -5,6 +5,7 @@ declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
declare const __GIT_BRANCH_PREFIX__: string
|
||||
|
||||
interface ImpactQueueFunction {
|
||||
(...args: unknown[]): void
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"axios": "catalog:",
|
||||
"chart.js": "^4.5.0",
|
||||
"cva": "catalog:",
|
||||
"dialkit": "catalog:",
|
||||
"dompurify": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"es-toolkit": "^1.39.9",
|
||||
@@ -114,12 +115,14 @@
|
||||
"jsonata": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"motion-v": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"primeicons": "catalog:",
|
||||
"primevue": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"semver": "^7.7.2",
|
||||
"shiki": "catalog:",
|
||||
"three": "catalog:",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
|
||||
203
pnpm-lock.yaml
generated
203
pnpm-lock.yaml
generated
@@ -198,6 +198,9 @@ catalogs:
|
||||
cva:
|
||||
specifier: 1.0.0-beta.4
|
||||
version: 1.0.0-beta.4
|
||||
dialkit:
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
dompurify:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.5
|
||||
@@ -285,6 +288,9 @@ catalogs:
|
||||
monocart-coverage-reports:
|
||||
specifier: ^2.12.9
|
||||
version: 2.12.9
|
||||
motion-v:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
oxfmt:
|
||||
specifier: ^0.54.0
|
||||
version: 0.54.0
|
||||
@@ -321,6 +327,9 @@ catalogs:
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^6.0.4
|
||||
version: 6.0.4
|
||||
shiki:
|
||||
specifier: ^3.0.0
|
||||
version: 3.23.0
|
||||
storybook:
|
||||
specifier: ^10.2.10
|
||||
version: 10.2.10
|
||||
@@ -555,6 +564,9 @@ importers:
|
||||
cva:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.0-beta.4(typescript@5.9.3)
|
||||
dialkit:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.0(motion-v@2.3.0(@vueuse/core@14.2.0(vue@3.5.34(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.34(typescript@5.9.3)))(motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.34(typescript@5.9.3))
|
||||
dompurify:
|
||||
specifier: 'catalog:'
|
||||
version: 3.4.5
|
||||
@@ -588,6 +600,9 @@ importers:
|
||||
marked:
|
||||
specifier: ^15.0.11
|
||||
version: 15.0.11
|
||||
motion-v:
|
||||
specifier: 'catalog:'
|
||||
version: 2.3.0(@vueuse/core@14.2.0(vue@3.5.34(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.34(typescript@5.9.3))
|
||||
pinia:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
@@ -606,6 +621,9 @@ importers:
|
||||
semver:
|
||||
specifier: ^7.7.2
|
||||
version: 7.7.4
|
||||
shiki:
|
||||
specifier: 'catalog:'
|
||||
version: 3.23.0
|
||||
three:
|
||||
specifier: 'catalog:'
|
||||
version: 0.184.0
|
||||
@@ -3439,18 +3457,30 @@ packages:
|
||||
pinia:
|
||||
optional: true
|
||||
|
||||
'@shikijs/core@3.23.0':
|
||||
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
|
||||
|
||||
'@shikijs/core@4.1.0':
|
||||
resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-javascript@3.23.0':
|
||||
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
|
||||
|
||||
'@shikijs/engine-javascript@4.1.0':
|
||||
resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||
|
||||
'@shikijs/engine-oniguruma@4.1.0':
|
||||
resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@3.23.0':
|
||||
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
|
||||
|
||||
'@shikijs/langs@4.1.0':
|
||||
resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -3459,10 +3489,16 @@ packages:
|
||||
resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/themes@3.23.0':
|
||||
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
|
||||
|
||||
'@shikijs/themes@4.1.0':
|
||||
resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
|
||||
|
||||
'@shikijs/types@4.1.0':
|
||||
resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -5207,6 +5243,30 @@ packages:
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
dialkit@1.3.0:
|
||||
resolution: {integrity: sha512-//tnKhG+6gWhH3h9L7RpRPQZJkRBj0qQyUu1Og1l0VI6Nn/7iKaZZDFs/hG+Jsrtvwp4hlLrrmzj8M6hkXR3EA==}
|
||||
peerDependencies:
|
||||
motion: '>=11.0.0'
|
||||
motion-v: '>=2.0.0'
|
||||
react: '>=18.0.0'
|
||||
react-dom: '>=18.0.0'
|
||||
solid-js: '>=1.6.0'
|
||||
svelte: '>=5.8.0'
|
||||
vue: '>=3.3.0'
|
||||
peerDependenciesMeta:
|
||||
motion-v:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
solid-js:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
diff@8.0.3:
|
||||
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
@@ -5762,6 +5822,20 @@ packages:
|
||||
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
|
||||
engines: {node: '>= 12.20'}
|
||||
|
||||
framer-motion@12.42.2:
|
||||
resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fs-extra@10.1.0:
|
||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5971,6 +6045,9 @@ packages:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
hey-listen@1.0.8:
|
||||
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
|
||||
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
@@ -6924,6 +7001,32 @@ packages:
|
||||
monocart-locator@1.0.2:
|
||||
resolution: {integrity: sha512-v8W5hJLcWMIxLCcSi/MHh+VeefI+ycFmGz23Froer9QzWjrbg4J3gFJBuI/T1VLNoYxF47bVPPxq8ZlNX4gVCw==}
|
||||
|
||||
motion-dom@12.42.2:
|
||||
resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==}
|
||||
|
||||
motion-utils@12.39.0:
|
||||
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
|
||||
|
||||
motion-v@2.3.0:
|
||||
resolution: {integrity: sha512-J0CCfXtICCni9RjotDUBOs57xNpYI9yyBSohEOxaRHrmjwOtlw291fhRu/mdgEdSasys96R028YDDOAtWBbRaA==}
|
||||
peerDependencies:
|
||||
'@vueuse/core': '>=10.0.0'
|
||||
vue: '>=3.0.0'
|
||||
|
||||
motion@12.42.2:
|
||||
resolution: {integrity: sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -7775,6 +7878,9 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shiki@3.23.0:
|
||||
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
|
||||
|
||||
shiki@4.1.0:
|
||||
resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -8732,8 +8838,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
vue-component-type-helpers@3.3.6:
|
||||
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -11326,6 +11432,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
|
||||
'@shikijs/core@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/core@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/primitive': 4.1.0
|
||||
@@ -11334,17 +11447,32 @@ snapshots:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-javascript@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/engine-oniguruma@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/langs@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
@@ -11355,10 +11483,19 @@ snapshots:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/themes@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/themes@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/types@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
@@ -11466,7 +11603,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.5
|
||||
vue-component-type-helpers: 3.3.6
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -13350,6 +13487,15 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
dialkit@1.3.0(motion-v@2.3.0(@vueuse/core@14.2.0(vue@3.5.34(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.34(typescript@5.9.3)))(motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
motion: 12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
optionalDependencies:
|
||||
motion-v: 2.3.0(@vueuse/core@14.2.0(vue@3.5.34(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.34(typescript@5.9.3))
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
|
||||
diff@8.0.3: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
@@ -14003,6 +14149,15 @@ snapshots:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 4.0.0-beta.3
|
||||
|
||||
framer-motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.42.2
|
||||
motion-utils: 12.39.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
fs-extra@10.1.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -14287,6 +14442,8 @@ snapshots:
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
hey-listen@1.0.8: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
hookified@1.14.0: {}
|
||||
@@ -15414,6 +15571,33 @@ snapshots:
|
||||
|
||||
monocart-locator@1.0.2: {}
|
||||
|
||||
motion-dom@12.42.2:
|
||||
dependencies:
|
||||
motion-utils: 12.39.0
|
||||
|
||||
motion-utils@12.39.0: {}
|
||||
|
||||
motion-v@2.3.0(@vueuse/core@14.2.0(vue@3.5.34(typescript@5.9.3)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vueuse/core': 14.2.0(vue@3.5.34(typescript@5.9.3))
|
||||
framer-motion: 12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
hey-listen: 1.0.8
|
||||
motion-dom: 12.42.2
|
||||
motion-utils: 12.39.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@emotion/is-prop-valid'
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
framer-motion: 12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
@@ -16507,6 +16691,17 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shiki@3.23.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 3.23.0
|
||||
'@shikijs/engine-javascript': 3.23.0
|
||||
'@shikijs/engine-oniguruma': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
shiki@4.1.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.1.0
|
||||
@@ -17637,7 +17832,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
vue-component-type-helpers@3.3.6: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -75,6 +75,7 @@ catalog:
|
||||
class-variance-authority: ^0.7.1
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
dialkit: ^1.3.0
|
||||
dompurify: ^3.4.5
|
||||
dotenv: ^16.4.5
|
||||
eslint: ^10.4.0
|
||||
@@ -104,6 +105,7 @@ catalog:
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
motion-v: ^2.3.0
|
||||
oxfmt: ^0.54.0
|
||||
oxlint: ^1.69.0
|
||||
oxlint-tsgolint: ^0.23.0
|
||||
@@ -116,6 +118,7 @@ catalog:
|
||||
primevue: ^4.2.5
|
||||
reka-ui: 2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
shiki: ^3.0.0
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
|
||||
BIN
public/assets/images/reference.png
Normal file
BIN
public/assets/images/reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
11
src/App.vue
11
src/App.vue
@@ -2,12 +2,13 @@
|
||||
<router-view />
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
<component :is="AgentPersonalityDevPanel" v-if="AgentPersonalityDevPanel" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, defineAsyncComponent, onMounted, watch } from 'vue'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
@@ -18,6 +19,14 @@ import { electronAPI } from '@/utils/envUtil'
|
||||
import { parsePreloadError } from '@/utils/preloadErrorUtil'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
// Dev-only tuning panel for the agent's shader/hover personality. The dynamic
|
||||
// import keeps `dialkit` out of production bundles entirely.
|
||||
const AgentPersonalityDevPanel = import.meta.env.DEV
|
||||
? defineAsyncComponent(
|
||||
() => import('@/platform/agent/components/AgentPersonalityDevPanel.vue')
|
||||
)
|
||||
: undefined
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
|
||||
@@ -1,5 +1,243 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
/* Markdown prose styles for the agent chat, matching Figma DES-455 tokens */
|
||||
.agent-markdown h1,
|
||||
.agent-markdown h2,
|
||||
.agent-markdown p,
|
||||
.agent-markdown ol,
|
||||
.agent-markdown ul,
|
||||
.agent-markdown li,
|
||||
.agent-markdown table {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-markdown h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-markdown h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding-top: 0.875rem;
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.agent-markdown p {
|
||||
font-size: 0.875rem;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-markdown p:has(> em:only-child) {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.agent-markdown ol,
|
||||
.agent-markdown ul {
|
||||
font-size: 0.875rem;
|
||||
padding-left: 1.25rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.agent-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.agent-markdown strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-markdown a {
|
||||
color: var(--color-primary-background);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-markdown blockquote {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-left: 3px solid var(--color-border-default);
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.agent-markdown table {
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-collapse: collapse;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-secondary-background);
|
||||
}
|
||||
|
||||
.agent-markdown th {
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
background-color: var(--color-secondary-background-hover);
|
||||
}
|
||||
|
||||
.agent-markdown td {
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.agent-markdown tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-markdown > *:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.agent-markdown > *:last-child {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Scroll-driven fade mask for conversation containers.
|
||||
Top edge fades in as you scroll away from the start;
|
||||
bottom edge fades out when you reach the end. */
|
||||
@property --sf-top {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
@property --sf-bottom {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 40px;
|
||||
}
|
||||
|
||||
@keyframes sf-grow-top {
|
||||
from {
|
||||
--sf-top: 0;
|
||||
}
|
||||
to {
|
||||
--sf-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sf-shrink-bottom {
|
||||
from {
|
||||
--sf-bottom: 40px;
|
||||
}
|
||||
to {
|
||||
--sf-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-fade {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
black var(--sf-top),
|
||||
black calc(100% - var(--sf-bottom)),
|
||||
transparent 100%
|
||||
);
|
||||
animation:
|
||||
sf-grow-top linear both,
|
||||
sf-shrink-bottom linear both;
|
||||
animation-timeline: scroll(self y), scroll(self y);
|
||||
animation-range:
|
||||
0 80px,
|
||||
calc(100% - 80px) 100%;
|
||||
}
|
||||
|
||||
.agent-code-block {
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-code-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background-color: var(--color-secondary-background-hover);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.agent-code-block-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.6875rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.agent-code-block-filename {
|
||||
color: var(--color-base-foreground);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.agent-code-block-copy {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-family: inherit;
|
||||
padding: 0.125rem 0.5rem;
|
||||
line-height: 1.5;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.agent-code-block-copy:hover {
|
||||
background-color: var(--color-secondary-background);
|
||||
color: var(--color-base-foreground);
|
||||
}
|
||||
|
||||
.agent-code-block pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.agent-code-block code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-base-foreground);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.agent-inline-code {
|
||||
background-color: var(--color-secondary-background-hover);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
padding: 0.125rem 0.375rem;
|
||||
}
|
||||
|
||||
@keyframes shimmer-sweep {
|
||||
from {
|
||||
background-position: 100% center;
|
||||
}
|
||||
to {
|
||||
background-position: 0% center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
.disable-animations *,
|
||||
|
||||
@@ -1,133 +1,154 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-row"
|
||||
>
|
||||
<slot name="workflow-tabs" />
|
||||
<!-- Left column: workflow tabs + canvas/panels -->
|
||||
<div class="pointer-events-none flex flex-1 flex-col overflow-hidden">
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel
|
||||
class="graph-canvas-panel relative overflow-visible"
|
||||
>
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: agent panel, full viewport height -->
|
||||
<div
|
||||
v-if="agentPanelVisible"
|
||||
class="pointer-events-auto relative h-full shrink-0 overflow-hidden border-l border-interface-stroke bg-comfy-menu-bg"
|
||||
:style="{ width: `${agentPanelWidth}px` }"
|
||||
>
|
||||
<div
|
||||
class="agent-resize-handle absolute top-0 left-0 z-10 h-full w-[5px] cursor-col-resize"
|
||||
:data-resizing="isResizing"
|
||||
@pointerdown="onResizePointerDown"
|
||||
@lostpointercapture="isResizing = false"
|
||||
/>
|
||||
<slot name="agent-panel" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import type { SplitterResizeStartEvent } from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -137,6 +158,7 @@ import {
|
||||
SIDEBAR_MIN_SIZE,
|
||||
SIDE_PANEL_SIZE
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -147,6 +169,26 @@ const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { isOpen: agentPanelVisible, width: agentPanelWidth } =
|
||||
storeToRefs(agentPanelStore)
|
||||
|
||||
const isResizing = ref(false)
|
||||
let resizeStartX = 0
|
||||
let resizeStartWidth = 0
|
||||
|
||||
function onResizePointerDown(e: PointerEvent) {
|
||||
isResizing.value = true
|
||||
resizeStartX = e.clientX
|
||||
resizeStartWidth = agentPanelStore.width
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
useEventListener(document, 'pointermove', (e: PointerEvent) => {
|
||||
if (!isResizing.value) return
|
||||
agentPanelStore.setWidth(resizeStartWidth + (resizeStartX - e.clientX))
|
||||
})
|
||||
const { t } = useI18n()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -304,4 +346,14 @@ const lastPanelStyle = computed(() => {
|
||||
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
.agent-resize-handle:hover {
|
||||
transition: background-color 0.2s ease 300ms;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.agent-resize-handle[data-resizing='true'] {
|
||||
transition: none;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
35
src/components/ai-elements/code-block/CodeBlock.vue
Normal file
35
src/components/ai-elements/code-block/CodeBlock.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import CodeBlockContainer from './CodeBlockContainer.vue'
|
||||
import CodeBlockContent from './CodeBlockContent.vue'
|
||||
import { CodeBlockKey } from './context'
|
||||
|
||||
const {
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
code: string
|
||||
language: string
|
||||
showLineNumbers?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
provide(CodeBlockKey, { code: computed(() => code) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CodeBlockContainer :class="cn('text-xs', className)" :language="language">
|
||||
<slot />
|
||||
<CodeBlockContent
|
||||
:code="code"
|
||||
:language="language"
|
||||
:show-line-numbers="showLineNumbers"
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</template>
|
||||
15
src/components/ai-elements/code-block/CodeBlockActions.vue
Normal file
15
src/components/ai-elements/code-block/CodeBlockActions.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
30
src/components/ai-elements/code-block/CodeBlockContainer.vue
Normal file
30
src/components/ai-elements/code-block/CodeBlockContainer.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, language } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
language: string
|
||||
}>()
|
||||
|
||||
const containerStyle = {
|
||||
contentVisibility: 'auto' as const,
|
||||
containIntrinsicSize: 'auto 200px'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group relative w-full overflow-hidden rounded-md border border-border-default bg-base-background text-base-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
:data-language="language"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
105
src/components/ai-elements/code-block/CodeBlockContent.vue
Normal file
105
src/components/ai-elements/code-block/CodeBlockContent.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import type { BundledLanguage, ThemedToken } from 'shiki'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { TokenizedCode } from './utils'
|
||||
import {
|
||||
createRawTokens,
|
||||
highlightCode,
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline
|
||||
} from './utils'
|
||||
|
||||
const {
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false
|
||||
} = defineProps<{
|
||||
code: string
|
||||
language: string
|
||||
showLineNumbers?: boolean
|
||||
}>()
|
||||
|
||||
const rawTokens = computed(() => createRawTokens(code))
|
||||
const tokenized = ref<TokenizedCode>(
|
||||
highlightCode(code, language as BundledLanguage) ?? rawTokens.value
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [code, language],
|
||||
() => {
|
||||
tokenized.value =
|
||||
highlightCode(code, language as BundledLanguage) ?? rawTokens.value
|
||||
highlightCode(code, language as BundledLanguage, (result) => {
|
||||
tokenized.value = result
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const preStyle = computed(() => ({
|
||||
color: tokenized.value.fg
|
||||
}))
|
||||
|
||||
interface KeyedToken {
|
||||
token: ThemedToken
|
||||
key: string
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[]
|
||||
key: string
|
||||
}
|
||||
|
||||
const keyedLines = computed<KeyedLine[]>(() =>
|
||||
tokenized.value.tokens.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
token,
|
||||
key: `line-${lineIdx}-${tokenIdx}`
|
||||
}))
|
||||
}))
|
||||
)
|
||||
|
||||
const lineNumberClasses = cn(
|
||||
'block',
|
||||
'before:content-[counter(line)]',
|
||||
'before:inline-block',
|
||||
'before:[counter-increment:line]',
|
||||
'before:w-8',
|
||||
'before:mr-4',
|
||||
'before:text-right',
|
||||
'before:text-muted-foreground/50',
|
||||
'before:font-mono',
|
||||
'before:select-none'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative overflow-auto">
|
||||
<pre
|
||||
class="m-0 overflow-auto bg-base-background p-4 text-sm"
|
||||
:style="preStyle"
|
||||
><code
|
||||
:class="
|
||||
cn(
|
||||
'font-mono text-sm',
|
||||
showLineNumbers && '[counter-increment:line_0] [counter-reset:line]',
|
||||
)
|
||||
"
|
||||
><template v-for="line in keyedLines" :key="line.key"><span :class="showLineNumbers ? lineNumberClasses : 'block'"><span
|
||||
v-for="tokenObj in line.tokens"
|
||||
:key="tokenObj.key"
|
||||
:style="{
|
||||
color: tokenObj.token.color,
|
||||
backgroundColor: tokenObj.token.bgColor,
|
||||
fontStyle: isItalic(tokenObj.token.fontStyle) ? 'italic' : undefined,
|
||||
fontWeight: isBold(tokenObj.token.fontStyle) ? 'bold' : undefined,
|
||||
textDecoration: isUnderline(tokenObj.token.fontStyle)
|
||||
? 'underline'
|
||||
: undefined,
|
||||
}"
|
||||
>{{ tokenObj.token.content }}</span></span></template></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, inject, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import { CodeBlockKey } from './context'
|
||||
|
||||
const { timeout = 2000, class: className } = defineProps<{
|
||||
timeout?: number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: []
|
||||
error: [error: Error]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const context = inject(CodeBlockKey)
|
||||
if (!context)
|
||||
throw new Error('CodeBlockCopyButton must be used within a CodeBlock')
|
||||
|
||||
const { code } = context
|
||||
const isCopied = ref(false)
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const label = computed(() => (isCopied.value ? t('g.copied') : t('g.copy')))
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!navigator?.clipboard?.writeText) {
|
||||
emit('error', new Error('Clipboard API not available'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.value)
|
||||
isCopied.value = true
|
||||
emit('copy')
|
||||
clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => {
|
||||
isCopied.value = false
|
||||
}, timeout)
|
||||
} catch (error) {
|
||||
emit('error', error instanceof Error ? error : new Error('Copy failed'))
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:class="cn('shrink-0', className)"
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
:aria-label="label"
|
||||
@click="copyToClipboard"
|
||||
>
|
||||
<i
|
||||
:class="isCopied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
17
src/components/ai-elements/code-block/CodeBlockFilename.vue
Normal file
17
src/components/ai-elements/code-block/CodeBlockFilename.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="cn('font-mono text-xs font-medium text-base-foreground', className)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
22
src/components/ai-elements/code-block/CodeBlockHeader.vue
Normal file
22
src/components/ai-elements/code-block/CodeBlockHeader.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between border-b border-border-default bg-secondary-background-hover px-3 py-1.5 text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
15
src/components/ai-elements/code-block/CodeBlockTitle.vue
Normal file
15
src/components/ai-elements/code-block/CodeBlockTitle.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1.5', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
7
src/components/ai-elements/code-block/context.ts
Normal file
7
src/components/ai-elements/code-block/context.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ComputedRef, InjectionKey } from 'vue'
|
||||
|
||||
export interface CodeBlockContext {
|
||||
code: ComputedRef<string>
|
||||
}
|
||||
|
||||
export const CodeBlockKey: InjectionKey<CodeBlockContext> = Symbol('CodeBlock')
|
||||
91
src/components/ai-elements/code-block/utils.ts
Normal file
91
src/components/ai-elements/code-block/utils.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken
|
||||
} from 'shiki'
|
||||
import { createHighlighter } from 'shiki'
|
||||
|
||||
export const isItalic = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 1)
|
||||
export const isBold = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 2)
|
||||
export const isUnderline = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 4)
|
||||
|
||||
export interface TokenizedCode {
|
||||
tokens: ThemedToken[][]
|
||||
fg: string
|
||||
bg: string
|
||||
}
|
||||
|
||||
const THEME: BundledTheme = 'one-dark-pro'
|
||||
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>()
|
||||
const tokensCache = new Map<string, TokenizedCode>()
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>()
|
||||
|
||||
function cacheKey(code: string, language: BundledLanguage): string {
|
||||
const start = code.slice(0, 100)
|
||||
const end = code.length > 100 ? code.slice(-100) : ''
|
||||
return `${language}:${code.length}:${start}:${end}`
|
||||
}
|
||||
|
||||
function getHighlighter(
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> {
|
||||
const cached = highlighterCache.get(language)
|
||||
if (cached) return cached
|
||||
|
||||
const promise = createHighlighter({ themes: [THEME], langs: [language] })
|
||||
highlighterCache.set(language, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function createRawTokens(code: string): TokenizedCode {
|
||||
return {
|
||||
tokens: code
|
||||
.split('\n')
|
||||
.map((line) =>
|
||||
line === '' ? [] : [{ content: line, color: 'inherit' } as ThemedToken]
|
||||
),
|
||||
fg: 'inherit',
|
||||
bg: 'transparent'
|
||||
}
|
||||
}
|
||||
|
||||
export function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
callback?: (result: TokenizedCode) => void
|
||||
): TokenizedCode | null {
|
||||
const key = cacheKey(code, language)
|
||||
const cached = tokensCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
if (callback) {
|
||||
if (!subscribers.has(key)) subscribers.set(key, new Set())
|
||||
subscribers.get(key)!.add(callback)
|
||||
}
|
||||
|
||||
getHighlighter(language)
|
||||
.then((highlighter) => {
|
||||
const loadedLangs = highlighter.getLoadedLanguages()
|
||||
const lang = loadedLangs.includes(language) ? language : 'text'
|
||||
const result = highlighter.codeToTokens(code, { lang, theme: THEME })
|
||||
const tokenized: TokenizedCode = {
|
||||
tokens: result.tokens,
|
||||
fg: result.fg ?? 'inherit',
|
||||
bg: result.bg ?? 'transparent'
|
||||
}
|
||||
tokensCache.set(key, tokenized)
|
||||
subscribers.get(key)?.forEach((sub) => sub(tokenized))
|
||||
subscribers.delete(key)
|
||||
})
|
||||
.catch(() => subscribers.delete(key))
|
||||
|
||||
return null
|
||||
}
|
||||
52
src/components/ai-elements/conversation/Conversation.vue
Normal file
52
src/components/ai-elements/conversation/Conversation.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useMutationObserver } from '@vueuse/core'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { provide, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { conversationKey } from './context'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const scrollEl = useTemplateRef<HTMLDivElement>('scrollEl')
|
||||
const isAtBottom = ref(true)
|
||||
|
||||
function updateAtBottom() {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
isAtBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useMutationObserver(
|
||||
scrollEl,
|
||||
() => {
|
||||
if (isAtBottom.value) {
|
||||
requestAnimationFrame(scrollToBottom)
|
||||
}
|
||||
},
|
||||
{ childList: true, subtree: true, characterData: true }
|
||||
)
|
||||
|
||||
provide(conversationKey, { isAtBottom, scrollToBottom })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<div
|
||||
ref="scrollEl"
|
||||
:class="cn('scroll-fade absolute inset-0 scrollbar-custom', className)"
|
||||
@scroll="updateAtBottom"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-4 p-4', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import { useConversation } from './context'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isAtBottom, scrollToBottom } = useConversation()
|
||||
const label = t('agent.scrollToBottom')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isAtBottom"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-2 z-10 flex justify-center"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
size="icon"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto rounded-full shadow-md ring-1 ring-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
:aria-label="label"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ai-elements/conversation/context.ts
Normal file
18
src/components/ai-elements/conversation/context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
export interface ConversationContext {
|
||||
isAtBottom: Ref<boolean>
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
export const conversationKey: InjectionKey<ConversationContext> =
|
||||
Symbol('conversation')
|
||||
|
||||
export function useConversation(): ConversationContext {
|
||||
const context = inject(conversationKey)
|
||||
if (!context) {
|
||||
throw new Error('Conversation parts must be used within <Conversation>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
23
src/components/ai-elements/message/Message.vue
Normal file
23
src/components/ai-elements/message/Message.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { from, class: className } = defineProps<{
|
||||
from: 'user' | 'assistant' | 'system'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group flex w-full gap-2',
|
||||
from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
36
src/components/ai-elements/message/MessageAction.vue
Normal file
36
src/components/ai-elements/message/MessageAction.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { tooltip, pressed = false } = defineProps<{
|
||||
tooltip: string
|
||||
pressed?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip :delay-duration="500">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="tooltip"
|
||||
:aria-pressed="pressed"
|
||||
:class="
|
||||
pressed
|
||||
? 'text-base-foreground'
|
||||
: 'text-muted-foreground hover:text-base-foreground'
|
||||
"
|
||||
class="flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-1 transition-colors hover:bg-secondary-background-hover"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="whitespace-nowrap">{{
|
||||
tooltip
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
5
src/components/ai-elements/message/MessageActions.vue
Normal file
5
src/components/ai-elements/message/MessageActions.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-0.5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
48
src/components/ai-elements/message/MessageAttachments.vue
Normal file
48
src/components/ai-elements/message/MessageAttachments.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { MessageAttachment } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
|
||||
const { attachments } = defineProps<{
|
||||
attachments: readonly MessageAttachment[]
|
||||
}>()
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="(attachment, i) in attachments"
|
||||
:key="i"
|
||||
class="flex items-center gap-3 rounded-lg border border-border-default bg-secondary-background p-2"
|
||||
>
|
||||
<div
|
||||
class="size-10 shrink-0 overflow-hidden rounded-md border border-border-default"
|
||||
>
|
||||
<img
|
||||
v-if="attachment.type.startsWith('image/')"
|
||||
:src="attachment.url"
|
||||
:alt="attachment.name"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center bg-secondary-background-hover"
|
||||
>
|
||||
<i class="icon-[lucide--file] size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs font-medium text-base-foreground">
|
||||
{{ attachment.name }}
|
||||
</span>
|
||||
<span class="block text-xs text-muted-foreground">
|
||||
{{ formatFileSize(attachment.size) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
22
src/components/ai-elements/message/MessageContent.vue
Normal file
22
src/components/ai-elements/message/MessageContent.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full flex-col gap-2 overflow-hidden text-xs text-base-foreground',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:w-fit group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary-background group-[.is-user]:px-4 group-[.is-user]:py-3',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
16
src/components/ai-elements/message/MessageResponse.vue
Normal file
16
src/components/ai-elements/message/MessageResponse.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import Response from '@/components/ai-elements/response/Response.vue'
|
||||
|
||||
const { content, class: className } = defineProps<{
|
||||
content?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Response :content="content" :class="className">
|
||||
<slot />
|
||||
</Response>
|
||||
</template>
|
||||
10
src/components/ai-elements/message/MessageThinking.vue
Normal file
10
src/components/ai-elements/message/MessageThinking.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import Shimmer from '@/components/ai-elements/shimmer/Shimmer.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5 text-sm">
|
||||
<i class="icon-[lucide--brain] size-3.5 text-muted-foreground" />
|
||||
<Shimmer>{{ $t('agent.thinking') }}</Shimmer>
|
||||
</div>
|
||||
</template>
|
||||
102
src/components/ai-elements/message/MessageToolCalls.vue
Normal file
102
src/components/ai-elements/message/MessageToolCalls.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { ToolCall } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
|
||||
const { toolCalls, complete = false } = defineProps<{
|
||||
toolCalls: readonly ToolCall[]
|
||||
complete?: boolean
|
||||
}>()
|
||||
|
||||
const expanded = ref(!complete)
|
||||
const shouldAnimate = ref(!complete)
|
||||
const totalDurationMs = toolCalls.reduce((sum, c) => sum + c.durationMs, 0)
|
||||
|
||||
watch(
|
||||
() => complete,
|
||||
(done) => {
|
||||
if (done)
|
||||
setTimeout(() => {
|
||||
expanded.value = false
|
||||
shouldAnimate.value = false
|
||||
}, 1200)
|
||||
}
|
||||
)
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary-background-hover hover:text-base-foreground"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<i class="icon-[lucide--wrench] size-4 shrink-0" />
|
||||
<span class="flex-1">
|
||||
{{
|
||||
$t('agent.toolCalls.summary', {
|
||||
count: toolCalls.length,
|
||||
duration: formatDuration(totalDurationMs)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<i
|
||||
:class="
|
||||
expanded ? 'icon-[lucide--chevron-up]' : 'icon-[lucide--chevron-down]'
|
||||
"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ul v-if="expanded" class="flex list-none flex-col pl-0">
|
||||
<li
|
||||
v-for="(call, i) in toolCalls"
|
||||
:key="i"
|
||||
:class="
|
||||
cn(
|
||||
'relative pl-6',
|
||||
shouldAnimate &&
|
||||
'animate-in fade-in-0 fill-mode-both slide-in-from-top-1'
|
||||
)
|
||||
"
|
||||
:style="
|
||||
shouldAnimate
|
||||
? { animationDelay: `${i * 80}ms`, animationDuration: '200ms' }
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-4 w-px bg-border-default" />
|
||||
<div class="flex h-8 items-center gap-2 rounded-md px-2">
|
||||
<i
|
||||
:class="
|
||||
call.status === 'success'
|
||||
? 'icon-[lucide--circle-check] text-muted-foreground'
|
||||
: 'icon-[lucide--circle-x] text-muted-foreground/50'
|
||||
"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm text-muted-foreground">{{
|
||||
call.name
|
||||
}}</span>
|
||||
<span class="text-sm text-muted-foreground/60 tabular-nums">{{
|
||||
formatDuration(call.durationMs)
|
||||
}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
29
src/components/ai-elements/prompt-input/PromptInput.vue
Normal file
29
src/components/ai-elements/prompt-input/PromptInput.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import { PROMPT_INPUT_FOCUSED_KEY } from './promptInputContext'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [event: Event]
|
||||
}>()
|
||||
|
||||
const isFocused = ref(false)
|
||||
provide(PROMPT_INPUT_FOCUSED_KEY, isFocused)
|
||||
|
||||
function onSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
emit('submit', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form :class="cn('w-full', className)" @submit="onSubmit">
|
||||
<slot />
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { attachments } = defineProps<{
|
||||
attachments: File[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [index: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const objectUrls = ref<string[]>([])
|
||||
|
||||
watch(
|
||||
() => attachments,
|
||||
(files) => {
|
||||
objectUrls.value.forEach(URL.revokeObjectURL)
|
||||
objectUrls.value = files.map((f) =>
|
||||
f.type.startsWith('image/') ? URL.createObjectURL(f) : ''
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
objectUrls.value.forEach(URL.revokeObjectURL)
|
||||
})
|
||||
|
||||
function fileTypeIcon(file: File): string {
|
||||
if (file.type.startsWith('audio/')) return 'icon-[lucide--music]'
|
||||
if (file.type.startsWith('video/')) return 'icon-[lucide--video]'
|
||||
if (file.type === 'application/pdf') return 'icon-[lucide--file-text]'
|
||||
if (file.type.startsWith('text/')) return 'icon-[lucide--file-text]'
|
||||
return 'icon-[lucide--paperclip]'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="attachments.length" class="flex flex-wrap gap-1.5 px-4 pt-3">
|
||||
<div
|
||||
v-for="(file, i) in attachments"
|
||||
:key="i"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 items-center gap-1.5 rounded-md border border-border-default select-none',
|
||||
'bg-secondary-background px-1.5 text-sm font-medium transition-colors'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="size-5 shrink-0 overflow-hidden rounded-sm">
|
||||
<img
|
||||
v-if="file.type.startsWith('image/')"
|
||||
:src="objectUrls[i]"
|
||||
:alt="file.name"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="fileTypeIcon(file)" class="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="max-w-36 truncate text-xs text-base-foreground">{{
|
||||
file.name
|
||||
}}</span>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
class="size-4 shrink-0"
|
||||
:aria-label="t('g.remove')"
|
||||
@click="emit('remove', i)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-2.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ t('g.remove') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
41
src/components/ai-elements/prompt-input/PromptInputBody.vue
Normal file
41
src/components/ai-elements/prompt-input/PromptInputBody.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
import type { PromptInputFocusedContext } from './promptInputContext'
|
||||
import { PROMPT_INPUT_FOCUSED_KEY } from './promptInputContext'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const isFocused = inject<PromptInputFocusedContext>(PROMPT_INPUT_FOCUSED_KEY)
|
||||
|
||||
function onFocusIn() {
|
||||
if (isFocused) isFocused.value = true
|
||||
}
|
||||
|
||||
function onFocusOut(e: FocusEvent) {
|
||||
const current = e.currentTarget as HTMLElement | null
|
||||
if (isFocused && !current?.contains(e.relatedTarget as Node)) {
|
||||
isFocused.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col rounded-2xl border bg-secondary-background transition-colors',
|
||||
isFocused ? 'border-muted-foreground' : 'border-border-default',
|
||||
className
|
||||
)
|
||||
"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
|
||||
const {
|
||||
class: className,
|
||||
variant = 'muted-textonly',
|
||||
size = 'icon'
|
||||
} = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button type="button" :variant="variant" :size="size" :class="className">
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ default: 'Auto' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button type="button" variant="muted-textonly" size="sm" :class="className">
|
||||
{{ model }}
|
||||
<i class="icon-[lucide--chevron-down] size-3" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
|
||||
import type { ChatStatus } from './types'
|
||||
|
||||
const {
|
||||
class: className,
|
||||
status = 'ready',
|
||||
variant = 'inverted',
|
||||
size = 'icon',
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
status?: ChatStatus
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const iconClass = computed(() => {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return 'icon-[lucide--loader-circle] size-4 animate-spin'
|
||||
case 'streaming':
|
||||
return 'icon-[lucide--square] size-4'
|
||||
case 'error':
|
||||
return 'icon-[lucide--x] size-4'
|
||||
default:
|
||||
return 'icon-[lucide--arrow-up] size-4'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="variant"
|
||||
:size="size"
|
||||
:disabled="disabled"
|
||||
:class="cn('rounded-xl', className)"
|
||||
:aria-label="$t('agent.send')"
|
||||
>
|
||||
<slot>
|
||||
<i :class="iconClass" />
|
||||
</slot>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { class: className, placeholder } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const isComposing = ref(false)
|
||||
const textareaEl = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || event.shiftKey || isComposing.value) return
|
||||
event.preventDefault()
|
||||
const form = (event.target as HTMLElement).closest('form')
|
||||
form?.requestSubmit()
|
||||
}
|
||||
|
||||
function focus() {
|
||||
const el = textareaEl.value
|
||||
if (!el) return
|
||||
el.focus()
|
||||
el.setSelectionRange(el.value.length, el.value.length)
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
ref="textareaEl"
|
||||
v-model="model"
|
||||
rows="1"
|
||||
:placeholder="placeholder"
|
||||
:class="
|
||||
cn(
|
||||
'field-sizing-content max-h-48 min-h-20 w-full resize-none border-none bg-transparent px-4 py-3 font-[inherit] text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
@keydown="onKeydown"
|
||||
@compositionstart="isComposing = true"
|
||||
@compositionend="isComposing = false"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex items-center justify-between gap-1 px-3 py-2', className)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
src/components/ai-elements/prompt-input/PromptInputTools.vue
Normal file
14
src/components/ai-elements/prompt-input/PromptInputTools.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export const PROMPT_INPUT_FOCUSED_KEY = Symbol('promptInputFocused')
|
||||
export type PromptInputFocusedContext = Ref<boolean>
|
||||
1
src/components/ai-elements/prompt-input/types.ts
Normal file
1
src/components/ai-elements/prompt-input/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ChatStatus = 'ready' | 'submitted' | 'streaming' | 'error'
|
||||
151
src/components/ai-elements/response/MarkdownRenderer.vue
Normal file
151
src/components/ai-elements/response/MarkdownRenderer.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import CodeBlock from '../code-block/CodeBlock.vue'
|
||||
import CodeBlockActions from '../code-block/CodeBlockActions.vue'
|
||||
import CodeBlockCopyButton from '../code-block/CodeBlockCopyButton.vue'
|
||||
import CodeBlockFilename from '../code-block/CodeBlockFilename.vue'
|
||||
import CodeBlockHeader from '../code-block/CodeBlockHeader.vue'
|
||||
import CodeBlockTitle from '../code-block/CodeBlockTitle.vue'
|
||||
|
||||
const { content, class: className } = defineProps<{
|
||||
content: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Matches complete fenced code blocks: ```lang\n...content...\n```
|
||||
const FENCE_RE = /^```([^\n]*)\n([\s\S]*?)^```[ \t]*$/gm
|
||||
|
||||
// Matches an opening fence with no closing fence — used to detect mid-stream blocks.
|
||||
// Captures: [1] newline-or-start before the fence, [2] language info, [3] code content so far.
|
||||
const OPEN_FENCE_RE = /(^|\n)```([^\n]*)\n([\s\S]*)$/
|
||||
|
||||
interface HtmlSegment {
|
||||
type: 'html'
|
||||
key: string
|
||||
html: string
|
||||
}
|
||||
|
||||
interface CodeSegment {
|
||||
type: 'code'
|
||||
key: string
|
||||
code: string
|
||||
language: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
type Segment = HtmlSegment | CodeSegment
|
||||
|
||||
function parseCodeInfo(info: string): { language: string; filename: string } {
|
||||
const colonIdx = info.indexOf(':')
|
||||
return {
|
||||
language: colonIdx >= 0 ? info.slice(0, colonIdx) : info,
|
||||
filename: colonIdx >= 0 ? info.slice(colonIdx + 1) : ''
|
||||
}
|
||||
}
|
||||
|
||||
const segments = computed<Segment[]>(() => {
|
||||
if (!content) return []
|
||||
|
||||
const result: Segment[] = []
|
||||
let lastIdx = 0
|
||||
let keyIdx = 0
|
||||
|
||||
for (const match of content.matchAll(FENCE_RE)) {
|
||||
const before = content.slice(lastIdx, match.index)
|
||||
if (before) {
|
||||
result.push({
|
||||
type: 'html',
|
||||
key: `h${keyIdx++}`,
|
||||
html: renderMarkdownToHtml(before)
|
||||
})
|
||||
}
|
||||
|
||||
const { language, filename } = parseCodeInfo(match[1].trim())
|
||||
result.push({
|
||||
type: 'code',
|
||||
key: `c${keyIdx++}`,
|
||||
code: match[2].replace(/\n$/, ''),
|
||||
language,
|
||||
filename
|
||||
})
|
||||
|
||||
lastIdx = match.index! + match[0].length
|
||||
}
|
||||
|
||||
const tail = content.slice(lastIdx)
|
||||
|
||||
const openMatch = tail.match(OPEN_FENCE_RE)
|
||||
if (openMatch) {
|
||||
const fenceStart = openMatch.index! + openMatch[1].length
|
||||
const before = tail.slice(0, fenceStart)
|
||||
if (before) {
|
||||
result.push({
|
||||
type: 'html',
|
||||
key: `h${keyIdx++}`,
|
||||
html: renderMarkdownToHtml(before)
|
||||
})
|
||||
}
|
||||
const { language, filename } = parseCodeInfo(openMatch[2].trim())
|
||||
result.push({
|
||||
type: 'code',
|
||||
key: `c${keyIdx}`,
|
||||
code: openMatch[3],
|
||||
language,
|
||||
filename
|
||||
})
|
||||
} else if (tail) {
|
||||
result.push({
|
||||
type: 'html',
|
||||
key: `h${keyIdx}`,
|
||||
html: renderMarkdownToHtml(tail)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('agent-markdown', className)">
|
||||
<template v-for="segment in segments" :key="segment.key">
|
||||
<div
|
||||
v-if="segment.type === 'html'"
|
||||
class="contents"
|
||||
v-html="segment.html"
|
||||
/>
|
||||
<CodeBlock
|
||||
v-else
|
||||
class="mb-2"
|
||||
:code="segment.code"
|
||||
:language="segment.language"
|
||||
>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>
|
||||
<i
|
||||
:class="
|
||||
segment.filename
|
||||
? 'icon-[lucide--file-code]'
|
||||
: 'icon-[lucide--code-2]'
|
||||
"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<CodeBlockFilename v-if="segment.filename">
|
||||
{{ segment.filename }}
|
||||
</CodeBlockFilename>
|
||||
<span v-else class="font-mono text-xs">
|
||||
{{ segment.language || 'plaintext' }}
|
||||
</span>
|
||||
</CodeBlockTitle>
|
||||
<CodeBlockActions>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlockActions>
|
||||
</CodeBlockHeader>
|
||||
</CodeBlock>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
29
src/components/ai-elements/response/Response.vue
Normal file
29
src/components/ai-elements/response/Response.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import MarkdownRenderer from './MarkdownRenderer.vue'
|
||||
|
||||
const { content, class: className } = defineProps<{
|
||||
content?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const markdown = computed(() => {
|
||||
if (content !== undefined) return content
|
||||
const nodes = slots.default?.() ?? []
|
||||
return nodes
|
||||
.map((node) => (typeof node.children === 'string' ? node.children : ''))
|
||||
.join('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownRenderer
|
||||
:content="markdown"
|
||||
:class="cn('text-xs/relaxed', className)"
|
||||
/>
|
||||
</template>
|
||||
51
src/components/ai-elements/shimmer/Shimmer.vue
Normal file
51
src/components/ai-elements/shimmer/Shimmer.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const {
|
||||
as = 'span',
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
as?: keyof HTMLElementTagNameMap
|
||||
class?: HTMLAttributes['class']
|
||||
duration?: number
|
||||
spread?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
:class="['shimmer', className]"
|
||||
:style="{
|
||||
'--shimmer-duration': `${duration}s`,
|
||||
'--shimmer-spread': `${(($slots.default?.()[0]?.children as string)?.length ?? 10) * spread}px`
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shimmer {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent calc(50% - var(--shimmer-spread)),
|
||||
var(--color-base-foreground),
|
||||
transparent calc(50% + var(--shimmer-spread))
|
||||
),
|
||||
linear-gradient(
|
||||
var(--color-muted-foreground),
|
||||
var(--color-muted-foreground)
|
||||
);
|
||||
background-size:
|
||||
250% 100%,
|
||||
auto;
|
||||
background-repeat: no-repeat;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: shimmer-sweep var(--shimmer-duration) linear infinite;
|
||||
}
|
||||
</style>
|
||||
28
src/components/ai-elements/suggestion/Suggestion.vue
Normal file
28
src/components/ai-elements/suggestion/Suggestion.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { suggestion, class: className } = defineProps<{
|
||||
suggestion: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [suggestion: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'text-foreground flex h-8 w-full cursor-pointer items-center justify-start gap-2 rounded-full border-0 bg-secondary-background px-3 text-sm whitespace-nowrap transition-colors outline-none hover:bg-secondary-background-hover @[460px]:w-auto',
|
||||
className
|
||||
)
|
||||
"
|
||||
@click="emit('select', suggestion)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
21
src/components/ai-elements/suggestion/Suggestions.vue
Normal file
21
src/components/ai-elements/suggestion/Suggestions.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full flex-wrap justify-start gap-2 @[460px]:justify-center',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,6 +50,15 @@
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
</template>
|
||||
<template #agent-panel>
|
||||
<div class="size-full p-2">
|
||||
<div
|
||||
class="size-full overflow-hidden rounded-lg border border-(--interface-stroke)"
|
||||
>
|
||||
<AgentChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
<canvas
|
||||
id="graph-canvas"
|
||||
@@ -141,6 +150,7 @@ import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
import AgentChatPanel from '@/platform/agent/components/AgentChatPanel.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
|
||||
@@ -84,6 +84,28 @@
|
||||
data-testid="integrated-tab-bar-actions"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<motion.button
|
||||
type="button"
|
||||
class="no-drag relative flex h-6 shrink-0 cursor-pointer items-center gap-2 overflow-hidden rounded-sm border px-2 text-xs text-base-foreground transition-colors"
|
||||
:class="
|
||||
cn(
|
||||
isAgentPanelOpen
|
||||
? 'border-plum-500 bg-plum-600/20'
|
||||
: 'border-plum-600 bg-ink-700 hover:border-plum-500'
|
||||
)
|
||||
"
|
||||
:while-hover="agentWhileHover"
|
||||
:animate="agentIdleAnimate"
|
||||
:transition="agentHoverTransition"
|
||||
:aria-label="$t('agent.ask')"
|
||||
@click="agentPanelStore.toggle()"
|
||||
>
|
||||
<AgentShaderBackground />
|
||||
<i
|
||||
class="relative z-10 icon-[comfy--comfy-c] size-3 text-brand-yellow"
|
||||
/>
|
||||
<span class="relative z-10">{{ $t('agent.ask') }}</span>
|
||||
</motion.button>
|
||||
<Button
|
||||
v-if="isCloud || isNightly"
|
||||
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||
@@ -93,7 +115,7 @@
|
||||
:aria-label="$t('actionbar.feedback')"
|
||||
@click="openFeedback"
|
||||
>
|
||||
<i class="icon-[lucide--message-square-text]" />
|
||||
<i class="icon-[lucide--megaphone]" />
|
||||
</Button>
|
||||
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
|
||||
<LoginButton
|
||||
@@ -106,7 +128,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import { motion } from 'motion-v'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
@@ -124,6 +149,10 @@ import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import AgentShaderBackground from '@/platform/agent/components/AgentShaderBackground.vue'
|
||||
import { useAgentPersonality } from '@/platform/agent/composables/agentPersonalityState'
|
||||
import { useAgentHoverMotion } from '@/platform/agent/composables/useAgentHoverMotion'
|
||||
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
@@ -145,8 +174,24 @@ const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { isOpen: isAgentPanelOpen } = storeToRefs(agentPanelStore)
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
const agentPersonality = useAgentPersonality()
|
||||
const agentReducedMotion = computed(
|
||||
() => !!settingStore.get('Comfy.Appearance.DisableAnimations')
|
||||
)
|
||||
const {
|
||||
transition: agentHoverTransition,
|
||||
whileHover: agentWhileHover,
|
||||
animate: agentIdleAnimate
|
||||
} = useAgentHoverMotion(
|
||||
agentPersonality.hover,
|
||||
agentPersonality.idle,
|
||||
agentReducedMotion
|
||||
)
|
||||
|
||||
// Dismiss a tab's terminal status badge once it has been viewed
|
||||
useWorkflowStatusDismissal()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
23
src/components/ui/empty/Empty.vue
Normal file
23
src/components/ui/empty/Empty.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg p-6 text-center text-balance md:p-12',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ui/empty/EmptyDescription.vue
Normal file
18
src/components/ui/empty/EmptyDescription.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="empty-description"
|
||||
:class="cn('text-sm text-muted-foreground', className)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
20
src/components/ui/empty/EmptyHeader.vue
Normal file
20
src/components/ui/empty/EmptyHeader.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
:class="
|
||||
cn('flex max-w-sm flex-col items-center gap-2 text-center', className)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
27
src/components/ui/empty/EmptyMedia.vue
Normal file
27
src/components/ui/empty/EmptyMedia.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { variant = 'default', class: className } = defineProps<{
|
||||
variant?: 'default' | 'icon'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-media"
|
||||
:data-variant="variant"
|
||||
:class="
|
||||
cn(
|
||||
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
variant === 'icon' &&
|
||||
'text-foreground size-10 rounded-lg bg-muted [&_svg:not([class*=\'size-\'])]:size-6',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ui/empty/EmptyTitle.vue
Normal file
18
src/components/ui/empty/EmptyTitle.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
:class="cn('text-lg font-medium tracking-tight', className)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
42
src/components/ui/tooltip/Tooltip.vue
Normal file
42
src/components/ui/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, provide, ref } from 'vue'
|
||||
|
||||
import type { TooltipContext } from './tooltipContext'
|
||||
import { TOOLTIP_KEY } from './tooltipContext'
|
||||
|
||||
const { delayDuration = 300 } = defineProps<{
|
||||
delayDuration?: number
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const triggerEl = ref<HTMLElement | null>(null)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function scheduleOpen() {
|
||||
timer = setTimeout(() => {
|
||||
open.value = true
|
||||
}, delayDuration)
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
open.value = false
|
||||
}
|
||||
|
||||
onUnmounted(close)
|
||||
|
||||
provide<TooltipContext>(TOOLTIP_KEY, {
|
||||
open,
|
||||
triggerEl,
|
||||
delayDuration,
|
||||
scheduleOpen,
|
||||
close
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
81
src/components/ui/tooltip/TooltipContent.vue
Normal file
81
src/components/ui/tooltip/TooltipContent.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { CSSProperties, HTMLAttributes } from 'vue'
|
||||
import { inject, ref, watch } from 'vue'
|
||||
|
||||
import { TOOLTIP_KEY } from './tooltipContext'
|
||||
|
||||
const { class: className, side = 'bottom' } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}>()
|
||||
|
||||
const ctx = inject(TOOLTIP_KEY)
|
||||
const style = ref<CSSProperties>({})
|
||||
|
||||
function computeStyle() {
|
||||
if (!ctx?.triggerEl.value) return {}
|
||||
const rect = ctx.triggerEl.value.getBoundingClientRect()
|
||||
const gap = 6
|
||||
|
||||
if (side === 'top') {
|
||||
return {
|
||||
left: `${rect.left + rect.width / 2}px`,
|
||||
top: `${rect.top - gap}px`,
|
||||
transform: 'translate(-50%, -100%)'
|
||||
}
|
||||
}
|
||||
if (side === 'left') {
|
||||
return {
|
||||
left: `${rect.left - gap}px`,
|
||||
top: `${rect.top + rect.height / 2}px`,
|
||||
transform: 'translate(-100%, -50%)'
|
||||
}
|
||||
}
|
||||
if (side === 'right') {
|
||||
return {
|
||||
left: `${rect.right + gap}px`,
|
||||
top: `${rect.top + rect.height / 2}px`,
|
||||
transform: 'translateY(-50%)'
|
||||
}
|
||||
}
|
||||
return {
|
||||
left: `${rect.left + rect.width / 2}px`,
|
||||
top: `${rect.bottom + gap}px`,
|
||||
transform: 'translateX(-50%)'
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ctx?.open.value,
|
||||
(open) => {
|
||||
if (open) style.value = computeStyle()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-100"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-75"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="ctx?.open.value"
|
||||
:style="style"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none fixed z-9999 max-w-xs rounded-md border border-node-component-tooltip-border bg-node-component-tooltip-surface px-2 py-1 text-xs leading-none text-node-component-tooltip',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
50
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
50
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { TOOLTIP_KEY } from './tooltipContext'
|
||||
|
||||
const ctx = inject(TOOLTIP_KEY)
|
||||
|
||||
const el = ref<HTMLElement | null>(null)
|
||||
|
||||
function onMouseEnter() {
|
||||
ctx?.scheduleOpen()
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
ctx?.close()
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
ctx?.scheduleOpen()
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
ctx?.close()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value || !ctx) return
|
||||
// display:contents removes the wrapper's box; use the real child for positioning
|
||||
ctx.triggerEl.value =
|
||||
(el.value.firstElementChild as HTMLElement | null) ?? el.value
|
||||
el.value.addEventListener('mouseenter', onMouseEnter)
|
||||
el.value.addEventListener('mouseleave', onMouseLeave)
|
||||
el.value.addEventListener('focus', onFocus)
|
||||
el.value.addEventListener('blur', onBlur)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!el.value) return
|
||||
el.value.removeEventListener('mouseenter', onMouseEnter)
|
||||
el.value.removeEventListener('mouseleave', onMouseLeave)
|
||||
el.value.removeEventListener('focus', onFocus)
|
||||
el.value.removeEventListener('blur', onBlur)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" class="contents">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
11
src/components/ui/tooltip/tooltipContext.ts
Normal file
11
src/components/ui/tooltip/tooltipContext.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
export interface TooltipContext {
|
||||
open: Ref<boolean>
|
||||
triggerEl: Ref<HTMLElement | null>
|
||||
delayDuration: number
|
||||
scheduleOpen: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const TOOLTIP_KEY: InjectionKey<TooltipContext> = Symbol('tooltip')
|
||||
@@ -9,6 +9,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const DEFAULT_TITLE = 'ComfyUI'
|
||||
const TITLE_SUFFIX = ' - ComfyUI'
|
||||
const BRANCH_PREFIX = __GIT_BRANCH_PREFIX__ ? `[${__GIT_BRANCH_PREFIX__}] ` : ''
|
||||
|
||||
export const useBrowserTabTitle = () => {
|
||||
const executionStore = useExecutionStore()
|
||||
@@ -90,6 +91,8 @@ export const useBrowserTabTitle = () => {
|
||||
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
|
||||
)
|
||||
|
||||
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
|
||||
const title = computed(
|
||||
() => BRANCH_PREFIX + (nodeExecutionTitle.value || workflowTitle.value)
|
||||
)
|
||||
useTitle(title)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,52 @@
|
||||
{
|
||||
"agent": {
|
||||
"title": "Comfy Agent",
|
||||
"label": "Agent",
|
||||
"ask": "Ask Comfy Agent",
|
||||
"alpha": "ALPHA",
|
||||
"newChat": "New chat",
|
||||
"maximize": "Maximize panel",
|
||||
"minimize": "Minimize panel",
|
||||
"togglePanel": "Toggle panel",
|
||||
"greeting": "Hello,",
|
||||
"greetingNamed": "Hello {name},",
|
||||
"greetingQuestion": "What do you want to make?",
|
||||
"placeholder": "Ask Comfy Agent…",
|
||||
"attach": "Attach files or photos",
|
||||
"mention": "Mention",
|
||||
"send": "Send",
|
||||
"scrollToBottom": "Scroll to latest",
|
||||
"disclaimer": "Agent generation does not impact the graph.",
|
||||
"suggestions": {
|
||||
"duck": "Generate a yellow duck with a hockey mask",
|
||||
"savedWorkflows": "List my saved workflows",
|
||||
"skinUpscaling": "Find the best workflow for skin upscaling",
|
||||
"explainNode": "Explain the selected node",
|
||||
"imageToVideo": "Build a workflow for image to video with 3 models"
|
||||
},
|
||||
"thinking": "Thinking…",
|
||||
"toolCalls": {
|
||||
"summary": "Ran {count} tool calls for {duration}"
|
||||
},
|
||||
"message": {
|
||||
"thumbsUp": "Good response",
|
||||
"thumbsDown": "Bad response",
|
||||
"copy": "Copy"
|
||||
},
|
||||
"history": {
|
||||
"title": "Chat History",
|
||||
"show": "Show chat history",
|
||||
"back": "Back to conversation",
|
||||
"current": "Current",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"last7Days": "Last 7 days",
|
||||
"last30Days": "Last 30 days",
|
||||
"emptyTitle": "No chats yet",
|
||||
"emptyDescription": "Start a conversation and it will appear here.",
|
||||
"startChat": "Start a chat"
|
||||
}
|
||||
},
|
||||
"g": {
|
||||
"shortcutSuffix": " ({shortcut})",
|
||||
"user": "User",
|
||||
|
||||
58
src/platform/agent/components/AgentChatEmptyState.vue
Normal file
58
src/platform/agent/components/AgentChatEmptyState.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { motion } from 'motion-v'
|
||||
|
||||
import Empty from '@/components/ui/empty/Empty.vue'
|
||||
import EmptyHeader from '@/components/ui/empty/EmptyHeader.vue'
|
||||
import EmptyMedia from '@/components/ui/empty/EmptyMedia.vue'
|
||||
import EmptyTitle from '@/components/ui/empty/EmptyTitle.vue'
|
||||
import AgentShaderBackground from '@/platform/agent/components/AgentShaderBackground.vue'
|
||||
import { useAgentPersonality } from '@/platform/agent/composables/agentPersonalityState'
|
||||
import { useAgentHoverMotion } from '@/platform/agent/composables/useAgentHoverMotion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const { name } = defineProps<{
|
||||
name?: string
|
||||
}>()
|
||||
|
||||
const agentPersonality = useAgentPersonality()
|
||||
const reducedMotion = computed(
|
||||
() => !!useSettingStore().get('Comfy.Appearance.DisableAnimations')
|
||||
)
|
||||
const { transition, whileHover, animate } = useAgentHoverMotion(
|
||||
agentPersonality.hover,
|
||||
agentPersonality.idle,
|
||||
reducedMotion
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Empty class="pt-12">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia>
|
||||
<motion.div
|
||||
class="relative flex size-12 items-center justify-center overflow-hidden rounded-xl border border-plum-600 bg-ink-700"
|
||||
:while-hover="whileHover"
|
||||
:animate="animate"
|
||||
:transition="transition"
|
||||
>
|
||||
<AgentShaderBackground />
|
||||
<i
|
||||
class="relative z-10 icon-[comfy--comfy-c] size-6 text-brand-yellow"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle
|
||||
class="text-base/snug font-semibold text-base-foreground @min-[570px]:text-2xl/snug"
|
||||
>
|
||||
<span class="block">
|
||||
{{
|
||||
name ? $t('agent.greetingNamed', { name }) : $t('agent.greeting')
|
||||
}}
|
||||
</span>
|
||||
<span class="block">{{ $t('agent.greetingQuestion') }}</span>
|
||||
</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</template>
|
||||
88
src/platform/agent/components/AgentChatHeader.vue
Normal file
88
src/platform/agent/components/AgentChatHeader.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { isMaximized } = defineProps<{
|
||||
isMaximized: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
newChat: []
|
||||
toggleMaximize: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sizeToggleIcon = computed(() =>
|
||||
isMaximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
|
||||
)
|
||||
const sizeToggleLabel = computed(() =>
|
||||
isMaximized ? t('agent.minimize') : t('agent.maximize')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-12 shrink-0 items-center justify-between border-b border-component-node-border px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">{{ $t('agent.title') }}</span>
|
||||
<span
|
||||
class="rounded-full border border-border-default px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('agent.alpha') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('agent.newChat')"
|
||||
@click="emit('newChat')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--message-circle-plus] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ $t('agent.newChat') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="sizeToggleLabel"
|
||||
@click="emit('toggleMaximize')"
|
||||
>
|
||||
<i :class="`${sizeToggleIcon} size-4 text-muted-foreground`" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="whitespace-nowrap">
|
||||
{{ sizeToggleLabel }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ $t('g.close') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
131
src/platform/agent/components/AgentChatHistory.vue
Normal file
131
src/platform/agent/components/AgentChatHistory.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Empty from '@/components/ui/empty/Empty.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
import EmptyDescription from '@/components/ui/empty/EmptyDescription.vue'
|
||||
import EmptyHeader from '@/components/ui/empty/EmptyHeader.vue'
|
||||
import EmptyMedia from '@/components/ui/empty/EmptyMedia.vue'
|
||||
import EmptyTitle from '@/components/ui/empty/EmptyTitle.vue'
|
||||
import type { AgentConversation } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
import AgentChatHistoryGroupLabel from './AgentChatHistoryGroupLabel.vue'
|
||||
import AgentChatHistoryItem from './AgentChatHistoryItem.vue'
|
||||
|
||||
const { conversations, activeId } = defineProps<{
|
||||
conversations: readonly AgentConversation[]
|
||||
activeId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
select: [id: string]
|
||||
delete: [id: string]
|
||||
copy: [id: string]
|
||||
newChat: []
|
||||
}>()
|
||||
|
||||
type GroupKey = 'today' | 'yesterday' | 'last7Days' | 'last30Days'
|
||||
|
||||
interface Group {
|
||||
key: GroupKey
|
||||
labelKey: string
|
||||
items: AgentConversation[]
|
||||
}
|
||||
|
||||
function getGroupKey(date: Date): GroupKey {
|
||||
const now = new Date()
|
||||
const diffDays = (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
|
||||
|
||||
if (diffDays < 1) return 'today'
|
||||
if (diffDays < 2) return 'yesterday'
|
||||
if (diffDays < 7) return 'last7Days'
|
||||
return 'last30Days'
|
||||
}
|
||||
|
||||
const labelKeys: Record<GroupKey, string> = {
|
||||
today: 'agent.history.today',
|
||||
yesterday: 'agent.history.yesterday',
|
||||
last7Days: 'agent.history.last7Days',
|
||||
last30Days: 'agent.history.last30Days'
|
||||
}
|
||||
|
||||
const order: GroupKey[] = ['today', 'yesterday', 'last7Days', 'last30Days']
|
||||
|
||||
const groups = computed<Group[]>(() => {
|
||||
const buckets: Record<GroupKey, AgentConversation[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
last7Days: [],
|
||||
last30Days: []
|
||||
}
|
||||
|
||||
for (const conv of conversations) {
|
||||
buckets[getGroupKey(conv.createdAt)].push(conv)
|
||||
}
|
||||
|
||||
return order
|
||||
.filter((key) => buckets[key].length > 0)
|
||||
.map((key) => ({ key, labelKey: labelKeys[key], items: buckets[key] }))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex shrink-0 items-center px-2 py-1.5">
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 cursor-pointer items-center gap-1 rounded-sm border-0 bg-transparent px-2 text-xs text-muted-foreground hover:bg-secondary-background-hover"
|
||||
:aria-label="$t('agent.history.back')"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-left] size-3" />
|
||||
<span>{{ $t('agent.history.title') }}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{
|
||||
$t('agent.history.back')
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-y-auto p-2">
|
||||
<Empty v-if="groups.length === 0">
|
||||
<EmptyMedia variant="icon">
|
||||
<i class="icon-[lucide--history] size-5" />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{{ $t('agent.history.emptyTitle') }}</EmptyTitle>
|
||||
<EmptyDescription>{{
|
||||
$t('agent.history.emptyDescription')
|
||||
}}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button variant="primary" size="lg" @click="emit('newChat')">
|
||||
{{ $t('agent.history.startChat') }}
|
||||
</Button>
|
||||
</Empty>
|
||||
|
||||
<div v-for="group in groups" :key="group.key" class="mb-3">
|
||||
<AgentChatHistoryGroupLabel>{{
|
||||
$t(group.labelKey)
|
||||
}}</AgentChatHistoryGroupLabel>
|
||||
<ul class="flex list-none flex-col gap-0.5 pl-0">
|
||||
<AgentChatHistoryItem
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
:active="item.id === activeId"
|
||||
@select="emit('select', item.id)"
|
||||
@delete="emit('delete', item.id)"
|
||||
@copy="emit('copy', item.id)"
|
||||
>
|
||||
<span class="truncate">{{ item.title }}</span>
|
||||
</AgentChatHistoryItem>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<p class="my-0 py-0 text-xs font-medium text-muted-foreground">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
61
src/platform/agent/components/AgentChatHistoryItem.vue
Normal file
61
src/platform/agent/components/AgentChatHistoryItem.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { active = false } = defineProps<{
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: []
|
||||
delete: []
|
||||
copy: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-secondary-background-hover"
|
||||
:class="{ 'bg-secondary-background': active }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 cursor-pointer items-center gap-2 overflow-hidden border-0 bg-transparent text-left text-sm text-base-foreground"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--circle-check] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<slot />
|
||||
</button>
|
||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-muted-foreground hover:bg-secondary-background-hover hover:text-base-foreground"
|
||||
:aria-label="$t('g.copy')"
|
||||
@click.stop="emit('copy')"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('g.copy') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-danger flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-muted-foreground hover:bg-destructive-background/10"
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('g.delete') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
312
src/platform/agent/components/AgentChatPanel.vue
Normal file
312
src/platform/agent/components/AgentChatPanel.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import Conversation from '@/components/ai-elements/conversation/Conversation.vue'
|
||||
import ConversationContent from '@/components/ai-elements/conversation/ConversationContent.vue'
|
||||
import ConversationEmptyState from '@/components/ai-elements/conversation/ConversationEmptyState.vue'
|
||||
import ConversationScrollButton from '@/components/ai-elements/conversation/ConversationScrollButton.vue'
|
||||
import Message from '@/components/ai-elements/message/Message.vue'
|
||||
import MessageAction from '@/components/ai-elements/message/MessageAction.vue'
|
||||
import MessageActions from '@/components/ai-elements/message/MessageActions.vue'
|
||||
import MessageAttachments from '@/components/ai-elements/message/MessageAttachments.vue'
|
||||
import MessageContent from '@/components/ai-elements/message/MessageContent.vue'
|
||||
import MessageResponse from '@/components/ai-elements/message/MessageResponse.vue'
|
||||
import MessageThinking from '@/components/ai-elements/message/MessageThinking.vue'
|
||||
import MessageToolCalls from '@/components/ai-elements/message/MessageToolCalls.vue'
|
||||
import PromptInput from '@/components/ai-elements/prompt-input/PromptInput.vue'
|
||||
import PromptInputAttachments from '@/components/ai-elements/prompt-input/PromptInputAttachments.vue'
|
||||
import PromptInputBody from '@/components/ai-elements/prompt-input/PromptInputBody.vue'
|
||||
import PromptInputButton from '@/components/ai-elements/prompt-input/PromptInputButton.vue'
|
||||
import PromptInputModelSelect from '@/components/ai-elements/prompt-input/PromptInputModelSelect.vue'
|
||||
import PromptInputSubmit from '@/components/ai-elements/prompt-input/PromptInputSubmit.vue'
|
||||
import PromptInputTextarea from '@/components/ai-elements/prompt-input/PromptInputTextarea.vue'
|
||||
import PromptInputToolbar from '@/components/ai-elements/prompt-input/PromptInputToolbar.vue'
|
||||
import PromptInputTools from '@/components/ai-elements/prompt-input/PromptInputTools.vue'
|
||||
import { useAgentChatPrototype } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import AgentChatEmptyState from './AgentChatEmptyState.vue'
|
||||
import AgentChatHeader from './AgentChatHeader.vue'
|
||||
import AgentChatHistory from './AgentChatHistory.vue'
|
||||
import AgentPromptSuggestions from './AgentPromptSuggestions.vue'
|
||||
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
status,
|
||||
isEmpty,
|
||||
chatHistory,
|
||||
currentConversationId,
|
||||
send,
|
||||
stop,
|
||||
applySuggestion,
|
||||
startNewChat,
|
||||
loadConversation,
|
||||
deleteConversation,
|
||||
copyConversation
|
||||
} = useAgentChatPrototype()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
|
||||
const model = ref('Auto')
|
||||
const showHistory = ref(false)
|
||||
const promptTextarea = ref<{ focus: () => void } | null>(null)
|
||||
const reactions = ref<Record<string, 'liked' | 'disliked' | null>>({})
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const attachments = ref<File[]>([])
|
||||
|
||||
const userName = computed(
|
||||
() => authStore.currentUser?.displayName?.split(' ')[0] ?? ''
|
||||
)
|
||||
|
||||
const conversationTitle = computed(
|
||||
() => messages.value.find((message) => message.role === 'user')?.text
|
||||
)
|
||||
|
||||
const submitDisabled = computed(
|
||||
() => status.value === 'ready' && input.value.trim() === ''
|
||||
)
|
||||
|
||||
function onSubmit() {
|
||||
if (status.value === 'submitted' || status.value === 'streaming') {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
send(undefined, attachments.value)
|
||||
attachments.value = []
|
||||
}
|
||||
|
||||
function removeAttachment(index: number) {
|
||||
attachments.value = attachments.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function close() {
|
||||
agentPanelStore.close()
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFilesSelected(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (!files) return
|
||||
attachments.value = [...attachments.value, ...Array.from(files)]
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
}
|
||||
|
||||
function toggleReaction(id: string, reaction: 'liked' | 'disliked') {
|
||||
reactions.value[id] = reactions.value[id] === reaction ? null : reaction
|
||||
}
|
||||
|
||||
function copyMessage(text: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
function onSelectConversation(id: string) {
|
||||
loadConversation(id)
|
||||
showHistory.value = false
|
||||
}
|
||||
|
||||
function onSuggestionSelect(text: string) {
|
||||
applySuggestion(text)
|
||||
setTimeout(() => promptTextarea.value?.focus(), 0)
|
||||
}
|
||||
|
||||
function onNewChatFromHistory() {
|
||||
startNewChat()
|
||||
showHistory.value = false
|
||||
nextTick(() => promptTextarea.value?.focus())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="@container flex h-full flex-col overflow-hidden bg-base-background"
|
||||
>
|
||||
<AgentChatHeader
|
||||
:is-maximized="agentPanelStore.isMaximized"
|
||||
@new-chat="onNewChatFromHistory"
|
||||
@toggle-maximize="agentPanelStore.toggleMaximize"
|
||||
@close="close"
|
||||
/>
|
||||
|
||||
<template v-if="showHistory">
|
||||
<AgentChatHistory
|
||||
:conversations="chatHistory"
|
||||
:active-id="currentConversationId"
|
||||
@back="showHistory = false"
|
||||
@select="onSelectConversation"
|
||||
@delete="deleteConversation"
|
||||
@copy="copyConversation"
|
||||
@new-chat="onNewChatFromHistory"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex shrink-0 items-center px-2 py-1.5">
|
||||
<Tooltip :delay-duration="500">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 cursor-pointer items-center gap-1 rounded-sm border-0 bg-transparent px-2 text-xs text-muted-foreground hover:bg-secondary-background-hover"
|
||||
@click="showHistory = true"
|
||||
>
|
||||
<i class="icon-[lucide--align-justify] size-3.5" />
|
||||
<span class="max-w-56 truncate">
|
||||
{{ conversationTitle ?? $t('agent.newChat') }}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{{ $t('agent.history.show') }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<ConversationEmptyState v-if="isEmpty">
|
||||
<AgentChatEmptyState :name="userName" />
|
||||
</ConversationEmptyState>
|
||||
<Conversation v-else>
|
||||
<template #overlay>
|
||||
<ConversationScrollButton />
|
||||
</template>
|
||||
<ConversationContent class="mx-auto w-full max-w-[640px]">
|
||||
<Message
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:from="message.role"
|
||||
>
|
||||
<!-- User messages: attachments float above the text bubble -->
|
||||
<template v-if="message.role === 'user'">
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<MessageAttachments
|
||||
v-if="message.attachments?.length"
|
||||
:attachments="message.attachments"
|
||||
/>
|
||||
<MessageContent v-if="message.text">
|
||||
<MessageResponse
|
||||
:content="message.text"
|
||||
class="agent-markdown"
|
||||
/>
|
||||
</MessageContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Assistant messages -->
|
||||
<MessageContent v-else>
|
||||
<MessageThinking v-if="message.thinking" />
|
||||
<MessageToolCalls
|
||||
v-else-if="message.toolCalls?.length"
|
||||
:tool-calls="message.toolCalls"
|
||||
:complete="
|
||||
status === 'ready' ||
|
||||
message !== messages[messages.length - 1]
|
||||
"
|
||||
/>
|
||||
<MessageResponse
|
||||
v-if="message.text"
|
||||
:content="message.text"
|
||||
class="agent-markdown"
|
||||
/>
|
||||
<MessageActions
|
||||
v-if="
|
||||
message.text &&
|
||||
(status === 'ready' ||
|
||||
message !== messages[messages.length - 1])
|
||||
"
|
||||
>
|
||||
<MessageAction
|
||||
:tooltip="$t('agent.message.thumbsUp')"
|
||||
:pressed="reactions[message.id] === 'liked'"
|
||||
@click="toggleReaction(message.id, 'liked')"
|
||||
>
|
||||
<i class="icon-[lucide--thumbs-up] size-3.5" />
|
||||
</MessageAction>
|
||||
<MessageAction
|
||||
:tooltip="$t('agent.message.thumbsDown')"
|
||||
:pressed="reactions[message.id] === 'disliked'"
|
||||
@click="toggleReaction(message.id, 'disliked')"
|
||||
>
|
||||
<i class="icon-[lucide--thumbs-down] size-3.5" />
|
||||
</MessageAction>
|
||||
<MessageAction
|
||||
:tooltip="$t('agent.message.copy')"
|
||||
@click="copyMessage(message.text)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
|
||||
<div class="flex shrink-0 flex-col gap-4 p-4">
|
||||
<div
|
||||
class="@container mx-auto flex w-full max-w-[640px] flex-col gap-4"
|
||||
>
|
||||
<AgentPromptSuggestions v-if="isEmpty" @select="onSuggestionSelect" />
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<PromptInput @submit="onSubmit">
|
||||
<PromptInputBody>
|
||||
<PromptInputAttachments
|
||||
:attachments="attachments"
|
||||
@remove="removeAttachment"
|
||||
/>
|
||||
<PromptInputTextarea
|
||||
ref="promptTextarea"
|
||||
v-model="input"
|
||||
:placeholder="$t('agent.placeholder')"
|
||||
/>
|
||||
<PromptInputToolbar>
|
||||
<PromptInputTools>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onFilesSelected"
|
||||
/>
|
||||
<Tooltip :delay-duration="500">
|
||||
<TooltipTrigger>
|
||||
<PromptInputButton
|
||||
:aria-label="$t('agent.attach')"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<i class="icon-[lucide--paperclip] size-4" />
|
||||
</PromptInputButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="whitespace-nowrap">
|
||||
{{ $t('agent.attach') }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PromptInputButton :aria-label="$t('agent.mention')">
|
||||
<i class="icon-[lucide--at-sign] size-4" />
|
||||
</PromptInputButton>
|
||||
</PromptInputTools>
|
||||
<PromptInputTools>
|
||||
<PromptInputModelSelect v-model="model" />
|
||||
<PromptInputSubmit
|
||||
:status="status"
|
||||
:disabled="submitDisabled"
|
||||
/>
|
||||
</PromptInputTools>
|
||||
</PromptInputToolbar>
|
||||
</PromptInputBody>
|
||||
</PromptInput>
|
||||
<p class="my-0 text-center text-xs text-muted-foreground">
|
||||
{{ $t('agent.disclaimer') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
42
src/platform/agent/components/AgentPersonalityDevPanel.vue
Normal file
42
src/platform/agent/components/AgentPersonalityDevPanel.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { DialRoot, useDialKit } from 'dialkit/vue'
|
||||
|
||||
import 'dialkit/styles.css'
|
||||
|
||||
import { setAgentPersonality } from '@/platform/agent/composables/agentPersonalityState'
|
||||
|
||||
const params = useDialKit(
|
||||
'Agent Personality',
|
||||
{
|
||||
shader: {
|
||||
hueBase: [45, 0, 360, 1],
|
||||
hueRange: [40, 0, 180, 1],
|
||||
speed: [0.6, 0, 2, 0.01],
|
||||
scale: [3, 0.5, 10, 0.1],
|
||||
intensity: [0.8, 0, 1, 0.01],
|
||||
glow: [0.5, 0, 1, 0.01]
|
||||
},
|
||||
hover: {
|
||||
scale: [1.06, 1, 1.5, 0.01],
|
||||
stiffness: [260, 50, 600, 1],
|
||||
damping: [20, 1, 60, 1]
|
||||
},
|
||||
idle: {
|
||||
amplitude: [0.015, 0, 0.1, 0.001],
|
||||
period: [4, 1, 12, 0.1]
|
||||
}
|
||||
},
|
||||
{ id: 'agent-personality', persist: true }
|
||||
)
|
||||
|
||||
watch(params, (values) => setAgentPersonality(values), {
|
||||
deep: true,
|
||||
immediate: true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialRoot />
|
||||
</template>
|
||||
30
src/platform/agent/components/AgentPromptSuggestions.vue
Normal file
30
src/platform/agent/components/AgentPromptSuggestions.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import Suggestion from '@/components/ai-elements/suggestion/Suggestion.vue'
|
||||
import Suggestions from '@/components/ai-elements/suggestion/Suggestions.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [suggestion: string]
|
||||
}>()
|
||||
|
||||
const suggestions = [
|
||||
{ key: 'duck', icon: 'icon-[lucide--lightbulb]' },
|
||||
{ key: 'savedWorkflows', icon: 'icon-[lucide--list]' },
|
||||
{ key: 'skinUpscaling', icon: 'icon-[lucide--search]' },
|
||||
{ key: 'explainNode', icon: 'icon-[lucide--message-circle-warning]' },
|
||||
{ key: 'imageToVideo', icon: 'icon-[lucide--workflow]' }
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Suggestions>
|
||||
<Suggestion
|
||||
v-for="item in suggestions"
|
||||
:key="item.key"
|
||||
:suggestion="$t(`agent.suggestions.${item.key}`)"
|
||||
@select="emit('select', $event)"
|
||||
>
|
||||
<i :class="item.icon" class="size-3 shrink-0 text-muted-foreground" />
|
||||
<span>{{ $t(`agent.suggestions.${item.key}`) }}</span>
|
||||
</Suggestion>
|
||||
</Suggestions>
|
||||
</template>
|
||||
23
src/platform/agent/components/AgentShaderBackground.vue
Normal file
23
src/platform/agent/components/AgentShaderBackground.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAgentPersonality } from '@/platform/agent/composables/agentPersonalityState'
|
||||
import { useAgentShaderBackground } from '@/platform/agent/composables/useAgentShaderBackground'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const personality = useAgentPersonality()
|
||||
const reducedMotion = computed(
|
||||
() => !!useSettingStore().get('Comfy.Appearance.DisableAnimations')
|
||||
)
|
||||
|
||||
useAgentShaderBackground(canvasRef, personality.shader, reducedMotion)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 size-full rounded-[inherit]"
|
||||
/>
|
||||
</template>
|
||||
72
src/platform/agent/composables/agentPersonalityState.test.ts
Normal file
72
src/platform/agent/composables/agentPersonalityState.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
setAgentPersonality,
|
||||
useAgentPersonality
|
||||
} from '@/platform/agent/composables/agentPersonalityState'
|
||||
|
||||
describe('agentPersonalityState', () => {
|
||||
it('exposes baked-in defaults for shader, hover, and idle params', () => {
|
||||
const personality = useAgentPersonality()
|
||||
|
||||
expect(personality.shader).toMatchObject({
|
||||
hueBase: 45,
|
||||
hueRange: 40,
|
||||
speed: 0.6,
|
||||
scale: 3,
|
||||
intensity: 0.8,
|
||||
glow: 0.5
|
||||
})
|
||||
expect(personality.hover).toMatchObject({
|
||||
scale: 1.06,
|
||||
stiffness: 260,
|
||||
damping: 20
|
||||
})
|
||||
expect(personality.idle).toMatchObject({ amplitude: 0.015, period: 4 })
|
||||
})
|
||||
|
||||
it('mirrors live values from setAgentPersonality into the shared state', () => {
|
||||
const personality = useAgentPersonality()
|
||||
|
||||
setAgentPersonality({
|
||||
shader: {
|
||||
hueBase: 200,
|
||||
hueRange: 10,
|
||||
speed: 1,
|
||||
scale: 5,
|
||||
intensity: 1,
|
||||
glow: 1
|
||||
},
|
||||
hover: { scale: 1.2, stiffness: 300, damping: 30 },
|
||||
idle: { amplitude: 0.05, period: 2 }
|
||||
})
|
||||
|
||||
expect(personality.shader.hueBase).toBe(200)
|
||||
expect(personality.hover.scale).toBe(1.2)
|
||||
expect(personality.idle.period).toBe(2)
|
||||
|
||||
// Restore defaults so other tests observing the shared singleton aren't affected.
|
||||
setAgentPersonality({
|
||||
shader: {
|
||||
hueBase: 45,
|
||||
hueRange: 40,
|
||||
speed: 0.6,
|
||||
scale: 3,
|
||||
intensity: 0.8,
|
||||
glow: 0.5
|
||||
},
|
||||
hover: { scale: 1.06, stiffness: 260, damping: 20 },
|
||||
idle: { amplitude: 0.015, period: 4 }
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores direct mutation attempts on the readonly view', () => {
|
||||
const personality = useAgentPersonality()
|
||||
const original = personality.shader.hueBase
|
||||
|
||||
// @ts-expect-error verifying runtime readonly enforcement
|
||||
personality.shader.hueBase = 999
|
||||
|
||||
expect(personality.shader.hueBase).toBe(original)
|
||||
})
|
||||
})
|
||||
65
src/platform/agent/composables/agentPersonalityState.ts
Normal file
65
src/platform/agent/composables/agentPersonalityState.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { reactive, readonly } from 'vue'
|
||||
|
||||
export interface AgentShaderParams {
|
||||
hueBase: number
|
||||
hueRange: number
|
||||
speed: number
|
||||
scale: number
|
||||
intensity: number
|
||||
glow: number
|
||||
}
|
||||
|
||||
export interface AgentHoverParams {
|
||||
scale: number
|
||||
stiffness: number
|
||||
damping: number
|
||||
}
|
||||
|
||||
export interface AgentIdleParams {
|
||||
amplitude: number
|
||||
period: number
|
||||
}
|
||||
|
||||
export interface AgentPersonalityParams {
|
||||
shader: AgentShaderParams
|
||||
hover: AgentHoverParams
|
||||
idle: AgentIdleParams
|
||||
}
|
||||
|
||||
const DEFAULT_PERSONALITY: AgentPersonalityParams = {
|
||||
shader: {
|
||||
hueBase: 45,
|
||||
hueRange: 40,
|
||||
speed: 0.6,
|
||||
scale: 3,
|
||||
intensity: 0.8,
|
||||
glow: 0.5
|
||||
},
|
||||
hover: {
|
||||
scale: 1.06,
|
||||
stiffness: 260,
|
||||
damping: 20
|
||||
},
|
||||
idle: {
|
||||
amplitude: 0.015,
|
||||
period: 4
|
||||
}
|
||||
}
|
||||
|
||||
const state = reactive<AgentPersonalityParams>({
|
||||
shader: { ...DEFAULT_PERSONALITY.shader },
|
||||
hover: { ...DEFAULT_PERSONALITY.hover },
|
||||
idle: { ...DEFAULT_PERSONALITY.idle }
|
||||
})
|
||||
|
||||
/** Read-only tunable params for the agent's shader background and hover/idle motion. */
|
||||
export function useAgentPersonality() {
|
||||
return readonly(state)
|
||||
}
|
||||
|
||||
/** Mirrors live values from the dev-only tuning panel into the shared state. */
|
||||
export function setAgentPersonality(values: AgentPersonalityParams): void {
|
||||
Object.assign(state.shader, values.shader)
|
||||
Object.assign(state.hover, values.hover)
|
||||
Object.assign(state.idle, values.idle)
|
||||
}
|
||||
81
src/platform/agent/composables/useAgentChatPrototype.test.ts
Normal file
81
src/platform/agent/composables/useAgentChatPrototype.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAgentChatPrototype } from './useAgentChatPrototype'
|
||||
|
||||
describe('useAgentChatPrototype', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
useAgentChatPrototype().startNewChat()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts empty', () => {
|
||||
const { messages, isEmpty } = useAgentChatPrototype()
|
||||
expect(messages.value).toHaveLength(0)
|
||||
expect(isEmpty.value).toBe(true)
|
||||
})
|
||||
|
||||
it('appends a user message and clears the input on send', () => {
|
||||
const { messages, input, send, isEmpty } = useAgentChatPrototype()
|
||||
|
||||
input.value = 'make a duck'
|
||||
send()
|
||||
|
||||
expect(messages.value).toHaveLength(1)
|
||||
expect(messages.value[0]).toMatchObject({
|
||||
role: 'user',
|
||||
text: 'make a duck'
|
||||
})
|
||||
expect(input.value).toBe('')
|
||||
expect(isEmpty.value).toBe(false)
|
||||
})
|
||||
|
||||
it('streams a mocked assistant reply after sending', () => {
|
||||
const { messages, send, status } = useAgentChatPrototype()
|
||||
|
||||
send('make a duck')
|
||||
expect(status.value).toBe('submitted')
|
||||
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(status.value).toBe('ready')
|
||||
expect(messages.value).toHaveLength(2)
|
||||
const reply = messages.value[1]
|
||||
expect(reply.role).toBe('assistant')
|
||||
expect(reply.text).toContain('make a duck')
|
||||
})
|
||||
|
||||
it('ignores send while a reply is in progress', () => {
|
||||
const { messages, send } = useAgentChatPrototype()
|
||||
|
||||
send('first')
|
||||
send('second')
|
||||
|
||||
expect(messages.value).toHaveLength(1)
|
||||
expect(messages.value[0].text).toBe('first')
|
||||
})
|
||||
|
||||
it('applies a suggestion to the input', () => {
|
||||
const { input, applySuggestion } = useAgentChatPrototype()
|
||||
|
||||
applySuggestion('List my saved workflows')
|
||||
|
||||
expect(input.value).toBe('List my saved workflows')
|
||||
})
|
||||
|
||||
it('clears the conversation on startNewChat', () => {
|
||||
const { messages, send, startNewChat, isEmpty } = useAgentChatPrototype()
|
||||
|
||||
send('make a duck')
|
||||
vi.advanceTimersByTime(10_000)
|
||||
expect(messages.value.length).toBeGreaterThan(0)
|
||||
|
||||
startNewChat()
|
||||
|
||||
expect(messages.value).toHaveLength(0)
|
||||
expect(isEmpty.value).toBe(true)
|
||||
})
|
||||
})
|
||||
479
src/platform/agent/composables/useAgentChatPrototype.ts
Normal file
479
src/platform/agent/composables/useAgentChatPrototype.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
|
||||
import type { ChatStatus } from '@/components/ai-elements/prompt-input/types'
|
||||
|
||||
export interface ToolCall {
|
||||
name: string
|
||||
status: 'success' | 'error'
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
name: string
|
||||
type: string
|
||||
url: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface AgentMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
attachments?: readonly MessageAttachment[]
|
||||
thinking?: boolean
|
||||
toolCalls?: readonly ToolCall[]
|
||||
}
|
||||
|
||||
export interface AgentConversation {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: Date
|
||||
messages: readonly AgentMessage[]
|
||||
}
|
||||
|
||||
const STREAM_INTERVAL_MS = 40
|
||||
const THINKING_DELAY_MS = 500
|
||||
const TOOL_CALLS_DELAY_MS = 1200
|
||||
|
||||
const MOCK_TOOL_CALLS: ToolCall[] = [
|
||||
{ name: 'Opening template', status: 'success', durationMs: 200 },
|
||||
{ name: 'New workflow', status: 'success', durationMs: 1300 },
|
||||
{ name: 'Set node widget', status: 'error', durationMs: 1200 },
|
||||
{ name: 'Pointing to node', status: 'success', durationMs: 1100 },
|
||||
{ name: 'Set node widget', status: 'error', durationMs: 200 }
|
||||
]
|
||||
|
||||
const daysAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000)
|
||||
|
||||
const FENCE = '```'
|
||||
|
||||
const DEMO_CONVERSATIONS: AgentConversation[] = [
|
||||
{
|
||||
id: 'demo-code-block',
|
||||
title: 'Code block',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{ id: 'demo-code-1', role: 'user', text: 'Show me a workflow as code' },
|
||||
{
|
||||
id: 'demo-code-2',
|
||||
role: 'assistant',
|
||||
text: `${FENCE}javascript:workflow.js
|
||||
export default {
|
||||
nodes: [
|
||||
{ id: 1, type: "CheckpointLoaderSimple", inputs: { ckpt_name: "flux1-dev-fp8.safetensors" } },
|
||||
{ id: 2, type: "CLIPTextEncode", inputs: { text: "a golden hour sunset over mountains" } },
|
||||
{ id: 3, type: "KSampler", inputs: { seed: 42, steps: 20, cfg: 7, sampler_name: "euler" } },
|
||||
{ id: 4, type: "VAEDecode" },
|
||||
{ id: 5, type: "SaveImage", inputs: { filename_prefix: "output" } },
|
||||
],
|
||||
links: [
|
||||
[1, 0, 3, 0], // model → KSampler
|
||||
[2, 0, 3, 1], // conditioning → KSampler
|
||||
[3, 0, 4, 0], // latent → VAEDecode
|
||||
[4, 0, 5, 0], // image → SaveImage
|
||||
],
|
||||
}
|
||||
${FENCE}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-markdown',
|
||||
title: 'Markdown',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-md-1',
|
||||
role: 'user',
|
||||
text: 'Explain how to build a basic workflow'
|
||||
},
|
||||
{
|
||||
id: 'demo-md-2',
|
||||
role: 'assistant',
|
||||
text: `# Workflow Overview
|
||||
|
||||
This guide explains how to **build a basic image generation workflow** in ComfyUI.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Nodes** are the building blocks — each one performs a single operation
|
||||
- **Edges** carry data between nodes
|
||||
- Use _italics_ for emphasis and \`inline code\` for node names
|
||||
|
||||
## Steps
|
||||
|
||||
1. Load a checkpoint with \`CheckpointLoaderSimple\`
|
||||
2. Add \`CLIPTextEncode\` and write your prompt
|
||||
3. Connect both to \`KSampler\` to run diffusion
|
||||
4. Decode the result with \`VAEDecode\`
|
||||
5. Save the image with \`SaveImage\`
|
||||
|
||||
> Start with a simple 4-node chain and expand from there.
|
||||
|
||||
See the full reference at [docs.comfy.org](https://docs.comfy.org).`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-table',
|
||||
title: 'Table',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-table-1',
|
||||
role: 'user',
|
||||
text: 'Compare the available samplers'
|
||||
},
|
||||
{
|
||||
id: 'demo-table-2',
|
||||
role: 'assistant',
|
||||
text: `Here is a comparison of common samplers:
|
||||
|
||||
| Sampler | Steps | Quality | Speed |
|
||||
| --- | --- | --- | --- |
|
||||
| euler | 20 | Good | Fast |
|
||||
| euler_a | 20 | Great | Fast |
|
||||
| dpm++ 2m | 25 | Excellent | Medium |
|
||||
| dpm++ sde | 30 | Best | Slow |
|
||||
| ddim | 50 | Good | Slow |
|
||||
|
||||
Use **euler** or **euler_a** to get started quickly.`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-thinking',
|
||||
title: 'Thinking',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{ id: 'demo-think-1', role: 'user', text: 'Analyze my current workflow' },
|
||||
{ id: 'demo-think-2', role: 'assistant', text: '', thinking: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-tool-calls',
|
||||
title: 'Tool calls',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-tools-1',
|
||||
role: 'user',
|
||||
text: 'Build a workflow for image to video'
|
||||
},
|
||||
{
|
||||
id: 'demo-tools-2',
|
||||
role: 'assistant',
|
||||
text: 'I set up the nodes and connections for your image-to-video workflow. The KSampler is configured with sensible defaults — adjust the steps and CFG scale to taste.',
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-attachments',
|
||||
title: 'Attachments',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-attach-1',
|
||||
role: 'user',
|
||||
text: 'Use this image as a reference',
|
||||
attachments: [
|
||||
{
|
||||
name: 'reference.png',
|
||||
type: 'image/png',
|
||||
url: '/assets/images/reference.png',
|
||||
size: 204800
|
||||
},
|
||||
{
|
||||
name: 'style-guide.pdf',
|
||||
type: 'application/pdf',
|
||||
url: '',
|
||||
size: 512000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-attach-2',
|
||||
role: 'assistant',
|
||||
text: "I can see the reference image. I'll use the visual style and color palette as a guide when configuring the workflow nodes."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const messages = ref<AgentMessage[]>([])
|
||||
const input = ref('')
|
||||
const status = ref<ChatStatus>('ready')
|
||||
const currentConversationId = ref<string | null>(null)
|
||||
const chatHistory = ref<AgentConversation[]>([
|
||||
...DEMO_CONVERSATIONS,
|
||||
{
|
||||
id: 'h-yesterday',
|
||||
title: 'Generate a yellow duck with a hockey mask',
|
||||
createdAt: daysAgo(1),
|
||||
messages: [
|
||||
{
|
||||
id: 'h-y-1',
|
||||
role: 'user',
|
||||
text: 'Generate a yellow duck with a hockey mask'
|
||||
},
|
||||
{
|
||||
id: 'h-y-2',
|
||||
role: 'assistant',
|
||||
text: buildMockReply('Generate a yellow duck with a hockey mask'),
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'h-last7',
|
||||
title: 'Build a workflow for image to video with 3 models',
|
||||
createdAt: daysAgo(4),
|
||||
messages: [
|
||||
{
|
||||
id: 'h-l7-1',
|
||||
role: 'user',
|
||||
text: 'Build a workflow for image to video with 3 models'
|
||||
},
|
||||
{
|
||||
id: 'h-l7-2',
|
||||
role: 'assistant',
|
||||
text: buildMockReply(
|
||||
'Build a workflow for image to video with 3 models'
|
||||
),
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'h-last30',
|
||||
title: 'Find the best workflow for skin upscaling',
|
||||
createdAt: daysAgo(15),
|
||||
messages: [
|
||||
{
|
||||
id: 'h-l30-1',
|
||||
role: 'user',
|
||||
text: 'Find the best workflow for skin upscaling'
|
||||
},
|
||||
{
|
||||
id: 'h-l30-2',
|
||||
role: 'assistant',
|
||||
text: buildMockReply('Find the best workflow for skin upscaling'),
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
let idCounter = 0
|
||||
let streamTimer: ReturnType<typeof setInterval> | null = null
|
||||
let thinkingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function nextId() {
|
||||
idCounter += 1
|
||||
return `agent-msg-${idCounter}`
|
||||
}
|
||||
|
||||
function buildMockReply(prompt: string) {
|
||||
return [
|
||||
`# Plan for ${prompt}`,
|
||||
'',
|
||||
'## Overview',
|
||||
'',
|
||||
`This is a mocked response for **${prompt}**. It demonstrates the markdown rendering capabilities of the agent chat panel.`,
|
||||
'',
|
||||
'## Steps',
|
||||
'',
|
||||
'1. Inspect the current graph and selected nodes.',
|
||||
'2. Assemble the nodes needed for the request.',
|
||||
'3. Wire the connections and set sensible defaults.',
|
||||
'4. Validate the output and iterate as needed.',
|
||||
'',
|
||||
'## Key Concepts',
|
||||
'',
|
||||
'- **Nodes** are the building blocks of a workflow.',
|
||||
'- **Edges** connect nodes and carry data between them.',
|
||||
'- Use _italics_ for emphasis and `inline code` for node names.',
|
||||
'',
|
||||
'## Before You Start',
|
||||
'',
|
||||
'> Make sure your checkpoint model is downloaded and placed in the `models/checkpoints` folder. The workflow will not run without it.',
|
||||
'',
|
||||
'## Node Reference',
|
||||
'',
|
||||
'| Node | Type | Description |',
|
||||
'| --- | --- | --- |',
|
||||
'| KSampler | Sampler | Runs the diffusion sampling loop |',
|
||||
'| CLIPTextEncode | Conditioning | Encodes a text prompt |',
|
||||
'| VAEDecode | Latent | Decodes latent image to pixels |',
|
||||
'',
|
||||
'## Example Workflow',
|
||||
'',
|
||||
'```javascript:workflow.js',
|
||||
'export default {',
|
||||
' nodes: [',
|
||||
' { id: 1, type: "CheckpointLoaderSimple", inputs: { ckpt_name: "flux1-dev-fp8.safetensors" } },',
|
||||
' { id: 2, type: "CLIPTextEncode", inputs: { text: "a photo of a mountain at sunset" } },',
|
||||
' { id: 3, type: "KSampler", inputs: { seed: 42, steps: 20, cfg: 7, sampler_name: "euler" } },',
|
||||
' { id: 4, type: "VAEDecode" },',
|
||||
' { id: 5, type: "SaveImage", inputs: { filename_prefix: "output" } },',
|
||||
' ],',
|
||||
' links: [',
|
||||
' [1, 0, 3, 0], // model → KSampler',
|
||||
' [2, 0, 3, 1], // conditioning → KSampler',
|
||||
' [3, 0, 4, 0], // latent → VAEDecode',
|
||||
' [4, 0, 5, 0], // image → SaveImage',
|
||||
' ],',
|
||||
'}',
|
||||
'```',
|
||||
'',
|
||||
'## Resources',
|
||||
'',
|
||||
'Download the completed workflow: https://comfyhub.com/workflows/flux-img2img-v2.json',
|
||||
'',
|
||||
'Or grab the model checkpoint from the registry:',
|
||||
'https://comfy.org/models/flux1-dev-fp8.safetensors',
|
||||
'',
|
||||
'_This is a prototype response and does not modify your graph._'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (streamTimer) {
|
||||
clearInterval(streamTimer)
|
||||
streamTimer = null
|
||||
}
|
||||
if (thinkingTimer) {
|
||||
clearTimeout(thinkingTimer)
|
||||
thinkingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function streamReply(reply: string) {
|
||||
messages.value.push({
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
text: '',
|
||||
thinking: true,
|
||||
toolCalls: undefined
|
||||
})
|
||||
const message = messages.value[messages.value.length - 1]
|
||||
|
||||
thinkingTimer = setTimeout(() => {
|
||||
thinkingTimer = null
|
||||
message.thinking = false
|
||||
message.toolCalls = MOCK_TOOL_CALLS
|
||||
status.value = 'streaming'
|
||||
|
||||
const tokens = reply.split(' ')
|
||||
let index = 0
|
||||
streamTimer = setInterval(() => {
|
||||
if (index >= tokens.length) {
|
||||
clearTimers()
|
||||
status.value = 'ready'
|
||||
return
|
||||
}
|
||||
message.text += (index === 0 ? '' : ' ') + tokens[index]
|
||||
index += 1
|
||||
}, STREAM_INTERVAL_MS)
|
||||
}, TOOL_CALLS_DELAY_MS)
|
||||
}
|
||||
|
||||
function send(text?: string, files: File[] = []) {
|
||||
const content = (text ?? input.value).trim()
|
||||
if (!content || status.value !== 'ready') return
|
||||
|
||||
const attachments: MessageAttachment[] = files.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
url: URL.createObjectURL(f),
|
||||
size: f.size
|
||||
}))
|
||||
|
||||
messages.value.push({
|
||||
id: nextId(),
|
||||
role: 'user',
|
||||
text: content,
|
||||
attachments: attachments.length ? attachments : undefined
|
||||
})
|
||||
input.value = ''
|
||||
status.value = 'submitted'
|
||||
|
||||
if (!currentConversationId.value) {
|
||||
const id = `conv-${Date.now()}`
|
||||
currentConversationId.value = id
|
||||
chatHistory.value.unshift({
|
||||
id,
|
||||
title: content,
|
||||
createdAt: new Date(),
|
||||
messages: messages.value
|
||||
})
|
||||
}
|
||||
|
||||
thinkingTimer = setTimeout(() => {
|
||||
thinkingTimer = null
|
||||
streamReply(buildMockReply(content))
|
||||
}, THINKING_DELAY_MS)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearTimers()
|
||||
status.value = 'ready'
|
||||
}
|
||||
|
||||
function applySuggestion(text: string) {
|
||||
input.value = text
|
||||
}
|
||||
|
||||
function startNewChat() {
|
||||
clearTimers()
|
||||
messages.value = []
|
||||
input.value = ''
|
||||
status.value = 'ready'
|
||||
currentConversationId.value = null
|
||||
}
|
||||
|
||||
function loadConversation(id: string) {
|
||||
const conv = chatHistory.value.find((c) => c.id === id)
|
||||
if (!conv) return
|
||||
clearTimers()
|
||||
messages.value = conv.messages.map((m) => ({ ...m }))
|
||||
currentConversationId.value = id
|
||||
status.value = 'ready'
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
const idx = chatHistory.value.findIndex((c) => c.id === id)
|
||||
if (idx !== -1) chatHistory.value.splice(idx, 1)
|
||||
if (currentConversationId.value === id) startNewChat()
|
||||
}
|
||||
|
||||
async function copyConversation(id: string) {
|
||||
const conv = chatHistory.value.find((c) => c.id === id)
|
||||
if (!conv) return
|
||||
const lines =
|
||||
conv.messages.length > 0
|
||||
? conv.messages.map(
|
||||
(m) => `${m.role === 'user' ? 'You' : 'Assistant'}: ${m.text}`
|
||||
)
|
||||
: [conv.title]
|
||||
await navigator.clipboard.writeText(lines.join('\n\n'))
|
||||
}
|
||||
|
||||
export function useAgentChatPrototype() {
|
||||
return {
|
||||
messages: readonly(messages),
|
||||
input,
|
||||
status: readonly(status),
|
||||
chatHistory: readonly(chatHistory),
|
||||
currentConversationId: readonly(currentConversationId),
|
||||
isEmpty: computed(() => messages.value.length === 0),
|
||||
send,
|
||||
stop,
|
||||
applySuggestion,
|
||||
startNewChat,
|
||||
loadConversation,
|
||||
deleteConversation,
|
||||
copyConversation
|
||||
}
|
||||
}
|
||||
43
src/platform/agent/composables/useAgentHoverMotion.ts
Normal file
43
src/platform/agent/composables/useAgentHoverMotion.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type {
|
||||
AgentHoverParams,
|
||||
AgentIdleParams
|
||||
} from '@/platform/agent/composables/agentPersonalityState'
|
||||
|
||||
/**
|
||||
* Builds reactive `motion-v` props for a hover "pop" plus a subtle infinite
|
||||
* idle breathing animation, both driven by tunable params. Spread the result
|
||||
* onto a `<motion.div>`/`<motion.button>` in the template.
|
||||
*/
|
||||
export function useAgentHoverMotion(
|
||||
hover: AgentHoverParams,
|
||||
idle: AgentIdleParams,
|
||||
reducedMotion: Ref<boolean>
|
||||
) {
|
||||
const transition = computed(() => ({
|
||||
type: 'spring' as const,
|
||||
stiffness: hover.stiffness,
|
||||
damping: hover.damping
|
||||
}))
|
||||
|
||||
const whileHover = computed(() =>
|
||||
reducedMotion.value ? {} : { scale: hover.scale }
|
||||
)
|
||||
|
||||
const animate = computed(() =>
|
||||
reducedMotion.value
|
||||
? { scale: 1 }
|
||||
: {
|
||||
scale: [1, 1 + idle.amplitude, 1],
|
||||
transition: {
|
||||
duration: idle.period,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut' as const
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return { transition, whileHover, animate }
|
||||
}
|
||||
116
src/platform/agent/composables/useAgentShaderBackground.test.ts
Normal file
116
src/platform/agent/composables/useAgentShaderBackground.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, ref } from 'vue'
|
||||
|
||||
import type { AgentShaderParams } from '@/platform/agent/composables/agentPersonalityState'
|
||||
import { useAgentShaderBackground } from '@/platform/agent/composables/useAgentShaderBackground'
|
||||
|
||||
const DEFAULT_PARAMS: AgentShaderParams = {
|
||||
hueBase: 45,
|
||||
hueRange: 40,
|
||||
speed: 0.6,
|
||||
scale: 3,
|
||||
intensity: 0.8,
|
||||
glow: 0.5
|
||||
}
|
||||
|
||||
function createMockGL2Context() {
|
||||
return {
|
||||
VERTEX_SHADER: 0x8b31,
|
||||
FRAGMENT_SHADER: 0x8b30,
|
||||
COMPILE_STATUS: 0x8b81,
|
||||
LINK_STATUS: 0x8b82,
|
||||
COLOR_BUFFER_BIT: 0x4000,
|
||||
BLEND: 0x0be2,
|
||||
SRC_ALPHA: 0x0302,
|
||||
ONE_MINUS_SRC_ALPHA: 0x0303,
|
||||
TRIANGLES: 0x0004,
|
||||
createShader: vi.fn().mockReturnValue({}),
|
||||
shaderSource: vi.fn(),
|
||||
compileShader: vi.fn(),
|
||||
getShaderParameter: vi.fn().mockReturnValue(true),
|
||||
deleteShader: vi.fn(),
|
||||
createProgram: vi.fn().mockReturnValue({}),
|
||||
attachShader: vi.fn(),
|
||||
linkProgram: vi.fn(),
|
||||
getProgramParameter: vi.fn().mockReturnValue(true),
|
||||
deleteProgram: vi.fn(),
|
||||
getUniformLocation: vi.fn().mockReturnValue(null),
|
||||
useProgram: vi.fn(),
|
||||
uniform1f: vi.fn(),
|
||||
clearColor: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
blendFunc: vi.fn(),
|
||||
drawArrays: vi.fn(),
|
||||
viewport: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// `HTMLCanvasElement.prototype.getContext` is overloaded per contextId, which
|
||||
// collapses `vi.spyOn`'s inferred return type to a single unrelated overload
|
||||
// (e.g. `GPUCanvasContext`). Casting through this narrow interface avoids
|
||||
// fighting that inference for a mock instance's actual method availability.
|
||||
function mockGetContext(value: unknown) {
|
||||
const spy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
||||
const castSpy = spy as unknown as { mockReturnValue: (v: unknown) => void }
|
||||
castSpy.mockReturnValue(value)
|
||||
}
|
||||
|
||||
function mountShaderBackground(reducedMotion = false) {
|
||||
let result: ReturnType<typeof useAgentShaderBackground> | undefined
|
||||
|
||||
const Child = defineComponent({
|
||||
setup() {
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
result = useAgentShaderBackground(
|
||||
canvasRef,
|
||||
DEFAULT_PARAMS,
|
||||
ref(reducedMotion)
|
||||
)
|
||||
return () => h('canvas', { ref: canvasRef })
|
||||
}
|
||||
})
|
||||
|
||||
const host = document.createElement('div')
|
||||
const app = createApp(Child)
|
||||
app.mount(host)
|
||||
|
||||
if (!result) throw new Error('useAgentShaderBackground did not initialize')
|
||||
return { isSupported: result.isSupported, unmount: () => app.unmount() }
|
||||
}
|
||||
|
||||
describe('useAgentShaderBackground', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('no-ops gracefully when WebGL2 is unavailable', () => {
|
||||
mockGetContext(null)
|
||||
|
||||
const { isSupported, unmount } = mountShaderBackground()
|
||||
|
||||
expect(isSupported.value).toBe(false)
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('marks itself supported and disconnects the resize observer on unmount', () => {
|
||||
const disconnect = vi.fn()
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe = vi.fn()
|
||||
disconnect = disconnect
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
)
|
||||
mockGetContext(createMockGL2Context())
|
||||
|
||||
const { isSupported, unmount } = mountShaderBackground()
|
||||
|
||||
expect(isSupported.value).toBe(true)
|
||||
|
||||
unmount()
|
||||
expect(disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
207
src/platform/agent/composables/useAgentShaderBackground.ts
Normal file
207
src/platform/agent/composables/useAgentShaderBackground.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { AgentShaderParams } from '@/platform/agent/composables/agentPersonalityState'
|
||||
|
||||
const VERTEX_SHADER_SOURCE = `#version 300 es
|
||||
out vec2 v_uv;
|
||||
void main() {
|
||||
vec2 verts[3] = vec2[](vec2(-1, -1), vec2(3, -1), vec2(-1, 3));
|
||||
v_uv = verts[gl_VertexID] * 0.5 + 0.5;
|
||||
gl_Position = vec4(verts[gl_VertexID], 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAGMENT_SHADER_SOURCE = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 v_uv;
|
||||
out vec4 outColor;
|
||||
|
||||
uniform float u_time;
|
||||
uniform float u_hueBase;
|
||||
uniform float u_hueRange;
|
||||
uniform float u_speed;
|
||||
uniform float u_scale;
|
||||
uniform float u_intensity;
|
||||
uniform float u_glow;
|
||||
|
||||
vec3 hsl2rgb(float h, float s, float l) {
|
||||
vec3 rgb = clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
|
||||
return l + s * (rgb - 0.5) * (1.0 - abs(2.0 * l - 1.0));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 p = (v_uv - 0.5) * u_scale;
|
||||
float t = u_time * u_speed;
|
||||
|
||||
float plasma = sin(p.x + t) + sin(p.y * 1.3 - t * 0.8) +
|
||||
sin((p.x + p.y) * 0.7 + t * 1.4) + sin(length(p) * 2.0 - t);
|
||||
plasma = plasma * 0.25 + 0.5;
|
||||
|
||||
float hue = fract((u_hueBase + plasma * u_hueRange) / 360.0);
|
||||
vec3 color = hsl2rgb(hue, 0.75, 0.5 + plasma * 0.15);
|
||||
|
||||
float dist = length(v_uv - 0.5);
|
||||
float glow = smoothstep(0.7, 0.0, dist) * u_glow;
|
||||
|
||||
float alpha = clamp(plasma * u_intensity + glow, 0.0, 1.0);
|
||||
outColor = vec4(color, alpha);
|
||||
}
|
||||
`
|
||||
|
||||
const UNIFORM_NAMES = [
|
||||
'u_time',
|
||||
'u_hueBase',
|
||||
'u_hueRange',
|
||||
'u_speed',
|
||||
'u_scale',
|
||||
'u_intensity',
|
||||
'u_glow'
|
||||
] as const
|
||||
|
||||
function compileShader(
|
||||
gl: WebGL2RenderingContext,
|
||||
type: GLenum,
|
||||
source: string
|
||||
): WebGLShader | null {
|
||||
const shader = gl.createShader(type)
|
||||
if (!shader) return null
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
gl.deleteShader(shader)
|
||||
return null
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
function createProgram(gl: WebGL2RenderingContext): WebGLProgram | null {
|
||||
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE)
|
||||
const fragmentShader = compileShader(
|
||||
gl,
|
||||
gl.FRAGMENT_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE
|
||||
)
|
||||
if (!vertexShader || !fragmentShader) return null
|
||||
|
||||
const program = gl.createProgram()
|
||||
if (!program) return null
|
||||
gl.attachShader(program, vertexShader)
|
||||
gl.attachShader(program, fragmentShader)
|
||||
gl.linkProgram(program)
|
||||
gl.deleteShader(vertexShader)
|
||||
gl.deleteShader(fragmentShader)
|
||||
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
gl.deleteProgram(program)
|
||||
return null
|
||||
}
|
||||
return program
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an animated plasma-style aura into a canvas, driven by the given
|
||||
* tunable params. No-ops safely when WebGL2 is unavailable (e.g. jsdom,
|
||||
* unsupported browsers) so it never breaks the elements it decorates.
|
||||
*/
|
||||
export function useAgentShaderBackground(
|
||||
canvasRef: Ref<HTMLCanvasElement | null | undefined>,
|
||||
params: AgentShaderParams,
|
||||
reducedMotion: Ref<boolean>
|
||||
) {
|
||||
const isSupported = ref(false)
|
||||
|
||||
let gl: WebGL2RenderingContext | null = null
|
||||
let program: WebGLProgram | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let rafId: number | null = null
|
||||
let startTime: number | null = null
|
||||
const uniformLocations = new Map<string, WebGLUniformLocation | null>()
|
||||
|
||||
function resize() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas || !gl) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const width = Math.max(1, Math.round(rect.width * dpr))
|
||||
const height = Math.max(1, Math.round(rect.height * dpr))
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
gl.viewport(0, 0, width, height)
|
||||
}
|
||||
}
|
||||
|
||||
function draw(elapsedSeconds: number) {
|
||||
if (!gl || !program) return
|
||||
gl.useProgram(program)
|
||||
gl.uniform1f(uniformLocations.get('u_time') ?? null, elapsedSeconds)
|
||||
gl.uniform1f(uniformLocations.get('u_hueBase') ?? null, params.hueBase)
|
||||
gl.uniform1f(uniformLocations.get('u_hueRange') ?? null, params.hueRange)
|
||||
gl.uniform1f(uniformLocations.get('u_speed') ?? null, params.speed)
|
||||
gl.uniform1f(uniformLocations.get('u_scale') ?? null, params.scale)
|
||||
gl.uniform1f(uniformLocations.get('u_intensity') ?? null, params.intensity)
|
||||
gl.uniform1f(uniformLocations.get('u_glow') ?? null, params.glow)
|
||||
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||
gl.enable(gl.BLEND)
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
}
|
||||
|
||||
function frame(now: number) {
|
||||
if (!gl || !program) return
|
||||
startTime ??= now
|
||||
draw((now - startTime) / 1000)
|
||||
if (!reducedMotion.value) {
|
||||
rafId = requestAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const context = canvas.getContext('webgl2', {
|
||||
alpha: true,
|
||||
premultipliedAlpha: false
|
||||
})
|
||||
if (!context) return
|
||||
|
||||
const compiledProgram = createProgram(context)
|
||||
if (!compiledProgram) return
|
||||
|
||||
gl = context
|
||||
program = compiledProgram
|
||||
isSupported.value = true
|
||||
|
||||
for (const name of UNIFORM_NAMES) {
|
||||
uniformLocations.set(name, gl.getUniformLocation(program, name))
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver(resize)
|
||||
resizeObserver.observe(canvas)
|
||||
resize()
|
||||
|
||||
rafId = requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (rafId != null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
if (gl && program) gl.deleteProgram(program)
|
||||
program = null
|
||||
gl = null
|
||||
startTime = null
|
||||
}
|
||||
|
||||
onMounted(start)
|
||||
onBeforeUnmount(stop)
|
||||
|
||||
return { isSupported }
|
||||
}
|
||||
43
src/platform/agent/stores/agentPanelStore.ts
Normal file
43
src/platform/agent/stores/agentPanelStore.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const PANEL_MIN_WIDTH = 420
|
||||
const PANEL_MAX_WIDTH = 960
|
||||
|
||||
export const useAgentPanelStore = defineStore('agentPanel', () => {
|
||||
const isOpen = ref(false)
|
||||
const width = ref(PANEL_MIN_WIDTH)
|
||||
|
||||
const isMaximized = computed(() => width.value === PANEL_MAX_WIDTH)
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function setWidth(px: number) {
|
||||
width.value = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, px))
|
||||
}
|
||||
|
||||
function toggleMaximize() {
|
||||
setWidth(isMaximized.value ? PANEL_MIN_WIDTH : PANEL_MAX_WIDTH)
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
width,
|
||||
isMaximized,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
setWidth,
|
||||
toggleMaximize
|
||||
}
|
||||
})
|
||||
@@ -96,6 +96,41 @@ describe('markdownRendererUtil', () => {
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
})
|
||||
|
||||
it('should render code blocks with header and copy button', () => {
|
||||
const markdown = '```typescript\nconst x = 1\n```'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('class="agent-code-block"')
|
||||
expect(html).toContain('class="agent-code-block-header"')
|
||||
expect(html).toContain('class="agent-code-block-copy"')
|
||||
expect(html).toContain('typescript')
|
||||
expect(html).toContain('const x = 1')
|
||||
})
|
||||
|
||||
it('should show filename in code block header when lang:filename syntax is used', () => {
|
||||
const markdown = '```typescript:utils.ts\nconst x = 1\n```'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('class="agent-code-block-filename"')
|
||||
expect(html).toContain('utils.ts')
|
||||
})
|
||||
|
||||
it('should render inline code with agent-inline-code class', () => {
|
||||
const markdown = 'Use the `KSampler` node.'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('class="agent-inline-code"')
|
||||
expect(html).toContain('KSampler')
|
||||
})
|
||||
|
||||
it('should HTML-escape code block content', () => {
|
||||
const markdown = '```html\n<script>alert("xss")</script>\n```'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('<script>')
|
||||
expect(html).not.toContain('<script>alert')
|
||||
})
|
||||
|
||||
it('should render complex markdown with links, images, and text', () => {
|
||||
const markdown = `
|
||||
# Release Notes
|
||||
|
||||
@@ -17,10 +17,37 @@ const ALLOWED_ATTRS = [
|
||||
const MEDIA_SRC_REGEX =
|
||||
/(<(?:img|source|video)[^>]*\ssrc=['"])(?!(?:\/|https?:\/\/))([^'"\s>]+)(['"])/gi
|
||||
|
||||
const FILE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><polyline points="14 2 14 8 20 8"/></svg>`
|
||||
|
||||
const CODE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>`
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// Create a marked Renderer that prefixes relative URLs with base
|
||||
export function createMarkdownRenderer(baseUrl?: string): Renderer {
|
||||
const normalizedBase = baseUrl ? baseUrl.replace(/\/+$/, '') : ''
|
||||
const renderer = new Renderer()
|
||||
|
||||
renderer.code = ({ text, lang: rawLang }) => {
|
||||
const info = rawLang ?? ''
|
||||
const colonIdx = info.indexOf(':')
|
||||
const lang = colonIdx >= 0 ? info.slice(0, colonIdx) : info
|
||||
const filename = colonIdx >= 0 ? info.slice(colonIdx + 1) : ''
|
||||
const langLabel = lang || 'plaintext'
|
||||
|
||||
const icon = filename ? FILE_ICON : CODE_ICON
|
||||
const label = filename
|
||||
? `<span class="agent-code-block-filename">${filename}</span>`
|
||||
: `<span>${langLabel}</span>`
|
||||
|
||||
return `<div class="agent-code-block"><div class="agent-code-block-header"><div class="agent-code-block-label">${icon}${label}</div><button class="agent-code-block-copy" type="button">Copy</button></div><pre><code>${escapeHtml(text)}</code></pre></div>`
|
||||
}
|
||||
|
||||
renderer.codespan = ({ text }) =>
|
||||
`<code class="agent-inline-code">${text}</code>`
|
||||
|
||||
renderer.image = ({ href, title, text }) => {
|
||||
let src = href
|
||||
if (normalizedBase && !/^(?:\/|https?:\/\/)/.test(href)) {
|
||||
|
||||
@@ -74,6 +74,26 @@ if (!GIT_COMMIT) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the current branch name at build time.
|
||||
// Priority: VERCEL_GIT_COMMIT_REF (set by Vercel's own build infrastructure) →
|
||||
// git rev-parse --abbrev-ref HEAD (for local builds deployed via vercel deploy) → ''
|
||||
let GIT_BRANCH = process.env.VERCEL_GIT_COMMIT_REF || ''
|
||||
if (!GIT_BRANCH) {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
timeout: 5000,
|
||||
windowsHide: true
|
||||
})
|
||||
.toString()
|
||||
.trim()
|
||||
if (branch !== 'HEAD') GIT_BRANCH = branch
|
||||
} catch {
|
||||
GIT_BRANCH = ''
|
||||
}
|
||||
}
|
||||
const PRODUCTION_BRANCHES = new Set(['main', 'master'])
|
||||
const GIT_BRANCH_PREFIX = PRODUCTION_BRANCHES.has(GIT_BRANCH) ? '' : GIT_BRANCH
|
||||
|
||||
// Disable Vue DevTools for production cloud distribution
|
||||
const DISABLE_VUE_PLUGINS =
|
||||
process.env.DISABLE_VUE_PLUGINS === 'true' ||
|
||||
@@ -629,6 +649,7 @@ export default defineConfig({
|
||||
process.env.npm_package_version
|
||||
),
|
||||
__COMFYUI_FRONTEND_COMMIT__: JSON.stringify(GIT_COMMIT),
|
||||
__GIT_BRANCH_PREFIX__: JSON.stringify(GIT_BRANCH_PREFIX),
|
||||
__SENTRY_ENABLED__: JSON.stringify(
|
||||
!(process.env.NODE_ENV === 'development' || !process.env.SENTRY_DSN)
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user