[System Pop Up] Add help center with release notifications and "What's New" popup (#4256)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
bmcomfy
2025-06-26 14:11:15 -07:00
committed by GitHub
parent c2ae40bab5
commit 2d2cec2e79
23 changed files with 2952 additions and 1 deletions

View File

@@ -325,6 +325,11 @@ onMounted(async () => {
await workflowPersistence.restorePreviousWorkflow()
workflowPersistence.restoreWorkflowTabsState()
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } = await import('@/stores/releaseStore')
const releaseStore = useReleaseStore()
void releaseStore.initialize()
// Start watching for locale change after the initial value is loaded.
watch(
() => settingStore.get('Comfy.Locale'),

View File

@@ -0,0 +1,586 @@
<template>
<div class="help-center-menu" role="menu" aria-label="Help Center Menu">
<!-- Main Menu Items -->
<nav class="help-menu-section" role="menubar">
<button
v-for="menuItem in menuItems"
:key="menuItem.key"
type="button"
class="help-menu-item"
:class="{ 'more-item': menuItem.key === 'more' }"
role="menuitem"
@click="menuItem.action"
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@mouseleave="onMenuItemLeave(menuItem.key)"
>
<i :class="menuItem.icon" class="help-menu-icon" />
<span class="menu-label">{{ menuItem.label }}</span>
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
</button>
</nav>
<!-- More Submenu -->
<Teleport to="body">
<div
v-if="isSubmenuVisible"
ref="submenuRef"
class="more-submenu"
:style="submenuStyle"
@mouseenter="onSubmenuHover"
@mouseleave="onSubmenuLeave"
>
<template v-for="submenuItem in submenuItems" :key="submenuItem.key">
<div v-if="submenuItem.type === 'divider'" class="submenu-divider" />
<button
v-else
type="button"
class="help-menu-item submenu-item"
:class="{ disabled: submenuItem.disabled }"
:disabled="submenuItem.disabled"
role="menuitem"
@click="submenuItem.action"
>
<span class="menu-label">{{ submenuItem.label }}</span>
</button>
</template>
</div>
</Teleport>
<!-- What's New Section -->
<section class="whats-new-section">
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
<!-- Release Items -->
<div v-if="hasReleases" role="group" aria-label="Recent releases">
<article
v-for="release in releaseStore.recentReleases"
:key="release.id || release.version"
class="help-menu-item release-menu-item"
role="button"
tabindex="0"
@click="onReleaseClick(release)"
@keydown.enter="onReleaseClick(release)"
@keydown.space.prevent="onReleaseClick(release)"
>
<i class="pi pi-refresh help-menu-icon" aria-hidden="true" />
<div class="release-content">
<span class="release-title">
Comfy {{ release.version }} Release
</span>
<time class="release-date" :datetime="release.published_at">
<span class="normal-state">
{{ formatReleaseDate(release.published_at) }}
</span>
<span class="hover-state">
{{ $t('helpCenter.clickToLearnMore') }}
</span>
</time>
</div>
<Button
v-if="shouldShowUpdateButton(release)"
:label="$t('helpCenter.updateAvailable')"
size="small"
class="update-button"
@click.stop="onUpdate(release)"
/>
</article>
</div>
<!-- Loading State -->
<div
v-else-if="releaseStore.isLoading"
class="help-menu-item"
role="status"
aria-live="polite"
>
<i class="pi pi-spin pi-spinner help-menu-icon" aria-hidden="true" />
<span>{{ $t('helpCenter.loadingReleases') }}</span>
</div>
<!-- No Releases State -->
<div v-else class="help-menu-item" role="status">
<i class="pi pi-info-circle help-menu-icon" aria-hidden="true" />
<span>{{ $t('helpCenter.noRecentReleases') }}</span>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { type CSSProperties, computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { type ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
// Types
interface MenuItem {
key: string
icon: string
label: string
action: () => void
}
interface SubmenuItem {
key: string
type?: 'item' | 'divider'
label?: string
action?: () => void
disabled?: boolean
}
// Constants
const EXTERNAL_LINKS = {
DOCS: 'https://docs.comfy.org/',
DISCORD: 'https://www.comfy.org/discord',
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
DESKTOP_GUIDE: 'https://docs.comfy.org/installation/desktop',
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
} as const
const TIME_UNITS = {
MINUTE: 60 * 1000,
HOUR: 60 * 60 * 1000,
DAY: 24 * 60 * 60 * 1000,
WEEK: 7 * 24 * 60 * 60 * 1000,
MONTH: 30 * 24 * 60 * 60 * 1000,
YEAR: 365 * 24 * 60 * 60 * 1000
} as const
const SUBMENU_CONFIG = {
DELAY_MS: 100,
OFFSET_PX: 8,
Z_INDEX: 1002
} as const
// Composables
const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
// State
const isSubmenuVisible = ref(false)
const submenuRef = ref<HTMLElement | null>(null)
const submenuStyle = ref<CSSProperties>({})
let hoverTimeout: number | null = null
// Computed
const hasReleases = computed(() => releaseStore.releases.length > 0)
const menuItems = computed<MenuItem[]>(() => [
{
key: 'docs',
icon: 'pi pi-book',
label: t('helpCenter.docs'),
action: () => openExternalLink(EXTERNAL_LINKS.DOCS)
},
{
key: 'discord',
icon: 'pi pi-discord',
label: 'Discord',
action: () => openExternalLink(EXTERNAL_LINKS.DISCORD)
},
{
key: 'github',
icon: 'pi pi-github',
label: t('helpCenter.github'),
action: () => openExternalLink(EXTERNAL_LINKS.GITHUB)
},
{
key: 'help',
icon: 'pi pi-question-circle',
label: t('helpCenter.helpFeedback'),
action: () => openExternalLink(EXTERNAL_LINKS.DISCORD)
},
{
key: 'more',
icon: '',
label: t('helpCenter.more'),
action: () => {} // No action for more item
}
])
const submenuItems = computed<SubmenuItem[]>(() => [
{
key: 'desktop-guide',
type: 'item',
label: t('helpCenter.desktopUserGuide'),
action: () => openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE),
disabled: false
},
{
key: 'dev-tools',
type: 'item',
label: t('helpCenter.openDevTools'),
action: openDevTools,
disabled: !isElectron()
},
{
key: 'divider-1',
type: 'divider'
},
{
key: 'reinstall',
type: 'item',
label: t('helpCenter.reinstall'),
action: onReinstall,
disabled: !isElectron()
}
])
// Utility Functions
const openExternalLink = (url: string): void => {
window.open(url, '_blank', 'noopener,noreferrer')
}
const clearHoverTimeout = (): void => {
if (hoverTimeout) {
clearTimeout(hoverTimeout)
hoverTimeout = null
}
}
const calculateSubmenuPosition = (button: HTMLElement): CSSProperties => {
const rect = button.getBoundingClientRect()
const submenuWidth = 210 // Width defined in CSS
// Get actual submenu height if available, otherwise use estimated height
const submenuHeight = submenuRef.value?.offsetHeight || 120 // More realistic estimate for 2 items
// Get viewport dimensions
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Calculate basic position (aligned with button)
let top = rect.top
let left = rect.right + SUBMENU_CONFIG.OFFSET_PX
// Check if submenu would overflow viewport on the right
if (left + submenuWidth > viewportWidth) {
// Position submenu to the left of the button instead
left = rect.left - submenuWidth - SUBMENU_CONFIG.OFFSET_PX
}
// Check if submenu would overflow viewport at the bottom
if (top + submenuHeight > viewportHeight) {
// Position submenu above the button, aligned to bottom
top = Math.max(
SUBMENU_CONFIG.OFFSET_PX, // Minimum distance from top of viewport
rect.bottom - submenuHeight
)
}
// Ensure submenu doesn't go above viewport
if (top < SUBMENU_CONFIG.OFFSET_PX) {
top = SUBMENU_CONFIG.OFFSET_PX
}
return {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
zIndex: SUBMENU_CONFIG.Z_INDEX
}
}
const formatReleaseDate = (dateString?: string): string => {
if (!dateString) return 'date'
const date = new Date(dateString)
const now = new Date()
const diffTime = Math.abs(now.getTime() - date.getTime())
const timeUnits = [
{ unit: TIME_UNITS.YEAR, suffix: 'y' },
{ unit: TIME_UNITS.MONTH, suffix: 'mo' },
{ unit: TIME_UNITS.WEEK, suffix: 'w' },
{ unit: TIME_UNITS.DAY, suffix: 'd' },
{ unit: TIME_UNITS.HOUR, suffix: 'h' },
{ unit: TIME_UNITS.MINUTE, suffix: 'min' }
]
for (const { unit, suffix } of timeUnits) {
const value = Math.floor(diffTime / unit)
if (value > 0) {
return `${value}${suffix} ago`
}
}
return 'now'
}
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
return (
releaseStore.shouldShowUpdateButton &&
release === releaseStore.recentReleases[0]
)
}
// Event Handlers
const onMenuItemHover = async (
key: string,
event: MouseEvent
): Promise<void> => {
if (key !== 'more') return
clearHoverTimeout()
const moreButton = event.currentTarget as HTMLElement
// Calculate initial position before showing submenu
submenuStyle.value = calculateSubmenuPosition(moreButton)
// Show submenu with correct position
isSubmenuVisible.value = true
// After submenu is rendered, refine position if needed
await nextTick()
if (submenuRef.value) {
submenuStyle.value = calculateSubmenuPosition(moreButton)
}
}
const onMenuItemLeave = (key: string): void => {
if (key !== 'more') return
hoverTimeout = window.setTimeout(() => {
isSubmenuVisible.value = false
}, SUBMENU_CONFIG.DELAY_MS)
}
const onSubmenuHover = (): void => {
clearHoverTimeout()
}
const onSubmenuLeave = (): void => {
isSubmenuVisible.value = false
}
const openDevTools = (): void => {
if (isElectron()) {
electronAPI().openDevTools()
}
}
const onReinstall = (): void => {
if (isElectron()) {
void electronAPI().reinstall()
}
}
const onReleaseClick = (release: ReleaseNote): void => {
void releaseStore.handleShowChangelog(release.version)
const versionAnchor = formatVersionAnchor(release.version)
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
openExternalLink(changelogUrl)
}
const onUpdate = (_: ReleaseNote): void => {
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
}
// Generate language-aware changelog URL
const getChangelogUrl = (): string => {
const isChineseLocale = locale.value === 'zh'
return isChineseLocale
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
}
// Lifecycle
onMounted(async () => {
if (!hasReleases.value) {
await releaseStore.fetchReleases()
}
})
</script>
<style scoped>
.help-center-menu {
width: 380px;
max-height: 500px;
overflow-y: auto;
background: var(--p-content-background);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
border: 1px solid var(--p-content-border-color);
backdrop-filter: blur(8px);
position: relative;
}
.help-menu-section {
padding: 0.5rem 0;
border-bottom: 1px solid var(--p-content-border-color);
}
.help-menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.9rem;
color: inherit;
text-align: left;
}
.help-menu-item:hover {
background-color: #007aff26;
}
.help-menu-item:focus,
.help-menu-item:focus-visible {
outline: none;
box-shadow: none;
}
.help-menu-icon {
margin-right: 0.75rem;
font-size: 1rem;
color: var(--p-text-muted-color);
width: 16px;
display: flex;
justify-content: center;
flex-shrink: 0;
}
.menu-label {
flex: 1;
}
.more-item {
justify-content: space-between;
}
.whats-new-section {
padding: 0.5rem 0;
}
.section-description {
font-size: 0.8rem;
font-weight: 600;
color: var(--p-text-muted-color);
margin: 0 0 0.5rem 0;
padding: 0 1rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.release-menu-item {
position: relative;
}
.release-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.release-title {
font-size: 0.9rem;
line-height: 1.2;
font-weight: 500;
}
.release-date {
height: 16px;
font-size: 0.75rem;
color: var(--p-text-muted-color);
}
.release-date .hover-state {
display: none;
}
.release-menu-item:hover .release-date .normal-state,
.release-menu-item:focus-within .release-date .normal-state {
display: none;
}
.release-menu-item:hover .release-date .hover-state,
.release-menu-item:focus-within .release-date .hover-state {
display: inline;
}
.update-button {
margin-left: 0.5rem;
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
flex-shrink: 0;
}
/* Submenu Styles */
.more-submenu {
width: 210px;
padding: 0.5rem 0;
background: var(--p-content-background);
border-radius: 12px;
border: 1px solid var(--p-content-border-color);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
overflow: hidden;
transition: opacity 0.15s ease-out;
}
.submenu-item {
padding: 0.75rem 1rem;
color: inherit;
font-size: 0.9rem;
font-weight: inherit;
line-height: inherit;
}
.submenu-item:hover {
background-color: #007aff26;
}
.submenu-item:focus,
.submenu-item:focus-visible {
outline: none;
box-shadow: none;
}
.submenu-item.disabled,
.submenu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.submenu-divider {
height: 1px;
background: #3e3e3e;
margin: 0.5rem 0;
}
/* Scrollbar Styling */
.help-center-menu::-webkit-scrollbar {
width: 6px;
}
.help-center-menu::-webkit-scrollbar-track {
background: transparent;
}
.help-center-menu::-webkit-scrollbar-thumb {
background: var(--p-content-border-color);
border-radius: 3px;
}
.help-center-menu::-webkit-scrollbar-thumb:hover {
background: var(--p-text-muted-color);
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
.help-menu-item {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,308 @@
<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 type { ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { formatVersionAnchor } from '@/utils/formatUtil'
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,428 @@
<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="Close" @click="closePopup">
<div class="close-icon"></div>
</button>
<!-- Release Content -->
<div class="popup-content">
<div class="content-text" v-html="formattedContent"></div>
</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>
</template>
<script setup lang="ts">
import { marked } from 'marked'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { formatVersionAnchor } from '@/utils/formatUtil'
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 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>No release notes available.</p>'
}
try {
// Use marked to parse markdown to HTML
return marked(latestRelease.value.content, {
breaks: true, // Convert line breaks to <br>
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
}
const closePopup = async () => {
// Mark "what's new" seen when popup is closed
if (latestRelease.value) {
await releaseStore.handleWhatsNewSeen(latestRelease.value.version)
}
hide()
}
// Learn more handled by anchor href
// 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 {
position: absolute;
bottom: 1rem;
z-index: 1000;
pointer-events: auto;
}
/* Arrow pointing to help center */
.help-center-arrow {
position: absolute;
bottom: calc(
var(--sidebar-width, 4rem) + 0.25rem
); /* Position toward center of help center icon */
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(2.5rem + 0.25rem); /* Adjust for small sidebar */
}
/* 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 {
padding: 32px 32px 24px;
background: #353535;
border-radius: 12px;
max-width: 400px;
width: 400px;
display: flex;
flex-direction: column;
gap: 32px;
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;
}
/* Close button */
.close-button {
position: absolute;
top: 0;
right: 0;
width: 31px;
height: 31px;
padding: 6px 7px;
background: #7c7c7c;
border-radius: 15.5px;
border: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
transform: translate(50%, -50%);
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(50%, -50%) 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;
font-family: 'Inter', sans-serif;
font-weight: 400;
line-height: 1.5;
word-wrap: break-word;
}
/* Style the markdown content */
.content-text :deep(h1) {
color: white;
font-size: 20px;
font-weight: 700;
margin: 0 0 16px 0;
line-height: 1.3;
}
.content-text :deep(h2) {
color: white;
font-size: 18px;
font-weight: 600;
margin: 16px 0 12px 0;
line-height: 1.3;
}
.content-text :deep(h2:first-child) {
margin-top: 0;
}
.content-text :deep(h3) {
color: white;
font-size: 16px;
font-weight: 600;
margin: 12px 0 8px 0;
line-height: 1.3;
}
.content-text :deep(h3:first-child) {
margin-top: 0;
}
.content-text :deep(h4) {
color: white;
font-size: 14px;
font-weight: 600;
margin: 8px 0 6px 0;
}
.content-text :deep(h4:first-child) {
margin-top: 0;
}
.content-text :deep(p) {
margin: 0 0 12px 0;
line-height: 1.6;
}
.content-text :deep(p:first-child) {
margin-top: 0;
}
.content-text :deep(p:last-child) {
margin-bottom: 0;
}
.content-text :deep(ul),
.content-text :deep(ol) {
margin: 0 0 12px 0;
padding-left: 24px;
}
.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;
}
/* 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: 12px;
}
/* Media elements */
.content-text :deep(img),
.content-text :deep(video),
.content-text :deep(iframe) {
width: calc(100% + 64px);
height: auto;
border-radius: 6px;
margin: 12px -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-family: 'Inter', sans-serif;
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-family: 'Inter', sans-serif;
font-weight: 500;
cursor: pointer;
}
.cta-button:hover {
background: #f0f0f0;
}
</style>

View File

@@ -14,6 +14,7 @@
<div class="side-tool-bar-end">
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarThemeToggleIcon />
<SidebarHelpCenterIcon />
<SidebarSettingsToggleIcon />
</div>
</nav>
@@ -36,6 +37,7 @@ import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'

View File

@@ -0,0 +1,157 @@
<template>
<div>
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
@click="toggleHelpCenter"
/>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
}"
>
<HelpCenterMenuContent @close="closeHelpCenter" />
</div>
</Teleport>
<!-- Release Notification Toast positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<ReleaseNotificationToast
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
}"
/>
</Teleport>
<!-- WhatsNew Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<WhatsNewPopup
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
}"
/>
</Teleport>
<!-- Backdrop to close popup when clicking outside -->
<Teleport to="#graph-canvas-container">
<div
v-if="isHelpCenterVisible"
class="help-center-backdrop"
@click="closeHelpCenter"
/>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const { shouldShowRedDot } = storeToRefs(releaseStore)
const isHelpCenterVisible = ref(false)
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
const toggleHelpCenter = () => {
isHelpCenterVisible.value = !isHelpCenterVisible.value
}
const closeHelpCenter = () => {
isHelpCenterVisible.value = false
}
// Initialize release store on mount
onMounted(async () => {
// Initialize release store to fetch releases for toast and popup
await releaseStore.initialize()
})
</script>
<style scoped>
.help-center-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: transparent;
}
.help-center-popup {
position: absolute;
bottom: 1rem;
z-index: 1000;
animation: slideInUp 0.2s ease-out;
pointer-events: auto;
}
.help-center-popup.sidebar-left {
left: 1rem;
}
.help-center-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.help-center-popup.sidebar-right {
right: 1rem;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:deep(.p-badge) {
background: #ff3b30;
color: #ff3b30;
min-width: 8px;
height: 8px;
padding: 0;
border-radius: 9999px;
font-size: 0;
margin-top: 4px;
margin-right: 4px;
border: none;
outline: none;
box-shadow: none;
}
:deep(.p-badge.p-badge-dot) {
width: 8px !important;
}
</style>

View File

@@ -847,5 +847,24 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: false,
versionAdded: '1.19.1'
},
// Release data stored in settings
{
id: 'Comfy.Release.Version',
name: 'Last seen release version',
type: 'hidden',
defaultValue: ''
},
{
id: 'Comfy.Release.Status',
name: 'Release status',
type: 'hidden',
defaultValue: 'skipped'
},
{
id: 'Comfy.Release.Timestamp',
name: 'Release seen timestamp',
type: 'hidden',
defaultValue: 0
}
]

