mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-25 08:49:36 +00:00
Compare commits
14 Commits
bl-selecti
...
rizumu/try
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08aca84c1f | ||
|
|
cc544d5fa5 | ||
|
|
a2c3ee29b5 | ||
|
|
26f587c956 | ||
|
|
7ad1112535 | ||
|
|
522656a2dc | ||
|
|
15d223ef9b | ||
|
|
c8146ffc64 | ||
|
|
c263f6da25 | ||
|
|
1f5191847a | ||
|
|
fc69924c4a | ||
|
|
5b5151f41f | ||
|
|
2018f1e671 | ||
|
|
8822f186e0 |
@@ -1,67 +1,163 @@
|
||||
import arCommands from '@frontend-locales/ar/commands.json' with { type: 'json' }
|
||||
import ar from '@frontend-locales/ar/main.json' with { type: 'json' }
|
||||
import arNodes from '@frontend-locales/ar/nodeDefs.json' with { type: 'json' }
|
||||
import arSettings from '@frontend-locales/ar/settings.json' with { type: 'json' }
|
||||
// Import only English locale eagerly as the default/fallback
|
||||
// ESLint cannot statically resolve dynamic imports with path aliases (@frontend-locales/*),
|
||||
// but these are properly configured in tsconfig.json and resolved by Vite at build time.
|
||||
// eslint-disable-next-line import-x/no-unresolved
|
||||
import enCommands from '@frontend-locales/en/commands.json' with { type: 'json' }
|
||||
// eslint-disable-next-line import-x/no-unresolved
|
||||
import en from '@frontend-locales/en/main.json' with { type: 'json' }
|
||||
// eslint-disable-next-line import-x/no-unresolved
|
||||
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
|
||||
// eslint-disable-next-line import-x/no-unresolved
|
||||
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
|
||||
import esCommands from '@frontend-locales/es/commands.json' with { type: 'json' }
|
||||
import es from '@frontend-locales/es/main.json' with { type: 'json' }
|
||||
import esNodes from '@frontend-locales/es/nodeDefs.json' with { type: 'json' }
|
||||
import esSettings from '@frontend-locales/es/settings.json' with { type: 'json' }
|
||||
import frCommands from '@frontend-locales/fr/commands.json' with { type: 'json' }
|
||||
import fr from '@frontend-locales/fr/main.json' with { type: 'json' }
|
||||
import frNodes from '@frontend-locales/fr/nodeDefs.json' with { type: 'json' }
|
||||
import frSettings from '@frontend-locales/fr/settings.json' with { type: 'json' }
|
||||
import jaCommands from '@frontend-locales/ja/commands.json' with { type: 'json' }
|
||||
import ja from '@frontend-locales/ja/main.json' with { type: 'json' }
|
||||
import jaNodes from '@frontend-locales/ja/nodeDefs.json' with { type: 'json' }
|
||||
import jaSettings from '@frontend-locales/ja/settings.json' with { type: 'json' }
|
||||
import koCommands from '@frontend-locales/ko/commands.json' with { type: 'json' }
|
||||
import ko from '@frontend-locales/ko/main.json' with { type: 'json' }
|
||||
import koNodes from '@frontend-locales/ko/nodeDefs.json' with { type: 'json' }
|
||||
import koSettings from '@frontend-locales/ko/settings.json' with { type: 'json' }
|
||||
import ruCommands from '@frontend-locales/ru/commands.json' with { type: 'json' }
|
||||
import ru from '@frontend-locales/ru/main.json' with { type: 'json' }
|
||||
import ruNodes from '@frontend-locales/ru/nodeDefs.json' with { type: 'json' }
|
||||
import ruSettings from '@frontend-locales/ru/settings.json' with { type: 'json' }
|
||||
import trCommands from '@frontend-locales/tr/commands.json' with { type: 'json' }
|
||||
import tr from '@frontend-locales/tr/main.json' with { type: 'json' }
|
||||
import trNodes from '@frontend-locales/tr/nodeDefs.json' with { type: 'json' }
|
||||
import trSettings from '@frontend-locales/tr/settings.json' with { type: 'json' }
|
||||
import zhTWCommands from '@frontend-locales/zh-TW/commands.json' with { type: 'json' }
|
||||
import zhTW from '@frontend-locales/zh-TW/main.json' with { type: 'json' }
|
||||
import zhTWNodes from '@frontend-locales/zh-TW/nodeDefs.json' with { type: 'json' }
|
||||
import zhTWSettings from '@frontend-locales/zh-TW/settings.json' with { type: 'json' }
|
||||
import zhCommands from '@frontend-locales/zh/commands.json' with { type: 'json' }
|
||||
import zh from '@frontend-locales/zh/main.json' with { type: 'json' }
|
||||
import zhNodes from '@frontend-locales/zh/nodeDefs.json' with { type: 'json' }
|
||||
import zhSettings from '@frontend-locales/zh/settings.json' with { type: 'json' }
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
|
||||
function buildLocale<
|
||||
M extends Record<string, unknown>,
|
||||
N extends Record<string, unknown>,
|
||||
C extends Record<string, unknown>,
|
||||
S extends Record<string, unknown>
|
||||
>(main: M, nodes: N, commands: C, settings: S) {
|
||||
return {
|
||||
...main,
|
||||
nodeDefs: nodes,
|
||||
commands: commands,
|
||||
settings: settings
|
||||
}
|
||||
} as M & { nodeDefs: N; commands: C; settings: S }
|
||||
}
|
||||
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings),
|
||||
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
|
||||
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings),
|
||||
ru: buildLocale(ru, ruNodes, ruCommands, ruSettings),
|
||||
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
|
||||
ko: buildLocale(ko, koNodes, koCommands, koSettings),
|
||||
fr: buildLocale(fr, frNodes, frCommands, frSettings),
|
||||
es: buildLocale(es, esNodes, esCommands, esSettings),
|
||||
ar: buildLocale(ar, arNodes, arCommands, arSettings),
|
||||
tr: buildLocale(tr, trNodes, trCommands, trSettings)
|
||||
// Locale loader map - dynamically import locales only when needed
|
||||
// ESLint cannot statically resolve these dynamic imports, but they are valid at build time
|
||||
/* eslint-disable import-x/no-unresolved */
|
||||
const localeLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('@frontend-locales/ar/main.json'),
|
||||
es: () => import('@frontend-locales/es/main.json'),
|
||||
fr: () => import('@frontend-locales/fr/main.json'),
|
||||
ja: () => import('@frontend-locales/ja/main.json'),
|
||||
ko: () => import('@frontend-locales/ko/main.json'),
|
||||
ru: () => import('@frontend-locales/ru/main.json'),
|
||||
tr: () => import('@frontend-locales/tr/main.json'),
|
||||
zh: () => import('@frontend-locales/zh/main.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/main.json')
|
||||
}
|
||||
|
||||
const nodeDefsLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('@frontend-locales/ar/nodeDefs.json'),
|
||||
es: () => import('@frontend-locales/es/nodeDefs.json'),
|
||||
fr: () => import('@frontend-locales/fr/nodeDefs.json'),
|
||||
ja: () => import('@frontend-locales/ja/nodeDefs.json'),
|
||||
ko: () => import('@frontend-locales/ko/nodeDefs.json'),
|
||||
ru: () => import('@frontend-locales/ru/nodeDefs.json'),
|
||||
tr: () => import('@frontend-locales/tr/nodeDefs.json'),
|
||||
zh: () => import('@frontend-locales/zh/nodeDefs.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/nodeDefs.json')
|
||||
}
|
||||
|
||||
const commandsLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('@frontend-locales/ar/commands.json'),
|
||||
es: () => import('@frontend-locales/es/commands.json'),
|
||||
fr: () => import('@frontend-locales/fr/commands.json'),
|
||||
ja: () => import('@frontend-locales/ja/commands.json'),
|
||||
ko: () => import('@frontend-locales/ko/commands.json'),
|
||||
ru: () => import('@frontend-locales/ru/commands.json'),
|
||||
tr: () => import('@frontend-locales/tr/commands.json'),
|
||||
zh: () => import('@frontend-locales/zh/commands.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/commands.json')
|
||||
}
|
||||
|
||||
const settingsLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('@frontend-locales/ar/settings.json'),
|
||||
es: () => import('@frontend-locales/es/settings.json'),
|
||||
fr: () => import('@frontend-locales/fr/settings.json'),
|
||||
ja: () => import('@frontend-locales/ja/settings.json'),
|
||||
ko: () => import('@frontend-locales/ko/settings.json'),
|
||||
ru: () => import('@frontend-locales/ru/settings.json'),
|
||||
tr: () => import('@frontend-locales/tr/settings.json'),
|
||||
zh: () => import('@frontend-locales/zh/settings.json'),
|
||||
'zh-TW': () => import('@frontend-locales/zh-TW/settings.json')
|
||||
}
|
||||
|
||||
// Track which locales have been loaded
|
||||
const loadedLocales = new Set<string>(['en'])
|
||||
|
||||
// Track locales currently being loaded to prevent race conditions
|
||||
const loadingLocales = new Map<string, Promise<void>>()
|
||||
|
||||
/**
|
||||
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
|
||||
*/
|
||||
export async function loadLocale(locale: string): Promise<void> {
|
||||
if (loadedLocales.has(locale)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If already loading, return the existing promise to prevent duplicate loads
|
||||
const existingLoad = loadingLocales.get(locale)
|
||||
if (existingLoad) {
|
||||
return existingLoad
|
||||
}
|
||||
|
||||
const loader = localeLoaders[locale]
|
||||
const nodeDefsLoader = nodeDefsLoaders[locale]
|
||||
const commandsLoader = commandsLoaders[locale]
|
||||
const settingsLoader = settingsLoaders[locale]
|
||||
|
||||
if (!loader || !nodeDefsLoader || !commandsLoader || !settingsLoader) {
|
||||
console.warn(`Locale "${locale}" is not supported`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create and track the loading promise
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const [main, nodes, commands, settings] = await Promise.all([
|
||||
loader(),
|
||||
nodeDefsLoader(),
|
||||
commandsLoader(),
|
||||
settingsLoader()
|
||||
])
|
||||
|
||||
const messages = buildLocale(
|
||||
main.default,
|
||||
nodes.default,
|
||||
commands.default,
|
||||
settings.default
|
||||
)
|
||||
|
||||
i18n.global.setLocaleMessage(locale, messages as LocaleMessages)
|
||||
loadedLocales.add(locale)
|
||||
} catch (error) {
|
||||
console.error(`Failed to load locale "${locale}":`, error)
|
||||
throw error
|
||||
} finally {
|
||||
// Clean up the loading promise once complete
|
||||
loadingLocales.delete(locale)
|
||||
}
|
||||
})()
|
||||
|
||||
loadingLocales.set(locale, loadPromise)
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
// Only include English in the initial bundle
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings)
|
||||
}
|
||||
|
||||
// Type for locale messages - inferred from the English locale structure
|
||||
type LocaleMessages = typeof messages.en
|
||||
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 83 KiB |
@@ -60,6 +60,7 @@ export default defineConfig([
|
||||
'**/vite.config.*.timestamp*',
|
||||
'**/vitest.config.*.timestamp*',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
'public/auth-sw.js',
|
||||
'src/extensions/core/*',
|
||||
'src/scripts/*',
|
||||
'src/types/generatedManagerTypes.ts',
|
||||
|
||||
6
global.d.ts
vendored
6
global.d.ts
vendored
@@ -4,6 +4,12 @@ declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
declare const __MIXPANEL_TOKEN__: string
|
||||
|
||||
type BuildFeatureFlags = {
|
||||
REQUIRE_SUBSCRIPTION: boolean
|
||||
}
|
||||
declare const __BUILD_FLAGS__: BuildFeatureFlags
|
||||
|
||||
interface Navigator {
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,10 @@ const config: KnipConfig = {
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}']
|
||||
},
|
||||
'apps/desktop-ui': {
|
||||
entry: ['src/main.ts', 'src/i18n.ts'],
|
||||
project: ['src/**/*.{js,ts,vue}', '*.{js,ts,mts}']
|
||||
},
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
@@ -39,7 +43,9 @@ const config: KnipConfig = {
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts'
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Service worker - registered at runtime via navigator.serviceWorker.register()
|
||||
'public/auth-sw.js'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"knip": "catalog:",
|
||||
"lint-staged": "catalog:",
|
||||
"markdown-table": "catalog:",
|
||||
"mixpanel-browser": "catalog:",
|
||||
"nx": "catalog:",
|
||||
"picocolors": "catalog:",
|
||||
"postcss-html": "catalog:",
|
||||
@@ -109,6 +110,7 @@
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-dts": "catalog:",
|
||||
"vite-plugin-html": "catalog:",
|
||||
"vite-plugin-inspect": "catalog:",
|
||||
"vite-plugin-vue-devtools": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vue-component-type-helpers": "catalog:",
|
||||
@@ -167,7 +169,7 @@
|
||||
"semver": "^7.7.2",
|
||||
"three": "^0.170.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"vue": "catalog:",
|
||||
"vue": "https://pkg.pr.new/vuejs/core/vue@20b5240",
|
||||
"vue-i18n": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
"vuefire": "catalog:",
|
||||
|
||||
486
pnpm-lock.yaml
generated
486
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ catalog:
|
||||
'@types/node': ^20.14.8
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vitejs/plugin-vue': ^5.1.4
|
||||
'@vitejs/plugin-vue': ^6.0.1
|
||||
'@vitest/coverage-v8': ^3.2.4
|
||||
'@vitest/ui': ^3.0.0
|
||||
'@vue/test-utils': ^2.4.6
|
||||
@@ -63,6 +63,7 @@ catalog:
|
||||
knip: ^5.62.0
|
||||
lint-staged: ^15.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 21.4.1
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^2.1.7
|
||||
@@ -85,14 +86,15 @@ catalog:
|
||||
vite: ^5.4.19
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-inspect: ^0.8.9
|
||||
vite-plugin-vue-devtools: ^7.7.6
|
||||
vitest: ^3.2.4
|
||||
vue: ^3.5.13
|
||||
vue-component-type-helpers: ^3.0.7
|
||||
vue: https://pkg.pr.new/vuejs/core/vue@20b5240
|
||||
vue-component-type-helpers: ^3.1.0
|
||||
vue-eslint-parser: ^10.2.0
|
||||
vue-i18n: ^9.14.3
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.0.7
|
||||
vue-tsc: ^3.1.1
|
||||
vuefire: ^3.2.1
|
||||
yjs: ^13.6.27
|
||||
zod: ^3.23.8
|
||||
|
||||
147
public/auth-sw.js
Normal file
147
public/auth-sw.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview Authentication Service Worker
|
||||
* Intercepts /api/view requests and adds Firebase authentication headers.
|
||||
* Required for browser-native requests (img, video, audio) that cannot send custom headers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AuthHeader
|
||||
* @property {string} Authorization - Bearer token for authentication
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CachedAuth
|
||||
* @property {AuthHeader|null} header
|
||||
* @property {number} expiresAt - Timestamp when cache expires
|
||||
*/
|
||||
|
||||
const CACHE_TTL_MS = 50 * 60 * 1000 // 50 minutes (Firebase tokens expire in 1 hour)
|
||||
|
||||
/** @type {CachedAuth|null} */
|
||||
let authCache = null
|
||||
|
||||
/** @type {Promise<AuthHeader|null>|null} */
|
||||
let authRequestInFlight = null
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'INVALIDATE_AUTH_HEADER') {
|
||||
authCache = null
|
||||
authRequestInFlight = null
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
if (
|
||||
!url.pathname.startsWith('/api/view') &&
|
||||
!url.pathname.startsWith('/api/viewvideo')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
const authHeader = await getAuthHeader()
|
||||
|
||||
if (!authHeader) {
|
||||
return fetch(event.request)
|
||||
}
|
||||
|
||||
const headers = new Headers(event.request.headers)
|
||||
for (const [key, value] of Object.entries(authHeader)) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
|
||||
return fetch(
|
||||
new Request(event.request.url, {
|
||||
method: event.request.method,
|
||||
headers: headers,
|
||||
mode: 'same-origin',
|
||||
credentials: event.request.credentials,
|
||||
cache: 'no-store',
|
||||
redirect: event.request.redirect,
|
||||
referrer: event.request.referrer,
|
||||
integrity: event.request.integrity
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[Auth SW] Request failed:', error)
|
||||
return fetch(event.request)
|
||||
}
|
||||
})()
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Gets auth header from cache or requests from main thread
|
||||
* @returns {Promise<AuthHeader|null>}
|
||||
*/
|
||||
async function getAuthHeader() {
|
||||
// Return cached value if valid
|
||||
if (authCache && authCache.expiresAt > Date.now()) {
|
||||
return authCache.header
|
||||
}
|
||||
|
||||
// Clear expired cache
|
||||
if (authCache) {
|
||||
authCache = null
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests
|
||||
if (authRequestInFlight) {
|
||||
return authRequestInFlight
|
||||
}
|
||||
|
||||
authRequestInFlight = requestAuthHeaderFromMainThread()
|
||||
const header = await authRequestInFlight
|
||||
authRequestInFlight = null
|
||||
|
||||
// Cache the result
|
||||
if (header) {
|
||||
authCache = {
|
||||
header,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS
|
||||
}
|
||||
}
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests auth header from main thread via MessageChannel
|
||||
* @returns {Promise<AuthHeader|null>}
|
||||
*/
|
||||
async function requestAuthHeaderFromMainThread() {
|
||||
const clients = await self.clients.matchAll()
|
||||
if (clients.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const messageChannel = new MessageChannel()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timeoutId
|
||||
|
||||
messageChannel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve(event.data.authHeader)
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
console.error(
|
||||
'[Auth SW] Timeout waiting for auth header from main thread'
|
||||
)
|
||||
resolve(null)
|
||||
}, 1000)
|
||||
|
||||
clients[0].postMessage({ type: 'REQUEST_AUTH_HEADER' }, [
|
||||
messageChannel.port2
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
@@ -86,6 +86,8 @@ import SplitButton from 'primevue/splitbutton'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
useQueuePendingTaskCountStore,
|
||||
@@ -141,10 +143,15 @@ const hasPendingTasks = computed(
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const queuePrompt = async (e: Event) => {
|
||||
const commandId =
|
||||
'shiftKey' in e && e.shiftKey
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
|
||||
}
|
||||
|
||||
await commandStore.execute(commandId)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,6 @@ import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
export default isCloud
|
||||
export default isCloud && __BUILD_FLAGS__.REQUIRE_SUBSCRIPTION
|
||||
? defineAsyncComponent(() => import('./CloudRunButtonWrapper.vue'))
|
||||
: defineAsyncComponent(() => import('./ComfyQueueButton.vue'))
|
||||
|
||||
@@ -67,10 +67,10 @@
|
||||
/>
|
||||
<Button
|
||||
v-if="!isApiKeyLogin"
|
||||
class="w-32"
|
||||
class="w-fit"
|
||||
variant="text"
|
||||
severity="danger"
|
||||
:label="$t('auth.deleteAccount.deleteAccount')"
|
||||
icon="pi pi-trash"
|
||||
@click="handleDeleteAccount"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
import type { Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -451,6 +453,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackWorkflowExecution()
|
||||
}
|
||||
|
||||
await app.queuePrompt(0, batchCount)
|
||||
}
|
||||
},
|
||||
@@ -462,6 +469,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
category: 'essentials' as const,
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackWorkflowExecution()
|
||||
}
|
||||
|
||||
await app.queuePrompt(-1, batchCount)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,5 +26,8 @@ import './widgetInputs'
|
||||
|
||||
if (isCloud) {
|
||||
import('./cloudBadge')
|
||||
import('./cloudSubscription')
|
||||
|
||||
if (__BUILD_FLAGS__.REQUIRE_SUBSCRIPTION) {
|
||||
import('./cloudSubscription')
|
||||
}
|
||||
}
|
||||
|
||||
189
src/i18n.ts
189
src/i18n.ts
@@ -1,68 +1,159 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import arCommands from './locales/ar/commands.json' with { type: 'json' }
|
||||
import ar from './locales/ar/main.json' with { type: 'json' }
|
||||
import arNodes from './locales/ar/nodeDefs.json' with { type: 'json' }
|
||||
import arSettings from './locales/ar/settings.json' with { type: 'json' }
|
||||
// ESLint cannot statically resolve dynamic imports with relative paths in template strings,
|
||||
// but these are valid ES module imports that Vite processes correctly at build time.
|
||||
|
||||
// Import only English locale eagerly as the default/fallback
|
||||
import enCommands from './locales/en/commands.json' with { type: 'json' }
|
||||
import en from './locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from './locales/en/nodeDefs.json' with { type: 'json' }
|
||||
import enSettings from './locales/en/settings.json' with { type: 'json' }
|
||||
import esCommands from './locales/es/commands.json' with { type: 'json' }
|
||||
import es from './locales/es/main.json' with { type: 'json' }
|
||||
import esNodes from './locales/es/nodeDefs.json' with { type: 'json' }
|
||||
import esSettings from './locales/es/settings.json' with { type: 'json' }
|
||||
import frCommands from './locales/fr/commands.json' with { type: 'json' }
|
||||
import fr from './locales/fr/main.json' with { type: 'json' }
|
||||
import frNodes from './locales/fr/nodeDefs.json' with { type: 'json' }
|
||||
import frSettings from './locales/fr/settings.json' with { type: 'json' }
|
||||
import jaCommands from './locales/ja/commands.json' with { type: 'json' }
|
||||
import ja from './locales/ja/main.json' with { type: 'json' }
|
||||
import jaNodes from './locales/ja/nodeDefs.json' with { type: 'json' }
|
||||
import jaSettings from './locales/ja/settings.json' with { type: 'json' }
|
||||
import koCommands from './locales/ko/commands.json' with { type: 'json' }
|
||||
import ko from './locales/ko/main.json' with { type: 'json' }
|
||||
import koNodes from './locales/ko/nodeDefs.json' with { type: 'json' }
|
||||
import koSettings from './locales/ko/settings.json' with { type: 'json' }
|
||||
import ruCommands from './locales/ru/commands.json' with { type: 'json' }
|
||||
import ru from './locales/ru/main.json' with { type: 'json' }
|
||||
import ruNodes from './locales/ru/nodeDefs.json' with { type: 'json' }
|
||||
import ruSettings from './locales/ru/settings.json' with { type: 'json' }
|
||||
import trCommands from './locales/tr/commands.json' with { type: 'json' }
|
||||
import tr from './locales/tr/main.json' with { type: 'json' }
|
||||
import trNodes from './locales/tr/nodeDefs.json' with { type: 'json' }
|
||||
import trSettings from './locales/tr/settings.json' with { type: 'json' }
|
||||
import zhTWCommands from './locales/zh-TW/commands.json' with { type: 'json' }
|
||||
import zhTW from './locales/zh-TW/main.json' with { type: 'json' }
|
||||
import zhTWNodes from './locales/zh-TW/nodeDefs.json' with { type: 'json' }
|
||||
import zhTWSettings from './locales/zh-TW/settings.json' with { type: 'json' }
|
||||
import zhCommands from './locales/zh/commands.json' with { type: 'json' }
|
||||
import zh from './locales/zh/main.json' with { type: 'json' }
|
||||
import zhNodes from './locales/zh/nodeDefs.json' with { type: 'json' }
|
||||
import zhSettings from './locales/zh/settings.json' with { type: 'json' }
|
||||
|
||||
function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
|
||||
function buildLocale<
|
||||
M extends Record<string, unknown>,
|
||||
N extends Record<string, unknown>,
|
||||
C extends Record<string, unknown>,
|
||||
S extends Record<string, unknown>
|
||||
>(main: M, nodes: N, commands: C, settings: S) {
|
||||
return {
|
||||
...main,
|
||||
nodeDefs: nodes,
|
||||
commands: commands,
|
||||
settings: settings
|
||||
}
|
||||
} as M & { nodeDefs: N; commands: C; settings: S }
|
||||
}
|
||||
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings),
|
||||
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
|
||||
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings),
|
||||
ru: buildLocale(ru, ruNodes, ruCommands, ruSettings),
|
||||
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
|
||||
ko: buildLocale(ko, koNodes, koCommands, koSettings),
|
||||
fr: buildLocale(fr, frNodes, frCommands, frSettings),
|
||||
es: buildLocale(es, esNodes, esCommands, esSettings),
|
||||
ar: buildLocale(ar, arNodes, arCommands, arSettings),
|
||||
tr: buildLocale(tr, trNodes, trCommands, trSettings)
|
||||
// Locale loader map - dynamically import locales only when needed
|
||||
const localeLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('./locales/ar/main.json'),
|
||||
es: () => import('./locales/es/main.json'),
|
||||
fr: () => import('./locales/fr/main.json'),
|
||||
ja: () => import('./locales/ja/main.json'),
|
||||
ko: () => import('./locales/ko/main.json'),
|
||||
ru: () => import('./locales/ru/main.json'),
|
||||
tr: () => import('./locales/tr/main.json'),
|
||||
zh: () => import('./locales/zh/main.json'),
|
||||
'zh-TW': () => import('./locales/zh-TW/main.json')
|
||||
}
|
||||
|
||||
const nodeDefsLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('./locales/ar/nodeDefs.json'),
|
||||
es: () => import('./locales/es/nodeDefs.json'),
|
||||
fr: () => import('./locales/fr/nodeDefs.json'),
|
||||
ja: () => import('./locales/ja/nodeDefs.json'),
|
||||
ko: () => import('./locales/ko/nodeDefs.json'),
|
||||
ru: () => import('./locales/ru/nodeDefs.json'),
|
||||
tr: () => import('./locales/tr/nodeDefs.json'),
|
||||
zh: () => import('./locales/zh/nodeDefs.json'),
|
||||
'zh-TW': () => import('./locales/zh-TW/nodeDefs.json')
|
||||
}
|
||||
|
||||
const commandsLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('./locales/ar/commands.json'),
|
||||
es: () => import('./locales/es/commands.json'),
|
||||
fr: () => import('./locales/fr/commands.json'),
|
||||
ja: () => import('./locales/ja/commands.json'),
|
||||
ko: () => import('./locales/ko/commands.json'),
|
||||
ru: () => import('./locales/ru/commands.json'),
|
||||
tr: () => import('./locales/tr/commands.json'),
|
||||
zh: () => import('./locales/zh/commands.json'),
|
||||
'zh-TW': () => import('./locales/zh-TW/commands.json')
|
||||
}
|
||||
|
||||
const settingsLoaders: Record<
|
||||
string,
|
||||
() => Promise<{ default: Record<string, unknown> }>
|
||||
> = {
|
||||
ar: () => import('./locales/ar/settings.json'),
|
||||
es: () => import('./locales/es/settings.json'),
|
||||
fr: () => import('./locales/fr/settings.json'),
|
||||
ja: () => import('./locales/ja/settings.json'),
|
||||
ko: () => import('./locales/ko/settings.json'),
|
||||
ru: () => import('./locales/ru/settings.json'),
|
||||
tr: () => import('./locales/tr/settings.json'),
|
||||
zh: () => import('./locales/zh/settings.json'),
|
||||
'zh-TW': () => import('./locales/zh-TW/settings.json')
|
||||
}
|
||||
|
||||
// Track which locales have been loaded
|
||||
const loadedLocales = new Set<string>(['en'])
|
||||
|
||||
// Track locales currently being loaded to prevent race conditions
|
||||
const loadingLocales = new Map<string, Promise<void>>()
|
||||
|
||||
/**
|
||||
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
|
||||
*/
|
||||
export async function loadLocale(locale: string): Promise<void> {
|
||||
if (loadedLocales.has(locale)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If already loading, return the existing promise to prevent duplicate loads
|
||||
const existingLoad = loadingLocales.get(locale)
|
||||
if (existingLoad) {
|
||||
return existingLoad
|
||||
}
|
||||
|
||||
const loader = localeLoaders[locale]
|
||||
const nodeDefsLoader = nodeDefsLoaders[locale]
|
||||
const commandsLoader = commandsLoaders[locale]
|
||||
const settingsLoader = settingsLoaders[locale]
|
||||
|
||||
if (!loader || !nodeDefsLoader || !commandsLoader || !settingsLoader) {
|
||||
console.warn(`Locale "${locale}" is not supported`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create and track the loading promise
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const [main, nodes, commands, settings] = await Promise.all([
|
||||
loader(),
|
||||
nodeDefsLoader(),
|
||||
commandsLoader(),
|
||||
settingsLoader()
|
||||
])
|
||||
|
||||
const messages = buildLocale(
|
||||
main.default,
|
||||
nodes.default,
|
||||
commands.default,
|
||||
settings.default
|
||||
)
|
||||
|
||||
i18n.global.setLocaleMessage(locale, messages as LocaleMessages)
|
||||
loadedLocales.add(locale)
|
||||
} catch (error) {
|
||||
console.error(`Failed to load locale "${locale}":`, error)
|
||||
throw error
|
||||
} finally {
|
||||
// Clean up the loading promise once complete
|
||||
loadingLocales.delete(locale)
|
||||
}
|
||||
})()
|
||||
|
||||
loadingLocales.set(locale, loadPromise)
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
// Only include English in the initial bundle
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings)
|
||||
}
|
||||
|
||||
// Type for locale messages - inferred from the English locale structure
|
||||
type LocaleMessages = typeof messages.en
|
||||
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
|
||||
@@ -8,11 +8,12 @@ import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { createApp } from 'vue'
|
||||
import { createApp, vaporInteropPlugin } from 'vue'
|
||||
import { VueFire, VueFireAuth } from 'vuefire'
|
||||
|
||||
import { FIREBASE_CONFIG } from '@/config/firebase'
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import '@/platform/auth/serviceWorker'
|
||||
import router from '@/router'
|
||||
|
||||
import App from './App.vue'
|
||||
@@ -44,6 +45,7 @@ Sentry.init({
|
||||
})
|
||||
app.directive('tooltip', Tooltip)
|
||||
app
|
||||
.use(vaporInteropPlugin) // Enable Vapor and vDOM mixed mode compatibility
|
||||
.use(router)
|
||||
.use(PrimeVue, {
|
||||
theme: {
|
||||
|
||||
9
src/platform/auth/serviceWorker/index.ts
Normal file
9
src/platform/auth/serviceWorker/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
/**
|
||||
* Auth service worker registration (cloud-only).
|
||||
* Tree-shaken for desktop/localhost builds via compile-time constant.
|
||||
*/
|
||||
if (isCloud) {
|
||||
void import('./register')
|
||||
}
|
||||
57
src/platform/auth/serviceWorker/register.ts
Normal file
57
src/platform/auth/serviceWorker/register.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
/**
|
||||
* Registers the authentication service worker for cloud distribution.
|
||||
* Intercepts /api/view requests to add auth headers for browser-native requests.
|
||||
*/
|
||||
async function registerAuthServiceWorker(): Promise<void> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.serviceWorker.register('/auth-sw.js')
|
||||
|
||||
setupAuthHeaderProvider()
|
||||
setupCacheInvalidation()
|
||||
} catch (error) {
|
||||
console.error('[Auth SW] Registration failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for auth header requests from the service worker
|
||||
*/
|
||||
function setupAuthHeaderProvider(): void {
|
||||
navigator.serviceWorker.addEventListener('message', async (event) => {
|
||||
if (event.data.type === 'REQUEST_AUTH_HEADER') {
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
const authHeader = await firebaseAuthStore.getAuthHeader()
|
||||
|
||||
event.ports[0].postMessage({
|
||||
type: 'AUTH_HEADER_RESPONSE',
|
||||
authHeader
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates cached auth header when user logs in/out
|
||||
*/
|
||||
function setupCacheInvalidation(): void {
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
watch(isLoggedIn, (newValue, oldValue) => {
|
||||
if (newValue !== oldValue) {
|
||||
navigator.serviceWorker.controller?.postMessage({
|
||||
type: 'INVALIDATE_AUTH_HEADER'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
void registerAuthServiceWorker()
|
||||
@@ -15,6 +15,8 @@ import Button from 'primevue/button'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -82,6 +84,10 @@ const stopPolling = () => {
|
||||
}
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked')
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
await subscribe()
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
severity="primary"
|
||||
size="small"
|
||||
data-testid="subscribe-to-run-button"
|
||||
@click="showSubscriptionDialog"
|
||||
@click="handleSubscribeToRun"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -18,6 +18,16 @@
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
const { showSubscriptionDialog } = useSubscription()
|
||||
|
||||
const handleSubscribeToRun = () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackRunButton({ subscribe_to_run: true })
|
||||
}
|
||||
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { COMFY_API_BASE_URL } from '@/config/comfyApi'
|
||||
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
@@ -26,7 +27,7 @@ interface CloudSubscriptionStatusResponse {
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(() => {
|
||||
if (!isCloud) return true
|
||||
if (!isCloud || !__BUILD_FLAGS__.REQUIRE_SUBSCRIPTION) return true
|
||||
|
||||
return subscriptionStatus.value?.is_active ?? false
|
||||
})
|
||||
@@ -78,6 +79,10 @@ export function useSubscription() {
|
||||
}, reportError)
|
||||
|
||||
const showSubscriptionDialog = () => {
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('modal_opened')
|
||||
}
|
||||
|
||||
dialogService.showSubscriptionRequiredDialog()
|
||||
}
|
||||
|
||||
|
||||
@@ -80,21 +80,22 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
const subscriptionPanel: SettingPanelItem | null = !isCloud
|
||||
? null
|
||||
: {
|
||||
node: {
|
||||
key: 'subscription',
|
||||
label: 'PlanCredits',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import(
|
||||
'@/platform/cloud/subscription/components/SubscriptionPanel.vue'
|
||||
)
|
||||
)
|
||||
}
|
||||
const subscriptionPanel: SettingPanelItem | null =
|
||||
!isCloud || !__BUILD_FLAGS__.REQUIRE_SUBSCRIPTION
|
||||
? null
|
||||
: {
|
||||
node: {
|
||||
key: 'subscription',
|
||||
label: 'PlanCredits',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import(
|
||||
'@/platform/cloud/subscription/components/SubscriptionPanel.vue'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const userPanel: SettingPanelItem = {
|
||||
node: {
|
||||
@@ -148,7 +149,9 @@ export function useSettingUI(
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isElectron() ? [serverConfigPanel] : []),
|
||||
...(isCloud && subscriptionPanel ? [subscriptionPanel] : [])
|
||||
...(isCloud && __BUILD_FLAGS__.REQUIRE_SUBSCRIPTION && subscriptionPanel
|
||||
? [subscriptionPanel]
|
||||
: [])
|
||||
].filter((panel) => panel.component)
|
||||
)
|
||||
|
||||
@@ -180,10 +183,16 @@ export function useSettingUI(
|
||||
label: 'Account',
|
||||
children: [
|
||||
userPanel.node,
|
||||
...(isLoggedIn.value && isCloud && subscriptionPanel
|
||||
...(isLoggedIn.value &&
|
||||
isCloud &&
|
||||
__BUILD_FLAGS__.REQUIRE_SUBSCRIPTION &&
|
||||
subscriptionPanel
|
||||
? [subscriptionPanel.node]
|
||||
: []),
|
||||
...(isLoggedIn.value && !isCloud ? [creditsPanel.node] : [])
|
||||
...(isLoggedIn.value &&
|
||||
!(isCloud && __BUILD_FLAGS__.REQUIRE_SUBSCRIPTION)
|
||||
? [creditsPanel.node]
|
||||
: [])
|
||||
].map(translateCategory)
|
||||
},
|
||||
// Normal settings stored in the settingStore
|
||||
|
||||
42
src/platform/telemetry/index.ts
Normal file
42
src/platform/telemetry/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Telemetry Provider - OSS Build Safety
|
||||
*
|
||||
* CRITICAL: OSS Build Safety
|
||||
* This module is conditionally compiled based on distribution. When building
|
||||
* the open source version (DISTRIBUTION unset), this entire module and its dependencies
|
||||
* are excluded through via tree-shaking.
|
||||
*
|
||||
* To verify OSS builds exclude this code:
|
||||
* 1. `DISTRIBUTION= pnpm build` (OSS build)
|
||||
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*
|
||||
* This approach maintains complete separation between cloud and OSS builds
|
||||
* while ensuring the open source version contains no telemetry dependencies.
|
||||
*/
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider'
|
||||
import type { TelemetryProvider } from './types'
|
||||
|
||||
// Singleton instance
|
||||
let _telemetryProvider: TelemetryProvider | null = null
|
||||
|
||||
/**
|
||||
* Telemetry factory - conditionally creates provider based on distribution
|
||||
* Returns singleton instance.
|
||||
*
|
||||
* CRITICAL: This returns undefined in OSS builds. There is no telemetry provider
|
||||
* for OSS builds and all tracking calls are no-ops.
|
||||
*/
|
||||
export function useTelemetry(): TelemetryProvider | null {
|
||||
if (_telemetryProvider === null) {
|
||||
// Use distribution check for tree-shaking
|
||||
if (isCloud) {
|
||||
_telemetryProvider = new MixpanelTelemetryProvider()
|
||||
}
|
||||
// For OSS builds, _telemetryProvider stays null
|
||||
}
|
||||
|
||||
return _telemetryProvider
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
ExecutionContext,
|
||||
RunButtonProperties,
|
||||
SurveyResponses,
|
||||
TelemetryEventName,
|
||||
TelemetryEventProperties,
|
||||
TelemetryProvider,
|
||||
TemplateMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
|
||||
interface QueuedEvent {
|
||||
eventName: TelemetryEventName
|
||||
properties?: TelemetryEventProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixpanel Telemetry Provider - Cloud Build Implementation
|
||||
*
|
||||
* CRITICAL: OSS Build Safety
|
||||
* This provider integrates with Mixpanel for cloud telemetry tracking.
|
||||
* Entire file is tree-shaken away in OSS builds (DISTRIBUTION unset).
|
||||
*
|
||||
* To verify OSS builds exclude this code:
|
||||
* 1. `DISTRIBUTION= pnpm build` (OSS build)
|
||||
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*/
|
||||
export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
private isEnabled = true
|
||||
private mixpanel: OverridedMixpanel | null = null
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private isInitialized = false
|
||||
|
||||
constructor() {
|
||||
const token = __MIXPANEL_TOKEN__
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Dynamic import to avoid bundling mixpanel in OSS builds
|
||||
void import('mixpanel-browser')
|
||||
.then((mixpanelModule) => {
|
||||
this.mixpanel = mixpanelModule.default
|
||||
this.mixpanel.init(token, {
|
||||
debug: import.meta.env.DEV,
|
||||
track_pageview: true,
|
||||
api_host: 'https://mp.comfy.org',
|
||||
cross_subdomain_cookie: true,
|
||||
persistence: 'cookie',
|
||||
loaded: () => {
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue() // flush events that were queued while initializing
|
||||
useCurrentUser().onUserResolved((user) => {
|
||||
if (this.mixpanel && user.id) {
|
||||
this.mixpanel.identify(user.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load Mixpanel:', error)
|
||||
this.isEnabled = false
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Mixpanel:', error)
|
||||
this.isEnabled = false
|
||||
}
|
||||
} else {
|
||||
console.warn('Mixpanel token not provided')
|
||||
this.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
private flushEventQueue(): void {
|
||||
if (!this.isInitialized || !this.mixpanel) {
|
||||
return
|
||||
}
|
||||
|
||||
while (this.eventQueue.length > 0) {
|
||||
const event = this.eventQueue.shift()!
|
||||
try {
|
||||
this.mixpanel.track(event.eventName, event.properties || {})
|
||||
} catch (error) {
|
||||
console.error('Failed to track queued event:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private trackEvent(
|
||||
eventName: TelemetryEventName,
|
||||
properties?: TelemetryEventProperties
|
||||
): void {
|
||||
if (!this.isEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const event: QueuedEvent = { eventName, properties }
|
||||
|
||||
if (this.isInitialized && this.mixpanel) {
|
||||
// Mixpanel is ready, track immediately
|
||||
try {
|
||||
this.mixpanel.track(eventName, properties || {})
|
||||
} catch (error) {
|
||||
console.error('Failed to track event:', error)
|
||||
}
|
||||
} else {
|
||||
// Mixpanel not ready yet, queue the event
|
||||
this.eventQueue.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
}
|
||||
|
||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
|
||||
const eventName =
|
||||
event === 'modal_opened'
|
||||
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
|
||||
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED
|
||||
|
||||
this.trackEvent(eventName)
|
||||
}
|
||||
|
||||
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
|
||||
const executionContext = this.getExecutionContext()
|
||||
|
||||
const runButtonProperties: RunButtonProperties = {
|
||||
subscribe_to_run: options?.subscribe_to_run || false,
|
||||
workflow_type: executionContext.is_template ? 'template' : 'custom',
|
||||
workflow_name: executionContext.workflow_name ?? 'untitled'
|
||||
}
|
||||
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
trackSurvey(
|
||||
stage: 'opened' | 'submitted',
|
||||
responses?: SurveyResponses
|
||||
): void {
|
||||
const eventName =
|
||||
stage === 'opened'
|
||||
? TelemetryEvents.USER_SURVEY_OPENED
|
||||
: TelemetryEvents.USER_SURVEY_SUBMITTED
|
||||
|
||||
this.trackEvent(eventName, responses)
|
||||
}
|
||||
|
||||
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
|
||||
let eventName: TelemetryEventName
|
||||
|
||||
switch (stage) {
|
||||
case 'opened':
|
||||
eventName = TelemetryEvents.USER_EMAIL_VERIFY_OPENED
|
||||
break
|
||||
case 'requested':
|
||||
eventName = TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED
|
||||
break
|
||||
case 'completed':
|
||||
eventName = TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED
|
||||
break
|
||||
}
|
||||
|
||||
this.trackEvent(eventName)
|
||||
}
|
||||
|
||||
trackTemplate(metadata: TemplateMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
const context = this.getExecutionContext()
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_EXECUTION_STARTED, context)
|
||||
}
|
||||
|
||||
getExecutionContext(): ExecutionContext {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const templatesStore = useWorkflowTemplatesStore()
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
|
||||
if (activeWorkflow?.filename) {
|
||||
const isTemplate = templatesStore.knownTemplateNames.has(
|
||||
activeWorkflow.filename
|
||||
)
|
||||
|
||||
if (isTemplate) {
|
||||
const template = templatesStore.getTemplateByName(
|
||||
activeWorkflow.filename
|
||||
)
|
||||
return {
|
||||
is_template: true,
|
||||
workflow_name: activeWorkflow.filename,
|
||||
template_source: template?.sourceModule,
|
||||
template_category: template?.category,
|
||||
template_tags: template?.tags,
|
||||
template_models: template?.models,
|
||||
template_use_case: template?.useCase,
|
||||
template_license: template?.license
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is_template: false,
|
||||
workflow_name: activeWorkflow.filename
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is_template: false,
|
||||
workflow_name: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/platform/telemetry/types.ts
Normal file
138
src/platform/telemetry/types.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Telemetry Provider Interface
|
||||
*
|
||||
* CRITICAL: OSS Build Safety
|
||||
* This module is excluded from OSS builds via conditional compilation.
|
||||
* When DISTRIBUTION is unset (OSS builds), Vite's tree-shaking removes this code entirely,
|
||||
* ensuring the open source build contains no telemetry dependencies.
|
||||
*
|
||||
* To verify OSS builds are clean:
|
||||
* 1. `DISTRIBUTION= pnpm build` (OSS build)
|
||||
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*/
|
||||
|
||||
/**
|
||||
* Authentication metadata for sign-up tracking
|
||||
*/
|
||||
export interface AuthMetadata {
|
||||
method?: 'email' | 'google' | 'github'
|
||||
is_new_user?: boolean
|
||||
referrer_url?: string
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
utm_campaign?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Survey response data for user profiling
|
||||
*/
|
||||
export interface SurveyResponses {
|
||||
industry?: string
|
||||
team_size?: string
|
||||
use_case?: string
|
||||
familiarity?: string
|
||||
intended_use?: 'personal' | 'client' | 'inhouse'
|
||||
}
|
||||
|
||||
/**
|
||||
* Run button tracking properties
|
||||
*/
|
||||
export interface RunButtonProperties {
|
||||
subscribe_to_run: boolean
|
||||
workflow_type: 'template' | 'custom'
|
||||
workflow_name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution context for workflow tracking
|
||||
*/
|
||||
export interface ExecutionContext {
|
||||
is_template: boolean
|
||||
workflow_name?: string
|
||||
// Template metadata (only present when is_template = true)
|
||||
template_source?: string
|
||||
template_category?: string
|
||||
template_tags?: string[]
|
||||
template_models?: string[]
|
||||
template_use_case?: string
|
||||
template_license?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Template metadata for workflow tracking
|
||||
*/
|
||||
export interface TemplateMetadata {
|
||||
workflow_name: string
|
||||
template_source?: string
|
||||
template_category?: string
|
||||
template_tags?: string[]
|
||||
template_models?: string[]
|
||||
template_use_case?: string
|
||||
template_license?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Core telemetry provider interface
|
||||
*/
|
||||
export interface TelemetryProvider {
|
||||
// Authentication flow events
|
||||
trackAuth(metadata: AuthMetadata): void
|
||||
|
||||
// Subscription flow events
|
||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
|
||||
trackRunButton(options?: { subscribe_to_run?: boolean }): void
|
||||
|
||||
// Survey flow events
|
||||
trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
|
||||
|
||||
// Email verification events
|
||||
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void
|
||||
|
||||
// Template workflow events
|
||||
trackTemplate(metadata: TemplateMetadata): void
|
||||
|
||||
// Workflow execution events
|
||||
trackWorkflowExecution(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry event constants
|
||||
*/
|
||||
export const TelemetryEvents = {
|
||||
// Authentication Flow
|
||||
USER_AUTH_COMPLETED: 'user_auth_completed',
|
||||
|
||||
// Subscription Flow
|
||||
RUN_BUTTON_CLICKED: 'run_button_clicked',
|
||||
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'subscription_required_modal_opened',
|
||||
SUBSCRIBE_NOW_BUTTON_CLICKED: 'subscribe_now_button_clicked',
|
||||
|
||||
// Onboarding Survey
|
||||
USER_SURVEY_OPENED: 'user_survey_opened',
|
||||
USER_SURVEY_SUBMITTED: 'user_survey_submitted',
|
||||
|
||||
// Email Verification
|
||||
USER_EMAIL_VERIFY_OPENED: 'user_email_verify_opened',
|
||||
USER_EMAIL_VERIFY_REQUESTED: 'user_email_verify_requested',
|
||||
USER_EMAIL_VERIFY_COMPLETED: 'user_email_verify_completed',
|
||||
|
||||
// Template Tracking
|
||||
TEMPLATE_WORKFLOW_OPENED: 'template_workflow_opened',
|
||||
|
||||
// Workflow Execution Tracking
|
||||
WORKFLOW_EXECUTION_STARTED: 'workflow_execution_started'
|
||||
} as const
|
||||
|
||||
export type TelemetryEventName =
|
||||
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
|
||||
|
||||
/**
|
||||
* Union type for all possible telemetry event properties
|
||||
*/
|
||||
export type TelemetryEventProperties =
|
||||
| AuthMetadata
|
||||
| SurveyResponses
|
||||
| TemplateMetadata
|
||||
| ExecutionContext
|
||||
| RunButtonProperties
|
||||
@@ -1,6 +1,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type {
|
||||
TemplateGroup,
|
||||
@@ -128,6 +130,13 @@ export function useTemplateWorkflows() {
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackTemplate({
|
||||
workflow_name: workflowName,
|
||||
template_source: actualSourceModule
|
||||
})
|
||||
}
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
@@ -142,6 +151,13 @@ export function useTemplateWorkflows() {
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackTemplate({
|
||||
workflow_name: workflowName,
|
||||
template_source: sourceModule
|
||||
})
|
||||
}
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
|
||||
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
|
||||
const isLoaded = ref(false)
|
||||
const knownTemplateNames = ref(new Set<string>())
|
||||
|
||||
const getTemplateByName = (name: string): EnhancedTemplate | undefined => {
|
||||
return enhancedTemplates.value.find((template) => template.name === name)
|
||||
}
|
||||
|
||||
// Store filter mappings for dynamic categories
|
||||
type FilterData = {
|
||||
@@ -432,6 +437,13 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
customTemplates.value = await api.getWorkflowTemplates()
|
||||
const locale = i18n.global.locale.value
|
||||
coreTemplates.value = await api.getCoreWorkflowTemplates(locale)
|
||||
|
||||
const coreNames = coreTemplates.value.flatMap((category) =>
|
||||
category.templates.map((template) => template.name)
|
||||
)
|
||||
const customNames = Object.values(customTemplates.value).flat()
|
||||
knownTemplateNames.value = new Set([...coreNames, ...customNames])
|
||||
|
||||
isLoaded.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -446,7 +458,9 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
templateFuse,
|
||||
filterTemplatesByCategory,
|
||||
isLoaded,
|
||||
loadWorkflowTemplates
|
||||
loadWorkflowTemplates,
|
||||
knownTemplateNames,
|
||||
getTemplateByName
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col"
|
||||
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.videoPreview')"
|
||||
data-capture-node="true"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@keydown="handleKeyDown"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col"
|
||||
data-capture-node="true"
|
||||
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.imagePreview')"
|
||||
|
||||
@@ -118,9 +118,7 @@ const slotWrapperClass = computed(() =>
|
||||
cn(
|
||||
'lg-slot lg-slot--input flex items-center group rounded-r-lg h-6',
|
||||
'cursor-crosshair',
|
||||
props.dotOnly
|
||||
? 'lg-slot--dot-only'
|
||||
: 'pr-6 hover:bg-black/5 hover:dark:bg-white/5',
|
||||
props.dotOnly ? 'lg-slot--dot-only' : 'pr-6',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible,
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts" vapor>
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue'
|
||||
|
||||
@@ -88,9 +88,7 @@ const slotWrapperClass = computed(() =>
|
||||
cn(
|
||||
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
|
||||
'cursor-crosshair',
|
||||
props.dotOnly
|
||||
? 'lg-slot--dot-only justify-center'
|
||||
: 'pl-6 hover:bg-black/5 hover:dark:bg-white/5',
|
||||
props.dotOnly ? 'lg-slot--dot-only justify-center' : 'pl-6',
|
||||
{
|
||||
'lg-slot--connected': props.connected,
|
||||
'lg-slot--compatible': props.compatible,
|
||||
|
||||
@@ -55,17 +55,6 @@ export function useNodePointerInteractions(
|
||||
|
||||
if (forwardMiddlePointerIfNeeded(event)) return
|
||||
|
||||
const stopNodeDragTarget =
|
||||
event.target instanceof HTMLElement
|
||||
? event.target.closest('[data-capture-node="true"]')
|
||||
: null
|
||||
if (stopNodeDragTarget) {
|
||||
if (!shouldHandleNodePointerEvents.value) {
|
||||
forwardEventToCanvas(event)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Only start drag on left-click (button 0)
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
|
||||
@@ -10,5 +10,5 @@ export const WidgetInputBaseClass = cn([
|
||||
// Rounded
|
||||
'rounded-lg',
|
||||
// Hover
|
||||
'hover:outline-blue-500/80'
|
||||
'hover:bg-node-component-surface-hovered'
|
||||
])
|
||||
|
||||
@@ -488,7 +488,7 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
function showSubscriptionRequiredDialog() {
|
||||
if (!isCloud) {
|
||||
if (!isCloud || !__BUILD_FLAGS__.REQUIRE_SUBSCRIPTION) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
browserLocalPersistence,
|
||||
createUserWithEmailAndPassword,
|
||||
deleteUser,
|
||||
getAdditionalUserInfo,
|
||||
onAuthStateChanged,
|
||||
sendPasswordResetEmail,
|
||||
setPersistence,
|
||||
@@ -21,6 +22,8 @@ import { useFirebaseAuth } from 'vuefire'
|
||||
|
||||
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
@@ -242,36 +245,79 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
const login = async (
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<UserCredential> =>
|
||||
executeAuthAction(
|
||||
): Promise<UserCredential> => {
|
||||
const result = await executeAuthAction(
|
||||
(authInstance) =>
|
||||
signInWithEmailAndPassword(authInstance, email, password),
|
||||
{ createCustomer: true }
|
||||
)
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackAuth({
|
||||
method: 'email',
|
||||
is_new_user: false
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const register = async (
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<UserCredential> => {
|
||||
return executeAuthAction(
|
||||
const result = await executeAuthAction(
|
||||
(authInstance) =>
|
||||
createUserWithEmailAndPassword(authInstance, email, password),
|
||||
{ createCustomer: true }
|
||||
)
|
||||
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackAuth({
|
||||
method: 'email',
|
||||
is_new_user: true
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const loginWithGoogle = async (): Promise<UserCredential> =>
|
||||
executeAuthAction(
|
||||
const loginWithGoogle = async (): Promise<UserCredential> => {
|
||||
const result = await executeAuthAction(
|
||||
(authInstance) => signInWithPopup(authInstance, googleProvider),
|
||||
{ createCustomer: true }
|
||||
)
|
||||
|
||||
const loginWithGithub = async (): Promise<UserCredential> =>
|
||||
executeAuthAction(
|
||||
if (isCloud) {
|
||||
const additionalUserInfo = getAdditionalUserInfo(result)
|
||||
const isNewUser = additionalUserInfo?.isNewUser ?? false
|
||||
useTelemetry()?.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: isNewUser
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const loginWithGithub = async (): Promise<UserCredential> => {
|
||||
const result = await executeAuthAction(
|
||||
(authInstance) => signInWithPopup(authInstance, githubProvider),
|
||||
{ createCustomer: true }
|
||||
)
|
||||
|
||||
if (isCloud) {
|
||||
const additionalUserInfo = getAdditionalUserInfo(result)
|
||||
const isNewUser = additionalUserInfo?.isNewUser ?? false
|
||||
useTelemetry()?.trackAuth({
|
||||
method: 'github',
|
||||
is_new_user: isNewUser
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const logout = async (): Promise<void> =>
|
||||
executeAuthAction((authInstance) => signOut(authInstance))
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
||||
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||
import { i18n } from '@/i18n'
|
||||
import { i18n, loadLocale } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
|
||||
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
|
||||
@@ -145,10 +145,17 @@ watchEffect(() => {
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
watchEffect(async () => {
|
||||
const locale = settingStore.get('Comfy.Locale')
|
||||
if (locale) {
|
||||
i18n.global.locale.value = locale as 'en' | 'zh' | 'ru' | 'ja'
|
||||
// Load the locale dynamically if not already loaded
|
||||
try {
|
||||
await loadLocale(locale)
|
||||
// Type assertion is safe here as loadLocale validates the locale exists
|
||||
i18n.global.locale.value = locale as typeof i18n.global.locale.value
|
||||
} catch (error) {
|
||||
console.error(`Failed to switch to locale "${locale}":`, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ vi.mock('@/scripts/app', () => ({
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: vi.fn((key, fallback) => fallback || key)
|
||||
}),
|
||||
createI18n: () => ({
|
||||
global: {
|
||||
t: (key: string) => key
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
useFirebaseAuthActions: vi.fn(() => ({
|
||||
reportError: mockReportError,
|
||||
|
||||
30
tests-ui/tests/platform/telemetry/useTelemetry.test.ts
Normal file
30
tests-ui/tests/platform/telemetry/useTelemetry.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
describe('useTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return null when not in cloud distribution', async () => {
|
||||
const { useTelemetry } = await import('@/platform/telemetry')
|
||||
const provider = useTelemetry()
|
||||
|
||||
// Should return null for OSS builds
|
||||
expect(provider).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null consistently for OSS builds', async () => {
|
||||
const { useTelemetry } = await import('@/platform/telemetry')
|
||||
|
||||
const provider1 = useTelemetry()
|
||||
const provider2 = useTelemetry()
|
||||
|
||||
// Both should be null for OSS builds
|
||||
expect(provider1).toBeNull()
|
||||
expect(provider2).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import Components from 'unplugin-vue-components/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import type { UserConfig } from 'vite'
|
||||
import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
import Inspect from 'vite-plugin-inspect'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
|
||||
@@ -32,6 +33,10 @@ const DISTRIBUTION = (process.env.DISTRIBUTION || 'localhost') as
|
||||
| 'localhost'
|
||||
| 'cloud'
|
||||
|
||||
const BUILD_FLAGS = {
|
||||
REQUIRE_SUBSCRIPTION: process.env.REQUIRE_SUBSCRIPTION === 'true'
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: '',
|
||||
server: {
|
||||
@@ -110,13 +115,14 @@ export default defineConfig({
|
||||
...(!DISABLE_VUE_PLUGINS
|
||||
? [vueDevTools(), vue(), createHtmlPlugin({})]
|
||||
: [vue()]),
|
||||
Inspect(),
|
||||
tailwindcss(),
|
||||
comfyAPIPlugin(IS_DEV),
|
||||
generateImportMapPlugin([
|
||||
{
|
||||
name: 'vue',
|
||||
pattern: 'vue',
|
||||
entry: './dist/vue.esm-browser.prod.js'
|
||||
entry: './dist/vue.runtime-with-vapor.esm-browser.prod.js'
|
||||
},
|
||||
{
|
||||
name: 'vue-i18n',
|
||||
@@ -188,7 +194,36 @@ export default defineConfig({
|
||||
target: 'es2022',
|
||||
sourcemap: GENERATE_SOURCEMAP,
|
||||
rollupOptions: {
|
||||
treeshake: true
|
||||
treeshake: true,
|
||||
output: {
|
||||
manualChunks: (id) => {
|
||||
if (!id.includes('node_modules')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (id.includes('primevue') || id.includes('@primeuix')) {
|
||||
return 'vendor-primevue'
|
||||
}
|
||||
|
||||
if (id.includes('@tiptap')) {
|
||||
return 'vendor-tiptap'
|
||||
}
|
||||
|
||||
if (id.includes('chart.js')) {
|
||||
return 'vendor-chart'
|
||||
}
|
||||
|
||||
if (id.includes('three') || id.includes('@xterm')) {
|
||||
return 'vendor-visualization'
|
||||
}
|
||||
|
||||
if (id.includes('/vue') || id.includes('pinia')) {
|
||||
return 'vendor-vue'
|
||||
}
|
||||
|
||||
return 'vendor-other'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -196,7 +231,29 @@ export default defineConfig({
|
||||
minifyIdentifiers: SHOULD_MINIFY,
|
||||
keepNames: true,
|
||||
minifySyntax: SHOULD_MINIFY,
|
||||
minifyWhitespace: SHOULD_MINIFY
|
||||
minifyWhitespace: SHOULD_MINIFY,
|
||||
pure: SHOULD_MINIFY
|
||||
? [
|
||||
'console.log',
|
||||
'console.debug',
|
||||
'console.info',
|
||||
'console.trace',
|
||||
'console.dir',
|
||||
'console.dirxml',
|
||||
'console.group',
|
||||
'console.groupCollapsed',
|
||||
'console.groupEnd',
|
||||
'console.table',
|
||||
'console.time',
|
||||
'console.timeEnd',
|
||||
'console.timeLog',
|
||||
'console.count',
|
||||
'console.countReset',
|
||||
'console.profile',
|
||||
'console.profileEnd',
|
||||
'console.clear'
|
||||
]
|
||||
: []
|
||||
},
|
||||
|
||||
test: {
|
||||
@@ -216,7 +273,9 @@ export default defineConfig({
|
||||
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
|
||||
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
|
||||
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
|
||||
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION)
|
||||
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
|
||||
__BUILD_FLAGS__: JSON.stringify(BUILD_FLAGS),
|
||||
__MIXPANEL_TOKEN__: JSON.stringify(process.env.MIXPANEL_TOKEN || '')
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
||||
@@ -9,6 +9,9 @@ globalThis.__ALGOLIA_APP_ID__ = ''
|
||||
globalThis.__ALGOLIA_API_KEY__ = ''
|
||||
globalThis.__USE_PROD_CONFIG__ = false
|
||||
globalThis.__DISTRIBUTION__ = 'localhost'
|
||||
globalThis.__BUILD_FLAGS__ = {
|
||||
REQUIRE_SUBSCRIPTION: true
|
||||
}
|
||||
|
||||
// Mock Worker for extendable-media-recorder
|
||||
globalThis.Worker = vi.fn().mockImplementation(() => ({
|
||||
|
||||
Reference in New Issue
Block a user