Compare commits

..

31 Commits

Author SHA1 Message Date
uytieu
6f5a6d773d stuff 2026-07-02 16:48:58 -04:00
uytieu
0aedb6360f typecheck error fix 2026-07-02 12:41:50 -04:00
uytieu
a7b89a4ad8 fixed lint error 2026-07-02 12:11:31 -04:00
uytieu
63f5c71a9e style adjustments to text size at breakpoint, add tooltips 2026-07-02 11:56:01 -04:00
uytieu
5654c4edab add full example on old chat history examples 2026-07-02 11:45:58 -04:00
uytieu
ef57ee29ea update css error 2026-07-02 11:43:03 -04:00
uytieu
b751e717b3 component examples in chat history 2026-07-02 11:21:10 -04:00
uytieu
80b1c3cd71 fixed mouse release on sidebar max width bug 2026-07-02 10:41:45 -04:00
uytieu
462029b004 update issue with browser tab name 2026-07-02 10:30:44 -04:00
uytieu
65021e2b8a fix browser title to display 2026-07-02 10:25:31 -04:00
uytieu
e8d8ab412c branch name added browser tab for vercel previews 2026-07-02 10:20:41 -04:00
uytieu
7f8e7f7fb2 update attachment component 2026-07-02 10:15:40 -04:00
uytieu
9182ef4948 fix typecheck error 2026-07-02 09:51:11 -04:00
uytieu
a94b3d541b scroll button and markdown updates 2026-07-02 09:06:05 -04:00
uytieu
8ce6f6e234 add code block 2026-07-01 21:29:26 -04:00
uytieu
b571db1897 update attachment style in message stream 2026-07-01 21:13:56 -04:00
uytieu
c1b5a5166c table head bg color change for contrast 2026-07-01 21:01:15 -04:00
uytieu
11e0446bb8 style update to markdown 2026-07-01 20:59:19 -04:00
uytieu
e45a1bed17 added markdown styles in messages 2026-07-01 20:43:39 -04:00
uytieu
ddb0a181ea add file and photo attachment 2026-07-01 20:19:19 -04:00
uytieu
927ba00e91 removed unused files and types 2026-07-01 19:35:58 -04:00
uytieu
8a61e9aa72 update to message styles 2026-07-01 19:28:13 -04:00
uytieu
636608664d fix 2026-06-30 19:33:51 -04:00
uytieu
499a706081 update to suggestion pill layout on larger width and add chat history section 2026-06-30 11:00:07 -04:00
uytieu
fb40f2fdb9 update suggestion pill button styles and prompt box 2026-06-30 09:16:31 -04:00
uytieu
2c9cce86d7 added agent panel 2026-06-30 07:51:24 -04:00
Rizumu Ayaka
f4e0430072 fix: disable global keybindings while a modal dialog is open (#12184)
## Summary

Block background keybindings from firing while a modal dialog (e.g.
Templates) is open, so typing `w` no longer toggles the workflow sidebar
behind the modal.

## Changes

- **What**: In `keybindingService.keybindHandler`, gate command
execution on `dialogStore.dialogStack`. When a dialog is open, only
keybindings whose event target is inside the dialog (`[role="dialog"]`)
fire; all other matches are dropped.

## Review Focus

- The dialog scope check uses `target.closest('[role="dialog"]')` so
dialog-internal shortcuts still work — confirm PrimeVue/Reka dialogs
render with `role="dialog"` on the wrapper (they do; this is the
WAI-ARIA standard the libraries follow).
- Updated `keybindingService.escape.test.ts` "modifiers regardless of
dialog state" case to the new contract (modifiers also blocked),
matching the team consensus in FE-642 that all keybindings should be
disabled when a modal is open.
- New `keybindingService.dialog.test.ts` covers: no-dialog → fires;
dialog open + target outside → blocked; dialog open + target inside →
fires.

Fixes FE-642

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12184-fix-disable-global-keybindings-while-a-modal-dialog-is-open-35e6d73d3650812fbc5dd5490ccde24f)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-06-30 06:33:33 +00:00
Rizumu Ayaka
c78592c1ec feat: add upload button to dropdown menu filter bar (#12507)
## Summary

Add an Upload button to the dropdown popover's filter bar so users can
pick a file without closing the dropdown to reach the small upload icon
next to the input.

The upload button in the dropdown menu includes text and uses the same
icon as the external quick upload button. This design ensures that after
using it, users will understand that the icon on the external button
means upload. Even if users didn't understand it before, they will
correctly interpret it next time.

related linear FE-581

## Changes

- **What**:
- Expose `showPicker()` from `FormDropdownInput`; it calls
`HTMLInputElement.showPicker()` on the single existing hidden `<input
type="file">` (falls back to `input.click()` on browsers without
showPicker).
- Add an Upload button in `FormDropdownMenuFilter` that emits
`show-picker`, bubbled up through `FormDropdownMenu` to `FormDropdown`,
which then calls `triggerRef.showPicker()`. The whole chain runs in the
click event's synchronous stack to satisfy the browser's transient
activation requirement, so no extra `<input type="file">` is added to
the DOM.
- Style the button with the project's standard inverted-button tokens
(`bg-base-foreground` / `text-base-background`) so it tracks theme
changes.

## Review Focus

- The `triggerRef!.showPicker()` non-null assertion in
`FormDropdown.vue` is intentional: by the time `show-picker` is emitted
the trigger is guaranteed to be mounted; a null here would indicate a
real bug we want to surface, not swallow.
- Verify the new button reuses the same upload path as the inline icon
button (single `<input type="file">`, single `handleFileChange`).

## Screenshots

<img width="1304" height="1442" alt="CleanShot 2026-06-02 at 14 39
33@2x"
src="https://github.com/user-attachments/assets/b2d1cdd8-e28a-467d-8142-afd707264d0e"
/>


<details><summary>Old Versions</summary>
<p>


https://github.com/user-attachments/assets/2d64873b-6bec-4eca-aa89-a72dd11aa809

</p>
</details>
2026-06-30 06:25:24 +00:00
Rizumu Ayaka
00b0c6b434 fix: close widget dropdown on outside pointerdown and canvas viewport moves (#12812)
## Summary

Model/widget dropdowns stayed open until mouseup, detached from their
node when the canvas moved while open, and needed two clicks to dismiss
after the inner scrollbar took focus.

## Changes

- **What**:
- Dismiss the dropdown on `pointerdown` outside the menu/trigger
(capture phase) instead of PrimeVue's `click` (mouseup) dismissal. The
dropdown now closes the instant a press lands, before a drag or
box-select can start, and a focused inner scrollbar no longer swallows
the first outside click.
- Close the dropdown whenever the canvas viewport moves, by watching the
reactive `useTransformState().camera`. This reacts to the canvas
abstraction layer rather than guessing input intent, so it covers
pan/zoom from any device — mouse drag, trackpad pan, wheel scroll/zoom —
where no `pointerdown` ever fires. The popover is teleported to the
document body and cannot follow the viewport, so closing is the correct
behavior.

## Review Focus

- Box-select and node-drag both begin with a `pointerdown` outside the
popover, so they are covered by the immediate dismissal path; the camera
watch handles pointer-less viewport motion.
- `closeOnEscape` and in-menu interactions are unaffected; presses
inside the menu or on the trigger are excluded via `composedPath()`.

Fixes FE-808

---------

Co-authored-by: Dante <bunggl@naver.com>
2026-06-30 05:58:55 +00:00
Christian Byrne
da34fa3944 docs(website): update ToS payment terms for Free Tier overages (#13315)
*PR Created by the Glary-Bot Agent*

---

Updates two sections on https://comfy.org/terms-of-service per legal
copy provided in [the website-and-docs Slack
thread](https://comfy-org.slack.com/archives/C098QHJ8YDR/p1782775899132369).

## Changes

Edits `apps/website/src/i18n/translations.ts` (the source of truth for
the ToS page rendered by
`apps/website/src/pages/terms-of-service.astro`):

- **`tos.payment.block.1` — Plans; Fees; Free Tier.** Adds language
clarifying that a Free Tier user who provides a payment method expressly
authorizes Comfy to charge it for overages (intentional use, third-party
use, or technical factors), and that approach-to-cap notifications are
best-effort, not a precondition to charging.
- **`tos.payment.block.3` — Self-Serve Credit Card Billing.** Clarifies
that the billing authorization applies to paid Plan and Free Tier
overages alike, and that retry rights for failed charges extend to Free
Tier overage charges.

`en` and `zh-CN` values are kept in sync per the existing convention for
these keys (the `/zh-CN/terms-of-service` page is a redirect to the
English page).

## Open question for legal / requester

`tos.effectiveDate` is currently `May 13, 2026` and was **not** bumped
in this PR — the original request did not mention it. If legal wants
this revision to carry a new effective date, that should be a follow-up
commit on this branch before merge.

## Verification

- `pnpm typecheck` (apps/website): 0 errors, 0 warnings.
- `pnpm build` (apps/website): 497 pages built; the rendered
`/terms-of-service` HTML contains both new sentences.
- `pnpm exec eslint` / `oxfmt --check` on the changed file: clean.
- Husky pre-commit (`lint-staged` + `check-unused-i18n-keys`): clean.
- Manual: served the built `dist/` via local HTTP and verified the
rendered Payment section in a real browser (screenshot below).

## Screenshots

![Rendered /terms-of-service Payment section showing the updated Plans;
Fees; Free Tier and Self-Serve Credit Card Billing
copy](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/3d085431f019603d3250f274fdae4f9186eaaecbdaee4cbc6b924e2b84854661/pr-images/1782796953351-4deec91c-ac02-4bc5-b8cd-cd0a3413613e.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-30 05:29:13 +00:00
Alexander Piskun
c8ed15da31 feat: follow --comfy-api-base for staging and preview backends (#13054)
## Summary

Let the running ComfyUI server decide which backend the web UI talks to
(and which Firebase project it signs you into), so launching with
`--comfy-api-base` just works with the regular bundled frontend.

## Changes

- **What**: At startup the frontend reads `/api/features` on every build
(not just cloud) and treats the server's `comfy_api_base_url` /
`comfy_platform_base_url` as authoritative, falling back to the
build-time defaults.
When that api base is a staging-tier host (staging, or a
`*.testenvs.comfy.org` preview env) and the server hasn't supplied its
own Firebase config, the frontend picks the dev Firebase project,
derived from the api base.
Production is left exactly as it is today.
- `main.ts`: load remote config first thing, before Firebase
initializes, so every module sees the right values from the first render
- `config/comfyApi.ts`: the api/platform getters now read the server's
values on all distributions
- `config/firebase.ts`: `getFirebaseConfig()` resolves in order: a
server-provided config first (cloud), then the dev project for a
staging-tier api base, then the build-time default
- `platform/remoteConfig/refreshRemoteConfig.ts`: the startup fetch now
has a 5s timeout, so a slow or wedged `/features` can never keep the app
from mounting; on failure we fall back to the build-time defaults
- **Breaking**: None. With no `/features` overrides (production and
ordinary self-hosting), behavior is unchanged

## Review Focus

- The precedence in `getFirebaseConfig()` (`config/firebase.ts`): server
config first, then the staging-tier dev project, then the build-time
default. The staging-tier check matches `stagingapi.comfy.org` and any
`*.testenvs.comfy.org` host, and falls back to build-time for anything
it can't parse.
- Running `refreshRemoteConfig()` unconditionally and first in
`main.ts`, with the new fetch timeout as the safety net.

## Testing

I tested every case by hand, locally, on top of the automated checks.
Tested both with `pnpm run build` and `USE_PROD_CONFIG=true pnpm build`
and running Comfy from that folder.

Pointed a local ComfyUI at each backend with `--comfy-api-base` and
signed in with Google each time:

- **Production** (default / `https://api.comfy.org`): stays on
production and signs into the production Firebase project, identical to
today.
- **Staging** (`https://stagingapi.comfy.org`): follows it and signs
into the dev project.
- **Ephemeral preview env** (`https://pr-<n>.testenvs.comfy.org`): the
friendly host is accepted as-is, the frontend follows it, lands in the
dev project, and Google sign-in completes.

The only exception where fronted does not respect the `--comfy-api-base`
is when Comfy runs against `prod` and frontend runs with the `pnpm run
dev` - due to overridden config(this is expected behavior).

Supersedes: https://github.com/Comfy-Org/ComfyUI_frontend/pull/12560
Companion Core PR: https://github.com/Comfy-Org/ComfyUI/pull/14569

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->
2026-06-30 05:18:24 +00:00
103 changed files with 4580 additions and 727 deletions

View 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.

View 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.

View File

@@ -2913,18 +2913,18 @@ const translations = {
'zh-CN': 'Plans; Fees; Free Tier.'
},
'tos.payment.block.1': {
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. If a Free Tier user provides a valid payment method in connection with their account (including for identity verification, future upgrade purposes, or any other reason), such user expressly authorizes Comfy to charge that payment method for any usage that exceeds the applicable Free Tier limits, including overages resulting from intentional use, usage by authorized users or third parties under the account, or technical factors. Comfy will use reasonable efforts to notify users when they approach or exceed Free Tier limits, but such notice is not a condition of Comfys right to charge for overages. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
'zh-CN':
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. If a Free Tier user provides a valid payment method in connection with their account (including for identity verification, future upgrade purposes, or any other reason), such user expressly authorizes Comfy to charge that payment method for any usage that exceeds the applicable Free Tier limits, including overages resulting from intentional use, usage by authorized users or third parties under the account, or technical factors. Comfy will use reasonable efforts to notify users when they approach or exceed Free Tier limits, but such notice is not a condition of Comfys right to charge for overages. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
},
'tos.payment.block.2.heading': {
en: 'Self-Serve Credit Card Billing.',
'zh-CN': 'Self-Serve Credit Card Billing.'
},
'tos.payment.block.3': {
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.',
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). This billing authorization applies regardless of whether the Customer is on a paid Plan or a Free Tier at the time the overage is incurred. Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method. The same retry rights apply to any failed overage charges incurred by Free Tier users.',
'zh-CN':
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.'
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). This billing authorization applies regardless of whether the Customer is on a paid Plan or a Free Tier at the time the overage is incurred. Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method. The same retry rights apply to any failed overage charges incurred by Free Tier users.'
},
'tos.payment.block.4.heading': {
en: 'Invoiced Billing.',

1
global.d.ts vendored
View File

@@ -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

View File

@@ -120,6 +120,7 @@
"primevue": "catalog:",
"reka-ui": "catalog:",
"semver": "^7.7.2",
"shiki": "catalog:",
"three": "catalog:",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",

180
pnpm-lock.yaml generated
View File

@@ -9,15 +9,6 @@ catalogs:
'@alloc/quick-lru':
specifier: ^5.2.0
version: 5.2.0
'@astrojs/check':
specifier: ^0.9.9
version: 0.9.9
'@astrojs/sitemap':
specifier: ^3.7.3
version: 3.7.3
'@astrojs/vue':
specifier: ^6.0.1
version: 6.0.1
'@comfyorg/comfyui-electron-types':
specifier: 0.6.2
version: 0.6.2
@@ -30,30 +21,15 @@ catalogs:
'@formkit/auto-animate':
specifier: ^0.9.0
version: 0.9.0
'@iconify-json/lucide':
specifier: ^1.1.178
version: 1.2.79
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
'@iconify/tailwind4':
specifier: ^1.2.3
version: 1.2.3
'@iconify/tools':
specifier: ^5.0.3
version: 5.0.11
'@iconify/utils':
specifier: ^3.1.0
version: 3.1.0
'@intlify/eslint-plugin-vue-i18n':
specifier: ^4.5.0
version: 4.5.0
'@lobehub/i18n-cli':
specifier: ^1.26.1
version: 1.26.1
'@lucide/vue':
specifier: ^1.17.0
version: 1.18.0
'@pinia/testing':
specifier: ^1.0.3
version: 1.0.3
@@ -159,9 +135,6 @@ catalogs:
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1
'@vitejs/plugin-vue':
specifier: ^6.0.0
version: 6.0.3
@@ -183,15 +156,9 @@ catalogs:
algoliasearch:
specifier: ^5.21.0
version: 5.21.0
astro:
specifier: ^6.4.2
version: 6.4.2
axios:
specifier: ^1.15.2
version: 1.16.1
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
cross-env:
specifier: ^10.1.0
version: 10.1.0
@@ -249,9 +216,6 @@ catalogs:
globals:
specifier: ^16.5.0
version: 16.5.0
gsap:
specifier: ^3.14.2
version: 3.14.2
happy-dom:
specifier: ^20.8.9
version: 20.9.0
@@ -270,9 +234,6 @@ catalogs:
knip:
specifier: ^6.15.0
version: 6.15.0
lenis:
specifier: ^1.3.21
version: 1.3.21
lint-staged:
specifier: ^16.2.7
version: 16.4.0
@@ -321,6 +282,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
@@ -330,18 +294,12 @@ catalogs:
tailwindcss:
specifier: ^4.3.0
version: 4.3.0
tailwindcss-primeui:
specifier: ^0.6.1
version: 0.6.1
three:
specifier: ^0.184.0
version: 0.184.0
tsx:
specifier: ^4.15.6
version: 4.19.4
tw-animate-css:
specifier: ^1.3.8
version: 1.3.8
typegpu:
specifier: ^0.8.2
version: 0.8.2
@@ -606,6 +564,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
@@ -666,16 +627,16 @@ importers:
version: 4.6.0
'@storybook/addon-docs':
specifier: 'catalog:'
version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(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))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/addon-mcp':
specifier: 'catalog:'
version: 0.1.6(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))(typescript@5.9.3)
version: 0.1.6(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
'@storybook/vue3':
specifier: 'catalog:'
version: 10.2.10(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))(vue@3.5.34(typescript@5.9.3))
version: 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.34(typescript@5.9.3))
'@storybook/vue3-vite':
specifier: 'catalog:'
version: 10.2.10(esbuild@0.27.3)(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))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
version: 10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.3.0(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
@@ -744,7 +705,7 @@ importers:
version: 2.10.1(eslint@10.4.0(jiti@2.6.1))
eslint-plugin-storybook:
specifier: 'catalog:'
version: 10.2.10(eslint@10.4.0(jiti@2.6.1))(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))(typescript@5.9.3)
version: 10.2.10(eslint@10.4.0(jiti@2.6.1))(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
eslint-plugin-testing-library:
specifier: 'catalog:'
version: 7.16.1(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3)
@@ -813,7 +774,7 @@ importers:
version: 6.0.4(rolldown@1.0.1)
storybook:
specifier: 'catalog:'
version: 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)
version: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
stylelint:
specifier: 'catalog:'
version: 16.26.1(typescript@5.9.3)
@@ -3439,18 +3400,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 +3432,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'}
@@ -7775,6 +7754,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 +8714,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 +11308,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 +11323,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 +11359,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
@@ -11373,15 +11386,15 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(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))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
'@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.1.9)(react@19.2.4)
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(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))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@storybook/react-dom-shim': 10.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(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))
'@storybook/react-dom-shim': 10.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
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)
storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ts-dedent: 2.2.0
transitivePeerDependencies:
- '@types/react'
@@ -11390,22 +11403,22 @@ snapshots:
- vite
- webpack
'@storybook/addon-mcp@0.1.6(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))(typescript@5.9.3)':
'@storybook/addon-mcp@0.1.6(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)':
dependencies:
'@storybook/mcp': 0.1.1(typescript@5.9.3)
'@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.0(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))
'@tmcp/transport-http': 0.8.3(tmcp@1.19.0(typescript@5.9.3))
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)
storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tmcp: 1.19.0(typescript@5.9.3)
valibot: 1.2.0(typescript@5.9.3)
transitivePeerDependencies:
- '@tmcp/auth'
- typescript
'@storybook/builder-vite@10.2.10(esbuild@0.27.3)(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))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
'@storybook/builder-vite@10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
dependencies:
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(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))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
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)
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ts-dedent: 2.2.0
vite: 8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
@@ -11413,9 +11426,9 @@ snapshots:
- rollup
- webpack
'@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(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))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
'@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
dependencies:
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)
storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.27.3
@@ -11438,18 +11451,18 @@ snapshots:
- '@tmcp/auth'
- typescript
'@storybook/react-dom-shim@10.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(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))':
'@storybook/react-dom-shim@10.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
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)
storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(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))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
'@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(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))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/vue3': 10.2.10(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))(vue@3.5.34(typescript@5.9.3))
'@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/vue3': 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.34(typescript@5.9.3))
magic-string: 0.30.21
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)
storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
typescript: 5.9.3
vite: 8.0.13(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue-component-meta: 2.2.12(typescript@5.9.3)
@@ -11460,13 +11473,13 @@ snapshots:
- vue
- webpack
'@storybook/vue3@10.2.10(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))(vue@3.5.34(typescript@5.9.3))':
'@storybook/vue3@10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@storybook/global': 5.0.0
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)
storybook: 10.2.10(@testing-library/dom@10.4.1)(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:
@@ -13656,11 +13669,11 @@ snapshots:
eslint: 10.4.0(jiti@2.6.1)
globals: 17.4.0
eslint-plugin-storybook@10.2.10(eslint@10.4.0(jiti@2.6.1))(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))(typescript@5.9.3):
eslint-plugin-storybook@10.2.10(eslint@10.4.0(jiti@2.6.1))(storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3):
dependencies:
'@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3)
eslint: 10.4.0(jiti@2.6.1)
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)
storybook: 10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- supports-color
- typescript
@@ -16507,6 +16520,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
@@ -16631,7 +16655,7 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
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):
storybook@10.2.10(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@storybook/global': 5.0.0
'@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -16645,8 +16669,6 @@ snapshots:
semver: 7.7.4
use-sync-external-store: 1.6.0(react@19.2.4)
ws: 8.21.0
optionalDependencies:
prettier: 3.7.4
transitivePeerDependencies:
- '@testing-library/dom'
- bufferutil
@@ -17637,7 +17659,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:

View File

@@ -116,6 +116,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@@ -1,5 +1,226 @@
@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 *,

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,7 @@
import type { ComputedRef, InjectionKey } from 'vue'
export interface CodeBlockContext {
code: ComputedRef<string>
}
export const CodeBlockKey: InjectionKey<CodeBlockContext> = Symbol('CodeBlock')

View 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
}