View File

@@ -410,6 +410,7 @@
},
"sideToolbar": {
"themeToggle": "Toggle Theme",
"helpCenter": "Help Center",
"logout": "Logout",
"queue": "Queue",
"nodeLibrary": "Node Library",
@@ -468,6 +469,26 @@
}
}
},
"helpCenter": {
"docs": "Docs",
"github": "Github",
"helpFeedback": "Help & Feedback",
"more": "More...",
"whatsNew": "What's New?",
"clickToLearnMore": "Click to learn more →",
"loadingReleases": "Loading releases...",
"noRecentReleases": "No recent releases",
"updateAvailable": "Update",
"desktopUserGuide": "Desktop User Guide",
"openDevTools": "Open Dev Tools",
"reinstall": "Re-Install"
},
"releaseToast": {
"newVersionAvailable": "New Version Available!",
"whatsNew": "What's New?",
"skip": "Skip",
"update": "Update"
},
"menu": {
"hideMenu": "Hide Menu",
"showMenu": "Show Menu",
@@ -1444,5 +1465,8 @@
"moreHelp": "For more help, visit the",
"documentationPage": "documentation page",
"loadError": "Failed to load help: {error}"
},
"whatsNewPopup": {
"learnMore": "Learn more"
}
}

View File

@@ -381,6 +381,20 @@
"create": "Crear nodo de grupo",
"enterName": "Introduzca el nombre"
},
"helpCenter": {
"clickToLearnMore": "Haz clic para saber más →",
"desktopUserGuide": "Guía de usuario de escritorio",
"docs": "Documentación",
"github": "Github",
"helpFeedback": "Ayuda y comentarios",
"loadingReleases": "Cargando versiones...",
"more": "Más...",
"noRecentReleases": "No hay versiones recientes",
"openDevTools": "Abrir herramientas de desarrollo",
"reinstall": "Reinstalar",
"updateAvailable": "Actualizar",
"whatsNew": "¿Qué hay de nuevo?"
},
"icon": {
"bookmark": "Marcador",
"box": "Caja",
@@ -872,6 +886,12 @@
},
"title": "Tu dispositivo no es compatible"
},
"releaseToast": {
"newVersionAvailable": "¡Nueva versión disponible!",
"skip": "Omitir",
"update": "Actualizar",
"whatsNew": "¿Qué hay de nuevo?"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "No hay nodos de salida seleccionados",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "Explorar plantillas de ejemplo",
"downloads": "Descargas",
"helpCenter": "Centro de ayuda",
"logout": "Cerrar sesión",
"modelLibrary": "Biblioteca de modelos",
"newBlankWorkflow": "Crear un nuevo flujo de trabajo en blanco",
@@ -1440,6 +1461,9 @@
"getStarted": "Empezar",
"title": "Bienvenido a ComfyUI"
},
"whatsNewPopup": {
"learnMore": "Aprende más"
},
"workflowService": {
"enterFilename": "Introduzca el nombre del archivo",
"exportWorkflow": "Exportar flujo de trabajo",

View File

@@ -381,6 +381,20 @@
"create": "Créer un nœud de groupe",
"enterName": "Entrer le nom"
},
"helpCenter": {
"clickToLearnMore": "Cliquez pour en savoir plus →",
"desktopUserGuide": "Guide utilisateur de bureau",
"docs": "Docs",
"github": "Github",
"helpFeedback": "Aide & Retour",
"loadingReleases": "Chargement des versions...",
"more": "Plus...",
"noRecentReleases": "Aucune version récente",
"openDevTools": "Ouvrir les outils de développement",
"reinstall": "Réinstaller",
"updateAvailable": "Mise à jour",
"whatsNew": "Quoi de neuf ?"
},
"icon": {
"bookmark": "Favori",
"box": "Boîte",
@@ -872,6 +886,12 @@
},
"title": "Votre appareil n'est pas pris en charge"
},
"releaseToast": {
"newVersionAvailable": "Nouvelle version disponible !",
"skip": "Ignorer",
"update": "Mettre à jour",
"whatsNew": "Quoi de neuf ?"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "Aucun nœud de sortie sélectionné",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "Parcourir les modèles d'exemple",
"downloads": "Téléchargements",
"helpCenter": "Centre d'aide",
"logout": "Déconnexion",
"modelLibrary": "Bibliothèque de modèles",
"newBlankWorkflow": "Créer un nouveau flux de travail vierge",
@@ -1440,6 +1461,9 @@
"getStarted": "Commencer",
"title": "Bienvenue sur ComfyUI"
},
"whatsNewPopup": {
"learnMore": "En savoir plus"
},
"workflowService": {
"enterFilename": "Entrez le nom du fichier",
"exportWorkflow": "Exporter le flux de travail",

View File

@@ -381,6 +381,20 @@
"create": "グループノードを作成",
"enterName": "名前を入力"
},
"helpCenter": {
"clickToLearnMore": "詳しくはこちらをクリック →",
"desktopUserGuide": "デスクトップユーザーガイド",
"docs": "ドキュメント",
"github": "Github",
"helpFeedback": "ヘルプとフィードバック",
"loadingReleases": "リリースを読み込み中...",
"more": "もっと見る...",
"noRecentReleases": "最近のリリースはありません",
"openDevTools": "開発者ツールを開く",
"reinstall": "再インストール",
"updateAvailable": "アップデート",
"whatsNew": "新着情報"
},
"icon": {
"bookmark": "ブックマーク",
"box": "ボックス",
@@ -872,6 +886,12 @@
},
"title": "お使いのデバイスはサポートされていません"
},
"releaseToast": {
"newVersionAvailable": "新しいバージョンが利用可能です!",
"skip": "スキップ",
"update": "アップデート",
"whatsNew": "新機能"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "出力ノードが選択されていません",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "サンプルテンプレートを表示",
"downloads": "ダウンロード",
"helpCenter": "ヘルプセンター",
"logout": "ログアウト",
"modelLibrary": "モデルライブラリ",
"newBlankWorkflow": "新しい空のワークフローを作成",
@@ -1440,6 +1461,9 @@
"getStarted": "はじめる",
"title": "ComfyUIへようこそ"
},
"whatsNewPopup": {
"learnMore": "詳細はこちら"
},
"workflowService": {
"enterFilename": "ファイル名を入力",
"exportWorkflow": "ワークフローをエクスポート",

View File

@@ -381,6 +381,20 @@
"create": "그룹 노드 만들기",
"enterName": "이름 입력"
},
"helpCenter": {
"clickToLearnMore": "자세히 알아보기 →",
"desktopUserGuide": "데스크톱 사용자 가이드",
"docs": "문서",
"github": "Github",
"helpFeedback": "도움말 및 피드백",
"loadingReleases": "릴리즈 불러오는 중...",
"more": "더보기...",
"noRecentReleases": "최근 릴리즈 없음",
"openDevTools": "개발자 도구 열기",
"reinstall": "재설치",
"updateAvailable": "업데이트",
"whatsNew": "새로운 소식?"
},
"icon": {
"bookmark": "북마크",
"box": "상자",
@@ -872,6 +886,12 @@
},
"title": "이 장치는 지원되지 않습니다."
},
"releaseToast": {
"newVersionAvailable": "새 버전이 있습니다!",
"skip": "건너뛰기",
"update": "업데이트",
"whatsNew": "새로운 기능 보기"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "선택된 출력 노드가 없습니다",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "예제 템플릿 탐색",
"downloads": "다운로드",
"helpCenter": "도움말 센터",
"logout": "로그아웃",
"modelLibrary": "모델 라이브러리",
"newBlankWorkflow": "새 빈 워크플로 만들기",
@@ -1440,6 +1461,9 @@
"getStarted": "시작하기",
"title": "ComfyUI에 오신 것을 환영합니다"
},
"whatsNewPopup": {
"learnMore": "자세히 알아보기"
},
"workflowService": {
"enterFilename": "파일 이름 입력",
"exportWorkflow": "워크플로 내보내기",

View File

@@ -381,6 +381,20 @@
"create": "Создать ноду группы",
"enterName": "Введите название"
},
"helpCenter": {
"clickToLearnMore": "Нажмите, чтобы узнать больше →",
"desktopUserGuide": "Руководство пользователя для Desktop",
"docs": "Документация",
"github": "Github",
"helpFeedback": "Помощь и обратная связь",
"loadingReleases": "Загрузка релизов...",
"more": "Ещё...",
"noRecentReleases": "Нет недавних релизов",
"openDevTools": "Открыть инструменты разработчика",
"reinstall": "Переустановить",
"updateAvailable": "Обновить",
"whatsNew": "Что нового?"
},
"icon": {
"bookmark": "Закладка",
"box": "Коробка",
@@ -872,6 +886,12 @@
},
"title": "Ваше устройство не поддерживается"
},
"releaseToast": {
"newVersionAvailable": "Доступна новая версия!",
"skip": "Пропустить",
"update": "Обновить",
"whatsNew": "Что нового?"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "Выходные узлы не выбраны",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "Просмотреть примеры шаблонов",
"downloads": "Загрузки",
"helpCenter": "Центр поддержки",
"logout": "Выйти",
"modelLibrary": "Библиотека моделей",
"newBlankWorkflow": "Создайте новый пустой рабочий процесс",
@@ -1440,6 +1461,9 @@
"getStarted": "Начать",
"title": "Добро пожаловать в ComfyUI"
},
"whatsNewPopup": {
"learnMore": "Узнать больше"
},
"workflowService": {
"enterFilename": "Введите название файла",
"exportWorkflow": "Экспорт рабочего процесса",

View File

@@ -381,6 +381,20 @@
"create": "创建组节点",
"enterName": "输入名称"
},
"helpCenter": {
"clickToLearnMore": "点击了解更多 →",
"desktopUserGuide": "桌面端用户指南",
"docs": "文档",
"github": "Github",
"helpFeedback": "帮助与反馈",
"loadingReleases": "加载发布信息...",
"more": "更多...",
"noRecentReleases": "没有最近的发布",
"openDevTools": "打开开发者工具",
"reinstall": "重新安装",
"updateAvailable": "更新",
"whatsNew": "新功能?"
},
"icon": {
"bookmark": "书签",
"box": "盒子",
@@ -872,6 +886,12 @@
},
"title": "您的设备不受支持"
},
"releaseToast": {
"newVersionAvailable": "新版本可用!",
"skip": "跳过",
"update": "更新",
"whatsNew": "新功能?"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "未选择输出节点",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "浏览示例模板",
"downloads": "下载",
"helpCenter": "帮助中心",
"logout": "登出",
"modelLibrary": "模型库",
"newBlankWorkflow": "创建空白工作流",
@@ -1440,6 +1461,9 @@
"getStarted": "开始使用",
"title": "欢迎使用 ComfyUI"
},
"whatsNewPopup": {
"learnMore": "了解更多"
},
"workflowService": {
"enterFilename": "输入文件名",
"exportWorkflow": "导出工作流",

View File

@@ -472,6 +472,14 @@ const zSettings = z.object({
'pysssss.SnapToGrid': z.boolean(),
/** VHS setting is used for queue video preview support. */
'VHS.AdvancedPreviews': z.string(),
/** Release data settings */
'Comfy.Release.Version': z.string(),
'Comfy.Release.Status': z.enum([
'skipped',
'changelog seen',
"what's new seen"
]),
'Comfy.Release.Timestamp': z.number(),
/** Settings used for testing */
'test.setting': z.any(),
'main.sub.setting.name': z.any(),

View File

@@ -0,0 +1,120 @@
import axios, { AxiosError, AxiosResponse } 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']
export type GetReleasesParams =
operations['getReleaseNotes']['parameters']['query']
// Use generated error response type
export 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
}
}

238
src/stores/releaseStore.ts Normal file
View File

@@ -0,0 +1,238 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { type ReleaseNote, useReleaseService } from '@/services/releaseService'
import { useSettingStore } from '@/stores/settingStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { compareVersions, stringToLocale } from '@/utils/formatUtil'
// 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')
)
// 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 &&
compareVersions(
recentRelease.value.version,
currentComfyUIVersion.value
) > 0
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
!compareVersions(recentRelease.value.version, currentComfyUIVersion.value)
)
const hasMediumOrHighAttention = computed(() =>
recentReleases.value
.slice(0, -1)
.some(
(release) =>
release.attention === 'medium' || release.attention === 'high'
)
)
// Show toast if needed
const shouldShowToast = computed(() => {
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(() => {
// 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(() => {
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
isLoading.value = true
error.value = null
try {
// Ensure system stats are loaded
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
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

@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { SystemStats } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { isElectron } from '@/utils/envUtil'
export const useSystemStatsStore = defineStore('systemStats', () => {
const systemStats = ref<SystemStats | null>(null)
@@ -26,10 +27,42 @@ export const useSystemStatsStore = defineStore('systemStats', () => {
}
}
function getFormFactor(): string {
if (!systemStats.value?.system?.os) {
return 'other'
}
const os = systemStats.value.system.os.toLowerCase()
const isDesktop = isElectron()
if (isDesktop) {
if (os.includes('windows')) {
return 'desktop-windows'
}
if (os.includes('darwin') || os.includes('mac')) {
return 'desktop-mac'
}
} else {
// Git/source installation
if (os.includes('windows')) {
return 'git-windows'
}
if (os.includes('darwin') || os.includes('mac')) {
return 'git-mac'
}
if (os.includes('linux')) {
return 'git-linux'
}
}
return 'other'
}
return {
systemStats,
isLoading,
error,
fetchSystemStats
fetchSystemStats,
getFormFactor
}
})

View File

@@ -1,4 +1,5 @@
import { ResultItem } from '@/schemas/apiSchema'
import type { operations } from '@/types/comfyRegistryTypes'
export function formatCamelCase(str: string): string {
// Check if the string is camel case
@@ -502,3 +503,46 @@ export function nl2br(text: string): string {
if (!text) return ''
return text.replace(/\n/g, '<br />')
}
/**
* Converts a version string to an anchor-safe format by replacing dots with dashes.
* @param version The version string (e.g., "1.0.0", "2.1.3-beta.1")
* @returns The anchor-safe version string (e.g., "v1-0-0", "v2-1-3-beta-1")
* @example
* formatVersionAnchor("1.0.0") // returns "v1-0-0"
* formatVersionAnchor("2.1.3-beta.1") // returns "v2-1-3-beta-1"
*/
export function formatVersionAnchor(version: string): string {
return `v${version.replace(/\./g, '-')}`
}
/**
* Supported locale types for the application (from OpenAPI schema)
*/
export type SupportedLocale = NonNullable<
operations['getReleaseNotes']['parameters']['query']['locale']
>
/**
* Converts a string to a valid locale type with 'en' as default
* @param locale - The locale string to validate and convert
* @returns A valid SupportedLocale type, defaults to 'en' if invalid
* @example
* stringToLocale('fr') // returns 'fr'
* stringToLocale('invalid') // returns 'en'
* stringToLocale('') // returns 'en'
*/
export function stringToLocale(locale: string): SupportedLocale {
const supportedLocales: SupportedLocale[] = [
'en',
'es',
'fr',
'ja',
'ko',
'ru',
'zh'
]
return supportedLocales.includes(locale as SupportedLocale)
? (locale as SupportedLocale)
: 'en'
}

View File

@@ -15,6 +15,7 @@
<GlobalToast />
<RerouteMigrationToast />
<!-- Release toast now managed by SidebarHelpCenterIcon component -->
<UnloadWindowConfirmDialog v-if="!isElectron()" />
<MenuHamburger />
</template>
@@ -218,6 +219,9 @@ onBeforeUnmount(() => {
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
const { wrapWithErrorHandling, wrapWithErrorHandlingAsync } = useErrorHandling()
// Note: WhatsNew popup functionality is now handled directly by the toast
const onGraphReady = () => {
requestIdleCallback(
() => {

View File

@@ -0,0 +1,220 @@
import axios from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReleaseService } from '@/services/releaseService'
// Hoist the mock to avoid hoisting issues
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn()
}))
vi.mock('axios', () => ({
default: {
create: vi.fn(() => mockAxiosInstance),
isAxiosError: vi.fn()
}
}))
describe('useReleaseService', () => {
let service: ReturnType<typeof useReleaseService>
const mockReleases = [
{
id: 1,
project: 'comfyui' as const,
version: '1.2.0',
attention: 'high' as const,
content: 'New features and improvements',
published_at: '2023-12-01T00:00:00Z'
}
]
beforeEach(() => {
vi.clearAllMocks()
service = useReleaseService()
})
it('should initialize with default state', () => {
expect(service.isLoading.value).toBe(false)
expect(service.error.value).toBeNull()
})
describe('getReleases', () => {
it('should fetch releases successfully', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
const result = await service.getReleases({
project: 'comfyui',
current_version: '1.0.0'
})
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: {
project: 'comfyui',
current_version: '1.0.0'
},
signal: undefined
})
expect(result).toEqual(mockReleases)
expect(service.isLoading.value).toBe(false)
expect(service.error.value).toBeNull()
})
it('should fetch releases with form_factor parameter', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
const result = await service.getReleases({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-windows'
})
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: {
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-windows'
},
signal: undefined
})
expect(result).toEqual(mockReleases)
})
it('should pass abort signal when provided', async () => {
const abortController = new AbortController()
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases({ project: 'comfyui' }, abortController.signal)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: abortController.signal
})
})
it('should handle API errors with response', async () => {
const errorResponse = {
response: {
status: 400,
data: { message: 'Invalid parameters' }
}
}
mockAxiosInstance.get.mockRejectedValue(errorResponse)
vi.mocked(axios.isAxiosError).mockReturnValue(true)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Invalid project or version parameter')
expect(service.isLoading.value).toBe(false)
})
it('should handle 401 errors', async () => {
const errorResponse = {
response: {
status: 401,
data: { message: 'Unauthorized' }
}
}
mockAxiosInstance.get.mockRejectedValue(errorResponse)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Unauthorized: Authentication required')
})
it('should handle 404 errors', async () => {
const errorResponse = {
response: {
status: 404,
data: { message: 'Not found' }
}
}
mockAxiosInstance.get.mockRejectedValue(errorResponse)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Not found: Not found')
})
it('should handle 500 errors', async () => {
const errorResponse = {
response: {
status: 500,
data: { message: 'Server error' }
}
}
mockAxiosInstance.get.mockRejectedValue(errorResponse)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Server error: Server error')
})
it('should handle network errors', async () => {
const networkError = new Error('Network Error')
mockAxiosInstance.get.mockRejectedValue(networkError)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Failed to get releases: Network Error')
})
it('should handle abort errors gracefully', async () => {
const abortError = {
name: 'AbortError',
message: 'Request aborted'
}
mockAxiosInstance.get.mockRejectedValue(abortError)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toContain('Request aborted') // Abort errors are handled
})
it('should handle non-Error objects', async () => {
const stringError = 'String error'
mockAxiosInstance.get.mockRejectedValue(stringError)
const result = await service.getReleases({ project: 'comfyui' })
expect(result).toBeNull()
expect(service.error.value).toBe('Failed to get releases: undefined')
})
it('should set loading state correctly', async () => {
let resolvePromise: (value: any) => void
const promise = new Promise((resolve) => {
resolvePromise = resolve
})
mockAxiosInstance.get.mockReturnValue(promise)
const fetchPromise = service.getReleases({ project: 'comfyui' })
expect(service.isLoading.value).toBe(true)
resolvePromise!({ data: mockReleases })
await fetchPromise
expect(service.isLoading.value).toBe(false)
})
it('should reset error state on new request', async () => {
// First request fails
mockAxiosInstance.get.mockRejectedValueOnce(new Error('First error'))
await service.getReleases({ project: 'comfyui' })
expect(service.error.value).toBe('Failed to get releases: First error')
// Second request succeeds
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockReleases })
await service.getReleases({ project: 'comfyui' })
expect(service.error.value).toBeNull()
})
})
})

