Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/components/topbar/WorkflowTabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const agentPanelHolder = vi.hoisted(() => ({
isOpen: { value: boolean }
enabled: { value: boolean }
toggle: ReturnType<typeof vi.fn>
open: ReturnType<typeof vi.fn>
}
}))
vi.mock(
Expand All @@ -112,12 +113,23 @@ vi.mock(
toggle: vi.fn(() => {
agentPanelHolder.store.isOpen.value =
!agentPanelHolder.store.isOpen.value
}),
open: vi.fn(() => {
agentPanelHolder.store.isOpen.value = true
})
}
return { useAgentPanelStore: () => agentPanelHolder.store }
}
)

const withConsent = vi.hoisted(() =>
vi.fn((onAccept: () => void) => onAccept())
)
vi.mock(
'@/workbench/extensions/agent/composables/agent/useAgentConsent',
() => ({ useAgentConsent: () => ({ withConsent }) })
)

const trackAgentEntryButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackAgentEntryButtonClicked })
Expand Down Expand Up @@ -191,6 +203,9 @@ describe('WorkflowTabs agent entry button', () => {
agentPanelHolder.store.isOpen.value = false
trackAgentEntryButtonClicked.mockClear()
agentPanelHolder.store.toggle.mockClear()
agentPanelHolder.store.open.mockClear()
withConsent.mockClear()
withConsent.mockImplementation((onAccept: () => void) => onAccept())
})