View 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>

View 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 flex-col gap-4 p-4', className)">
<slot />
</div>
</template>

View 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 flex-1 flex-col items-center justify-center gap-4 p-8 text-center',
className
)
"
>
<slot />
</div>
</template>

View File

@@ -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>

View 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
}

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex items-center justify-end gap-0.5">
<slot />
</div>
</template>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,4 @@
import type { Ref } from 'vue'
export const PROMPT_INPUT_FOCUSED_KEY = Symbol('promptInputFocused')
export type PromptInputFocusedContext = Ref<boolean>

View File

@@ -0,0 +1 @@
export type ChatStatus = 'ready' | 'submitted' | 'streaming' | 'error'

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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'

View File

@@ -84,6 +84,22 @@
data-testid="integrated-tab-bar-actions"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<button
type="button"
class="no-drag flex h-6 shrink-0 cursor-pointer items-center gap-2 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'
)
"
:aria-label="$t('agent.ask')"
@click="agentPanelStore.toggle()"
>
<i class="icon-[comfy--comfy-c] size-3 text-brand-yellow" />
{{ $t('agent.ask') }}
</button>
<Button
v-if="isCloud || isNightly"
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
@@ -93,7 +109,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 +122,9 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
@@ -124,6 +142,7 @@ 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 { 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,6 +164,8 @@ const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const agentPanelStore = useAgentPanelStore()
const { isOpen: isAgentPanelOpen } = storeToRefs(agentPanelStore)
const { isLoggedIn } = useCurrentUser()
// Dismiss a tab's terminal status badge once it has been viewed

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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')

View File

@@ -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)
}

