merge main into rh-test

This commit is contained in:
bymyself
2025-09-28 15:33:29 -07:00
parent 1c0f151d02
commit ff0c15b119
1317 changed files with 85439 additions and 18373 deletions

View File

@@ -0,0 +1,120 @@
import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { ref } from 'vue'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const releaseApiClient = axios.create({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
})
// Use generated types from OpenAPI spec
export type ReleaseNote = components['schemas']['ReleaseNote']
type GetReleasesParams = operations['getReleaseNotes']['parameters']['query']
// Use generated error response type
type ErrorResponse = components['schemas']['ErrorResponse']
// Release service for fetching release notes
export const useReleaseService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
// No transformation needed - API response matches the generated type
// Handle API errors with context
const handleApiError = (
err: unknown,
context: string,
routeSpecificErrors?: Record<number, string>
): string => {
if (!axios.isAxiosError(err))
return err instanceof Error
? `${context}: ${err.message}`
: `${context}: Unknown error occurred`
const axiosError = err as AxiosError<ErrorResponse>
if (axiosError.response) {
const { status, data } = axiosError.response
if (routeSpecificErrors && routeSpecificErrors[status])
return routeSpecificErrors[status]
switch (status) {
case 400:
return `Bad request: ${data?.message || 'Invalid input'}`
case 401:
return 'Unauthorized: Authentication required'
case 403:
return `Forbidden: ${data?.message || 'Access denied'}`
case 404:
return `Not found: ${data?.message || 'Resource not found'}`
case 500:
return `Server error: ${data?.message || 'Internal server error'}`
default:
return `${context}: ${data?.message || axiosError.message}`
}
}
return `${context}: ${axiosError.message}`
}
// Execute API request with error handling
const executeApiRequest = async <T>(
apiCall: () => Promise<AxiosResponse<T>>,
errorContext: string,
routeSpecificErrors?: Record<number, string>
): Promise<T | null> => {
isLoading.value = true
error.value = null
try {
const response = await apiCall()
return response.data
} catch (err) {
// Don't treat cancellations as errors
if (isAbortError(err)) return null
error.value = handleApiError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
}
// Fetch release notes from API
const getReleases = async (
params: GetReleasesParams,
signal?: AbortSignal
): Promise<ReleaseNote[] | null> => {
const endpoint = '/releases'
const errorContext = 'Failed to get releases'
const routeSpecificErrors = {
400: 'Invalid project or version parameter'
}
const apiResponse = await executeApiRequest(
() =>
releaseApiClient.get<ReleaseNote[]>(endpoint, {
params,
signal
}),
errorContext,
routeSpecificErrors
)
return apiResponse
}
return {
isLoading,
error,
getReleases
}
}

View File