afterEach(() => {
Expand All @@ -208,7 +223,7 @@ describe('WorkflowTabs agent entry button', () => {
expect(trackAgentEntryButtonClicked).toHaveBeenCalledWith({
resulting_state: 'opened'
})
expect(agentPanelHolder.store.toggle).toHaveBeenCalledTimes(1)
expect(agentPanelHolder.store.open).toHaveBeenCalledTimes(1)

agentPanelHolder.store.isOpen.value = true
await user.click(
Expand Down
16 changes: 13 additions & 3 deletions src/components/topbar/WorkflowTabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useWorkflowTabActivityStore } from '@/stores/workflowTabActivityStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useTelemetry } from '@/platform/telemetry'
import { useAgentConsent } from '@/workbench/extensions/agent/composables/agent/useAgentConsent'
import { useAgentPanelStore } from '@/workbench/extensions/agent/stores/agent/agentPanelStore'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { whileMouseDown } from '@/utils/mouseDownUtil'
Expand All @@ -186,15 +187,24 @@ const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const agentPanelStore = useAgentPanelStore()
const { withConsent } = useAgentConsent()
const tabActivity = useWorkflowTabActivityStore()
const { isOpen: isAgentPanelOpen, enabled: agentPanelEnabled } =
storeToRefs(agentPanelStore)

function onAgentEntryClick(): void {
useTelemetry()?.trackAgentEntryButtonClicked({
resulting_state: isAgentPanelOpen.value ? 'closed' : 'opened'
// Closing never needs consent; opening does, the first time. Report the
// click only once it has an outcome, so a declined consent is not counted
// as an open.
if (isAgentPanelOpen.value) {
useTelemetry()?.trackAgentEntryButtonClicked({ resulting_state: 'closed' })
agentPanelStore.toggle()
return
}
withConsent(() => {
useTelemetry()?.trackAgentEntryButtonClicked({ resulting_state: 'opened' })
agentPanelStore.open()
})
agentPanelStore.toggle()
}
const { isLoggedIn } = useCurrentUser()

Expand Down
11 changes: 10 additions & 1 deletion src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -4988,7 +4988,16 @@
"runModeTriggerAutoLimit": "Auto (limited)",
"runModeTriggerAutoLimitTooltip": "Ask when credit limit is reached",
"showMore": "Show more",
"showLess": "Show less"
"showLess": "Show less",
"consent": {
"title": "Let Comfy Agent work in your workflow",
"body1": "Comfy Agent can read the workflow you have open, add and edit nodes, and change widget values on your behalf. It only acts on the workflow you have open.",
"body2": "Running stays your decision: the agent prepares the graph and you click Run to execute it.",
"readDocs": "Read the docs",
"reject": "Reject",
"accept": "Accept",
"videoPlaceholder": "Video unavailable"
}
},
"gettingStarted": {
"title": "Let's make something",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'

import AgentConsentCard from '@/workbench/extensions/agent/components/agent/AgentConsentCard.vue'

const VIDEO_SRC = 'https://media.comfy.org/website/mcp/launch-film.mp4'

const paragraphs = [
'The agent can read your workflow, add and edit nodes, and run the graph on your behalf. It only acts on the workflow you have open.',
'You can revoke access at any time from settings. Nothing is shared until you accept.'
]

const meta: Meta<typeof AgentConsentCard> = {
title: 'Agent/ConsentCard',
component: AgentConsentCard,
tags: ['autodocs'],
// The card is designed on a dark surface; default the theme toolbar to dark.
globals: { theme: 'dark' },
args: {
title: 'Let the agent work in your workflow',
paragraphs,
videoSrc: VIDEO_SRC,
docsUrl: 'https://docs.comfy.org/agent-tools/in-app-agent'
},
decorators: [
() => ({
template:
'<div class="grid min-h-screen place-items-center bg-base-background p-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {}

export const WithoutVideo: Story = {
args: { videoSrc: '' }
}

export const SingleParagraph: Story = {
args: { paragraphs: [paragraphs[0]] }
}

export const LongCopy: Story = {
args: {
title:
'Let the agent read, edit, and run the workflow you currently have open',
paragraphs: [
...paragraphs,
'Generations started by the agent consume credits in the same way as generations you start yourself, and appear in the same job queue.'
]
}
}

/**
* The card responds to its container, not the viewport, so it stacks inside a
* narrow agent panel even on a wide screen.
*/
export const InNarrowPanel: Story = {
decorators: [
() => ({
template:
'<div class="grid min-h-screen place-items-center bg-base-background p-8"><div class="w-[380px]"><story /></div></div>'
})
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'

const {
title,
paragraphs,
videoSrc = '',
docsUrl = ''
} = defineProps<{
title: string
paragraphs: string[]
videoSrc?: string
docsUrl?: string
}>()

const emit = defineEmits<{
reject: []
accept: []
}>()

function openDocs(): void {
window.open(docsUrl, '_blank', 'noopener')
}
</script>

<template>
<div class="@container w-full max-w-[1040px]">
<div
class="bg-agent-surface border-agent-border grid max-h-[90dvh] grid-cols-1 overflow-hidden rounded-2xl border shadow-[0_20px_24px_-4px_rgba(10,13,18,0.4),0_8px_8px_-4px_rgba(10,13,18,0.25)] @2xl:min-h-[543px] @2xl:grid-cols-[555fr_483fr]"
>
<div class="shrink-0 p-2">
<video
v-if="videoSrc"
:src="videoSrc"
class="aspect-video w-full rounded-xl object-cover @2xl:aspect-auto @2xl:size-full"
autoplay
muted
loop
playsinline
/>
<div
v-else
class="text-agent-fg-muted bg-agent-surface-raised grid aspect-video w-full place-items-center rounded-xl text-xs @2xl:aspect-auto @2xl:size-full"
>
{{ $t('agent.consent.videoPlaceholder') }}
</div>
</div>

<section
class="flex min-h-0 flex-col gap-6 overflow-y-auto p-6 @2xl:gap-9 @2xl:p-9"
>
<div class="hidden flex-1 @2xl:block" />

<div class="flex flex-col gap-4">
<h2 class="text-agent-fg my-0 text-xl font-semibold @2xl:text-2xl">
{{ title }}
</h2>
<p
v-for="(paragraph, index) in paragraphs"
:key="index"
class="text-agent-fg-muted my-0 text-sm/5"
>
{{ paragraph }}
</p>
</div>

<footer class="flex flex-wrap items-center justify-between gap-2.5">
<Button
variant="textonly"
size="md"
class="-ml-2 gap-1"
@click="openDocs"
>
{{ $t('agent.consent.readDocs') }}
<span class="icon-[lucide--square-arrow-out-up-right] size-4" />
</Button>

<div class="ml-auto flex items-center gap-2.5">
<Button variant="secondary" size="md" @click="emit('reject')">
{{ $t('agent.consent.reject') }}
</Button>
<Button variant="inverted" size="md" @click="emit('accept')">
{{ $t('agent.consent.accept') }}
</Button>
</div>
</footer>
</section>
</div>
</div>
</template>
108 changes: 108 additions & 0 deletions src/workbench/extensions/agent/composables/agent/useAgentConsent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useLocalStorage } from '@vueuse/core'
import { computed, defineAsyncComponent } from 'vue'

import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { i18n } from '@/i18n'
import { useDialogStore } from '@/stores/dialogStore'

const CONSENT_STORAGE_KEY = 'Comfy.AgentPanel.consentAccepted'
const CONSENT_DIALOG_KEY = 'agent-consent'
const DOCS_URL = 'https://docs.comfy.org/agent-tools/in-app-agent'
const CONSENT_VIDEO_SRC = 'https://media.comfy.org/website/mcp/launch-film.mp4'
const SIGNED_OUT_KEY = 'signed-out'

/**
* The agent panel is cloud-only and tree-shaken out of OSS builds, but the
* topbar that gates it ships in every bundle. Load the card lazily so its
* markup stays out of distributions that can never show it.
*/
const AgentConsentCard = defineAsyncComponent(
() =>
import('@/workbench/extensions/agent/components/agent/AgentConsentCard.vue')
)

/**
* Consent belongs to a person, not a browser, so the record is keyed by user
* id — a second account on the same profile is asked in its own right.
*/
const acceptedByUser = useLocalStorage<Record<string, boolean>>(
CONSENT_STORAGE_KEY,
{}
)

/**
* `?agentConsent=always` re-asks on every open and does not record the answer,
* so a demo can be replayed without clearing storage by hand. Read per call so
* the URL can be changed without a reload.
*/
function alwaysAsk(): boolean {
return (
new URLSearchParams(window.location.search).get('agentConsent') === 'always'
)
}

export function useAgentConsent() {
const dialogStore = useDialogStore()
const { resolvedUserInfo } = useCurrentUser()
const { t } = i18n.global

const userKey = (): string => resolvedUserInfo.value?.id ?? SIGNED_OUT_KEY

const accepted = computed<boolean>({
get: () => acceptedByUser.value?.[userKey()] === true,
set: (value) => {
const current = acceptedByUser.value
acceptedByUser.value = {
...(current && typeof current === 'object' ? current : {}),
[userKey()]: value
}
}
})

/**
* Runs `onAccept` immediately once consent is on record, otherwise puts the
* consent card up first and runs it only if the reader accepts.
*/
function withConsent(onAccept: () => void): void {
const replaying = alwaysAsk()
if (accepted.value && !replaying) {
onAccept()
return
}

dialogStore.showDialog({
key: CONSENT_DIALOG_KEY,
component: AgentConsentCard,
props: {
title: t('agent.consent.title'),
paragraphs: [t('agent.consent.body1'), t('agent.consent.body2')],
videoSrc: CONSENT_VIDEO_SRC,
docsUrl: DOCS_URL,
onAccept: () => {
if (!replaying) accepted.value = true
dialogStore.closeDialog({ key: CONSENT_DIALOG_KEY })
onAccept()
},
onReject: () => dialogStore.closeDialog({ key: CONSENT_DIALOG_KEY })
},
dialogComponentProps: {
renderer: 'reka',
// The mask stays inert so consent is not lost to a stray click, but
// Escape declines and remains the way out if the card itself ever
// fails to render.
dismissableMask: false,
closeOnEscape: true,
modal: true,
headless: true,
size: 'xl',
// The card draws its own panel — neutralize the chrome box. It sizes
// itself with w-full, so the content box needs a real width: w-fit
// would collapse it to nothing.
contentClass:
'w-[min(1040px,calc(100vw-2rem))] border-none bg-transparent shadow-none sm:max-w-[1040px]'
}
})
}

return { accepted, withConsent }
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const useAgentPanelStore = defineStore('agentPanel', () => {
width,
isMaximized,
dismissedSelectionSignature,
open,
toggle,
close,
setWidth,
Expand Down
Loading