101
src/config/comfyApi.test.ts Normal file
View File

@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from './comfyApi'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (route: string) => `/api${route}`,
fetchApi: vi.fn()
}
}))
vi.stubGlobal('fetch', vi.fn())
describe('getComfyApiBaseUrl', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors the server-provided override', () => {
remoteConfig.value = { comfy_api_base_url: 'https://my-ephem.example.com' }
expect(getComfyApiBaseUrl()).toBe('https://my-ephem.example.com')
})
it('falls back to the build-time default when the key is absent', () => {
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
})
it('falls back to the build-time default when the value is empty', () => {
remoteConfig.value = { comfy_api_base_url: '' }
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
})
})
describe('getComfyPlatformBaseUrl', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors the server-provided override', () => {
remoteConfig.value = {
comfy_platform_base_url: 'https://my-ephem-platform.example.com'
}
expect(getComfyPlatformBaseUrl()).toBe(
'https://my-ephem-platform.example.com'
)
})
it('falls back to the build-time default when the key is absent', () => {
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
it('falls back to the build-time default when the value is empty', () => {
remoteConfig.value = { comfy_platform_base_url: '' }
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
})
describe('compatibility with comfyui servers that predate the override keys', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
vi.clearAllMocks()
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('falls back to build-time defaults when /features omits the URL keys', async () => {
// An older comfyui server has /features but doesn't know about
// comfy_api_base_url / comfy_platform_base_url yet.
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
supports_preview_metadata: true,
max_upload_size: 104857600
})
} as Response)
await refreshRemoteConfig({ useAuth: false })
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
})