@@ -0,0 +1,293 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia'
import { compare } from 'semver'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
import { stringToLocale } from '@/utils/formatUtil'
import { type ReleaseNote, useReleaseService } from './releaseService'
// Store for managing release notes
export const useReleaseStore = defineStore('release', () => {
// State
const releases = ref<ReleaseNote[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// Services
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
// Current ComfyUI version
const currentComfyUIVersion = computed(
() => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
)
// Release data from settings
const locale = computed(() => settingStore.get('Comfy.Locale'))
const releaseVersion = computed(() =>
settingStore.get('Comfy.Release.Version')
)
const releaseStatus = computed(() => settingStore.get('Comfy.Release.Status'))
const releaseTimestamp = computed(() =>
settingStore.get('Comfy.Release.Timestamp')
)
const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
// Most recent release
const recentRelease = computed(() => {
return releases.value[0] ?? null
})
// 3 most recent releases
const recentReleases = computed(() => {
return releases.value.slice(0, 3)
})
// Helper constants
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 // 3 days
// New version available?
const isNewVersionAvailable = computed(
() =>
!!recentRelease.value &&
compare(
recentRelease.value.version,
currentComfyUIVersion.value || '0.0.0'
) > 0
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
compare(
recentRelease.value.version,
currentComfyUIVersion.value || '0.0.0'
) === 0
)
const hasMediumOrHighAttention = computed(() =>
recentReleases.value
.slice(0, -1)
.some(
(release) =>
release.attention === 'medium' || release.attention === 'high'
)
)
// Show toast if needed
const shouldShowToast = computed(() => {
// Only show on desktop version
if (!isElectron()) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isNewVersionAvailable.value) {
return false
}
// Skip if low attention
if (!hasMediumOrHighAttention.value) {
return false
}
// Skip if user already skipped or changelog seen
if (
releaseVersion.value === recentRelease.value?.version &&
['skipped', 'changelog seen'].includes(releaseStatus.value)
) {
return false
}
return true
})
// Show red-dot indicator
const shouldShowRedDot = computed(() => {
// Only show on desktop version
if (!isElectron()) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
// Already latest → no dot
if (!isNewVersionAvailable.value) {
return false
}
const { version } = recentRelease.value
// Changelog seen → clear dot
if (
releaseVersion.value === version &&
releaseStatus.value === 'changelog seen'
) {
return false
}
// Attention medium / high (levels 2 & 3)
if (hasMediumOrHighAttention.value) {
// Persist until changelog is opened
return true
}
// Attention low (level 1) and skipped → keep up to 3 d
if (
releaseVersion.value === version &&
releaseStatus.value === 'skipped' &&
releaseTimestamp.value &&
Date.now() - releaseTimestamp.value >= THREE_DAYS_MS
) {
return false
}
// Not skipped → show
return true
})
// Show "What's New" popup
const shouldShowPopup = computed(() => {
// Only show on desktop version
if (!isElectron()) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isLatestVersion.value) {
return false
}
// Hide if already seen
if (
releaseVersion.value === recentRelease.value.version &&
releaseStatus.value === "what's new seen"
) {
return false
}
return true
})
// Action handlers for user interactions
async function handleSkipRelease(version: string): Promise<void> {
if (
version !== recentRelease.value?.version ||
releaseStatus.value === 'changelog seen'
) {
return
}
await settingStore.set('Comfy.Release.Version', version)
await settingStore.set('Comfy.Release.Status', 'skipped')
await settingStore.set('Comfy.Release.Timestamp', Date.now())
}
async function handleShowChangelog(version: string): Promise<void> {
if (version !== recentRelease.value?.version) {
return
}
await settingStore.set('Comfy.Release.Version', version)
await settingStore.set('Comfy.Release.Status', 'changelog seen')
await settingStore.set('Comfy.Release.Timestamp', Date.now())
}
async function handleWhatsNewSeen(version: string): Promise<void> {
if (version !== recentRelease.value?.version) {
return
}
await settingStore.set('Comfy.Release.Version', version)
await settingStore.set('Comfy.Release.Status', "what's new seen")
await settingStore.set('Comfy.Release.Timestamp', Date.now())
}
// Fetch releases from API
async function fetchReleases(): Promise<void> {
if (isLoading.value) {
return
}
// Skip fetching if notifications are disabled
if (!showVersionUpdates.value) {
return
}
// Skip fetching if API nodes are disabled via argv
if (
systemStatsStore.systemStats?.system?.argv?.includes(
'--disable-api-nodes'
)
) {
return
}
isLoading.value = true
error.value = null
try {
// Ensure system stats are loaded
if (!systemStatsStore.systemStats) {
await until(systemStatsStore.isInitialized)
}
const fetchedReleases = await releaseService.getReleases({
project: 'comfyui',
current_version: currentComfyUIVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})
if (fetchedReleases !== null) {
releases.value = fetchedReleases
} else if (releaseService.error.value) {
error.value = releaseService.error.value
}
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Unknown error occurred'
} finally {
isLoading.value = false
}
}
// Initialize store
async function initialize(): Promise<void> {
await fetchReleases()
}
return {
releases,
isLoading,
error,
recentRelease,
recentReleases,
shouldShowToast,
shouldShowRedDot,
shouldShowPopup,
shouldShowUpdateButton: isNewVersionAvailable,
handleSkipRelease,
handleShowChangelog,
handleWhatsNewSeen,
fetchReleases,
initialize
}
})

View File