View File

@@ -0,0 +1,289 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReleaseStore } from '@/stores/releaseStore'
// Mock the dependencies
vi.mock('@/utils/formatUtil')
vi.mock('@/services/releaseService')
vi.mock('@/stores/settingStore')
vi.mock('@/stores/systemStatsStore')
describe('useReleaseStore', () => {
let store: ReturnType<typeof useReleaseStore>
let mockReleaseService: any
let mockSettingStore: any
let mockSystemStatsStore: any
const mockRelease = {
id: 1,
project: 'comfyui' as const,
version: '1.2.0',
content: 'New features and improvements',
published_at: '2023-12-01T00:00:00Z',
attention: 'high' as const
}
beforeEach(async () => {
setActivePinia(createPinia())
// Reset all mocks
vi.clearAllMocks()
// Setup mock services
mockReleaseService = {
getReleases: vi.fn(),
isLoading: { value: false },
error: { value: null }
}
mockSettingStore = {
get: vi.fn(),
set: vi.fn()
}
mockSystemStatsStore = {
systemStats: {
system: {
comfyui_version: '1.0.0'
}
},
fetchSystemStats: vi.fn(),
getFormFactor: vi.fn(() => 'git-windows')
}
// Setup mock implementations
const { useReleaseService } = await import('@/services/releaseService')
const { useSettingStore } = await import('@/stores/settingStore')
const { useSystemStatsStore } = await import('@/stores/systemStatsStore')
vi.mocked(useReleaseService).mockReturnValue(mockReleaseService)
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore)
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
store = useReleaseStore()
})
describe('initial state', () => {
it('should initialize with default state', () => {
expect(store.releases).toEqual([])
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
})
})
describe('computed properties', () => {
it('should return most recent release', () => {
const olderRelease = {
...mockRelease,
id: 2,
version: '1.1.0',
published_at: '2023-11-01T00:00:00Z'
}
store.releases = [mockRelease, olderRelease]
expect(store.recentRelease).toEqual(mockRelease)
})
it('should return 3 most recent releases', () => {
const releases = [
mockRelease,
{ ...mockRelease, id: 2, version: '1.1.0' },
{ ...mockRelease, id: 3, version: '1.0.0' },
{ ...mockRelease, id: 4, version: '0.9.0' }
]
store.releases = releases
expect(store.recentReleases).toEqual(releases.slice(0, 3))
})
it('should show update button (shouldShowUpdateButton)', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1) // newer version available
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(true)
})
it('should not show update button when no new version', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(-1) // current version is newer
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(false)
})
})
describe('release initialization', () => {
it('should fetch releases successfully', async () => {
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows'
})
expect(store.releases).toEqual([mockRelease])
})
it('should include form_factor in API call', async () => {
mockSystemStatsStore.getFormFactor.mockReturnValue('desktop-mac')
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac'
})
})
it('should handle API errors gracefully', async () => {
mockReleaseService.getReleases.mockResolvedValue(null)
mockReleaseService.error.value = 'API Error'
await store.initialize()
expect(store.releases).toEqual([])
expect(store.error).toBe('API Error')
})
it('should handle non-Error objects', async () => {
mockReleaseService.getReleases.mockRejectedValue('String error')
await store.initialize()
expect(store.error).toBe('Unknown error occurred')
})
it('should set loading state correctly', async () => {
let resolvePromise: (value: any) => void
const promise = new Promise((resolve) => {
resolvePromise = resolve
})
mockReleaseService.getReleases.mockReturnValue(promise)
const initPromise = store.initialize()
expect(store.isLoading).toBe(true)
resolvePromise!([mockRelease])
await initPromise
expect(store.isLoading).toBe(false)
})
it('should fetch system stats if not available', async () => {
mockSystemStatsStore.systemStats = null
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
})
})
describe('action handlers', () => {
beforeEach(() => {
store.releases = [mockRelease]
})
it('should handle skip release', async () => {
await store.handleSkipRelease('1.2.0')
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
'skipped'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
it('should handle show changelog', async () => {
await store.handleShowChangelog('1.2.0')
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
'changelog seen'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
it('should handle whats new seen', async () => {
await store.handleWhatsNewSeen('1.2.0')
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
"what's new seen"
)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
})
describe('popup visibility', () => {
it('should show toast for medium/high attention releases', async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Release.Version') return null
if (key === 'Comfy.Release.Status') return null
return null
})
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
const mediumRelease = { ...mockRelease, attention: 'medium' as const }
store.releases = [
mockRelease,
mediumRelease,
{ ...mockRelease, attention: 'low' as const }
]
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot for new versions', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
mockSettingStore.get.mockReturnValue(null)
store.releases = [mockRelease]
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0' // Same as release
mockSettingStore.get.mockReturnValue(null)
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0) // versions are equal (latest version)
store.releases = [mockRelease]
expect(store.shouldShowPopup).toBe(true)
})
})
})