View File

@@ -1,4 +1,3 @@
import { isCloud } from '@/platform/distribution/types'
import {
configValueOrDefault,
remoteConfig
@@ -20,10 +19,6 @@ const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
STAGING_PLATFORM_BASE_URL)
export function getComfyApiBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_API_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
@@ -32,10 +27,6 @@ export function getComfyApiBaseUrl(): string {
}
export function getComfyPlatformBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',

View File

@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
async function loadFirebase(useProdConfig: boolean) {
vi.resetModules()
vi.stubGlobal('__USE_PROD_CONFIG__', useProdConfig)
const { remoteConfig } = await import('@/platform/remoteConfig/remoteConfig')
const { getFirebaseConfig } = await import('./firebase')
return { getFirebaseConfig, remoteConfig }
}
describe('getFirebaseConfig', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('honors a full server-provided firebase_config (cloud builds)', async () => {
const cloud = {
apiKey: 'cloud-key',
authDomain: 'cloud.example.com',
projectId: 'some-cloud-project',
storageBucket: 'cloud.appspot.com',
messagingSenderId: '1',
appId: '1:1:web:abc'
}
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
remoteConfig.value = { firebase_config: cloud }
expect(getFirebaseConfig()).toEqual(cloud)
})
it('uses the dev project when the server reports firebase_env "dev", even if the build-time fallback is prod', async () => {
const { getFirebaseConfig, remoteConfig } = await loadFirebase(true)
remoteConfig.value = { firebase_env: 'dev' }
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
})
it('falls back to the build-time config when the server reports no firebase_env', async () => {
const prod = await loadFirebase(true)
prod.remoteConfig.value = {}
expect(prod.getFirebaseConfig().projectId).toBe('dreamboothy')
const dev = await loadFirebase(false)
dev.remoteConfig.value = {}
expect(dev.getFirebaseConfig().projectId).toBe('dreamboothy-dev')
})
})

View File

@@ -1,6 +1,5 @@
import type { FirebaseOptions } from 'firebase/app'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const DEV_CONFIG: FirebaseOptions = {
@@ -28,15 +27,12 @@ const PROD_CONFIG: FirebaseOptions = {
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
/**
* Returns the Firebase configuration for the current environment.
* - Cloud builds use runtime configuration delivered via feature flags
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
* Firebase config for the current backend: the server's firebase_config (cloud builds),
* else the bundled DEV_CONFIG when the server reports a dev-tier backend, else the build-time default.
*/
export function getFirebaseConfig(): FirebaseOptions {
if (!isCloud) {
return BUILD_TIME_CONFIG
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? BUILD_TIME_CONFIG
if (runtimeConfig) return runtimeConfig
if (remoteConfig.value.firebase_env === 'dev') return DEV_CONFIG
return BUILD_TIME_CONFIG
}

View File

@@ -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",

View File

@@ -32,13 +32,12 @@ import { i18n } from './i18n'
const isCloud = __DISTRIBUTION__ === 'cloud'
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
if (requiresRemoteConfigBootstrap) {
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
}
// Load remote config before initializeApp() below, so getFirebaseConfig() resolves
// against the server's runtime values instead of the build-time defaults.
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
if (isCloud) {
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
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'
const { name } = defineProps<{
name?: string
}>()
</script>
<template>
<Empty class="pt-12">
<EmptyHeader>
<EmptyMedia>
<div class="rounded-xl border border-plum-600">
<img
src="/assets/images/comfy-logo-single.svg"
alt=""
class="block size-12"
aria-hidden="true"
/>
</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>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<p class="my-0 py-0 text-xs font-medium text-muted-foreground">
<slot />
</p>
</template>

View 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>

View 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>

View 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>

View 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)
})
})

View 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
}
}

View 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
}
})

View File

@@ -0,0 +1,117 @@
import { createPinia, setActivePinia } from 'pinia'
import { markRaw, reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import type { DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
function createTestDialogInstance(
key: string,
overrides: Partial<DialogInstance> = {}
): DialogInstance {
return {
key,
visible: true,
component: markRaw({ template: '<div />' }),
contentProps: {},
dialogComponentProps: {},
priority: 0,
...overrides
}
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))
}))
vi.mock('@/stores/dialogStore', () => {
const dialogStack = reactive<DialogInstance[]>([])
return {
useDialogStore: () => ({ dialogStack })
}
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: null
}
}))
describe('keybindingService - dialog gate', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let mockCommandExecute: ReturnType<typeof useCommandStore>['execute']
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
const commandStore = useCommandStore()
mockCommandExecute = vi.fn()
commandStore.execute = mockCommandExecute
const dialogStore = useDialogStore()
dialogStore.dialogStack.length = 0
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
function createKeyboardEvent(
key: string,
target: HTMLElement = document.body
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [target])
return event
}
it('executes a global keybinding when no dialog is open', async () => {
const event = createKeyboardEvent('w')
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).toHaveBeenCalledWith(
'Workspace.ToggleSidebarTab.workflows'
)
})
it('does NOT execute a global keybinding while a dialog is open', async () => {
const dialogStore = useDialogStore()
dialogStore.dialogStack.push(createTestDialogInstance('templates-dialog'))
const event = createKeyboardEvent('w')
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('still executes a keybinding whose target lives inside the open dialog', async () => {
const dialogStore = useDialogStore()
dialogStore.dialogStack.push(createTestDialogInstance('templates-dialog'))
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
const inner = document.createElement('button')
dialog.appendChild(inner)
document.body.appendChild(dialog)
try {
const event = createKeyboardEvent('w', inner)
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).toHaveBeenCalledWith(
'Workspace.ToggleSidebarTab.workflows'
)
} finally {
document.body.removeChild(dialog)
}
})
})

View File