@@ -0,0 +1,39 @@
// Within Vue component context, you can directly call useToast().add()
// instead of going through the store.
// The store is useful when you need to call it from outside the Vue component context.
import { defineStore } from 'pinia'
import type { ToastMessageOptions } from 'primevue/toast'
import { ref } from 'vue'
export const useToastStore = defineStore('toast', () => {
const messagesToAdd = ref<ToastMessageOptions[]>([])
const messagesToRemove = ref<ToastMessageOptions[]>([])
const removeAllRequested = ref(false)
function add(message: ToastMessageOptions) {
messagesToAdd.value = [...messagesToAdd.value, message]
}
function remove(message: ToastMessageOptions) {
messagesToRemove.value = [...messagesToRemove.value, message]
}
function removeAll() {
removeAllRequested.value = true
}
function addAlert(message: string) {
add({ severity: 'warn', summary: 'Alert', detail: message })
}
return {
messagesToAdd,
messagesToRemove,
removeAllRequested,
add,
remove,
removeAll,
addAlert
}
})

View File

@@ -0,0 +1,94 @@
import { whenever } from '@vueuse/core'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from './toastStore'
import { useVersionCompatibilityStore } from './versionCompatibilityStore'
interface UseFrontendVersionMismatchWarningOptions {
immediate?: boolean
}
/**
* Composable for handling frontend version mismatch warnings.
*
* Displays toast notifications when the frontend version is incompatible with the backend,
* either because the frontend is outdated or newer than the backend expects.
* Automatically dismisses warnings when shown and persists dismissal state for 7 days.
*
* @param options - Configuration options
* @param options.immediate - If true, automatically shows warning when version mismatch is detected
* @returns Object with methods and computed properties for managing version warnings
*
* @example
* ```ts
* // Show warning immediately when mismatch detected
* const { showWarning, shouldShowWarning } = useFrontendVersionMismatchWarning({ immediate: true })
*
* // Manual control
* const { showWarning } = useFrontendVersionMismatchWarning()
* showWarning() // Call when needed
* ```
*/
export function useFrontendVersionMismatchWarning(
options: UseFrontendVersionMismatchWarningOptions = {}
) {
const { immediate = false } = options
const { t } = useI18n()
const toastStore = useToastStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
// Track if we've already shown the warning
let hasShownWarning = false
const showWarning = () => {
// Prevent showing the warning multiple times
if (hasShownWarning) return
const message = versionCompatibilityStore.warningMessage
if (!message) return
const detailMessage = t('g.frontendOutdated', {
frontendVersion: message.frontendVersion,
requiredVersion: message.requiredVersion
})
const fullMessage = t('g.versionMismatchWarningMessage', {
warning: t('g.versionMismatchWarning'),
detail: detailMessage
})
toastStore.addAlert(fullMessage)
hasShownWarning = true
// Automatically dismiss the warning so it won't show again for 7 days
versionCompatibilityStore.dismissWarning()
}
onMounted(() => {
// Only set up the watcher if immediate is true
if (immediate) {
whenever(
() => versionCompatibilityStore.shouldShowWarning,
() => {
showWarning()
},
{
immediate: true,
once: true
}
)
}
})
return {
showWarning,
shouldShowWarning: computed(
() => versionCompatibilityStore.shouldShowWarning
),
dismissWarning: versionCompatibilityStore.dismissWarning,
hasVersionMismatch: computed(
() => versionCompatibilityStore.hasVersionMismatch
)
}
}

View File

