mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 08:08:04 +00:00
Compare commits
37 Commits
drjkl/new-
...
uy-agent-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aedb6360f | ||
|
|
a7b89a4ad8 | ||
|
|
63f5c71a9e | ||
|
|
5654c4edab | ||
|
|
ef57ee29ea | ||
|
|
b751e717b3 | ||
|
|
80b1c3cd71 | ||
|
|
462029b004 | ||
|
|
65021e2b8a | ||
|
|
e8d8ab412c | ||
|
|
7f8e7f7fb2 | ||
|
|
9182ef4948 | ||
|
|
a94b3d541b | ||
|
|
8ce6f6e234 | ||
|
|
b571db1897 | ||
|
|
c1b5a5166c | ||
|
|
11e0446bb8 | ||
|
|
e45a1bed17 | ||
|
|
ddb0a181ea | ||
|
|
927ba00e91 | ||
|
|
8a61e9aa72 | ||
|
|
636608664d | ||
|
|
499a706081 | ||
|
|
fb40f2fdb9 | ||
|
|
2c9cce86d7 | ||
|
|
f4e0430072 | ||
|
|
c78592c1ec | ||
|
|
00b0c6b434 | ||
|
|
da34fa3944 | ||
|
|
c8ed15da31 | ||
|
|
b132abc64a | ||
|
|
55c52a730a | ||
|
|
fbe462143a | ||
|
|
61cb1bcde0 | ||
|
|
9dcab4ee96 | ||
|
|
dc29f30b02 | ||
|
|
fb3350ee0e |
30
.cursor/rules/agent-panel-layout.md
Normal file
30
.cursor/rules/agent-panel-layout.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Agent chat panel layout rule — always full viewport height, never nested under the header bar
|
||||
globs:
|
||||
- src/components/LiteGraphCanvasSplitterOverlay.vue
|
||||
- src/platform/agent/**
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent Panel Layout
|
||||
|
||||
The Comfy Agent chat panel must always span the **full viewport height** — from the very top of the screen to the bottom, alongside the header bar and canvas, not below them.
|
||||
|
||||
## Correct structure
|
||||
|
||||
`LiteGraphCanvasSplitterOverlay` uses a top-level **`flex-row`** so the agent panel is a sibling of the entire left column (tabs + canvas), not a child inside it:
|
||||
|
||||
```
|
||||
div.flex-row (viewport)
|
||||
├── div.flex-col.flex-1 ← left side: everything else
|
||||
│ ├── slot#workflow-tabs ← header bar
|
||||
│ └── div.flex-1 ← canvas + sidebar panels
|
||||
└── div.shrink-0 (agent panel) ← RIGHT: full viewport height
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never** place the agent panel inside the `div` that sits below `slot#workflow-tabs`. That causes the panel to start below the header bar.
|
||||
- The agent panel div must be a **direct child** of the outermost `div.flex-row` container in `LiteGraphCanvasSplitterOverlay.vue`.
|
||||
- The left side (`flex-1 flex-col`) wraps both `slot#workflow-tabs` AND the canvas/splitter row.
|
||||
- The agent panel has `h-full` and `shrink-0` so it fills the full height and does not flex-shrink.
|
||||
34
.cursor/rules/icon-button-tooltip.mdc
Normal file
34
.cursor/rules/icon-button-tooltip.mdc
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
description: Icon buttons must always have a tooltip
|
||||
globs: src/**/*.vue
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Icon Button Tooltip Requirement
|
||||
|
||||
Every icon-only button (`size="icon"` or any button containing only an icon with no visible label) **must** be wrapped in a `Tooltip` so users can discover what it does.
|
||||
|
||||
## Required Pattern
|
||||
|
||||
```vue
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button size="icon" border-interface-stroke" :aria-label="$t('...')">
|
||||
<i class="icon-[lucide--some-icon] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('...') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```ts
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
```
|
||||
|
||||
- Always use `side="top"` unless a different direction is needed for layout reasons.
|
||||
- The `aria-label` on the button and the tooltip text should be the same translated string.
|
||||
- Use `vue-i18n` (`$t(...)`) for the label — never hardcode strings.
|
||||
@@ -15,6 +15,7 @@ type CardAction =
|
||||
href: string
|
||||
target?: '_blank'
|
||||
icon?: Component
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
| { type: 'code'; value: string }
|
||||
|
||||
@@ -26,25 +27,27 @@ export interface FeatureCard {
|
||||
action?: CardAction
|
||||
}
|
||||
|
||||
type ColumnCount = 2 | 3 | 4
|
||||
|
||||
const {
|
||||
cards,
|
||||
columns = 3,
|
||||
copiedLabel,
|
||||
copyLabel,
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle,
|
||||
columns = 3,
|
||||
cards,
|
||||
copyLabel,
|
||||
copiedLabel
|
||||
subtitle
|
||||
} = defineProps<{
|
||||
cards: readonly FeatureCard[]
|
||||
columns?: ColumnCount
|
||||
copiedLabel?: string
|
||||
copyLabel?: string
|
||||
eyebrow?: string
|
||||
heading: string
|
||||
subtitle?: string
|
||||
columns?: 2 | 3 | 4
|
||||
cards: readonly FeatureCard[]
|
||||
copyLabel?: string
|
||||
copiedLabel?: string
|
||||
}>()
|
||||
|
||||
const columnClass: Record<2 | 3 | 4, string> = {
|
||||
const columnClass: Record<ColumnCount, string> = {
|
||||
2: 'lg:grid-cols-2',
|
||||
3: 'lg:grid-cols-3',
|
||||
4: 'lg:grid-cols-4'
|
||||
@@ -99,7 +102,7 @@ const columnClass: Record<2 | 3 | 4, string> = {
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
"
|
||||
variant="outline"
|
||||
:variant="card.action.variant ?? 'outline'"
|
||||
:append-icon="card.action.icon"
|
||||
>
|
||||
{{ card.action.label }}
|
||||
|
||||
@@ -67,6 +67,7 @@ export const externalLinks = {
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
mcpServer: 'https://cloud.comfy.org/mcp',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
|
||||
@@ -127,7 +127,7 @@ export const drops: readonly Drop[] = [
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1895,31 +1895,31 @@ const translations = {
|
||||
'zh-CN': '三步完成 Comfy MCP 配置'
|
||||
},
|
||||
'mcp.setup.subtitle': {
|
||||
en: 'Add Comfy Cloud as a built-in connector in Claude, and the full ComfyUI toolset is available right in your chat.',
|
||||
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
'zh-CN':
|
||||
'将 Comfy Cloud 添加为 Claude 的内置连接器,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
en: 'Open Claude settings',
|
||||
'zh-CN': '打开 Claude 设置'
|
||||
en: 'Copy the MCP URL',
|
||||
'zh-CN': '复制 MCP URL'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
en: 'Launch the app or open claude.ai and go to Settings > Connections',
|
||||
'zh-CN': '启动应用或打开 claude.ai,前往"设置 > 连接"'
|
||||
},
|
||||
'mcp.setup.step1.cta': {
|
||||
en: 'SETTINGS → CONNECTIONS',
|
||||
'zh-CN': '设置 > 连接'
|
||||
en: "Click the copy button below. You'll paste it into your client in the next step.",
|
||||
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
|
||||
},
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Add the Comfy Cloud custom connector',
|
||||
'zh-CN': '添加 Comfy Cloud 自定义连接器'
|
||||
en: 'Add the connector',
|
||||
'zh-CN': '添加连接器'
|
||||
},
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Name it Comfy Cloud and paste the URL',
|
||||
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL'
|
||||
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
|
||||
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
|
||||
},
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
'zh-CN': 'COMFY CLOUD MCP 文档'
|
||||
},
|
||||
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
|
||||
'mcp.setup.step3.title': {
|
||||
@@ -1927,9 +1927,12 @@ const translations = {
|
||||
'zh-CN': '连接并登录'
|
||||
},
|
||||
'mcp.setup.step3.description': {
|
||||
en: "Click Add > Connect, sign in with your Comfy account. You're all set. Now just ask Claude to generate an image.",
|
||||
'zh-CN':
|
||||
'点击"添加 > 连接",使用 Comfy 账户登录。配置完成。现在直接让 Claude 生成图像即可。'
|
||||
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
|
||||
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
|
||||
},
|
||||
'mcp.setup.step3.cta': {
|
||||
en: 'COMFY CLOUD SKILLS',
|
||||
'zh-CN': 'COMFY CLOUD 技能'
|
||||
},
|
||||
|
||||
// MCP – WhyBuildSection
|
||||
@@ -2910,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 Comfy’s 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 Comfy’s 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.',
|
||||
|
||||
@@ -16,11 +16,8 @@ const cards: FeatureCard[] = [
|
||||
title: t('mcp.setup.step1.title', locale),
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step1.cta', locale),
|
||||
href: `${externalLinks.cloud}/settings/connections`,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight
|
||||
type: 'code',
|
||||
value: externalLinks.mcpServer
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -29,15 +26,27 @@ const cards: FeatureCard[] = [
|
||||
title: t('mcp.setup.step2.title', locale),
|
||||
description: t('mcp.setup.step2.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: externalLinks.mcpServer
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step2.cta', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
label: t('mcp.setup.step3.label', locale),
|
||||
title: t('mcp.setup.step3.title', locale),
|
||||
description: t('mcp.setup.step3.description', locale)
|
||||
description: t('mcp.setup.step3.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step3.cta', locale),
|
||||
href: externalLinks.mcpSkills,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
BIN
browser_tests/assets/video/video-preview-portrait.webm
Normal file
BIN
browser_tests/assets/video/video-preview-portrait.webm
Normal file
Binary file not shown.
BIN
browser_tests/assets/video/video-preview-square.webm
Normal file
BIN
browser_tests/assets/video/video-preview-square.webm
Normal file
Binary file not shown.
BIN
browser_tests/assets/video/video-preview-wide.webm
Normal file
BIN
browser_tests/assets/video/video-preview-wide.webm
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -1,3 +1,4 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -6,72 +7,370 @@ import { VideoPreview } from '@e2e/fixtures/components/VideoPreview'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
|
||||
const file1 = 'workflow.mp4' as const
|
||||
const file2 = 'workflow.webm' as const
|
||||
const file2 = 'video-preview-wide.webm' as const
|
||||
const file3 = 'video-preview-square.webm' as const
|
||||
const file4 = 'video-preview-portrait.webm' as const
|
||||
const MIN_PREVIEW_FRAME_HEIGHT = 100
|
||||
const CENTER_TOLERANCE_PX = 1
|
||||
const videoShapeFixtures = [
|
||||
[file2, 'landscape'],
|
||||
[file3, 'square'],
|
||||
[file4, 'portrait']
|
||||
] as const
|
||||
|
||||
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
|
||||
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
|
||||
const loadVideo = new VideoPreview(loadVideoNode)
|
||||
type ThumbnailShape = (typeof videoShapeFixtures)[number][1]
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
interface VideoPreviewLayout {
|
||||
objectFit: string
|
||||
objectPosition: string
|
||||
wrapperHeight: number
|
||||
wrapperWidth: number
|
||||
wrapperX: number
|
||||
wrapperY: number
|
||||
videoBoxHeight: number
|
||||
videoBoxWidth: number
|
||||
videoIntrinsicHeight: number
|
||||
videoIntrinsicWidth: number
|
||||
videoX: number
|
||||
videoY: number
|
||||
}
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Video')
|
||||
await expect(loadVideoNode).toHaveCount(1)
|
||||
await expect(loadVideoNode).toBeVisible()
|
||||
async function readVideoPreviewLayout(
|
||||
preview: Locator
|
||||
): Promise<VideoPreviewLayout | null> {
|
||||
return await preview.evaluate((previewElement) => {
|
||||
const video = previewElement.querySelector('video')
|
||||
const wrapper = video?.parentElement
|
||||
if (!(video instanceof HTMLVideoElement) || !wrapper) return null
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const videoRect = video.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
objectFit: getComputedStyle(video).objectFit,
|
||||
objectPosition: getComputedStyle(video).objectPosition,
|
||||
wrapperHeight: wrapperRect.height,
|
||||
wrapperWidth: wrapperRect.width,
|
||||
wrapperX: wrapperRect.x,
|
||||
wrapperY: wrapperRect.y,
|
||||
videoBoxHeight: videoRect.height,
|
||||
videoBoxWidth: videoRect.width,
|
||||
videoIntrinsicHeight: video.videoHeight,
|
||||
videoIntrinsicWidth: video.videoWidth,
|
||||
videoX: videoRect.x,
|
||||
videoY: videoRect.y
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await test.step('Upload a video file', async () => {
|
||||
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file1}`))
|
||||
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
|
||||
await expect(loadVideoNode).toContainText(file1)
|
||||
await expect(loadVideo.video).toBeVisible()
|
||||
})
|
||||
async function requireBoundingBox(locator: Locator, subject: string) {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error(`${subject} should have a bounding box`)
|
||||
|
||||
await test.step('Update displayed video', async () => {
|
||||
const initialSrc = await loadVideo.videoSrc()
|
||||
await loadVideo.upload.setInputFiles(assetPath(`workflowInMedia/${file2}`))
|
||||
comfyFiles.deleteAfterTest({ filename: file2, type: 'input' })
|
||||
await expect(loadVideoNode).toContainText(file2)
|
||||
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
|
||||
})
|
||||
return box
|
||||
}
|
||||
|
||||
await test.step('Display multiple videmus', async () => {
|
||||
await expect(loadVideo.navigationDots).toBeHidden()
|
||||
async function expectNodeBoxUnchanged(
|
||||
locator: Locator,
|
||||
before: { height: number; width: number },
|
||||
subject: string
|
||||
) {
|
||||
const after = await requireBoundingBox(locator, subject)
|
||||
expect(
|
||||
Math.abs(after.width - before.width),
|
||||
`${subject} should not change node width`
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
expect(
|
||||
Math.abs(after.height - before.height),
|
||||
`${subject} should not change node height`
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
}
|
||||
|
||||
//forcibly display multiple video files at once
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.evaluate(
|
||||
(names) => {
|
||||
graph!.nodes[0].images.splice(
|
||||
0,
|
||||
1,
|
||||
...names.map((filename) => ({
|
||||
type: 'input',
|
||||
filename,
|
||||
subfolder: ''
|
||||
}))
|
||||
function objectPositionFraction(value: string) {
|
||||
if (value.endsWith('%')) return Number.parseFloat(value) / 100
|
||||
|
||||
switch (value) {
|
||||
case 'left':
|
||||
case 'top':
|
||||
return 0
|
||||
case 'center':
|
||||
return 0.5
|
||||
case 'right':
|
||||
case 'bottom':
|
||||
return 1
|
||||
default:
|
||||
throw new Error(`Unsupported object-position value: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function objectPositionFractions(objectPosition: string) {
|
||||
const [x = '50%', y = '50%'] = objectPosition.split(/\s+/)
|
||||
|
||||
return {
|
||||
x: objectPositionFraction(x),
|
||||
y: objectPositionFraction(y)
|
||||
}
|
||||
}
|
||||
|
||||
function getPaintedVideoRect({
|
||||
objectPosition,
|
||||
videoBoxHeight,
|
||||
videoBoxWidth,
|
||||
videoIntrinsicHeight,
|
||||
videoIntrinsicWidth,
|
||||
videoX,
|
||||
videoY
|
||||
}: VideoPreviewLayout) {
|
||||
const videoAspectRatio = videoIntrinsicWidth / videoIntrinsicHeight
|
||||
const boxAspectRatio = videoBoxWidth / videoBoxHeight
|
||||
const paintedWidth =
|
||||
videoAspectRatio > boxAspectRatio
|
||||
? videoBoxWidth
|
||||
: videoBoxHeight * videoAspectRatio
|
||||
const paintedHeight =
|
||||
videoAspectRatio > boxAspectRatio
|
||||
? videoBoxWidth / videoAspectRatio
|
||||
: videoBoxHeight
|
||||
const position = objectPositionFractions(objectPosition)
|
||||
|
||||
return {
|
||||
height: paintedHeight,
|
||||
width: paintedWidth,
|
||||
x: videoX + (videoBoxWidth - paintedWidth) * position.x,
|
||||
y: videoY + (videoBoxHeight - paintedHeight) * position.y
|
||||
}
|
||||
}
|
||||
|
||||
function expectAspectRatioMatchesShape(
|
||||
aspectRatio: number,
|
||||
shape: ThumbnailShape
|
||||
) {
|
||||
if (shape === 'landscape') {
|
||||
expect(
|
||||
aspectRatio,
|
||||
'landscape fixture should be wider than tall'
|
||||
).toBeGreaterThan(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (shape === 'portrait') {
|
||||
expect(
|
||||
aspectRatio,
|
||||
'portrait fixture should be taller than wide'
|
||||
).toBeLessThan(1)
|
||||
return
|
||||
}
|
||||
|
||||
expect(
|
||||
Math.abs(aspectRatio - 1),
|
||||
'square fixture should have matching width and height'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX / 100)
|
||||
}
|
||||
|
||||
async function expectCenteredVideoPreview(preview: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const layout = await readVideoPreviewLayout(preview)
|
||||
return layout?.videoIntrinsicWidth ?? 0
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const layout = await readVideoPreviewLayout(preview)
|
||||
if (!layout) throw new Error('Video preview should render a video element')
|
||||
|
||||
expect(
|
||||
layout.wrapperHeight,
|
||||
'video preview should keep a usable minimum frame height'
|
||||
).toBeGreaterThanOrEqual(MIN_PREVIEW_FRAME_HEIGHT - CENTER_TOLERANCE_PX)
|
||||
expect(layout.videoBoxWidth).toBeGreaterThan(0)
|
||||
expect(layout.videoBoxHeight).toBeGreaterThan(0)
|
||||
expect(layout.objectFit).toBe('contain')
|
||||
|
||||
const objectPosition = objectPositionFractions(layout.objectPosition)
|
||||
expect(objectPosition.x).toBe(0.5)
|
||||
expect(objectPosition.y).toBe(0.5)
|
||||
|
||||
const wrapperCenterX = layout.wrapperX + layout.wrapperWidth / 2
|
||||
const wrapperCenterY = layout.wrapperY + layout.wrapperHeight / 2
|
||||
const paintedVideo = getPaintedVideoRect(layout)
|
||||
const paintedVideoCenterX = paintedVideo.x + paintedVideo.width / 2
|
||||
const paintedVideoCenterY = paintedVideo.y + paintedVideo.height / 2
|
||||
|
||||
expect(
|
||||
Math.abs(paintedVideoCenterX - wrapperCenterX),
|
||||
'painted video should be horizontally centered in the preview space'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
expect(
|
||||
Math.abs(paintedVideoCenterY - wrapperCenterY),
|
||||
'painted video should be vertically centered in the preview space'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
expect(layout.videoBoxWidth).toBeLessThanOrEqual(
|
||||
layout.wrapperWidth + CENTER_TOLERANCE_PX
|
||||
)
|
||||
expect(layout.videoBoxHeight).toBeLessThanOrEqual(
|
||||
layout.wrapperHeight + CENTER_TOLERANCE_PX
|
||||
)
|
||||
expect(paintedVideo.width).toBeLessThanOrEqual(
|
||||
layout.wrapperWidth + CENTER_TOLERANCE_PX
|
||||
)
|
||||
expect(paintedVideo.height).toBeLessThanOrEqual(
|
||||
layout.wrapperHeight + CENTER_TOLERANCE_PX
|
||||
)
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'VideoPreview',
|
||||
{ tag: ['@vue-nodes', '@node', '@widget'] },
|
||||
() => {
|
||||
test('@vue-nodes Load Video', async ({ comfyPage, comfyFiles }) => {
|
||||
const loadVideoNode = comfyPage.vueNodes.getNodeByTitle('Load Video')
|
||||
const loadVideo = new VideoPreview(loadVideoNode)
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Load Video')
|
||||
await expect(loadVideoNode).toHaveCount(1)
|
||||
await expect(loadVideoNode).toBeVisible()
|
||||
})
|
||||
|
||||
const loadVideoFixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Video')
|
||||
|
||||
await test.step('Upload a video file', async () => {
|
||||
await loadVideo.upload.setInputFiles(
|
||||
assetPath(`workflowInMedia/${file1}`)
|
||||
)
|
||||
},
|
||||
[file1, file2]
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
comfyFiles.deleteAfterTest({ filename: file1, type: 'input' })
|
||||
await expect(loadVideoNode).toContainText(file1)
|
||||
await expect(loadVideo.video).toBeVisible()
|
||||
|
||||
await expect(loadVideo.navigationDots).toHaveCount(2)
|
||||
await loadVideo.navigationDots.nth(0).click()
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
|
||||
await loadVideo.navigationDots.nth(1).click()
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
|
||||
})
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
expect(layout.videoIntrinsicWidth).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
await test.step('Can redownload uploaded file', async () => {
|
||||
await loadVideo.video.hover()
|
||||
await expect(loadVideo.download).toBeVisible()
|
||||
await test.step('Update displayed video across thumbnail shapes', async () => {
|
||||
for (const [filename, shape] of videoShapeFixtures) {
|
||||
const initialSrc = await loadVideo.videoSrc()
|
||||
const nodeBoxBeforeLoad = await requireBoundingBox(
|
||||
loadVideoNode,
|
||||
`Load Video node before loading ${filename}`
|
||||
)
|
||||
await loadVideo.upload.setInputFiles(assetPath(`video/${filename}`))
|
||||
comfyFiles.deleteAfterTest({
|
||||
filename,
|
||||
type: 'input'
|
||||
})
|
||||
await expect(loadVideoNode).toContainText(filename)
|
||||
await expect.poll(() => loadVideo.videoSrc()).not.toEqual(initialSrc)
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await loadVideo.download.click()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe(file2)
|
||||
})
|
||||
})
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
await expectNodeBoxUnchanged(
|
||||
loadVideoNode,
|
||||
nodeBoxBeforeLoad,
|
||||
`Load Video node after loading ${filename}`
|
||||
)
|
||||
const updatedVideoAspectRatio =
|
||||
layout.videoIntrinsicWidth / layout.videoIntrinsicHeight
|
||||
|
||||
expectAspectRatioMatchesShape(updatedVideoAspectRatio, shape)
|
||||
}
|
||||
})
|
||||
|
||||
await test.step('Keep video centered after horizontal resize', async () => {
|
||||
const nodeBox = await requireBoundingBox(
|
||||
loadVideoNode,
|
||||
'Load Video node before horizontal resize'
|
||||
)
|
||||
const initialLayout = await expectCenteredVideoPreview(
|
||||
loadVideo.preview
|
||||
)
|
||||
|
||||
await loadVideoFixture.resizeFromCorner('SE', 180, 0)
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(loadVideoFixture.pollWidth)
|
||||
.toBeGreaterThan(nodeBox.width + 100)
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
expect(
|
||||
layout.wrapperWidth - initialLayout.wrapperWidth,
|
||||
'video preview space should grow with a wider node'
|
||||
).toBeGreaterThan(100)
|
||||
expect(
|
||||
Math.abs(layout.wrapperHeight - initialLayout.wrapperHeight),
|
||||
'horizontal resize should not change the preview space height'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
})
|
||||
|
||||
await test.step('Keep video centered after vertical resize', async () => {
|
||||
const nodeBox = await requireBoundingBox(
|
||||
loadVideoNode,
|
||||
'Load Video node before vertical resize'
|
||||
)
|
||||
const initialLayout = await expectCenteredVideoPreview(
|
||||
loadVideo.preview
|
||||
)
|
||||
|
||||
await loadVideoFixture.resizeFromCorner('SE', 0, 180)
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(loadVideoFixture.pollHeight)
|
||||
.toBeGreaterThan(nodeBox.height + 100)
|
||||
const layout = await expectCenteredVideoPreview(loadVideo.preview)
|
||||
expect(
|
||||
layout.wrapperHeight - initialLayout.wrapperHeight,
|
||||
'video preview space should grow with a taller node'
|
||||
).toBeGreaterThan(100)
|
||||
expect(
|
||||
Math.abs(layout.wrapperWidth - initialLayout.wrapperWidth),
|
||||
'vertical resize should not change the preview space width'
|
||||
).toBeLessThanOrEqual(CENTER_TOLERANCE_PX)
|
||||
})
|
||||
|
||||
await test.step('Display multiple videos', async () => {
|
||||
await expect(loadVideo.navigationDots).toBeHidden()
|
||||
|
||||
try {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.evaluate(
|
||||
(names) => {
|
||||
graph!.nodes[0].images.splice(
|
||||
0,
|
||||
1,
|
||||
...names.map((filename) => ({
|
||||
type: 'input',
|
||||
filename,
|
||||
subfolder: ''
|
||||
}))
|
||||
)
|
||||
},
|
||||
[file1, file2]
|
||||
)
|
||||
} finally {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await expect(loadVideo.navigationDots).toHaveCount(2)
|
||||
await loadVideo.navigationDots.nth(0).press('Enter')
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file1)
|
||||
await loadVideo.navigationDots.nth(1).press('Enter')
|
||||
await expect.poll(() => loadVideo.videoSrc()).toContain(file2)
|
||||
})
|
||||
|
||||
await test.step('Can redownload uploaded file', async () => {
|
||||
await loadVideo.video.hover()
|
||||
await expect(loadVideo.download).toBeVisible()
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await loadVideo.download.click()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe(file2)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -5,6 +5,7 @@ declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
declare const __GIT_BRANCH_PREFIX__: string
|
||||
|
||||
interface ImpactQueueFunction {
|
||||
(...args: unknown[]): void
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
"primevue": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"semver": "^7.7.2",
|
||||
"shiki": "catalog:",
|
||||
"three": "catalog:",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
|
||||
77
pnpm-lock.yaml
generated
77
pnpm-lock.yaml
generated
@@ -321,6 +321,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
|
||||
@@ -606,6 +609,9 @@ importers:
|
||||
semver:
|
||||
specifier: ^7.7.2
|
||||
version: 7.7.4
|
||||
shiki:
|
||||
specifier: 'catalog:'
|
||||
version: 3.23.0
|
||||
three:
|
||||
specifier: 'catalog:'
|
||||
version: 0.184.0
|
||||
@@ -3439,18 +3445,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 +3477,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 +7799,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 +8759,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 +11353,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 +11368,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 +11404,19 @@ snapshots:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/themes@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/themes@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.1.0
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/types@4.1.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
@@ -11466,7 +11524,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.5
|
||||
vue-component-type-helpers: 3.3.6
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -16507,6 +16565,17 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shiki@3.23.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 3.23.0
|
||||
'@shikijs/engine-javascript': 3.23.0
|
||||
'@shikijs/engine-oniguruma': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
shiki@4.1.0:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.1.0
|
||||
@@ -17637,7 +17706,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
public/assets/images/reference.png
Normal file
BIN
public/assets/images/reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
@@ -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 *,
|
||||
|
||||
@@ -1,133 +1,154 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-row"
|
||||
>
|
||||
<slot name="workflow-tabs" />
|
||||
<!-- Left column: workflow tabs + canvas/panels -->
|
||||
<div class="pointer-events-none flex flex-1 flex-col overflow-hidden">
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel
|
||||
class="graph-canvas-panel relative overflow-visible"
|
||||
>
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: agent panel, full viewport height -->
|
||||
<div
|
||||
v-if="agentPanelVisible"
|
||||
class="pointer-events-auto relative h-full shrink-0 overflow-hidden border-l border-interface-stroke bg-comfy-menu-bg"
|
||||
:style="{ width: `${agentPanelWidth}px` }"
|
||||
>
|
||||
<div
|
||||
class="agent-resize-handle absolute top-0 left-0 z-10 h-full w-[5px] cursor-col-resize"
|
||||
:data-resizing="isResizing"
|
||||
@pointerdown="onResizePointerDown"
|
||||
@lostpointercapture="isResizing = false"
|
||||
/>
|
||||
<slot name="agent-panel" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import type { SplitterResizeStartEvent } from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -137,6 +158,7 @@ import {
|
||||
SIDEBAR_MIN_SIZE,
|
||||
SIDE_PANEL_SIZE
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -147,6 +169,26 @@ const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { isOpen: agentPanelVisible, width: agentPanelWidth } =
|
||||
storeToRefs(agentPanelStore)
|
||||
|
||||
const isResizing = ref(false)
|
||||
let resizeStartX = 0
|
||||
let resizeStartWidth = 0
|
||||
|
||||
function onResizePointerDown(e: PointerEvent) {
|
||||
isResizing.value = true
|
||||
resizeStartX = e.clientX
|
||||
resizeStartWidth = agentPanelStore.width
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
useEventListener(document, 'pointermove', (e: PointerEvent) => {
|
||||
if (!isResizing.value) return
|
||||
agentPanelStore.setWidth(resizeStartWidth + (resizeStartX - e.clientX))
|
||||
})
|
||||
const { t } = useI18n()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -304,4 +346,14 @@ const lastPanelStyle = computed(() => {
|
||||
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
.agent-resize-handle:hover {
|
||||
transition: background-color 0.2s ease 300ms;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.agent-resize-handle[data-resizing='true'] {
|
||||
transition: none;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
35
src/components/ai-elements/code-block/CodeBlock.vue
Normal file
35
src/components/ai-elements/code-block/CodeBlock.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import CodeBlockContainer from './CodeBlockContainer.vue'
|
||||
import CodeBlockContent from './CodeBlockContent.vue'
|
||||
import { CodeBlockKey } from './context'
|
||||
|
||||
const {
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
code: string
|
||||
language: string
|
||||
showLineNumbers?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
provide(CodeBlockKey, { code: computed(() => code) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CodeBlockContainer :class="cn('text-xs', className)" :language="language">
|
||||
<slot />
|
||||
<CodeBlockContent
|
||||
:code="code"
|
||||
:language="language"
|
||||
:show-line-numbers="showLineNumbers"
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</template>
|
||||
15
src/components/ai-elements/code-block/CodeBlockActions.vue
Normal file
15
src/components/ai-elements/code-block/CodeBlockActions.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
30
src/components/ai-elements/code-block/CodeBlockContainer.vue
Normal file
30
src/components/ai-elements/code-block/CodeBlockContainer.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, language } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
language: string
|
||||
}>()
|
||||
|
||||
const containerStyle = {
|
||||
contentVisibility: 'auto' as const,
|
||||
containIntrinsicSize: 'auto 200px'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group relative w-full overflow-hidden rounded-md border border-border-default bg-base-background text-base-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
:data-language="language"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
105
src/components/ai-elements/code-block/CodeBlockContent.vue
Normal file
105
src/components/ai-elements/code-block/CodeBlockContent.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import type { BundledLanguage, ThemedToken } from 'shiki'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { TokenizedCode } from './utils'
|
||||
import {
|
||||
createRawTokens,
|
||||
highlightCode,
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline
|
||||
} from './utils'
|
||||
|
||||
const {
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false
|
||||
} = defineProps<{
|
||||
code: string
|
||||
language: string
|
||||
showLineNumbers?: boolean
|
||||
}>()
|
||||
|
||||
const rawTokens = computed(() => createRawTokens(code))
|
||||
const tokenized = ref<TokenizedCode>(
|
||||
highlightCode(code, language as BundledLanguage) ?? rawTokens.value
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [code, language],
|
||||
() => {
|
||||
tokenized.value =
|
||||
highlightCode(code, language as BundledLanguage) ?? rawTokens.value
|
||||
highlightCode(code, language as BundledLanguage, (result) => {
|
||||
tokenized.value = result
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const preStyle = computed(() => ({
|
||||
color: tokenized.value.fg
|
||||
}))
|
||||
|
||||
interface KeyedToken {
|
||||
token: ThemedToken
|
||||
key: string
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[]
|
||||
key: string
|
||||
}
|
||||
|
||||
const keyedLines = computed<KeyedLine[]>(() =>
|
||||
tokenized.value.tokens.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
token,
|
||||
key: `line-${lineIdx}-${tokenIdx}`
|
||||
}))
|
||||
}))
|
||||
)
|
||||
|
||||
const lineNumberClasses = cn(
|
||||
'block',
|
||||
'before:content-[counter(line)]',
|
||||
'before:inline-block',
|
||||
'before:[counter-increment:line]',
|
||||
'before:w-8',
|
||||
'before:mr-4',
|
||||
'before:text-right',
|
||||
'before:text-muted-foreground/50',
|
||||
'before:font-mono',
|
||||
'before:select-none'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative overflow-auto">
|
||||
<pre
|
||||
class="m-0 overflow-auto bg-base-background p-4 text-sm"
|
||||
:style="preStyle"
|
||||
><code
|
||||
:class="
|
||||
cn(
|
||||
'font-mono text-sm',
|
||||
showLineNumbers && '[counter-increment:line_0] [counter-reset:line]',
|
||||
)
|
||||
"
|
||||
><template v-for="line in keyedLines" :key="line.key"><span :class="showLineNumbers ? lineNumberClasses : 'block'"><span
|
||||
v-for="tokenObj in line.tokens"
|
||||
:key="tokenObj.key"
|
||||
:style="{
|
||||
color: tokenObj.token.color,
|
||||
backgroundColor: tokenObj.token.bgColor,
|
||||
fontStyle: isItalic(tokenObj.token.fontStyle) ? 'italic' : undefined,
|
||||
fontWeight: isBold(tokenObj.token.fontStyle) ? 'bold' : undefined,
|
||||
textDecoration: isUnderline(tokenObj.token.fontStyle)
|
||||
? 'underline'
|
||||
: undefined,
|
||||
}"
|
||||
>{{ tokenObj.token.content }}</span></span></template></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, inject, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import { CodeBlockKey } from './context'
|
||||
|
||||
const { timeout = 2000, class: className } = defineProps<{
|
||||
timeout?: number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: []
|
||||
error: [error: Error]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const context = inject(CodeBlockKey)
|
||||
if (!context)
|
||||
throw new Error('CodeBlockCopyButton must be used within a CodeBlock')
|
||||
|
||||
const { code } = context
|
||||
const isCopied = ref(false)
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const label = computed(() => (isCopied.value ? t('g.copied') : t('g.copy')))
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!navigator?.clipboard?.writeText) {
|
||||
emit('error', new Error('Clipboard API not available'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.value)
|
||||
isCopied.value = true
|
||||
emit('copy')
|
||||
clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => {
|
||||
isCopied.value = false
|
||||
}, timeout)
|
||||
} catch (error) {
|
||||
emit('error', error instanceof Error ? error : new Error('Copy failed'))
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:class="cn('shrink-0', className)"
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
:aria-label="label"
|
||||
@click="copyToClipboard"
|
||||
>
|
||||
<i
|
||||
:class="isCopied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
17
src/components/ai-elements/code-block/CodeBlockFilename.vue
Normal file
17
src/components/ai-elements/code-block/CodeBlockFilename.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="cn('font-mono text-xs font-medium text-base-foreground', className)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
22
src/components/ai-elements/code-block/CodeBlockHeader.vue
Normal file
22
src/components/ai-elements/code-block/CodeBlockHeader.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between border-b border-border-default bg-secondary-background-hover px-3 py-1.5 text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
15
src/components/ai-elements/code-block/CodeBlockTitle.vue
Normal file
15
src/components/ai-elements/code-block/CodeBlockTitle.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1.5', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
7
src/components/ai-elements/code-block/context.ts
Normal file
7
src/components/ai-elements/code-block/context.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ComputedRef, InjectionKey } from 'vue'
|
||||
|
||||
export interface CodeBlockContext {
|
||||
code: ComputedRef<string>
|
||||
}
|
||||
|
||||
export const CodeBlockKey: InjectionKey<CodeBlockContext> = Symbol('CodeBlock')
|
||||
91
src/components/ai-elements/code-block/utils.ts
Normal file
91
src/components/ai-elements/code-block/utils.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken
|
||||
} from 'shiki'
|
||||
import { createHighlighter } from 'shiki'
|
||||
|
||||
export const isItalic = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 1)
|
||||
export const isBold = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 2)
|
||||
export const isUnderline = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 4)
|
||||
|
||||
export interface TokenizedCode {
|
||||
tokens: ThemedToken[][]
|
||||
fg: string
|
||||
bg: string
|
||||
}
|
||||
|
||||
const THEME: BundledTheme = 'one-dark-pro'
|
||||
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>()
|
||||
const tokensCache = new Map<string, TokenizedCode>()
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>()
|
||||
|
||||
function cacheKey(code: string, language: BundledLanguage): string {
|
||||
const start = code.slice(0, 100)
|
||||
const end = code.length > 100 ? code.slice(-100) : ''
|
||||
return `${language}:${code.length}:${start}:${end}`
|
||||
}
|
||||
|
||||
function getHighlighter(
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> {
|
||||
const cached = highlighterCache.get(language)
|
||||
if (cached) return cached
|
||||
|
||||
const promise = createHighlighter({ themes: [THEME], langs: [language] })
|
||||
highlighterCache.set(language, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function createRawTokens(code: string): TokenizedCode {
|
||||
return {
|
||||
tokens: code
|
||||
.split('\n')
|
||||
.map((line) =>
|
||||
line === '' ? [] : [{ content: line, color: 'inherit' } as ThemedToken]
|
||||
),
|
||||
fg: 'inherit',
|
||||
bg: 'transparent'
|
||||
}
|
||||
}
|
||||
|
||||
export function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
callback?: (result: TokenizedCode) => void
|
||||
): TokenizedCode | null {
|
||||
const key = cacheKey(code, language)
|
||||
const cached = tokensCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
if (callback) {
|
||||
if (!subscribers.has(key)) subscribers.set(key, new Set())
|
||||
subscribers.get(key)!.add(callback)
|
||||
}
|
||||
|
||||
getHighlighter(language)
|
||||
.then((highlighter) => {
|
||||
const loadedLangs = highlighter.getLoadedLanguages()
|
||||
const lang = loadedLangs.includes(language) ? language : 'text'
|
||||
const result = highlighter.codeToTokens(code, { lang, theme: THEME })
|
||||
const tokenized: TokenizedCode = {
|
||||
tokens: result.tokens,
|
||||
fg: result.fg ?? 'inherit',
|
||||
bg: result.bg ?? 'transparent'
|
||||
}
|
||||
tokensCache.set(key, tokenized)
|
||||
subscribers.get(key)?.forEach((sub) => sub(tokenized))
|
||||
subscribers.delete(key)
|
||||
})
|
||||
.catch(() => subscribers.delete(key))
|
||||
|
||||
return null
|
||||
}
|
||||
52
src/components/ai-elements/conversation/Conversation.vue
Normal file
52
src/components/ai-elements/conversation/Conversation.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useMutationObserver } from '@vueuse/core'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { provide, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { conversationKey } from './context'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const scrollEl = useTemplateRef<HTMLDivElement>('scrollEl')
|
||||
const isAtBottom = ref(true)
|
||||
|
||||
function updateAtBottom() {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
isAtBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useMutationObserver(
|
||||
scrollEl,
|
||||
() => {
|
||||
if (isAtBottom.value) {
|
||||
requestAnimationFrame(scrollToBottom)
|
||||
}
|
||||
},
|
||||
{ childList: true, subtree: true, characterData: true }
|
||||
)
|
||||
|
||||
provide(conversationKey, { isAtBottom, scrollToBottom })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<div
|
||||
ref="scrollEl"
|
||||
:class="cn('scroll-fade absolute inset-0 scrollbar-custom', className)"
|
||||
@scroll="updateAtBottom"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-4 p-4', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import { useConversation } from './context'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isAtBottom, scrollToBottom } = useConversation()
|
||||
const label = t('agent.scrollToBottom')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isAtBottom"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-2 z-10 flex justify-center"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
size="icon"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto rounded-full shadow-md ring-1 ring-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
:aria-label="label"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ai-elements/conversation/context.ts
Normal file
18
src/components/ai-elements/conversation/context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
export interface ConversationContext {
|
||||
isAtBottom: Ref<boolean>
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
export const conversationKey: InjectionKey<ConversationContext> =
|
||||
Symbol('conversation')
|
||||
|
||||
export function useConversation(): ConversationContext {
|
||||
const context = inject(conversationKey)
|
||||
if (!context) {
|
||||
throw new Error('Conversation parts must be used within <Conversation>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
23
src/components/ai-elements/message/Message.vue
Normal file
23
src/components/ai-elements/message/Message.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { from, class: className } = defineProps<{
|
||||
from: 'user' | 'assistant' | 'system'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group flex w-full gap-2',
|
||||
from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
36
src/components/ai-elements/message/MessageAction.vue
Normal file
36
src/components/ai-elements/message/MessageAction.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { tooltip, pressed = false } = defineProps<{
|
||||
tooltip: string
|
||||
pressed?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip :delay-duration="500">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="tooltip"
|
||||
:aria-pressed="pressed"
|
||||
:class="
|
||||
pressed
|
||||
? 'text-base-foreground'
|
||||
: 'text-muted-foreground hover:text-base-foreground'
|
||||
"
|
||||
class="flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-1 transition-colors hover:bg-secondary-background-hover"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="whitespace-nowrap">{{
|
||||
tooltip
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
5
src/components/ai-elements/message/MessageActions.vue
Normal file
5
src/components/ai-elements/message/MessageActions.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-0.5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
48
src/components/ai-elements/message/MessageAttachments.vue
Normal file
48
src/components/ai-elements/message/MessageAttachments.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { MessageAttachment } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
|
||||
const { attachments } = defineProps<{
|
||||
attachments: readonly MessageAttachment[]
|
||||
}>()
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="(attachment, i) in attachments"
|
||||
:key="i"
|
||||
class="flex items-center gap-3 rounded-lg border border-border-default bg-secondary-background p-2"
|
||||
>
|
||||
<div
|
||||
class="size-10 shrink-0 overflow-hidden rounded-md border border-border-default"
|
||||
>
|
||||
<img
|
||||
v-if="attachment.type.startsWith('image/')"
|
||||
:src="attachment.url"
|
||||
:alt="attachment.name"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center bg-secondary-background-hover"
|
||||
>
|
||||
<i class="icon-[lucide--file] size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs font-medium text-base-foreground">
|
||||
{{ attachment.name }}
|
||||
</span>
|
||||
<span class="block text-xs text-muted-foreground">
|
||||
{{ formatFileSize(attachment.size) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
22
src/components/ai-elements/message/MessageContent.vue
Normal file
22
src/components/ai-elements/message/MessageContent.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full flex-col gap-2 overflow-hidden text-xs text-base-foreground',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:w-fit group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary-background group-[.is-user]:px-4 group-[.is-user]:py-3',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
16
src/components/ai-elements/message/MessageResponse.vue
Normal file
16
src/components/ai-elements/message/MessageResponse.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import Response from '@/components/ai-elements/response/Response.vue'
|
||||
|
||||
const { content, class: className } = defineProps<{
|
||||
content?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Response :content="content" :class="className">
|
||||
<slot />
|
||||
</Response>
|
||||
</template>
|
||||
10
src/components/ai-elements/message/MessageThinking.vue
Normal file
10
src/components/ai-elements/message/MessageThinking.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import Shimmer from '@/components/ai-elements/shimmer/Shimmer.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5 text-sm">
|
||||
<i class="icon-[lucide--brain] size-3.5 text-muted-foreground" />
|
||||
<Shimmer>{{ $t('agent.thinking') }}</Shimmer>
|
||||
</div>
|
||||
</template>
|
||||
102
src/components/ai-elements/message/MessageToolCalls.vue
Normal file
102
src/components/ai-elements/message/MessageToolCalls.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { ToolCall } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
|
||||
const { toolCalls, complete = false } = defineProps<{
|
||||
toolCalls: readonly ToolCall[]
|
||||
complete?: boolean
|
||||
}>()
|
||||
|
||||
const expanded = ref(!complete)
|
||||
const shouldAnimate = ref(!complete)
|
||||
const totalDurationMs = toolCalls.reduce((sum, c) => sum + c.durationMs, 0)
|
||||
|
||||
watch(
|
||||
() => complete,
|
||||
(done) => {
|
||||
if (done)
|
||||
setTimeout(() => {
|
||||
expanded.value = false
|
||||
shouldAnimate.value = false
|
||||
}, 1200)
|
||||
}
|
||||
)
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary-background-hover hover:text-base-foreground"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<i class="icon-[lucide--wrench] size-4 shrink-0" />
|
||||
<span class="flex-1">
|
||||
{{
|
||||
$t('agent.toolCalls.summary', {
|
||||
count: toolCalls.length,
|
||||
duration: formatDuration(totalDurationMs)
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<i
|
||||
:class="
|
||||
expanded ? 'icon-[lucide--chevron-up]' : 'icon-[lucide--chevron-down]'
|
||||
"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ul v-if="expanded" class="flex list-none flex-col pl-0">
|
||||
<li
|
||||
v-for="(call, i) in toolCalls"
|
||||
:key="i"
|
||||
:class="
|
||||
cn(
|
||||
'relative pl-6',
|
||||
shouldAnimate &&
|
||||
'animate-in fade-in-0 fill-mode-both slide-in-from-top-1'
|
||||
)
|
||||
"
|
||||
:style="
|
||||
shouldAnimate
|
||||
? { animationDelay: `${i * 80}ms`, animationDuration: '200ms' }
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-4 w-px bg-border-default" />
|
||||
<div class="flex h-8 items-center gap-2 rounded-md px-2">
|
||||
<i
|
||||
:class="
|
||||
call.status === 'success'
|
||||
? 'icon-[lucide--circle-check] text-muted-foreground'
|
||||
: 'icon-[lucide--circle-x] text-muted-foreground/50'
|
||||
"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm text-muted-foreground">{{
|
||||
call.name
|
||||
}}</span>
|
||||
<span class="text-sm text-muted-foreground/60 tabular-nums">{{
|
||||
formatDuration(call.durationMs)
|
||||
}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
29
src/components/ai-elements/prompt-input/PromptInput.vue
Normal file
29
src/components/ai-elements/prompt-input/PromptInput.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import { PROMPT_INPUT_FOCUSED_KEY } from './promptInputContext'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [event: Event]
|
||||
}>()
|
||||
|
||||
const isFocused = ref(false)
|
||||
provide(PROMPT_INPUT_FOCUSED_KEY, isFocused)
|
||||
|
||||
function onSubmit(event: Event) {
|
||||
event.preventDefault()
|
||||
emit('submit', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form :class="cn('w-full', className)" @submit="onSubmit">
|
||||
<slot />
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { attachments } = defineProps<{
|
||||
attachments: File[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [index: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const objectUrls = ref<string[]>([])
|
||||
|
||||
watch(
|
||||
() => attachments,
|
||||
(files) => {
|
||||
objectUrls.value.forEach(URL.revokeObjectURL)
|
||||
objectUrls.value = files.map((f) =>
|
||||
f.type.startsWith('image/') ? URL.createObjectURL(f) : ''
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
objectUrls.value.forEach(URL.revokeObjectURL)
|
||||
})
|
||||
|
||||
function fileTypeIcon(file: File): string {
|
||||
if (file.type.startsWith('audio/')) return 'icon-[lucide--music]'
|
||||
if (file.type.startsWith('video/')) return 'icon-[lucide--video]'
|
||||
if (file.type === 'application/pdf') return 'icon-[lucide--file-text]'
|
||||
if (file.type.startsWith('text/')) return 'icon-[lucide--file-text]'
|
||||
return 'icon-[lucide--paperclip]'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="attachments.length" class="flex flex-wrap gap-1.5 px-4 pt-3">
|
||||
<div
|
||||
v-for="(file, i) in attachments"
|
||||
:key="i"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 items-center gap-1.5 rounded-md border border-border-default select-none',
|
||||
'bg-secondary-background px-1.5 text-sm font-medium transition-colors'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="size-5 shrink-0 overflow-hidden rounded-sm">
|
||||
<img
|
||||
v-if="file.type.startsWith('image/')"
|
||||
:src="objectUrls[i]"
|
||||
:alt="file.name"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="fileTypeIcon(file)" class="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="max-w-36 truncate text-xs text-base-foreground">{{
|
||||
file.name
|
||||
}}</span>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
class="size-4 shrink-0"
|
||||
:aria-label="t('g.remove')"
|
||||
@click="emit('remove', i)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-2.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ t('g.remove') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
41
src/components/ai-elements/prompt-input/PromptInputBody.vue
Normal file
41
src/components/ai-elements/prompt-input/PromptInputBody.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
import type { PromptInputFocusedContext } from './promptInputContext'
|
||||
import { PROMPT_INPUT_FOCUSED_KEY } from './promptInputContext'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const isFocused = inject<PromptInputFocusedContext>(PROMPT_INPUT_FOCUSED_KEY)
|
||||
|
||||
function onFocusIn() {
|
||||
if (isFocused) isFocused.value = true
|
||||
}
|
||||
|
||||
function onFocusOut(e: FocusEvent) {
|
||||
const current = e.currentTarget as HTMLElement | null
|
||||
if (isFocused && !current?.contains(e.relatedTarget as Node)) {
|
||||
isFocused.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col rounded-2xl border bg-secondary-background transition-colors',
|
||||
isFocused ? 'border-muted-foreground' : 'border-border-default',
|
||||
className
|
||||
)
|
||||
"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
|
||||
const {
|
||||
class: className,
|
||||
variant = 'muted-textonly',
|
||||
size = 'icon'
|
||||
} = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button type="button" :variant="variant" :size="size" :class="className">
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ default: 'Auto' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button type="button" variant="muted-textonly" size="sm" :class="className">
|
||||
{{ model }}
|
||||
<i class="icon-[lucide--chevron-down] size-3" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
|
||||
import type { ChatStatus } from './types'
|
||||
|
||||
const {
|
||||
class: className,
|
||||
status = 'ready',
|
||||
variant = 'inverted',
|
||||
size = 'icon',
|
||||
disabled = false
|
||||
} = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
status?: ChatStatus
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const iconClass = computed(() => {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return 'icon-[lucide--loader-circle] size-4 animate-spin'
|
||||
case 'streaming':
|
||||
return 'icon-[lucide--square] size-4'
|
||||
case 'error':
|
||||
return 'icon-[lucide--x] size-4'
|
||||
default:
|
||||
return 'icon-[lucide--arrow-up] size-4'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="variant"
|
||||
:size="size"
|
||||
:disabled="disabled"
|
||||
:class="cn('rounded-xl', className)"
|
||||
:aria-label="$t('agent.send')"
|
||||
>
|
||||
<slot>
|
||||
<i :class="iconClass" />
|
||||
</slot>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { class: className, placeholder } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const isComposing = ref(false)
|
||||
const textareaEl = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || event.shiftKey || isComposing.value) return
|
||||
event.preventDefault()
|
||||
const form = (event.target as HTMLElement).closest('form')
|
||||
form?.requestSubmit()
|
||||
}
|
||||
|
||||
function focus() {
|
||||
const el = textareaEl.value
|
||||
if (!el) return
|
||||
el.focus()
|
||||
el.setSelectionRange(el.value.length, el.value.length)
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
ref="textareaEl"
|
||||
v-model="model"
|
||||
rows="1"
|
||||
:placeholder="placeholder"
|
||||
:class="
|
||||
cn(
|
||||
'field-sizing-content max-h-48 min-h-20 w-full resize-none border-none bg-transparent px-4 py-3 font-[inherit] text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
@keydown="onKeydown"
|
||||
@compositionstart="isComposing = true"
|
||||
@compositionend="isComposing = false"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex items-center justify-between gap-1 px-3 py-2', className)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
src/components/ai-elements/prompt-input/PromptInputTools.vue
Normal file
14
src/components/ai-elements/prompt-input/PromptInputTools.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export const PROMPT_INPUT_FOCUSED_KEY = Symbol('promptInputFocused')
|
||||
export type PromptInputFocusedContext = Ref<boolean>
|
||||
1
src/components/ai-elements/prompt-input/types.ts
Normal file
1
src/components/ai-elements/prompt-input/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ChatStatus = 'ready' | 'submitted' | 'streaming' | 'error'
|
||||
151
src/components/ai-elements/response/MarkdownRenderer.vue
Normal file
151
src/components/ai-elements/response/MarkdownRenderer.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import CodeBlock from '../code-block/CodeBlock.vue'
|
||||
import CodeBlockActions from '../code-block/CodeBlockActions.vue'
|
||||
import CodeBlockCopyButton from '../code-block/CodeBlockCopyButton.vue'
|
||||
import CodeBlockFilename from '../code-block/CodeBlockFilename.vue'
|
||||
import CodeBlockHeader from '../code-block/CodeBlockHeader.vue'
|
||||
import CodeBlockTitle from '../code-block/CodeBlockTitle.vue'
|
||||
|
||||
const { content, class: className } = defineProps<{
|
||||
content: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Matches complete fenced code blocks: ```lang\n...content...\n```
|
||||
const FENCE_RE = /^```([^\n]*)\n([\s\S]*?)^```[ \t]*$/gm
|
||||
|
||||
// Matches an opening fence with no closing fence — used to detect mid-stream blocks.
|
||||
// Captures: [1] newline-or-start before the fence, [2] language info, [3] code content so far.
|
||||
const OPEN_FENCE_RE = /(^|\n)```([^\n]*)\n([\s\S]*)$/
|
||||
|
||||
interface HtmlSegment {
|
||||
type: 'html'
|
||||
key: string
|
||||
html: string
|
||||
}
|
||||
|
||||
interface CodeSegment {
|
||||
type: 'code'
|
||||
key: string
|
||||
code: string
|
||||
language: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
type Segment = HtmlSegment | CodeSegment
|
||||
|
||||
function parseCodeInfo(info: string): { language: string; filename: string } {
|
||||
const colonIdx = info.indexOf(':')
|
||||
return {
|
||||
language: colonIdx >= 0 ? info.slice(0, colonIdx) : info,
|
||||
filename: colonIdx >= 0 ? info.slice(colonIdx + 1) : ''
|
||||
}
|
||||
}
|
||||
|
||||
const segments = computed<Segment[]>(() => {
|
||||
if (!content) return []
|
||||
|
||||
const result: Segment[] = []
|
||||
let lastIdx = 0
|
||||
let keyIdx = 0
|
||||
|
||||
for (const match of content.matchAll(FENCE_RE)) {
|
||||
const before = content.slice(lastIdx, match.index)
|
||||
if (before) {
|
||||
result.push({
|
||||
type: 'html',
|
||||
key: `h${keyIdx++}`,
|
||||
html: renderMarkdownToHtml(before)
|
||||
})
|
||||
}
|
||||
|
||||
const { language, filename } = parseCodeInfo(match[1].trim())
|
||||
result.push({
|
||||
type: 'code',
|
||||
key: `c${keyIdx++}`,
|
||||
code: match[2].replace(/\n$/, ''),
|
||||
language,
|
||||
filename
|
||||
})
|
||||
|
||||
lastIdx = match.index! + match[0].length
|
||||
}
|
||||
|
||||
const tail = content.slice(lastIdx)
|
||||
|
||||
const openMatch = tail.match(OPEN_FENCE_RE)
|
||||
if (openMatch) {
|
||||
const fenceStart = openMatch.index! + openMatch[1].length
|
||||
const before = tail.slice(0, fenceStart)
|
||||
if (before) {
|
||||
result.push({
|
||||
type: 'html',
|
||||
key: `h${keyIdx++}`,
|
||||
html: renderMarkdownToHtml(before)
|
||||
})
|
||||
}
|
||||
const { language, filename } = parseCodeInfo(openMatch[2].trim())
|
||||
result.push({
|
||||
type: 'code',
|
||||
key: `c${keyIdx}`,
|
||||
code: openMatch[3],
|
||||
language,
|
||||
filename
|
||||
})
|
||||
} else if (tail) {
|
||||
result.push({
|
||||
type: 'html',
|
||||
key: `h${keyIdx}`,
|
||||
html: renderMarkdownToHtml(tail)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('agent-markdown', className)">
|
||||
<template v-for="segment in segments" :key="segment.key">
|
||||
<div
|
||||
v-if="segment.type === 'html'"
|
||||
class="contents"
|
||||
v-html="segment.html"
|
||||
/>
|
||||
<CodeBlock
|
||||
v-else
|
||||
class="mb-2"
|
||||
:code="segment.code"
|
||||
:language="segment.language"
|
||||
>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>
|
||||
<i
|
||||
:class="
|
||||
segment.filename
|
||||
? 'icon-[lucide--file-code]'
|
||||
: 'icon-[lucide--code-2]'
|
||||
"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<CodeBlockFilename v-if="segment.filename">
|
||||
{{ segment.filename }}
|
||||
</CodeBlockFilename>
|
||||
<span v-else class="font-mono text-xs">
|
||||
{{ segment.language || 'plaintext' }}
|
||||
</span>
|
||||
</CodeBlockTitle>
|
||||
<CodeBlockActions>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlockActions>
|
||||
</CodeBlockHeader>
|
||||
</CodeBlock>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
29
src/components/ai-elements/response/Response.vue
Normal file
29
src/components/ai-elements/response/Response.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import MarkdownRenderer from './MarkdownRenderer.vue'
|
||||
|
||||
const { content, class: className } = defineProps<{
|
||||
content?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const markdown = computed(() => {
|
||||
if (content !== undefined) return content
|
||||
const nodes = slots.default?.() ?? []
|
||||
return nodes
|
||||
.map((node) => (typeof node.children === 'string' ? node.children : ''))
|
||||
.join('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownRenderer
|
||||
:content="markdown"
|
||||
:class="cn('text-xs/relaxed', className)"
|
||||
/>
|
||||
</template>
|
||||
51
src/components/ai-elements/shimmer/Shimmer.vue
Normal file
51
src/components/ai-elements/shimmer/Shimmer.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const {
|
||||
as = 'span',
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
as?: keyof HTMLElementTagNameMap
|
||||
class?: HTMLAttributes['class']
|
||||
duration?: number
|
||||
spread?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
:class="['shimmer', className]"
|
||||
:style="{
|
||||
'--shimmer-duration': `${duration}s`,
|
||||
'--shimmer-spread': `${(($slots.default?.()[0]?.children as string)?.length ?? 10) * spread}px`
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shimmer {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent calc(50% - var(--shimmer-spread)),
|
||||
var(--color-base-foreground),
|
||||
transparent calc(50% + var(--shimmer-spread))
|
||||
),
|
||||
linear-gradient(
|
||||
var(--color-muted-foreground),
|
||||
var(--color-muted-foreground)
|
||||
);
|
||||
background-size:
|
||||
250% 100%,
|
||||
auto;
|
||||
background-repeat: no-repeat;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: shimmer-sweep var(--shimmer-duration) linear infinite;
|
||||
}
|
||||
</style>
|
||||
28
src/components/ai-elements/suggestion/Suggestion.vue
Normal file
28
src/components/ai-elements/suggestion/Suggestion.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { suggestion, class: className } = defineProps<{
|
||||
suggestion: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [suggestion: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'text-foreground flex h-8 w-full cursor-pointer items-center justify-start gap-2 rounded-full border-0 bg-secondary-background px-3 text-sm whitespace-nowrap transition-colors outline-none hover:bg-secondary-background-hover @[460px]:w-auto',
|
||||
className
|
||||
)
|
||||
"
|
||||
@click="emit('select', suggestion)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
21
src/components/ai-elements/suggestion/Suggestions.vue
Normal file
21
src/components/ai-elements/suggestion/Suggestions.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full flex-wrap justify-start gap-2 @[460px]:justify-center',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,7 +7,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/renderer/extensions/vueNodes/utils/nodeDataExtraction'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
@@ -70,8 +70,6 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<!-- Items declaring an icon key (even empty) keep the slot so labels align
|
||||
within icon-bearing menus; icon-less menus render labels flush-left. -->
|
||||
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
|
||||
@@ -24,7 +24,7 @@ function toggleCategory(category: string) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<DropdownMenu button-class="icon-[lucide--list-filter]">
|
||||
<DropdownMenu>
|
||||
<template #button>
|
||||
<Button size="icon" :aria-label="$t('g.filter')">
|
||||
<i class="icon-[lucide--list-filter]" />
|
||||
@@ -52,7 +52,7 @@ function toggleCategory(category: string) {
|
||||
>
|
||||
<span
|
||||
class="flex-1"
|
||||
v-text="$t(filterLabels?.[filter] ?? '') ?? filter"
|
||||
v-text="filterLabels?.[filter] ? $t(filterLabels[filter]) : filter"
|
||||
/>
|
||||
<DropdownMenuItemIndicator class="size-4 shrink-0">
|
||||
<i class="icon-[lucide--check]" />
|
||||
|
||||
@@ -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'
|
||||
@@ -149,7 +159,7 @@ import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { NodeDataState } from '@/types/nodeData'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
@@ -187,7 +197,6 @@ import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeDataStore } from '@/stores/nodeDataStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
@@ -207,7 +216,6 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||
typeof NodeSearchboxPopover
|
||||
> | null>(null)
|
||||
const settingStore = useSettingStore()
|
||||
const nodeDataStore = useNodeDataStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
@@ -254,12 +262,12 @@ const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
|
||||
// Error-clearing hooks run regardless of rendering mode (Vue or legacy canvas).
|
||||
const cleanupErrorHooks = shallowRef<(() => void) | null>(null)
|
||||
let cleanupErrorHooks: (() => void) | null = null
|
||||
watch(
|
||||
() => canvasStore.currentGraph,
|
||||
(graph) => {
|
||||
cleanupErrorHooks.value?.()
|
||||
cleanupErrorHooks.value = graph ? installErrorClearingHooks(graph) : null
|
||||
cleanupErrorHooks?.()
|
||||
cleanupErrorHooks = graph ? installErrorClearingHooks(graph) : null
|
||||
}
|
||||
)
|
||||
|
||||
@@ -283,10 +291,9 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const allNodes = computed((): NodeDataState[] => {
|
||||
const graphId = canvasStore.currentGraph?.id
|
||||
return graphId ? nodeDataStore.getGraphNodes(graphId) : []
|
||||
})
|
||||
const allNodes = computed((): VueNodeData[] =>
|
||||
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
|
||||
)
|
||||
watch(
|
||||
() => linearMode.value,
|
||||
(isLinearMode) => {
|
||||
@@ -542,7 +549,7 @@ onMounted(async () => {
|
||||
|
||||
// Install error-clearing hooks on the initial graph
|
||||
if (comfyApp.canvas?.graph) {
|
||||
cleanupErrorHooks.value = installErrorClearingHooks(comfyApp.canvas.graph)
|
||||
cleanupErrorHooks = installErrorClearingHooks(comfyApp.canvas.graph)
|
||||
}
|
||||
|
||||
vueNodeLifecycle.setupEmptyGraphListener()
|
||||
@@ -579,8 +586,8 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupErrorHooks.value?.()
|
||||
cleanupErrorHooks.value = null
|
||||
cleanupErrorHooks?.()
|
||||
cleanupErrorHooks = null
|
||||
vueNodeLifecycle.cleanup()
|
||||
})
|
||||
function forwardPointerDownPanEvent(e: PointerEvent) {
|
||||
|
||||
@@ -41,7 +41,7 @@ vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/utils/nodeDataExtraction', () => ({
|
||||
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
getControlWidget: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { computed, customRef, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getControlWidget } from '@/renderer/extensions/vueNodes/utils/nodeDataExtraction'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
getComponent,
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useNodeDataStore } from '@/stores/nodeDataStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
@@ -58,7 +58,6 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDataStore = useNodeDataStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
@@ -70,12 +69,9 @@ const widgetComponent = computed(() => {
|
||||
})
|
||||
|
||||
const isLinked = computed(() => {
|
||||
const graphId = node.graph?.id
|
||||
const safeWidget = graphId
|
||||
? nodeDataStore
|
||||
.getNodeData(graphId, node.id)
|
||||
?.widgets?.find((w) => w.name === widget.name)
|
||||
: undefined
|
||||
const safeWidget = useVueNodeLifecycle()
|
||||
.nodeManager.value?.vueNodeData.get(node.id)
|
||||
?.widgets?.find((w) => w.name === widget.name)
|
||||
return safeWidget?.slotMetadata
|
||||
? !!safeWidget.slotMetadata.linked
|
||||
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
|
||||
|
||||
@@ -117,8 +117,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mapValues } from 'es-toolkit'
|
||||
import { useEventListener, useLocalStorage } from '@vueuse/core'
|
||||
import { mapValues } from 'es-toolkit'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from 'reka-ui'
|
||||
import {
|
||||
|
||||
@@ -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
|
||||
|
||||
23
src/components/ui/empty/Empty.vue
Normal file
23
src/components/ui/empty/Empty.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg p-6 text-center text-balance md:p-12',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ui/empty/EmptyDescription.vue
Normal file
18
src/components/ui/empty/EmptyDescription.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="empty-description"
|
||||
:class="cn('text-sm text-muted-foreground', className)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
20
src/components/ui/empty/EmptyHeader.vue
Normal file
20
src/components/ui/empty/EmptyHeader.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
:class="
|
||||
cn('flex max-w-sm flex-col items-center gap-2 text-center', className)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
27
src/components/ui/empty/EmptyMedia.vue
Normal file
27
src/components/ui/empty/EmptyMedia.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { variant = 'default', class: className } = defineProps<{
|
||||
variant?: 'default' | 'icon'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-media"
|
||||
:data-variant="variant"
|
||||
:class="
|
||||
cn(
|
||||
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
variant === 'icon' &&
|
||||
'text-foreground size-10 rounded-lg bg-muted [&_svg:not([class*=\'size-\'])]:size-6',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ui/empty/EmptyTitle.vue
Normal file
18
src/components/ui/empty/EmptyTitle.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
:class="cn('text-lg font-medium tracking-tight', className)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
42
src/components/ui/tooltip/Tooltip.vue
Normal file
42
src/components/ui/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, provide, ref } from 'vue'
|
||||
|
||||
import type { TooltipContext } from './tooltipContext'
|
||||
import { TOOLTIP_KEY } from './tooltipContext'
|
||||
|
||||
const { delayDuration = 300 } = defineProps<{
|
||||
delayDuration?: number
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const triggerEl = ref<HTMLElement | null>(null)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function scheduleOpen() {
|
||||
timer = setTimeout(() => {
|
||||
open.value = true
|
||||
}, delayDuration)
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
open.value = false
|
||||
}
|
||||
|
||||
onUnmounted(close)
|
||||
|
||||
provide<TooltipContext>(TOOLTIP_KEY, {
|
||||
open,
|
||||
triggerEl,
|
||||
delayDuration,
|
||||
scheduleOpen,
|
||||
close
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
81
src/components/ui/tooltip/TooltipContent.vue
Normal file
81
src/components/ui/tooltip/TooltipContent.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { CSSProperties, HTMLAttributes } from 'vue'
|
||||
import { inject, ref, watch } from 'vue'
|
||||
|
||||
import { TOOLTIP_KEY } from './tooltipContext'
|
||||
|
||||
const { class: className, side = 'bottom' } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}>()
|
||||
|
||||
const ctx = inject(TOOLTIP_KEY)
|
||||
const style = ref<CSSProperties>({})
|
||||
|
||||
function computeStyle() {
|
||||
if (!ctx?.triggerEl.value) return {}
|
||||
const rect = ctx.triggerEl.value.getBoundingClientRect()
|
||||
const gap = 6
|
||||
|
||||
if (side === 'top') {
|
||||
return {
|
||||
left: `${rect.left + rect.width / 2}px`,
|
||||
top: `${rect.top - gap}px`,
|
||||
transform: 'translate(-50%, -100%)'
|
||||
}
|
||||
}
|
||||
if (side === 'left') {
|
||||
return {
|
||||
left: `${rect.left - gap}px`,
|
||||
top: `${rect.top + rect.height / 2}px`,
|
||||
transform: 'translate(-100%, -50%)'
|
||||
}
|
||||
}
|
||||
if (side === 'right') {
|
||||
return {
|
||||
left: `${rect.right + gap}px`,
|
||||
top: `${rect.top + rect.height / 2}px`,
|
||||
transform: 'translateY(-50%)'
|
||||
}
|
||||
}
|
||||
return {
|
||||
left: `${rect.left + rect.width / 2}px`,
|
||||
top: `${rect.bottom + gap}px`,
|
||||
transform: 'translateX(-50%)'
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ctx?.open.value,
|
||||
(open) => {
|
||||
if (open) style.value = computeStyle()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-100"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-75"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="ctx?.open.value"
|
||||
:style="style"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none fixed z-9999 max-w-xs rounded-md border border-node-component-tooltip-border bg-node-component-tooltip-surface px-2 py-1 text-xs leading-none text-node-component-tooltip',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
50
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
50
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { TOOLTIP_KEY } from './tooltipContext'
|
||||
|
||||
const ctx = inject(TOOLTIP_KEY)
|
||||
|
||||
const el = ref<HTMLElement | null>(null)
|
||||
|
||||
function onMouseEnter() {
|
||||
ctx?.scheduleOpen()
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
ctx?.close()
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
ctx?.scheduleOpen()
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
ctx?.close()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value || !ctx) return
|
||||
// display:contents removes the wrapper's box; use the real child for positioning
|
||||
ctx.triggerEl.value =
|
||||
(el.value.firstElementChild as HTMLElement | null) ?? el.value
|
||||
el.value.addEventListener('mouseenter', onMouseEnter)
|
||||
el.value.addEventListener('mouseleave', onMouseLeave)
|
||||
el.value.addEventListener('focus', onFocus)
|
||||
el.value.addEventListener('blur', onBlur)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!el.value) return
|
||||
el.value.removeEventListener('mouseenter', onMouseEnter)
|
||||
el.value.removeEventListener('mouseleave', onMouseLeave)
|
||||
el.value.removeEventListener('focus', onFocus)
|
||||
el.value.removeEventListener('blur', onBlur)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" class="contents">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
11
src/components/ui/tooltip/tooltipContext.ts
Normal file
11
src/components/ui/tooltip/tooltipContext.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
export interface TooltipContext {
|
||||
open: Ref<boolean>
|
||||
triggerEl: Ref<HTMLElement | null>
|
||||
delayDuration: number
|
||||
scheduleOpen: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const TOOLTIP_KEY: InjectionKey<TooltipContext> = Symbol('tooltip')
|
||||
@@ -1,76 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useGraphLayoutManager } from '@/composables/graph/useGraphLayoutManager'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
function createPositionedNode() {
|
||||
const node = new LGraphNode('test')
|
||||
node.pos[0] = 10
|
||||
node.pos[1] = 20
|
||||
node.size[0] = 120
|
||||
node.size[1] = 80
|
||||
return node
|
||||
}
|
||||
|
||||
describe('useGraphLayoutManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Object.defineProperty(window, 'app', {
|
||||
configurable: true,
|
||||
value: app
|
||||
})
|
||||
vi.spyOn(app, 'configuringGraph', 'get').mockReturnValue(false)
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
})
|
||||
|
||||
it('creates layout for added nodes from LiteGraph geometry', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createPositionedNode()
|
||||
useGraphLayoutManager(graph)
|
||||
|
||||
graph.add(node)
|
||||
|
||||
expect(layoutStore.getNodeLayoutRef(node.id).value).toMatchObject({
|
||||
id: node.id,
|
||||
position: { x: 10, y: 20 },
|
||||
size: { width: 120, height: 80 },
|
||||
zIndex: 0,
|
||||
visible: true
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes layout for removed nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createPositionedNode()
|
||||
useGraphLayoutManager(graph)
|
||||
|
||||
graph.add(node)
|
||||
graph.remove(node)
|
||||
|
||||
expect(layoutStore.getNodeLayoutRef(node.id).value).toBeNull()
|
||||
})
|
||||
|
||||
it('waits until graph configuration completes before creating missing layout', () => {
|
||||
vi.spyOn(app, 'configuringGraph', 'get').mockReturnValue(true)
|
||||
const graph = new LGraph()
|
||||
const node = createPositionedNode()
|
||||
const originalAfterConfigured = vi.fn()
|
||||
node.onAfterGraphConfigured = originalAfterConfigured
|
||||
useGraphLayoutManager(graph)
|
||||
|
||||
graph.add(node)
|
||||
|
||||
expect(layoutStore.getNodeLayoutRef(node.id).value).toBeNull()
|
||||
|
||||
node.onAfterGraphConfigured?.()
|
||||
|
||||
expect(originalAfterConfigured).toHaveBeenCalledTimes(1)
|
||||
expect(layoutStore.getNodeLayoutRef(node.id).value).toMatchObject({
|
||||
id: node.id,
|
||||
position: { x: 10, y: 20 },
|
||||
size: { width: 120, height: 80 }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
export function useGraphLayoutManager(graph: LGraph): () => void {
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
|
||||
const createMissingLayout = (node: LGraphNode) => {
|
||||
if (!graph.getNodeById(node.id)) return
|
||||
|
||||
const existingLayout = layoutStore.getNodeLayoutRef(node.id).value
|
||||
if (existingLayout) return
|
||||
|
||||
setSource(LayoutSource.Canvas)
|
||||
createNode(node.id, {
|
||||
position: { x: node.pos[0], y: node.pos[1] },
|
||||
size: { width: node.size[0], height: node.size[1] },
|
||||
zIndex: node.order || 0,
|
||||
visible: true
|
||||
})
|
||||
}
|
||||
|
||||
const handleNodeAdded = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
if (window.app?.configuringGraph) {
|
||||
node.onAfterGraphConfigured = useChainCallback(
|
||||
node.onAfterGraphConfigured,
|
||||
() => createMissingLayout(node)
|
||||
)
|
||||
} else {
|
||||
createMissingLayout(node)
|
||||
}
|
||||
|
||||
originalCallback?.(node)
|
||||
}
|
||||
|
||||
const handleNodeRemoved = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
setSource(LayoutSource.Canvas)
|
||||
deleteNode(node.id)
|
||||
originalCallback?.(node)
|
||||
}
|
||||
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
handleNodeAdded(node, originalOnNodeAdded)
|
||||
}
|
||||
|
||||
graph.onNodeRemoved = (node: LGraphNode) => {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
return () => {
|
||||
graph.onNodeAdded = originalOnNodeAdded || undefined
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
}
|
||||
}
|
||||
@@ -15,27 +15,8 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeDataStore } from '@/stores/nodeDataStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
function getStoredNodeData(
|
||||
graph: Pick<LGraph, 'id'>,
|
||||
nodeId: LGraphNode['id']
|
||||
) {
|
||||
return useNodeDataStore().getNodeData(graph.id, nodeId)
|
||||
}
|
||||
|
||||
function hasStoredNodeData(
|
||||
graph: Pick<LGraph, 'id'>,
|
||||
nodeId: LGraphNode['id']
|
||||
): boolean {
|
||||
return Boolean(getStoredNodeData(graph, nodeId))
|
||||
}
|
||||
|
||||
function getStoredNodeCount(graph: Pick<LGraph, 'id'>): number {
|
||||
return useNodeDataStore().getGraphNodes(graph.id).length
|
||||
}
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -48,9 +29,9 @@ describe('Node Reactivity', () => {
|
||||
node.addWidget('number', 'testnum', 2, () => undefined, {})
|
||||
graph.add(node)
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
return { node, graph }
|
||||
return { node, graph, vueNodeData }
|
||||
}
|
||||
|
||||
it('widget values are reactive through the store', async () => {
|
||||
@@ -133,9 +114,9 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
it('sets slotMetadata.linked to true when input has a link', () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
useGraphNodeManager(graph)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = getStoredNodeData(graph, node.id)
|
||||
const nodeData = vueNodeData.get(node.id)
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
@@ -144,9 +125,9 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
it('updates slotMetadata.linked to false after link disconnect event', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
useGraphNodeManager(graph)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = getStoredNodeData(graph, node.id)
|
||||
const nodeData = vueNodeData.get(node.id)
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
// Verify initially linked
|
||||
@@ -172,9 +153,9 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
it('reactively updates disabled state in a derived computed after disconnect', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
useGraphNodeManager(graph)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = getStoredNodeData(graph, node.id)!
|
||||
const nodeData = vueNodeData.get(node.id)!
|
||||
|
||||
// Mimic what processedWidgets does in NodeWidgets.vue:
|
||||
// derive disabled from slotMetadata.linked
|
||||
@@ -222,8 +203,8 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
if (!link)
|
||||
throw new Error('Expected SubgraphInput.connect to produce a link')
|
||||
|
||||
useGraphNodeManager(subgraph)
|
||||
const nodeData = getStoredNodeData(subgraph, node.id)
|
||||
const { vueNodeData } = useGraphNodeManager(subgraph)
|
||||
const nodeData = vueNodeData.get(node.id)
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
@@ -248,8 +229,8 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
const nodeData = getStoredNodeData(graph, subgraphNode.id)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(subgraphNode.id)
|
||||
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
|
||||
expect(widgetData).toBeDefined()
|
||||
@@ -259,9 +240,9 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
useGraphNodeManager(graph)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = getStoredNodeData(graph, node.id)!
|
||||
const nodeData = vueNodeData.get(node.id)!
|
||||
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
|
||||
|
||||
expect(widgetData.slotMetadata?.linked).toBe(true)
|
||||
@@ -296,16 +277,15 @@ describe('Subgraph output slot label reactivity', () => {
|
||||
node.addOutput('other_name', 'STRING')
|
||||
graph.add(node)
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeId = node.id
|
||||
const nodeData = getStoredNodeData(graph, nodeId)
|
||||
const nodeData = vueNodeData.get(nodeId)
|
||||
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
|
||||
|
||||
expect(nodeData.outputs[0].label).toBeUndefined()
|
||||
expect(nodeData.outputs[1].label).toBeUndefined()
|
||||
|
||||
const originalType = nodeData.type
|
||||
node.type = 'type_from_broad_sync'
|
||||
// Simulate what SubgraphNode does: set the label, then fire the trigger
|
||||
node.outputs[0].label = 'custom_label'
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: node.id,
|
||||
@@ -314,10 +294,9 @@ describe('Subgraph output slot label reactivity', () => {
|
||||
|
||||
await nextTick()
|
||||
|
||||
const updatedData = getStoredNodeData(graph, nodeId)
|
||||
const updatedData = vueNodeData.get(nodeId)
|
||||
expect(updatedData?.outputs?.[0]?.label).toBe('custom_label')
|
||||
expect(updatedData?.outputs?.[1]?.label).toBeUndefined()
|
||||
expect(updatedData?.type).toBe(originalType)
|
||||
})
|
||||
|
||||
it('updates input slot labels when node:slot-label:changed is triggered', async () => {
|
||||
@@ -326,15 +305,13 @@ describe('Subgraph output slot label reactivity', () => {
|
||||
node.addInput('original_name', 'STRING')
|
||||
graph.add(node)
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeId = node.id
|
||||
const nodeData = getStoredNodeData(graph, nodeId)
|
||||
const nodeData = vueNodeData.get(nodeId)
|
||||
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
|
||||
|
||||
expect(nodeData.inputs[0].label).toBeUndefined()
|
||||
|
||||
const originalType = nodeData.type
|
||||
node.type = 'type_from_broad_sync'
|
||||
node.inputs[0].label = 'custom_label'
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: node.id,
|
||||
@@ -343,9 +320,8 @@ describe('Subgraph output slot label reactivity', () => {
|
||||
|
||||
await nextTick()
|
||||
|
||||
const updatedData = getStoredNodeData(graph, nodeId)
|
||||
const updatedData = vueNodeData.get(nodeId)
|
||||
expect(updatedData?.inputs?.[0]?.label).toBe('custom_label')
|
||||
expect(updatedData?.type).toBe(originalType)
|
||||
})
|
||||
|
||||
it('ignores node:slot-label:changed for unknown node ids', () => {
|
||||
@@ -392,8 +368,8 @@ describe('Nested promoted widget mapping', () => {
|
||||
const graph = subgraphNodeB.graph as LGraph
|
||||
graph.add(subgraphNodeB)
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
const nodeData = getStoredNodeData(graph, subgraphNodeB.id)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(subgraphNodeB.id)
|
||||
const mappedWidget = nodeData?.widgets?.[0]
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
@@ -429,8 +405,8 @@ describe('Nested promoted widget mapping', () => {
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
const nodeData = getStoredNodeData(graph, subgraphNode.id)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(subgraphNode.id)
|
||||
const widgets = nodeData?.widgets
|
||||
|
||||
expect(widgets).toHaveLength(2)
|
||||
@@ -475,8 +451,8 @@ describe('Promoted widget sourceExecutionId', () => {
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
const nodeData = getStoredNodeData(graph, subgraphNode.id)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(subgraphNode.id)
|
||||
const promotedWidget = nodeData?.widgets?.find(
|
||||
(w) => w.name === 'ckpt_input'
|
||||
)
|
||||
@@ -498,8 +474,8 @@ describe('Promoted widget sourceExecutionId', () => {
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
const nodeData = getStoredNodeData(graph, node.id)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(node.id)
|
||||
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
|
||||
|
||||
expect(widget).toBeDefined()
|
||||
@@ -728,42 +704,42 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pre-remove node data store drain', () => {
|
||||
describe('Pre-remove vueNodeData drain', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('drops node data before node.onRemoved fires', () => {
|
||||
it('drops vueNodeData entry before node.onRemoved fires', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
useGraphNodeManager(graph)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const id = node.id
|
||||
|
||||
expect(hasStoredNodeData(graph, id)).toBe(true)
|
||||
expect(vueNodeData.has(id)).toBe(true)
|
||||
|
||||
const onRemoved = vi.fn(() => {
|
||||
expect(
|
||||
hasStoredNodeData(graph, id),
|
||||
'node data must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
|
||||
).toBe(false)
|
||||
})
|
||||
node.onRemoved = onRemoved
|
||||
let dataPresentInOnRemoved: boolean | undefined
|
||||
node.onRemoved = () => {
|
||||
dataPresentInOnRemoved = vueNodeData.has(id)
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(onRemoved).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
dataPresentInOnRemoved,
|
||||
'vueNodeData entry must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('clears node data when LGraph.clear() dispatches node:before-removed for each node', () => {
|
||||
it('clears vueNodeData when LGraph.clear() dispatches node:before-removed for each node', () => {
|
||||
const graph = new LGraph()
|
||||
const nodeA = new LGraphNode('a')
|
||||
const nodeB = new LGraphNode('b')
|
||||
graph.add(nodeA)
|
||||
graph.add(nodeB)
|
||||
useGraphNodeManager(graph)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(getStoredNodeCount(graph)).toBe(2)
|
||||
expect(vueNodeData.size).toBe(2)
|
||||
|
||||
const beforeRemovedSpy = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', beforeRemovedSpy)
|
||||
@@ -775,8 +751,8 @@ describe('Pre-remove node data store drain', () => {
|
||||
'clear() must dispatch node:before-removed so reactive consumers can drop refs before nodes detach'
|
||||
).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
getStoredNodeCount(graph),
|
||||
'node:before-removed listener must drain node data when clear() removes every node'
|
||||
vueNodeData.size,
|
||||
'node:before-removed listener must drain vueNodeData when clear() removes every node'
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,148 +1,837 @@
|
||||
/**
|
||||
* Vue node lifecycle management for LiteGraph integration
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import cloneDeep from 'es-toolkit/compat/cloneDeep'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphTriggerEvent
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import {
|
||||
buildSlotMetadata,
|
||||
extractVueNodeData
|
||||
} from '@/renderer/extensions/vueNodes/utils/nodeDataExtraction'
|
||||
import { useNodeDataStore } from '@/stores/nodeDataStore'
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): () => void {
|
||||
const nodeDataStore = useNodeDataStore()
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphBadge,
|
||||
LGraphNode,
|
||||
LGraphTriggerAction,
|
||||
LGraphTriggerEvent,
|
||||
LGraphTriggerParam,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export interface WidgetSlotMetadata {
|
||||
index: number
|
||||
linked: boolean
|
||||
originNodeId?: NodeId
|
||||
originOutputName?: string
|
||||
type: string
|
||||
}
|
||||
|
||||
type Badges = (LGraphBadge | (() => LGraphBadge))[]
|
||||
|
||||
/**
|
||||
* Minimal render-specific widget data extracted from LiteGraph widgets.
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
widgetId?: WidgetId
|
||||
nodeId?: NodeId
|
||||
name: string
|
||||
type: string
|
||||
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
/** Control widget for seed randomization/increment/decrement */
|
||||
controlWidget?: SafeControlWidget
|
||||
/** Whether widget has custom layout size computation */
|
||||
hasLayoutSize?: boolean
|
||||
/** Whether widget is a DOM widget */
|
||||
isDOMWidget?: boolean
|
||||
/**
|
||||
* Widget options needed for render decisions.
|
||||
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
|
||||
*/
|
||||
options?: {
|
||||
canvasOnly?: boolean
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
spec?: InputSpec
|
||||
/** Input slot metadata (index and link status) */
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
/**
|
||||
* Execution ID of the interior node that owns the source widget.
|
||||
* Only set for promoted widgets where the source node differs from the host
|
||||
* subgraph node. Retained for source-scoped validation errors.
|
||||
*/
|
||||
sourceExecutionId?: NodeExecutionId
|
||||
/**
|
||||
* Interior source widget name. Only set for promoted widgets, where `name` is
|
||||
* the host input slot name and the source widget name can differ.
|
||||
*/
|
||||
sourceWidgetName?: string
|
||||
/** Tooltip text from the resolved widget. */
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
executing: boolean
|
||||
id: NodeId
|
||||
mode: number
|
||||
selected: boolean
|
||||
title: string
|
||||
type: string
|
||||
apiNode?: boolean
|
||||
badges?: Badges
|
||||
bgcolor?: string
|
||||
color?: string
|
||||
flags?: {
|
||||
collapsed?: boolean
|
||||
ghost?: boolean
|
||||
pinned?: boolean
|
||||
}
|
||||
hasErrors?: boolean
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
resizable?: boolean
|
||||
shape?: number
|
||||
showAdvanced?: boolean
|
||||
subgraphId?: string | null
|
||||
titleMode?: TitleMode
|
||||
widgets?: SafeWidgetData[]
|
||||
}
|
||||
|
||||
export interface GraphNodeManager {
|
||||
// Reactive state - safe data extracted from LiteGraph nodes
|
||||
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
|
||||
|
||||
// Access to original LiteGraph nodes (non-reactive)
|
||||
getNode(id: NodeId): LGraphNode | undefined
|
||||
|
||||
// Lifecycle methods
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
export function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): SafeControlWidget | undefined {
|
||||
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
|
||||
if (!cagWidget) return
|
||||
return {
|
||||
value: normalizeControlOption(cagWidget.value),
|
||||
update: (value) => (cagWidget.value = normalizeControlOption(value))
|
||||
}
|
||||
}
|
||||
|
||||
interface SharedWidgetEnhancements {
|
||||
controlWidget?: SafeControlWidget
|
||||
spec?: InputSpec
|
||||
}
|
||||
|
||||
function getSharedWidgetEnhancements(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): SharedWidgetEnhancements {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
return {
|
||||
controlWidget: getControlWidget(widget),
|
||||
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
function normalizeWidgetValue(value: unknown): WidgetValue {
|
||||
if (value === null || value === undefined || value === void 0) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extractWidgetDisplayOptions(
|
||||
widget: IBaseWidget
|
||||
): SafeWidgetData['options'] {
|
||||
if (!widget.options) return undefined
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
function isDOMBackedWidget(widget: IBaseWidget): boolean {
|
||||
return (
|
||||
('element' in widget && !!widget.element) ||
|
||||
('component' in widget && !!widget.component)
|
||||
)
|
||||
}
|
||||
|
||||
interface PromotedWidgetMetadata {
|
||||
controlWidget?: SafeControlWidget
|
||||
isDOMWidget: boolean
|
||||
sourceExecutionId?: NodeExecutionId
|
||||
sourceWidgetName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the interior source of a promoted subgraph input to derive the
|
||||
* metadata that backend lookups key by (execution ID, interior widget name)
|
||||
* plus the source widget's control + DOM nature. Also seeds host widget state
|
||||
* if it is somehow missing. Returns undefined when the widget is not promoted.
|
||||
*/
|
||||
function resolvePromotedMetadata(
|
||||
node: SubgraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetMetadata | undefined {
|
||||
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
|
||||
if (!source) return undefined
|
||||
|
||||
ensurePromotedHostWidgetState(
|
||||
source.input.widgetId,
|
||||
source.input,
|
||||
source.sourceWidget
|
||||
)
|
||||
|
||||
return {
|
||||
controlWidget: getControlWidget(source.sourceWidget),
|
||||
isDOMWidget: isDOMBackedWidget(source.sourceWidget),
|
||||
sourceExecutionId: source.sourceExecutionId,
|
||||
sourceWidgetName: source.sourceWidgetName
|
||||
}
|
||||
}
|
||||
|
||||
function safeWidgetMapper(
|
||||
node: LGraphNode,
|
||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||
): (widget: IBaseWidget) => SafeWidgetData {
|
||||
const duplicateIndexByKey = new Map<string, number>()
|
||||
|
||||
return function (widget) {
|
||||
try {
|
||||
const duplicateKey = `${widget.name}:${widget.type}`
|
||||
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
|
||||
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
|
||||
// Wrapper callback specific to Nodes 2.0 rendering
|
||||
const callback = (v: unknown) => {
|
||||
const value = normalizeWidgetValue(v)
|
||||
widget.value = value ?? undefined
|
||||
// Match litegraph callback signature: (value, canvas, node, pos, event)
|
||||
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
|
||||
widget.callback?.(value, app.canvas, node)
|
||||
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
|
||||
// This ensures widgets that depend on other widget values get updated
|
||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||
}
|
||||
|
||||
const promoted = node.isSubgraphNode()
|
||||
? resolvePromotedMetadata(node, widget)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
...getSharedWidgetEnhancements(node, widget),
|
||||
...(promoted?.controlWidget && {
|
||||
controlWidget: promoted.controlWidget
|
||||
}),
|
||||
callback,
|
||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
|
||||
options: extractWidgetDisplayOptions(widget),
|
||||
slotMetadata: slotInfo,
|
||||
sourceExecutionId: promoted?.sourceExecutionId,
|
||||
sourceWidgetName: promoted?.sourceWidgetName,
|
||||
tooltip: widget.tooltip
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[safeWidgetMapper] Failed to map widget:',
|
||||
widget.name,
|
||||
error
|
||||
)
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePromotedHostWidgetState(
|
||||
id: WidgetId,
|
||||
input: INodeInputSlot,
|
||||
sourceWidget: IBaseWidget | undefined
|
||||
): void {
|
||||
if (!sourceWidget) return
|
||||
const store = useWidgetValueStore()
|
||||
if (store.getWidget(id)) return
|
||||
store.registerWidget(id, {
|
||||
type: sourceWidget.type,
|
||||
value: sourceWidget.value,
|
||||
options: cloneDeep(sourceWidget.options ?? {}),
|
||||
label: input.label ?? input.name,
|
||||
serialize: sourceWidget.serialize,
|
||||
disabled: sourceWidget.disabled
|
||||
})
|
||||
}
|
||||
|
||||
function buildSlotMetadata(
|
||||
inputs: INodeInputSlot[] | undefined,
|
||||
graphRef: LGraph | null | undefined
|
||||
): Map<string, WidgetSlotMetadata> {
|
||||
const metadata = new Map<string, WidgetSlotMetadata>()
|
||||
inputs?.forEach((input, index) => {
|
||||
let originNodeId: NodeId | undefined
|
||||
let originOutputName: string | undefined
|
||||
|
||||
if (input.link != null && graphRef) {
|
||||
const link = graphRef.getLink(input.link)
|
||||
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
|
||||
if (link && originNode) {
|
||||
originNodeId = link.origin_id
|
||||
originOutputName = originNode.outputs?.[link.origin_slot]?.name
|
||||
}
|
||||
}
|
||||
|
||||
const slotInfo: WidgetSlotMetadata = {
|
||||
index,
|
||||
linked: input.link != null,
|
||||
originNodeId,
|
||||
originOutputName,
|
||||
type: String(input.type)
|
||||
}
|
||||
if (input.name) metadata.set(input.name, slotInfo)
|
||||
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
|
||||
})
|
||||
return metadata
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
const subgraphId =
|
||||
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
|
||||
? String(node.graph.id)
|
||||
: null
|
||||
// Extract safe widget data
|
||||
const slotMetadata = new Map<string, WidgetSlotMetadata>()
|
||||
|
||||
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
|
||||
node,
|
||||
'widgets'
|
||||
)
|
||||
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
|
||||
if (existingWidgetsDescriptor?.get) {
|
||||
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
|
||||
// Preserve it but sync results into a reactive array for Vue.
|
||||
const originalGetter = existingWidgetsDescriptor.get
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
const current: IBaseWidget[] = originalGetter.call(node) ?? []
|
||||
if (
|
||||
current.length !== reactiveWidgets.length ||
|
||||
current.some((w, i) => w !== reactiveWidgets[i])
|
||||
) {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...current)
|
||||
}
|
||||
return reactiveWidgets
|
||||
},
|
||||
set: existingWidgetsDescriptor.set ?? (() => {}),
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
} else {
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return reactiveWidgets
|
||||
},
|
||||
set(v) {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
}
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
Object.defineProperty(node, 'inputs', {
|
||||
get() {
|
||||
return reactiveInputs
|
||||
},
|
||||
set(v) {
|
||||
reactiveInputs.splice(0, reactiveInputs.length, ...v)
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
|
||||
Object.defineProperty(node, 'outputs', {
|
||||
get() {
|
||||
return reactiveOutputs
|
||||
},
|
||||
set(v) {
|
||||
reactiveOutputs.splice(0, reactiveOutputs.length, ...v)
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
|
||||
slotMetadata.clear()
|
||||
for (const [key, value] of freshMetadata) {
|
||||
slotMetadata.set(key, value)
|
||||
}
|
||||
|
||||
const widgets = node.isSubgraphNode()
|
||||
? promotedInputWidgets(node)
|
||||
: (node.widgets ?? [])
|
||||
return widgets.map(safeWidgetMapper(node, slotMetadata))
|
||||
})
|
||||
|
||||
const nodeType =
|
||||
node.type ||
|
||||
node.constructor?.comfyClass ||
|
||||
node.constructor?.title ||
|
||||
node.constructor?.name ||
|
||||
'Unknown'
|
||||
|
||||
const apiNode = node.constructor?.nodeData?.api_node ?? false
|
||||
const badges = node.badges
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
title: typeof node.title === 'string' ? node.title : '',
|
||||
type: nodeType,
|
||||
mode: node.mode || 0,
|
||||
titleMode: node.title_mode,
|
||||
selected: node.selected || false,
|
||||
executing: false, // Will be updated separately based on execution state
|
||||
subgraphId,
|
||||
apiNode,
|
||||
badges,
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: reactiveInputs,
|
||||
outputs: reactiveOutputs,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
resizable: node.resizable,
|
||||
shape: node.shape,
|
||||
showAdvanced: node.showAdvanced
|
||||
}
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
|
||||
|
||||
// Non-reactive storage for original LiteGraph nodes
|
||||
const nodeRefs = new Map<NodeId, LGraphNode>()
|
||||
|
||||
const refreshNodeSlots = (nodeId: NodeId) => {
|
||||
const nodeRef = graph.getNodeById(nodeId)
|
||||
const currentData = nodeDataStore.getNodeData(graph.id, nodeId)
|
||||
const nodeRef = nodeRefs.get(nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (!nodeRef || !currentData) return
|
||||
|
||||
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
widget.slotMetadata = slotMetadata.get(widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
const upsertNodeData = (node: LGraphNode) => {
|
||||
const data = extractVueNodeData(node)
|
||||
if (!nodeDataStore.patchNodeData(graph.id, node.id, data)) {
|
||||
nodeDataStore.registerNodeData(graph.id, node.id, data)
|
||||
}
|
||||
// Get access to original LiteGraph node (non-reactive)
|
||||
const getNode = (id: NodeId): LGraphNode | undefined => {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
const currentNodes = new Set(graph._nodes.map((node) => node.id))
|
||||
const currentNodes = new Set(graph._nodes.map((n) => n.id))
|
||||
|
||||
for (const { id } of nodeDataStore.getGraphNodes(graph.id)) {
|
||||
// Remove deleted nodes
|
||||
for (const id of Array.from(vueNodeData.keys())) {
|
||||
if (!currentNodes.has(id)) {
|
||||
nodeDataStore.deleteNodeData(graph.id, id)
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update existing nodes
|
||||
graph._nodes.forEach((node) => {
|
||||
upsertNodeData(node)
|
||||
const id = node.id
|
||||
|
||||
// Store non-reactive reference
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Extract and store safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node addition to the graph - sets up Vue state and spatial indexing
|
||||
* Defers position extraction until after potential configure() calls
|
||||
*/
|
||||
const handleNodeAdded = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
upsertNodeData(node)
|
||||
originalCallback?.(node)
|
||||
const id = node.id
|
||||
|
||||
// Store non-reactive reference to original node
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
const initializeVueNodeLayout = () => {
|
||||
// Check if the node was removed mid-sequence
|
||||
if (!nodeRefs.has(id)) return
|
||||
|
||||
// Extract actual positions after configure() has potentially updated them
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
|
||||
// Skip layout creation if it already exists
|
||||
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
|
||||
const existingLayout = layoutStore.getNodeLayoutRef(id).value
|
||||
if (existingLayout) return
|
||||
|
||||
// Add node to layout store with final positions
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
position: nodePosition,
|
||||
size: nodeSize,
|
||||
zIndex: node.order || 0,
|
||||
visible: true
|
||||
})
|
||||
}
|
||||
|
||||
// Check if we're in the middle of configuring the graph (workflow loading)
|
||||
if (window.app?.configuringGraph) {
|
||||
// During workflow loading - defer layout initialization until configure completes
|
||||
// Chain our callback with any existing onAfterGraphConfigured callback
|
||||
node.onAfterGraphConfigured = useChainCallback(
|
||||
node.onAfterGraphConfigured,
|
||||
() => {
|
||||
// Re-extract data now that configure() has populated title/slots/widgets/etc.
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Not during workflow loading - initialize layout immediately
|
||||
// This handles individual node additions during normal operation
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
void originalCallback(node)
|
||||
}
|
||||
}
|
||||
|
||||
const dropNodeReferences = (id: NodeId) => {
|
||||
nodeDataStore.deleteNodeData(graph.id, id)
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
|
||||
const handleNodeRemoved = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
dropNodeReferences(node.id)
|
||||
const id = node.id
|
||||
|
||||
// Remove node from layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
dropNodeReferences(id)
|
||||
originalCallback?.(node)
|
||||
}
|
||||
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
const originalOnTrigger = graph.onTrigger
|
||||
/**
|
||||
* Creates cleanup function for event listeners and state
|
||||
*/
|
||||
const createCleanupFunction = (
|
||||
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined,
|
||||
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
|
||||
) => {
|
||||
return () => {
|
||||
// Restore original callbacks
|
||||
graph.onNodeAdded = originalOnNodeAdded || undefined
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
handleNodeAdded(node, originalOnNodeAdded)
|
||||
graph.events.removeEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
|
||||
// Clear all state maps
|
||||
nodeRefs.clear()
|
||||
vueNodeData.clear()
|
||||
}
|
||||
}
|
||||
|
||||
graph.onNodeRemoved = (node: LGraphNode) => {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
const setupEventListeners = (): (() => void) => {
|
||||
// Store original callbacks
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
const originalOnTrigger = graph.onTrigger
|
||||
|
||||
const beforeNodeRemovedListener = (e: CustomEvent<{ node: LGraphNode }>) => {
|
||||
dropNodeReferences(e.detail.node.id)
|
||||
}
|
||||
graph.events.addEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
|
||||
graph.onTrigger = (event: LGraphTriggerEvent) => {
|
||||
switch (event.type) {
|
||||
case 'node:property:changed':
|
||||
nodeDataStore.patchNodeProperty(
|
||||
graph.id,
|
||||
toNodeId(event.nodeId),
|
||||
event.property,
|
||||
event.newValue
|
||||
)
|
||||
break
|
||||
case 'node:slot-errors:changed':
|
||||
refreshNodeSlots(toNodeId(event.nodeId))
|
||||
break
|
||||
case 'node:slot-links:changed':
|
||||
if (event.slotType === NodeSlotType.INPUT) {
|
||||
refreshNodeSlots(toNodeId(event.nodeId))
|
||||
}
|
||||
break
|
||||
case 'node:slot-label:changed': {
|
||||
const nodeId = toNodeId(event.nodeId)
|
||||
const nodeRef = graph.getNodeById(nodeId)
|
||||
if (!nodeRef) break
|
||||
|
||||
if (event.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
|
||||
nodeDataStore.patchNodeData(graph.id, nodeId, {
|
||||
inputs: [...nodeRef.inputs]
|
||||
})
|
||||
}
|
||||
if (event.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
|
||||
nodeDataStore.patchNodeData(graph.id, nodeId, {
|
||||
outputs: [...nodeRef.outputs]
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
// Set up graph event handlers
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
handleNodeAdded(node, originalOnNodeAdded)
|
||||
}
|
||||
|
||||
originalOnTrigger?.(event)
|
||||
}
|
||||
graph.onNodeRemoved = (node: LGraphNode) => {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
syncWithGraph()
|
||||
|
||||
graph._nodes?.forEach((node: LGraphNode) => {
|
||||
graph.onNodeAdded?.(node)
|
||||
})
|
||||
|
||||
return () => {
|
||||
graph.onNodeAdded = originalOnNodeAdded || undefined
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
graph.events.removeEventListener(
|
||||
const beforeNodeRemovedListener = (
|
||||
e: CustomEvent<{ node: LGraphNode }>
|
||||
) => {
|
||||
dropNodeReferences(e.detail.node.id)
|
||||
}
|
||||
graph.events.addEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
nodeDataStore.clearGraph(graph.id)
|
||||
|
||||
const triggerHandlers: {
|
||||
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
|
||||
} = {
|
||||
'node:property:changed': (propertyEvent) => {
|
||||
const nodeId = toNodeId(propertyEvent.nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (currentData) {
|
||||
switch (propertyEvent.property) {
|
||||
case 'title':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
title: String(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'has_errors':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
hasErrors: Boolean(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'flags.collapsed':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
collapsed: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.ghost':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
ghost: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.pinned':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
pinned: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'mode':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
mode:
|
||||
typeof propertyEvent.newValue === 'number'
|
||||
? propertyEvent.newValue
|
||||
: 0
|
||||
})
|
||||
break
|
||||
case 'color':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
color:
|
||||
typeof propertyEvent.newValue === 'string'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'bgcolor':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
bgcolor:
|
||||
typeof propertyEvent.newValue === 'string'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'shape':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
shape:
|
||||
typeof propertyEvent.newValue === 'number'
|
||||
? propertyEvent.newValue
|
||||
: undefined
|
||||
})
|
||||
break
|
||||
case 'showAdvanced':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
showAdvanced: Boolean(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'badges':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
badges: propertyEvent.newValue as Badges
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
'node:slot-errors:changed': (slotErrorsEvent) => {
|
||||
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
|
||||
},
|
||||
'node:slot-links:changed': (slotLinksEvent) => {
|
||||
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
|
||||
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
|
||||
}
|
||||
},
|
||||
'node:slot-label:changed': (slotLabelEvent) => {
|
||||
const nodeId = toNodeId(slotLabelEvent.nodeId)
|
||||
const nodeRef = nodeRefs.get(nodeId)
|
||||
if (!nodeRef) return
|
||||
|
||||
// Force shallowReactive to detect the deep property change
|
||||
// by re-assigning the affected array through the defineProperty setter.
|
||||
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
|
||||
nodeRef.inputs = [...nodeRef.inputs]
|
||||
}
|
||||
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
|
||||
nodeRef.outputs = [...nodeRef.outputs]
|
||||
}
|
||||
// Re-extract widget data so the label reflects the rename
|
||||
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
|
||||
}
|
||||
}
|
||||
|
||||
graph.onTrigger = (event: LGraphTriggerEvent) => {
|
||||
switch (event.type) {
|
||||
case 'node:property:changed':
|
||||
triggerHandlers['node:property:changed'](event)
|
||||
break
|
||||
case 'node:slot-errors:changed':
|
||||
triggerHandlers['node:slot-errors:changed'](event)
|
||||
break
|
||||
case 'node:slot-links:changed':
|
||||
triggerHandlers['node:slot-links:changed'](event)
|
||||
break
|
||||
case 'node:slot-label:changed':
|
||||
triggerHandlers['node:slot-label:changed'](event)
|
||||
break
|
||||
}
|
||||
|
||||
// Chain to original handler
|
||||
originalOnTrigger?.(event)
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
return createCleanupFunction(
|
||||
originalOnNodeAdded || undefined,
|
||||
originalOnNodeRemoved || undefined,
|
||||
originalOnTrigger || undefined,
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
}
|
||||
|
||||
// Set up event listeners immediately
|
||||
const cleanup = setupEventListeners()
|
||||
|
||||
// Process any existing nodes after event listeners are set up
|
||||
if (graph._nodes && graph._nodes.length > 0) {
|
||||
graph._nodes.forEach((node: LGraphNode) => {
|
||||
if (graph.onNodeAdded) {
|
||||
graph.onNodeAdded(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
vueNodeData,
|
||||
getNode,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createSharedComposable, whenever } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { useGraphLayoutManager } from '@/composables/graph/useGraphLayoutManager'
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -16,16 +16,17 @@ function useVueNodeLifecycleIndividual() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const cleanupNodeManager = ref<(() => void) | null>(null)
|
||||
const cleanupLayoutManager = ref<(() => void) | null>(null)
|
||||
const nodeManager = shallowRef<GraphNodeManager | null>(null)
|
||||
const { startSync, stopSync } = useLayoutSync()
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
if (!activeGraph || cleanupNodeManager.value) return
|
||||
if (!activeGraph || nodeManager.value) return
|
||||
|
||||
cleanupNodeManager.value = useGraphNodeManager(activeGraph)
|
||||
// Initialize the core node manager
|
||||
const manager = useGraphNodeManager(activeGraph)
|
||||
nodeManager.value = manager
|
||||
|
||||
// Initialize layout system with existing nodes from active graph
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
@@ -59,8 +60,6 @@ function useVueNodeLifecycleIndividual() {
|
||||
)
|
||||
}
|
||||
|
||||
cleanupLayoutManager.value = useGraphLayoutManager(activeGraph)
|
||||
|
||||
// Start sync AFTER seeding so bootstrap operations don't trigger
|
||||
// the Layout→LiteGraph writeback loop redundantly.
|
||||
startSync(canvasStore.canvas)
|
||||
@@ -68,22 +67,14 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
const disposeNodeManagerAndSyncs = () => {
|
||||
stopSync()
|
||||
if (cleanupLayoutManager.value) {
|
||||
try {
|
||||
cleanupLayoutManager.value()
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
cleanupLayoutManager.value = null
|
||||
}
|
||||
if (!cleanupNodeManager.value) return
|
||||
if (!nodeManager.value) return
|
||||
|
||||
try {
|
||||
cleanupNodeManager.value()
|
||||
nodeManager.value.cleanup()
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
cleanupNodeManager.value = null
|
||||
nodeManager.value = null
|
||||
}
|
||||
|
||||
// Watch for Vue nodes enabled state changes
|
||||
@@ -136,7 +127,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
const activeGraph = comfyApp.canvas?.graph
|
||||
if (
|
||||
!shouldRenderVueNodes.value ||
|
||||
cleanupNodeManager.value ||
|
||||
nodeManager.value ||
|
||||
activeGraph?._nodes.length !== 0
|
||||
) {
|
||||
return
|
||||
@@ -147,7 +138,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
activeGraph.onNodeAdded = originalOnNodeAdded
|
||||
|
||||
// Initialize node manager if needed
|
||||
if (shouldRenderVueNodes.value && !cleanupNodeManager.value) {
|
||||
if (shouldRenderVueNodes.value && !nodeManager.value) {
|
||||
initializeNodeManager()
|
||||
}
|
||||
|
||||
@@ -160,17 +151,16 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Cleanup function for component unmounting
|
||||
const cleanup = () => {
|
||||
if (cleanupLayoutManager.value) {
|
||||
cleanupLayoutManager.value()
|
||||
cleanupLayoutManager.value = null
|
||||
}
|
||||
if (cleanupNodeManager.value) {
|
||||
cleanupNodeManager.value()
|
||||
cleanupNodeManager.value = null
|
||||
if (nodeManager.value) {
|
||||
nodeManager.value.cleanup()
|
||||
nodeManager.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeManager,
|
||||
|
||||
// Lifecycle methods
|
||||
initializeNodeManager,
|
||||
disposeNodeManagerAndSyncs,
|
||||
setupEmptyGraphListener,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -12,12 +12,11 @@ export function resolveEssentialTileNodeDef(
|
||||
): ComfyNodeDefImpl | undefined {
|
||||
const name = tile.nodeName
|
||||
if (!name) return undefined
|
||||
const byName = nodeDefStore.allNodeDefsByName[name]
|
||||
if (byName) return byName
|
||||
const target = name.startsWith(BLUEPRINT_TYPE_PREFIX)
|
||||
? name.slice(BLUEPRINT_TYPE_PREFIX.length)
|
||||
: name
|
||||
return nodeDefStore.nodeDefs.find((d) => d.display_name === target)
|
||||
if (!name.startsWith(BLUEPRINT_TYPE_PREFIX))
|
||||
return nodeDefStore.allNodeDefsByName[name]
|
||||
|
||||
const subgraphName = name.slice(BLUEPRINT_TYPE_PREFIX.length)
|
||||
return nodeDefStore.allNodeDefsByDisplayName[subgraphName]
|
||||
}
|
||||
|
||||
export function useEssentialTileNodeDef(tile: MaybeRefOrGetter<EssentialTile>) {
|
||||
|
||||
101
src/config/comfyApi.test.ts
Normal file
101
src/config/comfyApi.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
45
src/config/firebase.test.ts
Normal file
45
src/config/firebase.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -815,8 +815,10 @@ export class GroupNodeConfig {
|
||||
* `configure`. The load-time migration unpacks each instance via
|
||||
* {@link convertToNodes} and {@link LGraph.convertToSubgraph} repackages the
|
||||
* result as a subgraph.
|
||||
*
|
||||
* @knipIgnoreUnusedButUsedByCustomNodes
|
||||
*/
|
||||
class GroupNodeHandler {
|
||||
export class GroupNodeHandler {
|
||||
node: LGraphNode
|
||||
groupData: GroupNodeConfig
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
11
src/main.ts
11
src/main.ts
@@ -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')
|
||||
|
||||
37
src/platform/agent/components/AgentChatEmptyState.vue
Normal file
37
src/platform/agent/components/AgentChatEmptyState.vue
Normal 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>
|
||||
88
src/platform/agent/components/AgentChatHeader.vue
Normal file
88
src/platform/agent/components/AgentChatHeader.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { isMaximized } = defineProps<{
|
||||
isMaximized: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
newChat: []
|
||||
toggleMaximize: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sizeToggleIcon = computed(() =>
|
||||
isMaximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
|
||||
)
|
||||
const sizeToggleLabel = computed(() =>
|
||||
isMaximized ? t('agent.minimize') : t('agent.maximize')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-12 shrink-0 items-center justify-between border-b border-component-node-border px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">{{ $t('agent.title') }}</span>
|
||||
<span
|
||||
class="rounded-full border border-border-default px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('agent.alpha') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('agent.newChat')"
|
||||
@click="emit('newChat')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--message-circle-plus] size-4 text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ $t('agent.newChat') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="sizeToggleLabel"
|
||||
@click="emit('toggleMaximize')"
|
||||
>
|
||||
<i :class="`${sizeToggleIcon} size-4 text-muted-foreground`" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" class="whitespace-nowrap">
|
||||
{{ sizeToggleLabel }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{ $t('g.close') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
131
src/platform/agent/components/AgentChatHistory.vue
Normal file
131
src/platform/agent/components/AgentChatHistory.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Empty from '@/components/ui/empty/Empty.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
import EmptyDescription from '@/components/ui/empty/EmptyDescription.vue'
|
||||
import EmptyHeader from '@/components/ui/empty/EmptyHeader.vue'
|
||||
import EmptyMedia from '@/components/ui/empty/EmptyMedia.vue'
|
||||
import EmptyTitle from '@/components/ui/empty/EmptyTitle.vue'
|
||||
import type { AgentConversation } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
import AgentChatHistoryGroupLabel from './AgentChatHistoryGroupLabel.vue'
|
||||
import AgentChatHistoryItem from './AgentChatHistoryItem.vue'
|
||||
|
||||
const { conversations, activeId } = defineProps<{
|
||||
conversations: readonly AgentConversation[]
|
||||
activeId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
select: [id: string]
|
||||
delete: [id: string]
|
||||
copy: [id: string]
|
||||
newChat: []
|
||||
}>()
|
||||
|
||||
type GroupKey = 'today' | 'yesterday' | 'last7Days' | 'last30Days'
|
||||
|
||||
interface Group {
|
||||
key: GroupKey
|
||||
labelKey: string
|
||||
items: AgentConversation[]
|
||||
}
|
||||
|
||||
function getGroupKey(date: Date): GroupKey {
|
||||
const now = new Date()
|
||||
const diffDays = (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
|
||||
|
||||
if (diffDays < 1) return 'today'
|
||||
if (diffDays < 2) return 'yesterday'
|
||||
if (diffDays < 7) return 'last7Days'
|
||||
return 'last30Days'
|
||||
}
|
||||
|
||||
const labelKeys: Record<GroupKey, string> = {
|
||||
today: 'agent.history.today',
|
||||
yesterday: 'agent.history.yesterday',
|
||||
last7Days: 'agent.history.last7Days',
|
||||
last30Days: 'agent.history.last30Days'
|
||||
}
|
||||
|
||||
const order: GroupKey[] = ['today', 'yesterday', 'last7Days', 'last30Days']
|
||||
|
||||
const groups = computed<Group[]>(() => {
|
||||
const buckets: Record<GroupKey, AgentConversation[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
last7Days: [],
|
||||
last30Days: []
|
||||
}
|
||||
|
||||
for (const conv of conversations) {
|
||||
buckets[getGroupKey(conv.createdAt)].push(conv)
|
||||
}
|
||||
|
||||
return order
|
||||
.filter((key) => buckets[key].length > 0)
|
||||
.map((key) => ({ key, labelKey: labelKeys[key], items: buckets[key] }))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex shrink-0 items-center px-2 py-1.5">
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 cursor-pointer items-center gap-1 rounded-sm border-0 bg-transparent px-2 text-xs text-muted-foreground hover:bg-secondary-background-hover"
|
||||
:aria-label="$t('agent.history.back')"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<i class="icon-[lucide--arrow-left] size-3" />
|
||||
<span>{{ $t('agent.history.title') }}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{{
|
||||
$t('agent.history.back')
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-y-auto p-2">
|
||||
<Empty v-if="groups.length === 0">
|
||||
<EmptyMedia variant="icon">
|
||||
<i class="icon-[lucide--history] size-5" />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{{ $t('agent.history.emptyTitle') }}</EmptyTitle>
|
||||
<EmptyDescription>{{
|
||||
$t('agent.history.emptyDescription')
|
||||
}}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button variant="primary" size="lg" @click="emit('newChat')">
|
||||
{{ $t('agent.history.startChat') }}
|
||||
</Button>
|
||||
</Empty>
|
||||
|
||||
<div v-for="group in groups" :key="group.key" class="mb-3">
|
||||
<AgentChatHistoryGroupLabel>{{
|
||||
$t(group.labelKey)
|
||||
}}</AgentChatHistoryGroupLabel>
|
||||
<ul class="flex list-none flex-col gap-0.5 pl-0">
|
||||
<AgentChatHistoryItem
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
:active="item.id === activeId"
|
||||
@select="emit('select', item.id)"
|
||||
@delete="emit('delete', item.id)"
|
||||
@copy="emit('copy', item.id)"
|
||||
>
|
||||
<span class="truncate">{{ item.title }}</span>
|
||||
</AgentChatHistoryItem>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<p class="my-0 py-0 text-xs font-medium text-muted-foreground">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
61
src/platform/agent/components/AgentChatHistoryItem.vue
Normal file
61
src/platform/agent/components/AgentChatHistoryItem.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { active = false } = defineProps<{
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: []
|
||||
delete: []
|
||||
copy: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-secondary-background-hover"
|
||||
:class="{ 'bg-secondary-background': active }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 cursor-pointer items-center gap-2 overflow-hidden border-0 bg-transparent text-left text-sm text-base-foreground"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--circle-check] size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<slot />
|
||||
</button>
|
||||
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-muted-foreground hover:bg-secondary-background-hover hover:text-base-foreground"
|
||||
:aria-label="$t('g.copy')"
|
||||
@click.stop="emit('copy')"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('g.copy') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-danger flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-muted-foreground hover:bg-destructive-background/10"
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('g.delete') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
312
src/platform/agent/components/AgentChatPanel.vue
Normal file
312
src/platform/agent/components/AgentChatPanel.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import Conversation from '@/components/ai-elements/conversation/Conversation.vue'
|
||||
import ConversationContent from '@/components/ai-elements/conversation/ConversationContent.vue'
|
||||
import ConversationEmptyState from '@/components/ai-elements/conversation/ConversationEmptyState.vue'
|
||||
import ConversationScrollButton from '@/components/ai-elements/conversation/ConversationScrollButton.vue'
|
||||
import Message from '@/components/ai-elements/message/Message.vue'
|
||||
import MessageAction from '@/components/ai-elements/message/MessageAction.vue'
|
||||
import MessageActions from '@/components/ai-elements/message/MessageActions.vue'
|
||||
import MessageAttachments from '@/components/ai-elements/message/MessageAttachments.vue'
|
||||
import MessageContent from '@/components/ai-elements/message/MessageContent.vue'
|
||||
import MessageResponse from '@/components/ai-elements/message/MessageResponse.vue'
|
||||
import MessageThinking from '@/components/ai-elements/message/MessageThinking.vue'
|
||||
import MessageToolCalls from '@/components/ai-elements/message/MessageToolCalls.vue'
|
||||
import PromptInput from '@/components/ai-elements/prompt-input/PromptInput.vue'
|
||||
import PromptInputAttachments from '@/components/ai-elements/prompt-input/PromptInputAttachments.vue'
|
||||
import PromptInputBody from '@/components/ai-elements/prompt-input/PromptInputBody.vue'
|
||||
import PromptInputButton from '@/components/ai-elements/prompt-input/PromptInputButton.vue'
|
||||
import PromptInputModelSelect from '@/components/ai-elements/prompt-input/PromptInputModelSelect.vue'
|
||||
import PromptInputSubmit from '@/components/ai-elements/prompt-input/PromptInputSubmit.vue'
|
||||
import PromptInputTextarea from '@/components/ai-elements/prompt-input/PromptInputTextarea.vue'
|
||||
import PromptInputToolbar from '@/components/ai-elements/prompt-input/PromptInputToolbar.vue'
|
||||
import PromptInputTools from '@/components/ai-elements/prompt-input/PromptInputTools.vue'
|
||||
import { useAgentChatPrototype } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import AgentChatEmptyState from './AgentChatEmptyState.vue'
|
||||
import AgentChatHeader from './AgentChatHeader.vue'
|
||||
import AgentChatHistory from './AgentChatHistory.vue'
|
||||
import AgentPromptSuggestions from './AgentPromptSuggestions.vue'
|
||||
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
status,
|
||||
isEmpty,
|
||||
chatHistory,
|
||||
currentConversationId,
|
||||
send,
|
||||
stop,
|
||||
applySuggestion,
|
||||
startNewChat,
|
||||
loadConversation,
|
||||
deleteConversation,
|
||||
copyConversation
|
||||
} = useAgentChatPrototype()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
|
||||
const model = ref('Auto')
|
||||
const showHistory = ref(false)
|
||||
const promptTextarea = ref<{ focus: () => void } | null>(null)
|
||||
const reactions = ref<Record<string, 'liked' | 'disliked' | null>>({})
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const attachments = ref<File[]>([])
|
||||
|
||||
const userName = computed(
|
||||
() => authStore.currentUser?.displayName?.split(' ')[0] ?? ''
|
||||
)
|
||||
|
||||
const conversationTitle = computed(
|
||||
() => messages.value.find((message) => message.role === 'user')?.text
|
||||
)
|
||||
|
||||
const submitDisabled = computed(
|
||||
() => status.value === 'ready' && input.value.trim() === ''
|
||||
)
|
||||
|
||||
function onSubmit() {
|
||||
if (status.value === 'submitted' || status.value === 'streaming') {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
send(undefined, attachments.value)
|
||||
attachments.value = []
|
||||
}
|
||||
|
||||
function removeAttachment(index: number) {
|
||||
attachments.value = attachments.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function close() {
|
||||
agentPanelStore.close()
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function onFilesSelected(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (!files) return
|
||||
attachments.value = [...attachments.value, ...Array.from(files)]
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
}
|
||||
|
||||
function toggleReaction(id: string, reaction: 'liked' | 'disliked') {
|
||||
reactions.value[id] = reactions.value[id] === reaction ? null : reaction
|
||||
}
|
||||
|
||||
function copyMessage(text: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
function onSelectConversation(id: string) {
|
||||
loadConversation(id)
|
||||
showHistory.value = false
|
||||
}
|
||||
|
||||
function onSuggestionSelect(text: string) {
|
||||
applySuggestion(text)
|
||||
setTimeout(() => promptTextarea.value?.focus(), 0)
|
||||
}
|
||||
|
||||
function onNewChatFromHistory() {
|
||||
startNewChat()
|
||||
showHistory.value = false
|
||||
nextTick(() => promptTextarea.value?.focus())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="@container flex h-full flex-col overflow-hidden bg-base-background"
|
||||
>
|
||||
<AgentChatHeader
|
||||
:is-maximized="agentPanelStore.isMaximized"
|
||||
@new-chat="onNewChatFromHistory"
|
||||
@toggle-maximize="agentPanelStore.toggleMaximize"
|
||||
@close="close"
|
||||
/>
|
||||
|
||||
<template v-if="showHistory">
|
||||
<AgentChatHistory
|
||||
:conversations="chatHistory"
|
||||
:active-id="currentConversationId"
|
||||
@back="showHistory = false"
|
||||
@select="onSelectConversation"
|
||||
@delete="deleteConversation"
|
||||
@copy="copyConversation"
|
||||
@new-chat="onNewChatFromHistory"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex shrink-0 items-center px-2 py-1.5">
|
||||
<Tooltip :delay-duration="500">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 cursor-pointer items-center gap-1 rounded-sm border-0 bg-transparent px-2 text-xs text-muted-foreground hover:bg-secondary-background-hover"
|
||||
@click="showHistory = true"
|
||||
>
|
||||
<i class="icon-[lucide--align-justify] size-3.5" />
|
||||
<span class="max-w-56 truncate">
|
||||
{{ conversationTitle ?? $t('agent.newChat') }}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{{ $t('agent.history.show') }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<ConversationEmptyState v-if="isEmpty">
|
||||
<AgentChatEmptyState :name="userName" />
|
||||
</ConversationEmptyState>
|
||||
<Conversation v-else>
|
||||
<template #overlay>
|
||||
<ConversationScrollButton />
|
||||
</template>
|
||||
<ConversationContent class="mx-auto w-full max-w-[640px]">
|
||||
<Message
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:from="message.role"
|
||||
>
|
||||
<!-- User messages: attachments float above the text bubble -->
|
||||
<template v-if="message.role === 'user'">
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<MessageAttachments
|
||||
v-if="message.attachments?.length"
|
||||
:attachments="message.attachments"
|
||||
/>
|
||||
<MessageContent v-if="message.text">
|
||||
<MessageResponse
|
||||
:content="message.text"
|
||||
class="agent-markdown"
|
||||
/>
|
||||
</MessageContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Assistant messages -->
|
||||
<MessageContent v-else>
|
||||
<MessageThinking v-if="message.thinking" />
|
||||
<MessageToolCalls
|
||||
v-else-if="message.toolCalls?.length"
|
||||
:tool-calls="message.toolCalls"
|
||||
:complete="
|
||||
status === 'ready' ||
|
||||
message !== messages[messages.length - 1]
|
||||
"
|
||||
/>
|
||||
<MessageResponse
|
||||
v-if="message.text"
|
||||
:content="message.text"
|
||||
class="agent-markdown"
|
||||
/>
|
||||
<MessageActions
|
||||
v-if="
|
||||
message.text &&
|
||||
(status === 'ready' ||
|
||||
message !== messages[messages.length - 1])
|
||||
"
|
||||
>
|
||||
<MessageAction
|
||||
:tooltip="$t('agent.message.thumbsUp')"
|
||||
:pressed="reactions[message.id] === 'liked'"
|
||||
@click="toggleReaction(message.id, 'liked')"
|
||||
>
|
||||
<i class="icon-[lucide--thumbs-up] size-3.5" />
|
||||
</MessageAction>
|
||||
<MessageAction
|
||||
:tooltip="$t('agent.message.thumbsDown')"
|
||||
:pressed="reactions[message.id] === 'disliked'"
|
||||
@click="toggleReaction(message.id, 'disliked')"
|
||||
>
|
||||
<i class="icon-[lucide--thumbs-down] size-3.5" />
|
||||
</MessageAction>
|
||||
<MessageAction
|
||||
:tooltip="$t('agent.message.copy')"
|
||||
@click="copyMessage(message.text)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
|
||||
<div class="flex shrink-0 flex-col gap-4 p-4">
|
||||
<div
|
||||
class="@container mx-auto flex w-full max-w-[640px] flex-col gap-4"
|
||||
>
|
||||
<AgentPromptSuggestions v-if="isEmpty" @select="onSuggestionSelect" />
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<PromptInput @submit="onSubmit">
|
||||
<PromptInputBody>
|
||||
<PromptInputAttachments
|
||||
:attachments="attachments"
|
||||
@remove="removeAttachment"
|
||||
/>
|
||||
<PromptInputTextarea
|
||||
ref="promptTextarea"
|
||||
v-model="input"
|
||||
:placeholder="$t('agent.placeholder')"
|
||||
/>
|
||||
<PromptInputToolbar>
|
||||
<PromptInputTools>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onFilesSelected"
|
||||
/>
|
||||
<Tooltip :delay-duration="500">
|
||||
<TooltipTrigger>
|
||||
<PromptInputButton
|
||||
:aria-label="$t('agent.attach')"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<i class="icon-[lucide--paperclip] size-4" />
|
||||
</PromptInputButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="whitespace-nowrap">
|
||||
{{ $t('agent.attach') }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PromptInputButton :aria-label="$t('agent.mention')">
|
||||
<i class="icon-[lucide--at-sign] size-4" />
|
||||
</PromptInputButton>
|
||||
</PromptInputTools>
|
||||
<PromptInputTools>
|
||||
<PromptInputModelSelect v-model="model" />
|
||||
<PromptInputSubmit
|
||||
:status="status"
|
||||
:disabled="submitDisabled"
|
||||
/>
|
||||
</PromptInputTools>
|
||||
</PromptInputToolbar>
|
||||
</PromptInputBody>
|
||||
</PromptInput>
|
||||
<p class="my-0 text-center text-xs text-muted-foreground">
|
||||
{{ $t('agent.disclaimer') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
30
src/platform/agent/components/AgentPromptSuggestions.vue
Normal file
30
src/platform/agent/components/AgentPromptSuggestions.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import Suggestion from '@/components/ai-elements/suggestion/Suggestion.vue'
|
||||
import Suggestions from '@/components/ai-elements/suggestion/Suggestions.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [suggestion: string]
|
||||
}>()
|
||||
|
||||
const suggestions = [
|
||||
{ key: 'duck', icon: 'icon-[lucide--lightbulb]' },
|
||||
{ key: 'savedWorkflows', icon: 'icon-[lucide--list]' },
|
||||
{ key: 'skinUpscaling', icon: 'icon-[lucide--search]' },
|
||||
{ key: 'explainNode', icon: 'icon-[lucide--message-circle-warning]' },
|
||||
{ key: 'imageToVideo', icon: 'icon-[lucide--workflow]' }
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Suggestions>
|
||||
<Suggestion
|
||||
v-for="item in suggestions"
|
||||
:key="item.key"
|
||||
:suggestion="$t(`agent.suggestions.${item.key}`)"
|
||||
@select="emit('select', $event)"
|
||||
>
|
||||
<i :class="item.icon" class="size-3 shrink-0 text-muted-foreground" />
|
||||
<span>{{ $t(`agent.suggestions.${item.key}`) }}</span>
|
||||
</Suggestion>
|
||||
</Suggestions>
|
||||
</template>
|
||||
81
src/platform/agent/composables/useAgentChatPrototype.test.ts
Normal file
81
src/platform/agent/composables/useAgentChatPrototype.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAgentChatPrototype } from './useAgentChatPrototype'
|
||||
|
||||
describe('useAgentChatPrototype', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
useAgentChatPrototype().startNewChat()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts empty', () => {
|
||||
const { messages, isEmpty } = useAgentChatPrototype()
|
||||
expect(messages.value).toHaveLength(0)
|
||||
expect(isEmpty.value).toBe(true)
|
||||
})
|
||||
|
||||
it('appends a user message and clears the input on send', () => {
|
||||
const { messages, input, send, isEmpty } = useAgentChatPrototype()
|
||||
|
||||
input.value = 'make a duck'
|
||||
send()
|
||||
|
||||
expect(messages.value).toHaveLength(1)
|
||||
expect(messages.value[0]).toMatchObject({
|
||||
role: 'user',
|
||||
text: 'make a duck'
|
||||
})
|
||||
expect(input.value).toBe('')
|
||||
expect(isEmpty.value).toBe(false)
|
||||
})
|
||||
|
||||
it('streams a mocked assistant reply after sending', () => {
|
||||
const { messages, send, status } = useAgentChatPrototype()
|
||||
|
||||
send('make a duck')
|
||||
expect(status.value).toBe('submitted')
|
||||
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(status.value).toBe('ready')
|
||||
expect(messages.value).toHaveLength(2)
|
||||
const reply = messages.value[1]
|
||||
expect(reply.role).toBe('assistant')
|
||||
expect(reply.text).toContain('make a duck')
|
||||
})
|
||||
|
||||
it('ignores send while a reply is in progress', () => {
|
||||
const { messages, send } = useAgentChatPrototype()
|
||||
|
||||
send('first')
|
||||
send('second')
|
||||
|
||||
expect(messages.value).toHaveLength(1)
|
||||
expect(messages.value[0].text).toBe('first')
|
||||
})
|
||||
|
||||
it('applies a suggestion to the input', () => {
|
||||
const { input, applySuggestion } = useAgentChatPrototype()
|
||||
|
||||
applySuggestion('List my saved workflows')
|
||||
|
||||
expect(input.value).toBe('List my saved workflows')
|
||||
})
|
||||
|
||||
it('clears the conversation on startNewChat', () => {
|
||||
const { messages, send, startNewChat, isEmpty } = useAgentChatPrototype()
|
||||
|
||||
send('make a duck')
|
||||
vi.advanceTimersByTime(10_000)
|
||||
expect(messages.value.length).toBeGreaterThan(0)
|
||||
|
||||
startNewChat()
|
||||
|
||||
expect(messages.value).toHaveLength(0)
|
||||
expect(isEmpty.value).toBe(true)
|
||||
})
|
||||
})
|
||||
479
src/platform/agent/composables/useAgentChatPrototype.ts
Normal file
479
src/platform/agent/composables/useAgentChatPrototype.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
|
||||
import type { ChatStatus } from '@/components/ai-elements/prompt-input/types'
|
||||
|
||||
export interface ToolCall {
|
||||
name: string
|
||||
status: 'success' | 'error'
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface MessageAttachment {
|
||||
name: string
|
||||
type: string
|
||||
url: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface AgentMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
attachments?: readonly MessageAttachment[]
|
||||
thinking?: boolean
|
||||
toolCalls?: readonly ToolCall[]
|
||||
}
|
||||
|
||||
export interface AgentConversation {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: Date
|
||||
messages: readonly AgentMessage[]
|
||||
}
|
||||
|
||||
const STREAM_INTERVAL_MS = 40
|
||||
const THINKING_DELAY_MS = 500
|
||||
const TOOL_CALLS_DELAY_MS = 1200
|
||||
|
||||
const MOCK_TOOL_CALLS: ToolCall[] = [
|
||||
{ name: 'Opening template', status: 'success', durationMs: 200 },
|
||||
{ name: 'New workflow', status: 'success', durationMs: 1300 },
|
||||
{ name: 'Set node widget', status: 'error', durationMs: 1200 },
|
||||
{ name: 'Pointing to node', status: 'success', durationMs: 1100 },
|
||||
{ name: 'Set node widget', status: 'error', durationMs: 200 }
|
||||
]
|
||||
|
||||
const daysAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000)
|
||||
|
||||
const FENCE = '```'
|
||||
|
||||
const DEMO_CONVERSATIONS: AgentConversation[] = [
|
||||
{
|
||||
id: 'demo-code-block',
|
||||
title: 'Code block',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{ id: 'demo-code-1', role: 'user', text: 'Show me a workflow as code' },
|
||||
{
|
||||
id: 'demo-code-2',
|
||||
role: 'assistant',
|
||||
text: `${FENCE}javascript:workflow.js
|
||||
export default {
|
||||
nodes: [
|
||||
{ id: 1, type: "CheckpointLoaderSimple", inputs: { ckpt_name: "flux1-dev-fp8.safetensors" } },
|
||||
{ id: 2, type: "CLIPTextEncode", inputs: { text: "a golden hour sunset over mountains" } },
|
||||
{ id: 3, type: "KSampler", inputs: { seed: 42, steps: 20, cfg: 7, sampler_name: "euler" } },
|
||||
{ id: 4, type: "VAEDecode" },
|
||||
{ id: 5, type: "SaveImage", inputs: { filename_prefix: "output" } },
|
||||
],
|
||||
links: [
|
||||
[1, 0, 3, 0], // model → KSampler
|
||||
[2, 0, 3, 1], // conditioning → KSampler
|
||||
[3, 0, 4, 0], // latent → VAEDecode
|
||||
[4, 0, 5, 0], // image → SaveImage
|
||||
],
|
||||
}
|
||||
${FENCE}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-markdown',
|
||||
title: 'Markdown',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-md-1',
|
||||
role: 'user',
|
||||
text: 'Explain how to build a basic workflow'
|
||||
},
|
||||
{
|
||||
id: 'demo-md-2',
|
||||
role: 'assistant',
|
||||
text: `# Workflow Overview
|
||||
|
||||
This guide explains how to **build a basic image generation workflow** in ComfyUI.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Nodes** are the building blocks — each one performs a single operation
|
||||
- **Edges** carry data between nodes
|
||||
- Use _italics_ for emphasis and \`inline code\` for node names
|
||||
|
||||
## Steps
|
||||
|
||||
1. Load a checkpoint with \`CheckpointLoaderSimple\`
|
||||
2. Add \`CLIPTextEncode\` and write your prompt
|
||||
3. Connect both to \`KSampler\` to run diffusion
|
||||
4. Decode the result with \`VAEDecode\`
|
||||
5. Save the image with \`SaveImage\`
|
||||
|
||||
> Start with a simple 4-node chain and expand from there.
|
||||
|
||||
See the full reference at [docs.comfy.org](https://docs.comfy.org).`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-table',
|
||||
title: 'Table',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-table-1',
|
||||
role: 'user',
|
||||
text: 'Compare the available samplers'
|
||||
},
|
||||
{
|
||||
id: 'demo-table-2',
|
||||
role: 'assistant',
|
||||
text: `Here is a comparison of common samplers:
|
||||
|
||||
| Sampler | Steps | Quality | Speed |
|
||||
| --- | --- | --- | --- |
|
||||
| euler | 20 | Good | Fast |
|
||||
| euler_a | 20 | Great | Fast |
|
||||
| dpm++ 2m | 25 | Excellent | Medium |
|
||||
| dpm++ sde | 30 | Best | Slow |
|
||||
| ddim | 50 | Good | Slow |
|
||||
|
||||
Use **euler** or **euler_a** to get started quickly.`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-thinking',
|
||||
title: 'Thinking',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{ id: 'demo-think-1', role: 'user', text: 'Analyze my current workflow' },
|
||||
{ id: 'demo-think-2', role: 'assistant', text: '', thinking: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-tool-calls',
|
||||
title: 'Tool calls',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-tools-1',
|
||||
role: 'user',
|
||||
text: 'Build a workflow for image to video'
|
||||
},
|
||||
{
|
||||
id: 'demo-tools-2',
|
||||
role: 'assistant',
|
||||
text: 'I set up the nodes and connections for your image-to-video workflow. The KSampler is configured with sensible defaults — adjust the steps and CFG scale to taste.',
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-attachments',
|
||||
title: 'Attachments',
|
||||
createdAt: daysAgo(0),
|
||||
messages: [
|
||||
{
|
||||
id: 'demo-attach-1',
|
||||
role: 'user',
|
||||
text: 'Use this image as a reference',
|
||||
attachments: [
|
||||
{
|
||||
name: 'reference.png',
|
||||
type: 'image/png',
|
||||
url: '/assets/images/reference.png',
|
||||
size: 204800
|
||||
},
|
||||
{
|
||||
name: 'style-guide.pdf',
|
||||
type: 'application/pdf',
|
||||
url: '',
|
||||
size: 512000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'demo-attach-2',
|
||||
role: 'assistant',
|
||||
text: "I can see the reference image. I'll use the visual style and color palette as a guide when configuring the workflow nodes."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const messages = ref<AgentMessage[]>([])
|
||||
const input = ref('')
|
||||
const status = ref<ChatStatus>('ready')
|
||||
const currentConversationId = ref<string | null>(null)
|
||||
const chatHistory = ref<AgentConversation[]>([
|
||||
...DEMO_CONVERSATIONS,
|
||||
{
|
||||
id: 'h-yesterday',
|
||||
title: 'Generate a yellow duck with a hockey mask',
|
||||
createdAt: daysAgo(1),
|
||||
messages: [
|
||||
{
|
||||
id: 'h-y-1',
|
||||
role: 'user',
|
||||
text: 'Generate a yellow duck with a hockey mask'
|
||||
},
|
||||
{
|
||||
id: 'h-y-2',
|
||||
role: 'assistant',
|
||||
text: buildMockReply('Generate a yellow duck with a hockey mask'),
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'h-last7',
|
||||
title: 'Build a workflow for image to video with 3 models',
|
||||
createdAt: daysAgo(4),
|
||||
messages: [
|
||||
{
|
||||
id: 'h-l7-1',
|
||||
role: 'user',
|
||||
text: 'Build a workflow for image to video with 3 models'
|
||||
},
|
||||
{
|
||||
id: 'h-l7-2',
|
||||
role: 'assistant',
|
||||
text: buildMockReply(
|
||||
'Build a workflow for image to video with 3 models'
|
||||
),
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'h-last30',
|
||||
title: 'Find the best workflow for skin upscaling',
|
||||
createdAt: daysAgo(15),
|
||||
messages: [
|
||||
{
|
||||
id: 'h-l30-1',
|
||||
role: 'user',
|
||||
text: 'Find the best workflow for skin upscaling'
|
||||
},
|
||||
{
|
||||
id: 'h-l30-2',
|
||||
role: 'assistant',
|
||||
text: buildMockReply('Find the best workflow for skin upscaling'),
|
||||
toolCalls: MOCK_TOOL_CALLS
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
let idCounter = 0
|
||||
let streamTimer: ReturnType<typeof setInterval> | null = null
|
||||
let thinkingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function nextId() {
|
||||
idCounter += 1
|
||||
return `agent-msg-${idCounter}`
|
||||
}
|
||||
|
||||
function buildMockReply(prompt: string) {
|
||||
return [
|
||||
`# Plan for ${prompt}`,
|
||||
'',
|
||||
'## Overview',
|
||||
'',
|
||||
`This is a mocked response for **${prompt}**. It demonstrates the markdown rendering capabilities of the agent chat panel.`,
|
||||
'',
|
||||
'## Steps',
|
||||
'',
|
||||
'1. Inspect the current graph and selected nodes.',
|
||||
'2. Assemble the nodes needed for the request.',
|
||||
'3. Wire the connections and set sensible defaults.',
|
||||
'4. Validate the output and iterate as needed.',
|
||||
'',
|
||||
'## Key Concepts',
|
||||
'',
|
||||
'- **Nodes** are the building blocks of a workflow.',
|
||||
'- **Edges** connect nodes and carry data between them.',
|
||||
'- Use _italics_ for emphasis and `inline code` for node names.',
|
||||
'',
|
||||
'## Before You Start',
|
||||
'',
|
||||
'> Make sure your checkpoint model is downloaded and placed in the `models/checkpoints` folder. The workflow will not run without it.',
|
||||
'',
|
||||
'## Node Reference',
|
||||
'',
|
||||
'| Node | Type | Description |',
|
||||
'| --- | --- | --- |',
|
||||
'| KSampler | Sampler | Runs the diffusion sampling loop |',
|
||||
'| CLIPTextEncode | Conditioning | Encodes a text prompt |',
|
||||
'| VAEDecode | Latent | Decodes latent image to pixels |',
|
||||
'',
|
||||
'## Example Workflow',
|
||||
'',
|
||||
'```javascript:workflow.js',
|
||||
'export default {',
|
||||
' nodes: [',
|
||||
' { id: 1, type: "CheckpointLoaderSimple", inputs: { ckpt_name: "flux1-dev-fp8.safetensors" } },',
|
||||
' { id: 2, type: "CLIPTextEncode", inputs: { text: "a photo of a mountain at sunset" } },',
|
||||
' { id: 3, type: "KSampler", inputs: { seed: 42, steps: 20, cfg: 7, sampler_name: "euler" } },',
|
||||
' { id: 4, type: "VAEDecode" },',
|
||||
' { id: 5, type: "SaveImage", inputs: { filename_prefix: "output" } },',
|
||||
' ],',
|
||||
' links: [',
|
||||
' [1, 0, 3, 0], // model → KSampler',
|
||||
' [2, 0, 3, 1], // conditioning → KSampler',
|
||||
' [3, 0, 4, 0], // latent → VAEDecode',
|
||||
' [4, 0, 5, 0], // image → SaveImage',
|
||||
' ],',
|
||||
'}',
|
||||
'```',
|
||||
'',
|
||||
'## Resources',
|
||||
'',
|
||||
'Download the completed workflow: https://comfyhub.com/workflows/flux-img2img-v2.json',
|
||||
'',
|
||||
'Or grab the model checkpoint from the registry:',
|
||||
'https://comfy.org/models/flux1-dev-fp8.safetensors',
|
||||
'',
|
||||
'_This is a prototype response and does not modify your graph._'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (streamTimer) {
|
||||
clearInterval(streamTimer)
|
||||
streamTimer = null
|
||||
}
|
||||
if (thinkingTimer) {
|
||||
clearTimeout(thinkingTimer)
|
||||
thinkingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function streamReply(reply: string) {
|
||||
messages.value.push({
|
||||
id: nextId(),
|
||||
role: 'assistant',
|
||||
text: '',
|
||||
thinking: true,
|
||||
toolCalls: undefined
|
||||
})
|
||||
const message = messages.value[messages.value.length - 1]
|
||||
|
||||
thinkingTimer = setTimeout(() => {
|
||||
thinkingTimer = null
|
||||
message.thinking = false
|
||||
message.toolCalls = MOCK_TOOL_CALLS
|
||||
status.value = 'streaming'
|
||||
|
||||
const tokens = reply.split(' ')
|
||||
let index = 0
|
||||
streamTimer = setInterval(() => {
|
||||
if (index >= tokens.length) {
|
||||
clearTimers()
|
||||
status.value = 'ready'
|
||||
return
|
||||
}
|
||||
message.text += (index === 0 ? '' : ' ') + tokens[index]
|
||||
index += 1
|
||||
}, STREAM_INTERVAL_MS)
|
||||
}, TOOL_CALLS_DELAY_MS)
|
||||
}
|
||||
|
||||
function send(text?: string, files: File[] = []) {
|
||||
const content = (text ?? input.value).trim()
|
||||
if (!content || status.value !== 'ready') return
|
||||
|
||||
const attachments: MessageAttachment[] = files.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
url: URL.createObjectURL(f),
|
||||
size: f.size
|
||||
}))
|
||||
|
||||
messages.value.push({
|
||||
id: nextId(),
|
||||
role: 'user',
|
||||
text: content,
|
||||
attachments: attachments.length ? attachments : undefined
|
||||
})
|
||||
input.value = ''
|
||||
status.value = 'submitted'
|
||||
|
||||
if (!currentConversationId.value) {
|
||||
const id = `conv-${Date.now()}`
|
||||
currentConversationId.value = id
|
||||
chatHistory.value.unshift({
|
||||
id,
|
||||
title: content,
|
||||
createdAt: new Date(),
|
||||
messages: messages.value
|
||||
})
|
||||
}
|
||||
|
||||
thinkingTimer = setTimeout(() => {
|
||||
thinkingTimer = null
|
||||
streamReply(buildMockReply(content))
|
||||
}, THINKING_DELAY_MS)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearTimers()
|
||||
status.value = 'ready'
|
||||
}
|
||||
|
||||
function applySuggestion(text: string) {
|
||||
input.value = text
|
||||
}
|
||||
|
||||
function startNewChat() {
|
||||
clearTimers()
|
||||
messages.value = []
|
||||
input.value = ''
|
||||
status.value = 'ready'
|
||||
currentConversationId.value = null
|
||||
}
|
||||
|
||||
function loadConversation(id: string) {
|
||||
const conv = chatHistory.value.find((c) => c.id === id)
|
||||
if (!conv) return
|
||||
clearTimers()
|
||||
messages.value = conv.messages.map((m) => ({ ...m }))
|
||||
currentConversationId.value = id
|
||||
status.value = 'ready'
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
const idx = chatHistory.value.findIndex((c) => c.id === id)
|
||||
if (idx !== -1) chatHistory.value.splice(idx, 1)
|
||||
if (currentConversationId.value === id) startNewChat()
|
||||
}
|
||||
|
||||
async function copyConversation(id: string) {
|
||||
const conv = chatHistory.value.find((c) => c.id === id)
|
||||
if (!conv) return
|
||||
const lines =
|
||||
conv.messages.length > 0
|
||||
? conv.messages.map(
|
||||
(m) => `${m.role === 'user' ? 'You' : 'Assistant'}: ${m.text}`
|
||||
)
|
||||
: [conv.title]
|
||||
await navigator.clipboard.writeText(lines.join('\n\n'))
|
||||
}
|
||||
|
||||
export function useAgentChatPrototype() {
|
||||
return {
|
||||
messages: readonly(messages),
|
||||
input,
|
||||
status: readonly(status),
|
||||
chatHistory: readonly(chatHistory),
|
||||
currentConversationId: readonly(currentConversationId),
|
||||
isEmpty: computed(() => messages.value.length === 0),
|
||||
send,
|
||||
stop,
|
||||
applySuggestion,
|
||||
startNewChat,
|
||||
loadConversation,
|
||||
deleteConversation,
|
||||
copyConversation
|
||||
}
|
||||
}
|
||||
43
src/platform/agent/stores/agentPanelStore.ts
Normal file
43
src/platform/agent/stores/agentPanelStore.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const PANEL_MIN_WIDTH = 420
|
||||
const PANEL_MAX_WIDTH = 960
|
||||
|
||||
export const useAgentPanelStore = defineStore('agentPanel', () => {
|
||||
const isOpen = ref(false)
|
||||
const width = ref(PANEL_MIN_WIDTH)
|
||||
|
||||
const isMaximized = computed(() => width.value === PANEL_MAX_WIDTH)
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function setWidth(px: number) {
|
||||
width.value = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, px))
|
||||
}
|
||||
|
||||
function toggleMaximize() {
|
||||
setWidth(isMaximized.value ? PANEL_MIN_WIDTH : PANEL_MAX_WIDTH)
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
width,
|
||||
isMaximized,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
setWidth,
|
||||
toggleMaximize
|
||||
}
|
||||
})
|
||||
117
src/platform/keybindings/keybindingService.dialog.test.ts
Normal file
117
src/platform/keybindings/keybindingService.dialog.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user