@@ -108,7 +108,7 @@ describe('keybindingService - Escape key handling', () => {
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should execute Escape keybinding with modifiers regardless of dialog state', async () => {
it('should NOT execute Escape keybinding with modifiers when a dialog is open', async () => {
const dialogStore = useDialogStore()
dialogStore.dialogStack.push(createTestDialogInstance('test-dialog'))
@@ -125,7 +125,7 @@ describe('keybindingService - Escape key handling', () => {
const event = createKeyboardEvent('Escape', { ctrlKey: true })
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).toHaveBeenCalledWith('Test.CtrlEscape')
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should verify Escape keybinding exists in CORE_KEYBINDINGS', () => {

View File

@@ -55,6 +55,18 @@ export function useKeybindingService() {
}
}
/**
* Block global keybindings from triggering background actions while a
* modal dialog is open. Keybindings whose event target lives inside an
* open dialog still fire, so dialog-scoped shortcuts keep working.
*/
if (dialogStore.dialogStack.length > 0) {
const inDialog = target.closest?.('[role="dialog"]') != null
if (!inDialog) {
return
}
}
event.preventDefault()
const runCommandIds = new Set([
'Comfy.QueuePrompt',

View File

@@ -7,7 +7,8 @@ import { remoteConfig } from './remoteConfig'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
fetchApi: vi.fn(),
apiURL: vi.fn((route: string) => `/ComfyUI/api${route}`)
}
}))
@@ -43,9 +44,10 @@ describe('refreshRemoteConfig', () => {
await refreshRemoteConfig({ useAuth: true })
expect(api.fetchApi).toHaveBeenCalledWith('/features', {
cache: 'no-store'
})
expect(api.fetchApi).toHaveBeenCalledWith(
'/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(global.fetch).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
@@ -59,23 +61,56 @@ describe('refreshRemoteConfig', () => {
expect(api.fetchApi).toHaveBeenCalled()
expect(global.fetch).not.toHaveBeenCalled()
})
it('does not pass an abort signal on the authed branch (so it is never aborted)', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: true })
const init = vi.mocked(api.fetchApi).mock.calls[0][1]
expect(init?.signal).toBeUndefined()
})
})
describe('without auth', () => {
it('uses raw fetch when useAuth is false', async () => {
it('builds the no-auth url via api.apiURL so a path prefix is respected', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: false })
expect(global.fetch).toHaveBeenCalledWith('/api/features', {
cache: 'no-store'
})
expect(api.apiURL).toHaveBeenCalledWith('/features')
expect(global.fetch).toHaveBeenCalledWith(
'/ComfyUI/api/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(api.fetchApi).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
})
})
describe('timeout', () => {
it('passes an AbortSignal so a wedged /features cannot hang startup', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: false })
const init = vi.mocked(global.fetch).mock.calls[0][1]
expect(init?.signal).toBeInstanceOf(AbortSignal)
})
it('falls back to empty config when the request aborts', async () => {
vi.mocked(global.fetch).mockRejectedValue(
new DOMException('Aborted', 'AbortError')
)
await refreshRemoteConfig({ useAuth: false })
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
})
})
describe('error handling', () => {
it('clears config on 401 response', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(

View File

@@ -4,6 +4,11 @@ import {
remoteConfigState
} from './remoteConfig'
// Cap the bootstrap fetch so a wedged /features endpoint can never block app.mount indefinitely.
// A same-origin GET against the local comfyui server should resolve in well under a second;
// on timeout the catch below clears remoteConfig and consumers fall back to build-time defaults.
const FEATURES_FETCH_TIMEOUT_MS = 5_000
interface RefreshRemoteConfigOptions {
/**
* Whether to use authenticated API (default: true).
@@ -12,10 +17,14 @@ interface RefreshRemoteConfigOptions {
useAuth?: boolean
}
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
async function fetchRemoteConfig(
useAuth: boolean,
signal?: AbortSignal
): Promise<Response> {
const { api } = await import('@/scripts/api')
if (!useAuth) {
return fetch(api.apiURL('/features'), { cache: 'no-store', signal })
}
return api.fetchApi('/features', { cache: 'no-store' })
}
@@ -33,8 +42,13 @@ export async function refreshRemoteConfig(
): Promise<void> {
const { useAuth = true } = options
const controller = useAuth ? null : new AbortController()
const timeoutId = controller
? setTimeout(() => controller.abort(), FEATURES_FETCH_TIMEOUT_MS)
: null
try {
const response = await fetchRemoteConfig(useAuth)
const response = await fetchRemoteConfig(useAuth, controller?.signal)
if (response.ok) {
const config = await response.json()
@@ -59,5 +73,7 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = {}
remoteConfig.value = {}
remoteConfigState.value = 'error'
} finally {
if (timeoutId !== null) clearTimeout(timeoutId)
}
}

View File

@@ -93,6 +93,7 @@ export type RemoteConfig = {
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: FirebaseRuntimeConfig
firebase_env?: 'dev'
telemetry_disabled_events?: TelemetryEventName[]
enable_telemetry?: boolean
model_upload_button_enabled?: boolean

View File

@@ -1,11 +1,13 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import FormDropdown from './FormDropdown.vue'
import { DROPDOWN_PANEL_CLASS } from './shared'
import type { FormDropdownItem } from './types'
function createItem(id: string, name: string): FormDropdownItem {
@@ -20,6 +22,14 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
})
}))
const transformState = vi.hoisted(() => ({ camera: { x: 0, y: 0, z: 1 } }))
vi.mock('@/renderer/core/layout/transform/useTransformState', async () => {
const { reactive } = await import('vue')
transformState.camera = reactive(transformState.camera)
return { useTransformState: () => ({ camera: transformState.camera }) }
})
const MockFormDropdownMenu = {
name: 'FormDropdownMenu',
props: [
@@ -71,6 +81,7 @@ interface MountDropdownOptions {
multiple?: boolean | number
searchQuery?: string
onUpdateSelected?: (selected: Set<string>) => void
onUpdateIsOpen?: (isOpen: boolean) => void
}
function flushPromises() {
@@ -88,10 +99,11 @@ function mountDropdown(
multiple: options.multiple,
searcher: options.searcher,
searchQuery: options.searchQuery,
'onUpdate:selected': options.onUpdateSelected
'onUpdate:selected': options.onUpdateSelected,
'onUpdate:isOpen': options.onUpdateIsOpen
},
global: {
plugins: [PrimeVue, i18n],
plugins: [PrimeVue, i18n, createPinia()],
stubs: {
FormDropdownInput: MockFormDropdownInput,
Popover: MockPopover,
@@ -123,6 +135,12 @@ async function openDropdown(user: ReturnType<typeof userEvent.setup>) {
}
describe('FormDropdown', () => {
beforeEach(() => {
transformState.camera.x = 0
transformState.camera.y = 0
transformState.camera.z = 1
})
describe('filteredItems updates when items prop changes', () => {
it('updates displayed items when items prop changes', async () => {
const { rerender } = mountDropdown([
@@ -362,6 +380,74 @@ describe('FormDropdown', () => {
expect(onUpdateSelected).not.toHaveBeenCalled()
})
it('closes on a pointerdown outside the menu and trigger', async () => {
const onUpdateIsOpen = vi.fn()
const { user } = mountDropdown([createItem('1', 'alpha')], {
onUpdateIsOpen
})
await openDropdown(user)
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
const outside = document.createElement('div')
document.body.appendChild(outside)
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await flushPromises()
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
outside.remove()
})
it('closes when the canvas viewport moves', async () => {
const onUpdateIsOpen = vi.fn()
const { user } = mountDropdown([createItem('1', 'alpha')], {
onUpdateIsOpen
})
await openDropdown(user)
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
transformState.camera.x += 77
await flushPromises()
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
})
it('stays open on a pointerdown inside the menu', async () => {
const onUpdateIsOpen = vi.fn()
const { user } = mountDropdown([createItem('1', 'alpha')], {
onUpdateIsOpen
})
await openDropdown(user)
screen
.getByTestId('dropdown-menu')
.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await flushPromises()
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
})
it('stays open on a pointerdown inside a body-teleported sub-popover panel', async () => {
const onUpdateIsOpen = vi.fn()
const { user } = mountDropdown([createItem('1', 'alpha')], {
onUpdateIsOpen
})
await openDropdown(user)
const panel = document.createElement('div')
panel.classList.add(DROPDOWN_PANEL_CLASS)
const option = document.createElement('button')
panel.appendChild(option)
document.body.appendChild(panel)
option.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await flushPromises()
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(true)
panel.remove()
})
it('does not select a search result from multi-select dropdowns', async () => {
const onUpdateSelected = vi.fn()
const { user } = mountDropdown(

View File

@@ -1,10 +1,17 @@
<script setup lang="ts">
import { computedAsync, refDebounced } from '@vueuse/core'
import {
computedAsync,
refDebounced,
unrefElement,
useEventListener
} from '@vueuse/core'
import Popover from 'primevue/popover'
import type { ComponentPublicInstance } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDismissOnCanvasGesture } from '@/renderer/extensions/vueNodes/widgets/composables/useDismissOnCanvasGesture'
import type {
FilterOption,
@@ -14,7 +21,11 @@ import type {
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import {
DROPDOWN_PANEL_CLASS,
defaultSearcher,
getDefaultSortOptions
} from './shared'
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
@@ -102,6 +113,7 @@ const isOpen = defineModel<boolean>('isOpen', { default: false })
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerAnchorRef = useTemplateRef<HTMLElement>('triggerAnchorRef')
const menuRef = useTemplateRef<ComponentPublicInstance>('menuRef')
const triggerRef =
useTemplateRef<InstanceType<typeof FormDropdownInput>>('triggerRef')
const displayedSearchQuery = ref('')
@@ -204,6 +216,43 @@ const closeDropdown = ({ restoreFocus = false } = {}) => {
if (restoreFocus) focusTrigger()
}
/**
* Dismiss on `pointerdown` rather than PrimeVue's default `click` (mouseup) so
* the dropdown closes the instant an outside press lands, and a focused inner
* scrollbar cannot swallow the first outside click. Presses on the trigger and
* on the menu's body-teleported sub-popovers (Sort / Ownership / Base-model)
* are excluded so they keep working instead of closing the parent.
*/
useEventListener(
window,
'pointerdown',
(event) => {
if (!isOpen.value) return
const menuEl = unrefElement(menuRef)
const triggerEl = triggerAnchorRef.value
const path = event.composedPath()
if (menuEl && path.includes(menuEl)) return
if (triggerEl && path.includes(triggerEl)) return
if (path.some(isInsideDropdownPanel)) return
closeDropdown()
},
{ capture: true }
)
function isInsideDropdownPanel(target: EventTarget): boolean {
return (
target instanceof HTMLElement &&
target.classList.contains(DROPDOWN_PANEL_CLASS)
)
}
/**
* The popover is teleported to `document.body`, so canvas gestures (pan, zoom,
* box select — any input device) move the node while the popover stays put.
* Dismiss as soon as such a gesture begins.
*/
useDismissOnCanvasGesture(isOpen, () => closeDropdown())
function handleFileChange(event: Event) {
if (disabled) return
const target = event.target
@@ -268,6 +317,11 @@ async function selectTopSearchResult() {
function handleSearchEnter() {
void selectTopSearchResult()
}
function showPicker() {
triggerRef.value!.showPicker()
closeDropdown()
}
</script>
<template>
@@ -290,7 +344,7 @@ function handleSearchEnter() {
/>
<Popover
ref="popoverRef"
:dismissable="true"
:dismissable="false"
:close-on-escape="true"
unstyled
:pt="{
@@ -304,12 +358,14 @@ function handleSearchEnter() {
@hide="isOpen = false"
>
<FormDropdownMenu
ref="menuRef"
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:uploadable
:filter-options
:sort-options
:show-ownership-filter
@@ -326,6 +382,7 @@ function handleSearchEnter() {
@close="closeDropdown"
@search-enter="handleSearchEnter"
@item-click="handleSelection"
@show-picker="showPicker"
@approach-end="emit('approach-end')"
/>
</Popover>

View File

@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import FormDropdownInput from './FormDropdownInput.vue'
@@ -132,4 +133,57 @@ describe('FormDropdownInput', () => {
expect(onFileChange).toHaveBeenCalledTimes(1)
})
})
describe('Exposed showPicker', () => {
/** Mount a harness that captures the FormDropdownInput instance so we can
* invoke its exposed methods, mirroring how FormDropdown drives it. */
async function mountWithRef(props: Partial<FormDropdownInputProps> = {}) {
const inputRef = ref<InstanceType<typeof FormDropdownInput> | null>(null)
const Harness = defineComponent({
components: { FormDropdownInput },
setup: () => ({
inputRef,
bindings: {
items,
selected: new Set<string>(),
maxSelectable: 1,
uploadable: true,
disabled: false,
...props
}
}),
template: '<FormDropdownInput ref="inputRef" v-bind="bindings" />'
})
render(Harness, { global: { plugins: [i18n] } })
await nextTick()
return inputRef
}
it('calls showPicker on the file input when available', async () => {
const showPickerSpy = vi.fn()
Object.defineProperty(HTMLInputElement.prototype, 'showPicker', {
value: showPickerSpy,
configurable: true,
writable: true
})
const inputRef = await mountWithRef()
inputRef.value!.showPicker()
expect(showPickerSpy).toHaveBeenCalledTimes(1)
})
it('falls back to click() when showPicker is unavailable', async () => {
// Simulate older browsers
// @ts-expect-error -- intentional removal for fallback path
delete HTMLInputElement.prototype.showPicker
const clickSpy = vi.fn()
Object.defineProperty(HTMLInputElement.prototype, 'click', {
value: clickSpy,
configurable: true,
writable: true
})
const inputRef = await mountWithRef()
inputRef.value!.showPicker()
expect(clickSpy).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
@@ -43,12 +43,29 @@ const theButtonStyle = computed(() =>
)
const buttonRef = ref<HTMLButtonElement>()
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
function focus() {
buttonRef.value?.focus()
}
defineExpose({ focus })
/**
* Open the native file picker without a user click on the input itself.
* Must be invoked synchronously from a user-initiated event handler so the
* browser's transient activation requirement is satisfied. Falls back to
* `click()` on browsers that predate showPicker (Chrome <99, Firefox <101,
* Safari <16).
*/
function showPicker() {
const input = fileInputRef.value!
if (typeof input.showPicker === 'function') {
input.showPicker()
} else {
input.click()
}
}
defineExpose({ focus, showPicker })
</script>
<template>
@@ -108,6 +125,7 @@ defineExpose({ focus })
aria-hidden="true"
/>
<input
ref="fileInputRef"
type="file"
class="absolute inset-0 -z-1 opacity-0"
:aria-label="t('g.upload')"

View File

@@ -26,6 +26,7 @@ describe('FormDropdownMenu', () => {
const defaultProps = {
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
isSelected: () => false,
uploadable: false,
filterOptions: [],
sortOptions: []
}
@@ -158,6 +159,58 @@ describe('FormDropdownMenu', () => {
expect(event.defaultPrevented).toBe(true)
})
/** Stub that surfaces `uploadable` as a data attribute and exposes a button
* that emits `show-picker`, so the parent's prop-forwarding and event
* re-emission can be asserted from the DOM. */
const FormDropdownMenuFilterStub = {
name: 'FormDropdownMenuFilter',
props: ['uploadable', 'filterOptions'],
emits: ['show-picker'],
template:
'<button data-testid="filter-stub" :data-uploadable="String(uploadable)" @click="$emit(\'show-picker\')" />'
}
it('forwards uploadable prop to FormDropdownMenuFilter', () => {
render(FormDropdownMenu, {
props: {
...defaultProps,
uploadable: true,
filterOptions: [{ name: 'All', value: 'all' }]
},
global: {
stubs: {
FormDropdownMenuFilter: FormDropdownMenuFilterStub,
FormDropdownMenuActions: true,
VirtualGrid: VirtualGridStub
},
mocks: { $t: (key: string) => key }
}
})
expect(screen.getByTestId('filter-stub').dataset.uploadable).toBe('true')
})
it('re-emits show-picker when FormDropdownMenuFilter emits it', async () => {
const { emitted } = render(FormDropdownMenu, {
props: {
...defaultProps,
uploadable: true,
filterOptions: [{ name: 'All', value: 'all' }]
},
global: {
stubs: {
FormDropdownMenuFilter: FormDropdownMenuFilterStub,
FormDropdownMenuActions: true,
VirtualGrid: VirtualGridStub
},
mocks: { $t: (key: string) => key }
}
})
await userEvent.click(screen.getByTestId('filter-stub'))
expect(emitted('show-picker')).toHaveLength(1)
})
/** Vertical scrolling must remain native so the dropdown's own scroll
* container can scroll its content. */
it('does not suppress vertical scroll', () => {

View File

@@ -19,6 +19,7 @@ import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
items: FormDropdownItem[]
isSelected: (item: FormDropdownItem, index: number) => boolean
uploadable: boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showOwnershipFilter?: boolean
@@ -33,6 +34,7 @@ interface Props {
const {
items,
isSelected,
uploadable,
filterOptions,
sortOptions,
showOwnershipFilter,
@@ -46,6 +48,7 @@ const {
const emit = defineEmits<{
(e: 'item-click', item: FormDropdownItem, index: number): void
(e: 'search-enter'): void
(e: 'show-picker'): void
(e: 'approach-end'): void
}>()
@@ -126,6 +129,8 @@ const onWheel = (event: WheelEvent) => {
v-if="filterOptions.length > 0"
v-model:filter-selected="filterSelected"
:filter-options
:uploadable
@show-picker="emit('show-picker')"
/>
<FormDropdownMenuActions
v-model:layout-mode="layoutMode"

View File

@@ -12,6 +12,7 @@ import type {
import { cn } from '@comfyorg/tailwind-utils'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { DROPDOWN_PANEL_CLASS } from './shared'
import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
@@ -157,7 +158,7 @@ function handleSearchEnter(event: KeyboardEvent) {
unstyled
:pt="{
root: {
class: 'absolute z-50'
class: ['absolute z-50', DROPDOWN_PANEL_CLASS]
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
@@ -219,7 +220,7 @@ function handleSearchEnter(event: KeyboardEvent) {
unstyled
:pt="{
root: {
class: 'absolute z-50'
class: ['absolute z-50', DROPDOWN_PANEL_CLASS]
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
@@ -281,7 +282,7 @@ function handleSearchEnter(event: KeyboardEvent) {
unstyled
:pt="{
root: {
class: 'absolute z-50'
class: ['absolute z-50', DROPDOWN_PANEL_CLASS]
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']

View File

@@ -34,7 +34,7 @@ function getUploadMock() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { import: 'Import' } } }
messages: { en: { g: { import: 'Import', upload: 'Upload' } } }
})
const ButtonStub = {
@@ -52,14 +52,16 @@ const singleOption: FilterOption[] = [{ value: 'all', name: 'All' }]
function renderMenu(
filterOptions: FilterOption[] = options,
modelValue: string | undefined = 'all'
modelValue: string | undefined = 'all',
extraProps: { uploadable?: boolean } = {}
) {
const value = ref<string | undefined>(modelValue)
const onShowPicker = vi.fn()
const Harness = defineComponent({
components: { FormDropdownMenuFilter },
setup: () => ({ value, filterOptions }),
setup: () => ({ value, filterOptions, extraProps, onShowPicker }),
template:
'<FormDropdownMenuFilter v-model:filter-selected="value" :filter-options="filterOptions" />'
'<FormDropdownMenuFilter v-model:filter-selected="value" :filter-options="filterOptions" :uploadable="extraProps.uploadable ?? false" @show-picker="onShowPicker" />'
})
const utils = render(Harness, {
global: {
@@ -67,7 +69,7 @@ function renderMenu(
stubs: { Button: ButtonStub }
}
})
return { ...utils, value }
return { ...utils, value, onShowPicker }
}
describe('FormDropdownMenuFilter', () => {
@@ -134,4 +136,39 @@ describe('FormDropdownMenuFilter', () => {
expect(upload.showUploadDialog).toHaveBeenCalledTimes(1)
})
})
describe('Local-upload button (uploadable branch)', () => {
it('renders when uploadable is true and the Import button is disabled', () => {
getUploadMock().isUploadButtonEnabled.value = false
renderMenu(singleOption, 'all', { uploadable: true })
expect(
screen.getByRole('button', { name: /Upload/i })
).toBeInTheDocument()
})
it('does not render when uploadable is false', () => {
getUploadMock().isUploadButtonEnabled.value = false
renderMenu(singleOption, 'all', { uploadable: false })
expect(screen.queryByRole('button', { name: /Upload/i })).toBeNull()
})
it('prefers the Import button over Upload when both gates allow it', () => {
getUploadMock().isUploadButtonEnabled.value = true
renderMenu(singleOption, 'all', { uploadable: true })
expect(
screen.getByRole('button', { name: /Import/i })
).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Upload/i })).toBeNull()
})
it('emits show-picker when the upload button is clicked', async () => {
getUploadMock().isUploadButtonEnabled.value = false
const { onShowPicker } = renderMenu(singleOption, 'all', {
uploadable: true
})
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /Upload/i }))
expect(onShowPicker).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -8,6 +8,10 @@ import { cn } from '@comfyorg/tailwind-utils'
const { filterOptions } = defineProps<{
filterOptions: FilterOption[]
uploadable: boolean
}>()
const emit = defineEmits<{
(e: 'show-picker'): void
}>()
const filterSelected = defineModel<string>('filterSelected')
@@ -15,6 +19,12 @@ const filterSelected = defineModel<string>('filterSelected')
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload()
const singleFilterOption = computed(() => filterOptions.length === 1)
const uploadButtonStyle = cn(
'ml-auto h-8 rounded-lg bg-base-foreground text-base-background',
'flex items-center justify-center gap-2 p-2',
'transition-all duration-150 hover:bg-base-foreground/90 active:scale-95'
)
</script>
<template>
@@ -40,13 +50,24 @@ const singleFilterOption = computed(() => filterOptions.length === 1)
</button>
<Button
v-if="isUploadButtonEnabled && singleFilterOption"
class="ml-auto"
size="md"
variant="textonly"
size="md"
:class="uploadButtonStyle"
@click="showUploadDialog"
>
<i class="icon-[lucide--folder-input]" />
<span>{{ $t('g.import') }}</span>
</Button>
<Button
v-else-if="uploadable"
:title="$t('g.upload')"
variant="textonly"
size="md"
:class="uploadButtonStyle"
@click="emit('show-picker')"
>
<i class="icon-[lucide--folder-search] size-4" />
<span>{{ $t('g.upload') }}</span>
</Button>
</div>
</template>

View File

@@ -4,6 +4,14 @@ import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
import type { FormDropdownItem, SortOption } from './types'
/**
* Marker class for the dropdown's sub-popover panels (Sort / Ownership /
* Base-model). Those panels teleport to `document.body`, so they render outside
* the menu's DOM subtree; the outside-press dismiss logic uses this class to
* recognize a press inside them as still "inside" the dropdown.
*/
export const DROPDOWN_PANEL_CLASS = 'comfy-form-dropdown-panel'
export async function defaultSearcher(
query: string,
items: FormDropdownItem[]

View File

@@ -0,0 +1,89 @@
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { useDismissOnCanvasGesture } from './useDismissOnCanvasGesture'
function fakeCanvas(overrides: Partial<LGraphCanvas> = {}): LGraphCanvas {
return {
ds: { offset: [0, 0], scale: 1 },
dragging_rectangle: null,
...overrides
} as LGraphCanvas
}
function panCanvasTo(x: number, y: number) {
useTransformState().syncWithCanvas(
fakeCanvas({ ds: { offset: [x, y], scale: 1 } } as Partial<LGraphCanvas>)
)
}
function nextFrame() {
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
describe('useDismissOnCanvasGesture', () => {
beforeEach(() => {
setActivePinia(createPinia())
panCanvasTo(0, 0)
})
function setup(active = ref(true)) {
const onGesture = vi.fn()
const scope = effectScope()
scope.run(() => useDismissOnCanvasGesture(active, onGesture))
return { onGesture, active, scope }
}
it('fires when the canvas viewport moves while active', async () => {
const { onGesture, scope } = setup()
panCanvasTo(120, 40)
await nextTick()
expect(onGesture).toHaveBeenCalled()
scope.stop()
})
it('ignores viewport moves while inactive', async () => {
const { onGesture, scope } = setup(ref(false))
panCanvasTo(300, 0)
await nextTick()
expect(onGesture).not.toHaveBeenCalled()
scope.stop()
})
it('fires when a box selection starts while active', async () => {
const canvasStore = useCanvasStore()
canvasStore.canvas = fakeCanvas({
dragging_rectangle: new Float64Array([0, 0, 10, 10])
})
const { onGesture, scope } = setup()
await nextFrame()
await nextFrame()
expect(onGesture).toHaveBeenCalled()
scope.stop()
})
it('does not poll for box selection while inactive', async () => {
const canvasStore = useCanvasStore()
canvasStore.canvas = fakeCanvas({
dragging_rectangle: new Float64Array([0, 0, 10, 10])
})
const { onGesture, scope } = setup(ref(false))
await nextFrame()
await nextFrame()
expect(onGesture).not.toHaveBeenCalled()
scope.stop()
})
})

View File

@@ -0,0 +1,51 @@
import { useRafFn } from '@vueuse/core'
import { toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
/**
* Invokes `onGesture` when a canvas gesture begins while `active` is true.
*
* Watches the canvas at the semantic layer instead of raw pointer events, so
* every input device is covered: panning and zooming are observed through the
* shared camera transform (mouse drag, trackpad, wheel — anything that moves
* the viewport), and box selection through the canvas selection rectangle.
* Intended for dismissing popups that anchor to a node but are teleported to
* `document.body`, which would otherwise be left stranded as the node moves.
*/
export function useDismissOnCanvasGesture(
active: MaybeRefOrGetter<boolean>,
onGesture: () => void
) {
const { camera } = useTransformState()
const canvasStore = useCanvasStore()
watch(
() => [camera.x, camera.y, camera.z],
() => {
if (toValue(active)) onGesture()
}
)
/**
* `dragging_rectangle` lives on the raw LGraphCanvas instance and is not
* reactive, so it is polled per frame — same approach as
* SelectionRectangle.vue. Runs only while `active`.
*/
const { pause, resume } = useRafFn(
() => {
if (canvasStore.canvas?.dragging_rectangle) onGesture()
},
{ immediate: false }
)
watch(
() => toValue(active),
(isActive) => (isActive ? resume() : pause()),
{
immediate: true
}
)
}

View File

@@ -1,9 +1,8 @@
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -454,12 +453,9 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
handler(
new CustomEvent('progress_state', { detail: { nodes, prompt_id: jobId } })
)
// Flush the RAF so the batched update is applied immediately
vi.advanceTimersByTime(16)
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
apiEventHandlers.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -467,10 +463,6 @@ describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
store.bindExecutionEvents()
})
afterEach(() => {
vi.useRealTimers()
})
it('should retain entries below the limit', () => {
for (let i = 0; i < 5; i++) {
fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
@@ -1306,312 +1298,6 @@ describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
})
})
describe('useExecutionStore - RAF batching', () => {
let store: ReturnType<typeof useExecutionStore>
function getRegisteredHandler(eventName: string) {
const calls = vi.mocked(api.addEventListener).mock.calls
const call = calls.find(([name]) => name === eventName)
return call?.[1] as (e: CustomEvent) => void
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
})
afterEach(() => {
vi.useRealTimers()
})
describe('handleProgress', () => {
function makeProgressEvent(value: number, max: number): CustomEvent {
return new CustomEvent('progress', {
detail: { value, max, prompt_id: 'job-1', node: '1' }
})
}
it('batches multiple progress events into one reactive update per frame', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(1, 10))
handler(makeProgressEvent(5, 10))
handler(makeProgressEvent(9, 10))
expect(store._executingNodeProgress).toBeNull()
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual({
value: 9,
max: 10,
prompt_id: 'job-1',
node: '1'
})
})
it('does not update reactive state before RAF fires', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(3, 10))
expect(store._executingNodeProgress).toBeNull()
})
it('allows a new batch after the previous RAF fires', () => {
const handler = getRegisteredHandler('progress')
handler(makeProgressEvent(1, 10))
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(
expect.objectContaining({ value: 1 })
)
handler(makeProgressEvent(7, 10))
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(
expect.objectContaining({ value: 7 })
)
})
})
describe('handleProgressState', () => {
function makeProgressStateEvent(
nodeId: string,
state: string,
value = 0,
max = 10
): CustomEvent {
return new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
[nodeId]: {
value,
max,
state,
node_id: nodeId,
prompt_id: 'job-1',
display_node_id: nodeId
}
}
}
})
}
it('batches multiple progress_state events into one reactive update per frame', () => {
const handler = getRegisteredHandler('progress_state')
handler(makeProgressStateEvent('1', 'running', 1))
handler(makeProgressStateEvent('1', 'running', 5))
handler(makeProgressStateEvent('1', 'running', 9))
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
vi.advanceTimersByTime(16)
expect(store.nodeProgressStates['1']).toEqual(
expect.objectContaining({ value: 9, state: 'running' })
)
})
it('does not update reactive state before RAF fires', () => {
const handler = getRegisteredHandler('progress_state')
handler(makeProgressStateEvent('1', 'running'))
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
})
describe('pending RAF is discarded when execution completes', () => {
it('discards pending progress RAF on execution_success', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const successHandler = getRegisteredHandler('execution_success')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
successHandler(
new CustomEvent('execution_success', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('discards pending progress_state RAF on execution_success', () => {
const progressStateHandler = getRegisteredHandler('progress_state')
const startHandler = getRegisteredHandler('execution_start')
const successHandler = getRegisteredHandler('execution_success')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressStateHandler(
new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
'1': {
value: 5,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'job-1',
display_node_id: '1'
}
}
}
})
)
successHandler(
new CustomEvent('execution_success', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
vi.advanceTimersByTime(16)
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
it('discards pending progress RAF on execution_error', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const errorHandler = getRegisteredHandler('execution_error')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
errorHandler(
new CustomEvent('execution_error', {
detail: {
prompt_id: 'job-1',
node_id: '1',
node_type: 'TestNode',
exception_message: 'error',
exception_type: 'RuntimeError',
traceback: []
}
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('discards pending progress RAF on execution_interrupted', () => {
const progressHandler = getRegisteredHandler('progress')
const startHandler = getRegisteredHandler('execution_start')
const interruptedHandler = getRegisteredHandler('execution_interrupted')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: 'job-1', timestamp: 0 }
})
)
progressHandler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
interruptedHandler(
new CustomEvent('execution_interrupted', {
detail: {
prompt_id: 'job-1',
node_id: '1',
node_type: 'TestNode',
executed: []
}
})
)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
})
describe('unbindExecutionEvents cancels pending RAFs', () => {
it('cancels pending progress RAF on unbind', () => {
const handler = getRegisteredHandler('progress')
handler(
new CustomEvent('progress', {
detail: { value: 5, max: 10, prompt_id: 'job-1', node: '1' }
})
)
store.unbindExecutionEvents()
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toBeNull()
})
it('cancels pending progress_state RAF on unbind', () => {
const handler = getRegisteredHandler('progress_state')
handler(
new CustomEvent('progress_state', {
detail: {
prompt_id: 'job-1',
nodes: {
'1': {
value: 0,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'job-1',
display_node_id: '1'
}
}
}
})
)
store.unbindExecutionEvents()
vi.advanceTimersByTime(16)
expect(Object.keys(store.nodeProgressStates)).toHaveLength(0)
})
})
})
describe('useExecutionStore - WebSocket event handlers', () => {
let store: ReturnType<typeof useExecutionStore>
@@ -1905,21 +1591,12 @@ describe('useExecutionStore - WebSocket event handlers', () => {
})
describe('progress', () => {
it('sets _executingNodeProgress from the event payload (RAF-batched)', () => {
vi.useFakeTimers()
try {
const payload = { value: 3, max: 10, prompt_id: 'job-1', node: 'n1' }
it('sets _executingNodeProgress from the event payload', () => {
const payload = { value: 3, max: 10, prompt_id: 'job-1', node: 'n1' }
fire('progress', payload)
// RAF-batched: not applied synchronously
expect(store._executingNodeProgress).toBeNull()
fire('progress', payload)
vi.advanceTimersByTime(16)
expect(store._executingNodeProgress).toEqual(payload)
} finally {
vi.useRealTimers()
}
expect(store._executingNodeProgress).toEqual(payload)
})
})

View File

@@ -39,7 +39,6 @@ import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import type { AppMode } from '@/utils/appMode'
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
import { createRafCoalescer } from '@/utils/rafBatch'
interface ExecutionNodeInfo {
title?: string | null
@@ -370,8 +369,6 @@ export const useExecutionStore = defineStore('execution', () => {
if (workflowStatus.value.size > 0) workflowStatus.value = new Map()
pendingWorkflowStatusByJobId.clear()
jobIdToWorkflow.clear()
cancelPendingProgressUpdates()
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -436,10 +433,6 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecuting(e: CustomEvent<string | number | null>): void {
// Cancel any pending progress RAF before clearing state to prevent
// stale data from being written back on the next frame.
progressCoalescer.cancel()
// Clear the current node progress when a new node starts executing
_executingNodeProgress.value = null
@@ -482,15 +475,8 @@ export const useExecutionStore = defineStore('execution', () => {
nodeProgressStatesByJob.value = pruned
}
const progressStateCoalescer =
createRafCoalescer<ProgressStateWsMessage>(_applyProgressState)
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
progressStateCoalescer.push(e.detail)
}
function _applyProgressState(detail: ProgressStateWsMessage) {
const { nodes, prompt_id: jobId } = detail
const { nodes, prompt_id: jobId } = e.detail
// Revoke previews for nodes that are starting to execute
const previousForJob = nodeProgressStatesByJob.value[jobId] || {}
@@ -527,17 +513,8 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
const progressCoalescer = createRafCoalescer<ProgressWsMessage>((detail) => {
_executingNodeProgress.value = detail
})
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
progressCoalescer.push(e.detail)
}
function cancelPendingProgressUpdates() {
progressCoalescer.cancel()
progressStateCoalescer.cancel()
_executingNodeProgress.value = e.detail
}
function handleStatus() {
@@ -693,8 +670,6 @@ export const useExecutionStore = defineStore('execution', () => {
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState(jobIdParam?: JobId | null) {
cancelPendingProgressUpdates()
executionIdToLocatorCache.clear()
nodeProgressStates.value = {}
const jobId = jobIdParam ?? activeJobId.value ?? null

View File

@@ -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('&lt;script&gt;')
expect(html).not.toContain('<script>alert')
})
it('should render complex markdown with links, images, and text', () => {
const markdown = `
# Release Notes

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
// 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)) {

View File

@@ -1,85 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRafCoalescer } from '@/utils/rafBatch'
describe('createRafCoalescer', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('applies the latest pushed value on the next frame', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(1)
coalescer.push(2)
coalescer.push(3)
expect(apply).not.toHaveBeenCalled()
vi.advanceTimersByTime(16)
expect(apply).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledWith(3)
})
it('does not apply after cancel', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(42)
coalescer.cancel()
vi.advanceTimersByTime(16)
expect(apply).not.toHaveBeenCalled()
})
it('applies immediately on flush', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(99)
coalescer.flush()
expect(apply).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledWith(99)
})
it('does nothing on flush when no value is pending', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.flush()
expect(apply).not.toHaveBeenCalled()
})
it('does not double-apply after flush', () => {
const apply = vi.fn()
const coalescer = createRafCoalescer<number>(apply)
coalescer.push(1)
coalescer.flush()
vi.advanceTimersByTime(16)
expect(apply).toHaveBeenCalledOnce()
})
it('reports scheduled state correctly', () => {
const coalescer = createRafCoalescer<number>(vi.fn())
expect(coalescer.isScheduled()).toBe(false)
coalescer.push(1)
expect(coalescer.isScheduled()).toBe(true)
vi.advanceTimersByTime(16)
expect(coalescer.isScheduled()).toBe(false)
})
})

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