@@ -0,0 +1,138 @@
import { until, useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { gt, valid } from 'semver'
import { computed } from 'vue'
import config from '@/config'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export const useVersionCompatibilityStore = defineStore(
'versionCompatibility',
() => {
const systemStatsStore = useSystemStatsStore()
const frontendVersion = computed(() => config.app_version)
const backendVersion = computed(
() => systemStatsStore.systemStats?.system?.comfyui_version ?? ''
)
const requiredFrontendVersion = computed(
() =>
systemStatsStore.systemStats?.system?.required_frontend_version ?? ''
)
const isFrontendOutdated = computed(() => {
if (
!frontendVersion.value ||
!requiredFrontendVersion.value ||
!valid(frontendVersion.value) ||
!valid(requiredFrontendVersion.value)
) {
return false
}
// Returns true if required version is greater than frontend version
return gt(requiredFrontendVersion.value, frontendVersion.value)
})
const isFrontendNewer = computed(() => {
// We don't warn about frontend being newer than backend
// Only warn when frontend is outdated (behind required version)
return false
})
const hasVersionMismatch = computed(() => {
return isFrontendOutdated.value
})
const versionKey = computed(() => {
if (
!frontendVersion.value ||
!backendVersion.value ||
!requiredFrontendVersion.value
) {
return null
}
return `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}`
})
// Use reactive storage for dismissals - creates a reactive ref that syncs with localStorage
// All version mismatch dismissals are stored in a single object for clean localStorage organization
const dismissalStorage = useStorage(
'comfy.versionMismatch.dismissals',
{} as Record<string, number>,
localStorage,
{
serializer: {
read: (value: string) => {
try {
return JSON.parse(value)
} catch {
return {}
}
},
write: (value: Record<string, number>) => JSON.stringify(value)
}
}
)
const isDismissed = computed(() => {
if (!versionKey.value) return false
const dismissedUntil = dismissalStorage.value[versionKey.value]
if (!dismissedUntil) return false
// Check if dismissal has expired
return Date.now() < dismissedUntil
})
const shouldShowWarning = computed(() => {
return hasVersionMismatch.value && !isDismissed.value
})
const warningMessage = computed(() => {
if (isFrontendOutdated.value) {
return {
type: 'outdated' as const,
frontendVersion: frontendVersion.value,
requiredVersion: requiredFrontendVersion.value
}
}
return null
})
async function checkVersionCompatibility() {
if (!systemStatsStore.systemStats) {
await until(systemStatsStore.isInitialized)
}
}
function dismissWarning() {
if (!versionKey.value) return
const dismissUntil = Date.now() + DISMISSAL_DURATION_MS
dismissalStorage.value = {
...dismissalStorage.value,
[versionKey.value]: dismissUntil
}
}
async function initialize() {
await checkVersionCompatibility()
}
return {
frontendVersion,
backendVersion,
requiredFrontendVersion,
hasVersionMismatch,
shouldShowWarning,
warningMessage,
isFrontendOutdated,
isFrontendNewer,
checkVersionCompatibility,
dismissWarning,
initialize
}
}
)

View File

@@ -0,0 +1,309 @@
<template>
<div v-if="shouldShow" class="release-toast-popup">
<div class="release-notification-toast">
<!-- Header section with icon and text -->
<div class="toast-header">
<div class="toast-icon">
<i class="pi pi-download" />
</div>
<div class="toast-text">
<div class="toast-title">
{{ $t('releaseToast.newVersionAvailable') }}
</div>
<div class="toast-version-badge">
{{ latestRelease?.version }}
</div>
</div>
</div>
<!-- Actions section -->
<div class="toast-actions-section">
<div class="actions-row">
<div class="left-actions">
<a
class="learn-more-link"
:href="changelogUrl"
target="_blank"
rel="noopener,noreferrer"
@click="handleLearnMore"
>
{{ $t('releaseToast.whatsNew') }}
</a>
</div>
<div class="right-actions">
<button class="skip-button" @click="handleSkip">
{{ $t('releaseToast.skip') }}
</button>
<button class="cta-button" @click="handleUpdate">
{{ $t('releaseToast.update') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatVersionAnchor } from '@/utils/formatUtil'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
const { locale } = useI18n()
const releaseStore = useReleaseStore()
// Local state for dismissed status
const isDismissed = ref(false)
// Get latest release from store
const latestRelease = computed<ReleaseNote | null>(
() => releaseStore.recentRelease
)
// Show toast when new version available and not dismissed
const shouldShow = computed(
() => releaseStore.shouldShowToast && !isDismissed.value
)
// Generate changelog URL with version anchor (language-aware)
const changelogUrl = computed(() => {
const isChineseLocale = locale.value === 'zh'
const baseUrl = isChineseLocale
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
if (latestRelease.value?.version) {
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
return `${baseUrl}#${versionAnchor}`
}
return baseUrl
})
// Auto-hide timer
let hideTimer: ReturnType<typeof setTimeout> | null = null
const startAutoHide = () => {
if (hideTimer) clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
dismissToast()
}, 8000) // 8 second auto-hide
}
const clearAutoHide = () => {
if (hideTimer) {
clearTimeout(hideTimer)
hideTimer = null
}
}
const dismissToast = () => {
isDismissed.value = true
clearAutoHide()
}
const handleSkip = () => {
if (latestRelease.value) {
void releaseStore.handleSkipRelease(latestRelease.value.version)
}
dismissToast()
}
const handleLearnMore = () => {
if (latestRelease.value) {
void releaseStore.handleShowChangelog(latestRelease.value.version)
}
// Do not dismiss; anchor will navigate in new tab but keep toast? spec maybe wants dismiss? We'll dismiss.
dismissToast()
}
const handleUpdate = () => {
window.open('https://docs.comfy.org/installation/update_comfyui', '_blank')
dismissToast()
}
// Learn more handled by anchor href
// Start auto-hide when toast becomes visible
watch(shouldShow, (isVisible) => {
if (isVisible) {
startAutoHide()
} else {
clearAutoHide()
}
})
// Initialize on mount
onMounted(async () => {
// Fetch releases if not already loaded
if (!releaseStore.releases.length) {
await releaseStore.fetchReleases()
}
})
</script>
<style scoped>
/* Toast popup - positioning handled by parent */
.release-toast-popup {
position: absolute;
bottom: 1rem;
z-index: 1000;
pointer-events: auto;
}
/* Sidebar positioning classes applied by parent - matching help center */
.release-toast-popup.sidebar-left {
left: 1rem;
}
.release-toast-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.release-toast-popup.sidebar-right {
right: 1rem;
}
/* Main toast container */
.release-notification-toast {
width: 448px;
padding: 16px 16px 8px;
background: #353535;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 12px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Header section */
.toast-header {
display: flex;
gap: 16px;
align-items: flex-start;
}
/* Icon container */
.toast-icon {
width: 42px;
height: 42px;
padding: 10px;
background: rgba(0, 122, 255, 0.2);
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.toast-icon i {
color: #007aff;
font-size: 16px;
}
/* Text content */
.toast-text {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.toast-title {
color: white;
font-size: 14px;
font-family: 'Satoshi', sans-serif;
font-weight: 500;
line-height: 18.2px;
}
.toast-version-badge {
color: #a0a1a2;
font-size: 12px;
font-family: 'Satoshi', sans-serif;
font-weight: 500;
line-height: 15.6px;
}
/* Actions section */
.toast-actions-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions-row {
padding-left: 58px; /* Align with text content */
padding-right: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.left-actions {
display: flex;
align-items: center;
}
/* Learn more link - simple text link */
.learn-more-link {
color: #60a5fa;
font-size: 12px;
font-family: 'Inter', sans-serif;
font-weight: 500;
line-height: 15.6px;
text-decoration: none;
}
.learn-more-link:hover {
text-decoration: underline;
}
.right-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Button styles */
.skip-button {
padding: 8px 16px;
background: #353535;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
border: none;
color: #aeaeb2;
font-size: 12px;
font-family: 'Inter', sans-serif;
font-weight: 500;
cursor: pointer;
}
.skip-button:hover {
background: #404040;
}
.cta-button {
padding: 8px 16px;
background: white;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
border: none;
color: black;
font-size: 12px;
font-family: 'Inter', sans-serif;
font-weight: 500;
cursor: pointer;
}
.cta-button:hover {
background: #f0f0f0;
}
</style>

View File

@@ -0,0 +1,462 @@
<template>
<div v-if="shouldShow" class="whats-new-popup-container">
<!-- Arrow pointing to help center -->
<div class="help-center-arrow">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="19"
viewBox="0 0 16 19"
fill="none"
>
<!-- Arrow fill -->
<path
d="M15.25 1.27246L15.25 17.7275L0.999023 9.5L15.25 1.27246Z"
fill="#353535"
/>
<!-- Top and bottom outlines only -->
<path
d="M15.25 1.27246L0.999023 9.5"
stroke="#4e4e4e"
stroke-width="1"
fill="none"
/>
<path
d="M0.999023 9.5L15.25 17.7275"
stroke="#4e4e4e"
stroke-width="1"
fill="none"
/>
</svg>
</div>
<div class="whats-new-popup" @click.stop>
<!-- Close Button -->
<button
class="close-button"
:aria-label="$t('g.close')"
@click="closePopup"
>
<div class="close-icon"></div>
</button>
<!-- Release Content -->
<div class="popup-content">
<div class="content-text" v-html="formattedContent"></div>
<!-- Actions Section -->
<div class="popup-actions">
<a
class="learn-more-link"
:href="changelogUrl"
target="_blank"
rel="noopener,noreferrer"
@click="closePopup"
>
{{ $t('whatsNewPopup.learnMore') }}
</a>
<!-- TODO: CTA button -->
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { marked } from 'marked'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatVersionAnchor } from '@/utils/formatUtil'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
const { locale, t } = useI18n()
const releaseStore = useReleaseStore()
// Define emits
const emit = defineEmits<{
'whats-new-dismissed': []
}>()
// Local state for dismissed status
const isDismissed = ref(false)
// Get latest release from store
const latestRelease = computed<ReleaseNote | null>(
() => releaseStore.recentRelease
)
// Show popup when on latest version and not dismissed
const shouldShow = computed(
() => releaseStore.shouldShowPopup && !isDismissed.value
)
// Generate changelog URL with version anchor (language-aware)
const changelogUrl = computed(() => {
const isChineseLocale = locale.value === 'zh'
const baseUrl = isChineseLocale
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
if (latestRelease.value?.version) {
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
return `${baseUrl}#${versionAnchor}`
}
return baseUrl
})
// Format release content for display using marked
const formattedContent = computed(() => {
if (!latestRelease.value?.content) {
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
}
try {
// Use marked to parse markdown to HTML
return marked(latestRelease.value.content, {
gfm: true // Enable GitHub Flavored Markdown
})
} catch (error) {
console.error('Error parsing markdown:', error)
// Fallback to plain text with line breaks
return latestRelease.value.content.replace(/\n/g, '<br>')
}
})
const show = () => {
isDismissed.value = false
}
const hide = () => {
isDismissed.value = true
emit('whats-new-dismissed')
}
const closePopup = async () => {
// Mark "what's new" seen when popup is closed
if (latestRelease.value) {
await releaseStore.handleWhatsNewSeen(latestRelease.value.version)
}
hide()
}
// const handleCTA = async () => {
// window.open('https://docs.comfy.org/installation/update_comfyui', '_blank')
// await closePopup()
// }
// Initialize on mount
onMounted(async () => {
// Fetch releases if not already loaded
if (!releaseStore.releases.length) {
await releaseStore.fetchReleases()
}
})
// Expose methods for parent component
defineExpose({
show,
hide
})
</script>
<style scoped>
/* Popup container - positioning handled by parent */
.whats-new-popup-container {
--whats-new-popup-bottom: 1rem;
position: absolute;
bottom: var(--whats-new-popup-bottom);
z-index: 1000;
pointer-events: auto;
}
/* Arrow pointing to help center */
.help-center-arrow {
position: absolute;
bottom: calc(
var(--sidebar-width) * 2 + var(--sidebar-width) / 2
); /* Position to center of help center icon (2 icons below + half icon height for center) */
transform: none;
z-index: 999;
pointer-events: none;
}
/* Position arrow based on sidebar location */
.whats-new-popup-container.sidebar-left .help-center-arrow {
left: -14px; /* Overlap with popup outline */
}
.whats-new-popup-container.sidebar-left.small-sidebar .help-center-arrow {
left: -14px; /* Overlap with popup outline */
bottom: calc(
var(--sidebar-width) * 2 + var(--sidebar-icon-size) / 2 -
var(--whats-new-popup-bottom)
); /* Position to center of help center icon (2 icons below + half icon height for center - whats new popup bottom position ) */
}
/* Sidebar positioning classes applied by parent */
.whats-new-popup-container.sidebar-left {
left: 1rem;
}
.whats-new-popup-container.sidebar-left.small-sidebar {
left: 1rem;
}
.whats-new-popup-container.sidebar-right {
right: 1rem;
}
.whats-new-popup {
background: #353535;
border-radius: 12px;
max-width: 400px;
width: 400px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
box-shadow: 0px 8px 32px rgba(0, 0, 0, 0.3);
position: relative;
}
/* Content Section */
.popup-content {
display: flex;
flex-direction: column;
gap: 24px;
max-height: 80vh;
overflow-y: auto;
padding: 32px 32px 24px;
border-radius: 12px;
}
/* Close button */
.close-button {
position: absolute;
top: 0;
right: 0;
width: 32px;
height: 32px;
padding: 6px;
background: #7c7c7c;
border-radius: 16px;
border: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
transform: translate(30%, -30%);
transition:
background-color 0.2s ease,
transform 0.1s ease;
z-index: 1;
}
.close-button:hover {
background: #8e8e8e;
}
.close-button:active {
background: #6a6a6a;
transform: translate(30%, -30%) scale(0.95);
}
.close-icon {
width: 16px;
height: 16px;
position: relative;
opacity: 0.9;
transition: opacity 0.2s ease;
}
.close-button:hover .close-icon {
opacity: 1;
}
.close-icon::before,
.close-icon::after {
content: '';
position: absolute;
width: 12px;
height: 2px;
background: white;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(45deg);
transition: background-color 0.2s ease;
}
.close-icon::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
/* Content Section */
.popup-content {
display: flex;
flex-direction: column;
}
.content-text {
color: white;
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
}
/* Style the markdown content */
/* Title */
.content-text :deep(*) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.content-text :deep(h1) {
font-size: 16px;
font-weight: 700;
margin-bottom: 8px;
}
/* Version subtitle - targets the first p tag after h1 */
.content-text :deep(h1 + p) {
color: #c0c0c0;
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
opacity: 0.8;
}
/* Regular paragraphs - short description */
.content-text :deep(p) {
margin-bottom: 16px;
color: #e0e0e0;
}
/* List */
.content-text :deep(ul),
.content-text :deep(ol) {
margin-bottom: 16px;
padding-left: 0;
list-style: none;
}
.content-text :deep(ul:first-child),
.content-text :deep(ol:first-child) {
margin-top: 0;
}
.content-text :deep(ul:last-child),
.content-text :deep(ol:last-child) {
margin-bottom: 0;
}
/* List items */
.content-text :deep(li) {
margin-bottom: 8px;
position: relative;
padding-left: 20px;
}
.content-text :deep(li:last-child) {
margin-bottom: 0;
}
/* Custom bullet points */
.content-text :deep(li::before) {
content: '';
position: absolute;
left: 0;
top: 10px;
display: flex;
width: 8px;
height: 8px;
justify-content: center;
align-items: center;
aspect-ratio: 1/1;
border-radius: 100px;
background: #60a5fa;
}
/* List item strong text */
.content-text :deep(li strong) {
color: #fff;
font-size: 14px;
display: block;
margin-bottom: 4px;
}
.content-text :deep(li p) {
font-size: 12px;
margin-bottom: 0;
line-height: 2;
}
/* Code styling */
.content-text :deep(code) {
background-color: #2a2a2a;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 2px 6px;
color: #f8f8f2;
white-space: nowrap;
}
/* Remove top margin for first media element */
.content-text :deep(img:first-child),
.content-text :deep(video:first-child),
.content-text :deep(iframe:first-child) {
margin-top: -32px; /* Align with the top edge of the popup content */
margin-bottom: 24px;
}
/* Media elements */
.content-text :deep(img),
.content-text :deep(video),
.content-text :deep(iframe) {
width: calc(100% + 64px);
height: auto;
margin: 24px -32px;
display: block;
}
/* Actions Section */
.popup-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.learn-more-link {
color: #60a5fa;
font-size: 14px;
font-weight: 500;
line-height: 18.2px;
text-decoration: none;
}
.learn-more-link:hover {
text-decoration: underline;
}
.cta-button {
height: 40px;
padding: 0 20px;
background: white;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
border: none;
color: #121212;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.cta-button:hover {
background: #f0f0f0;
}
</style>