View File

@@ -0,0 +1,322 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
// Mock the API
vi.mock('@/scripts/api', () => ({
api: {
getSystemStats: vi.fn()
}
}))
// Mock the envUtil
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn()
}))
describe('useSystemStatsStore', () => {
let store: ReturnType<typeof useSystemStatsStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useSystemStatsStore()
vi.clearAllMocks()
})
it('should initialize with null systemStats', () => {
expect(store.systemStats).toBeNull()
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
})
describe('fetchSystemStats', () => {
it('should fetch system stats successfully', async () => {
const mockStats = {
system: {
os: 'Windows',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
vi.mocked(api.getSystemStats).mockResolvedValue(mockStats)
await store.fetchSystemStats()
expect(store.systemStats).toEqual(mockStats)
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
expect(api.getSystemStats).toHaveBeenCalled()
})
it('should handle API errors', async () => {
const error = new Error('API Error')
vi.mocked(api.getSystemStats).mockRejectedValue(error)
await store.fetchSystemStats()
expect(store.systemStats).toBeNull()
expect(store.isLoading).toBe(false)
expect(store.error).toBe('API Error')
})
it('should handle non-Error objects', async () => {
vi.mocked(api.getSystemStats).mockRejectedValue('String error')
await store.fetchSystemStats()
expect(store.error).toBe('An error occurred while fetching system stats')
})
it('should set loading state correctly', async () => {
let resolvePromise: (value: any) => void = () => {}
const promise = new Promise<any>((resolve) => {
resolvePromise = resolve
})
vi.mocked(api.getSystemStats).mockReturnValue(promise)
const fetchPromise = store.fetchSystemStats()
expect(store.isLoading).toBe(true)
resolvePromise({})
await fetchPromise
expect(store.isLoading).toBe(false)
})
})
describe('getFormFactor', () => {
beforeEach(() => {
// Reset systemStats for each test
store.systemStats = null
})
it('should return "other" when systemStats is null', () => {
expect(store.getFormFactor()).toBe('other')
})
it('should return "other" when os is not available', () => {
store.systemStats = {
system: {
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
} as any,
devices: []
}
expect(store.getFormFactor()).toBe('other')
})
describe('desktop environment (Electron)', () => {
beforeEach(() => {
vi.mocked(isElectron).mockReturnValue(true)
})
it('should return "desktop-windows" for Windows desktop', () => {
store.systemStats = {
system: {
os: 'Windows 11',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('desktop-windows')
})
it('should return "desktop-mac" for macOS desktop', () => {
store.systemStats = {
system: {
os: 'Darwin 22.0.0',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('desktop-mac')
})
it('should return "desktop-mac" for Mac desktop', () => {
store.systemStats = {
system: {
os: 'Mac OS X 13.0',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('desktop-mac')
})
it('should return "other" for unknown desktop OS', () => {
store.systemStats = {
system: {
os: 'Linux',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('other')
})
})
describe('git environment (non-Electron)', () => {
beforeEach(() => {
vi.mocked(isElectron).mockReturnValue(false)
})
it('should return "git-windows" for Windows git', () => {
store.systemStats = {
system: {
os: 'Windows 11',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('git-windows')
})
it('should return "git-mac" for macOS git', () => {
store.systemStats = {
system: {
os: 'Darwin 22.0.0',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('git-mac')
})
it('should return "git-linux" for Linux git', () => {
store.systemStats = {
system: {
os: 'linux Ubuntu 22.04',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('git-linux')
})
it('should return "other" for unknown git OS', () => {
store.systemStats = {
system: {
os: 'FreeBSD',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('other')
})
})
describe('case insensitive OS detection', () => {
beforeEach(() => {
vi.mocked(isElectron).mockReturnValue(false)
})
it('should handle uppercase OS names', () => {
store.systemStats = {
system: {
os: 'WINDOWS',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('git-windows')
})
it('should handle mixed case OS names', () => {
store.systemStats = {
system: {
os: 'LiNuX',
python_version: '3.10.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: [],
ram_total: 16000000000,
ram_free: 8000000000
},
devices: []
}
expect(store.getFormFactor()).toBe('git-linux')
})
})
})
})