import { useSyncExternalStore } from 'react'; import { appendClientIdHeader } from './clientIdentity'; import { getAutomationNotificationPreferenceTarget } from './notificationIdentity'; export const APP_CONFIG_STORAGE_KEY = 'work-server.app-config'; const APP_CONFIG_EVENT = 'work-server:app-config'; const APP_CONFIG_API_PATH = '/app-config'; const AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH = '/notifications/preferences/automation'; const APP_CONFIG_REQUEST_TIMEOUT_MS = 8000; let cachedConfig: AppConfig | null = null; let cachedRawConfig: string | null = null; export type AutomationScheduleType = 'interval' | 'daily' | 'weekly'; export type WeeklyScheduleDay = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'; export type PlanCostTimeUnit = 'hour' | 'minute' | 'second'; export type AppConfig = { chat: { maxContextMessages: number; maxContextChars: number; codexLiveMaxExecutionSeconds: number; }; automation: { autoRefreshEnabled: boolean; autoRefreshIntervalSeconds: number; autoReceiveScheduleType: AutomationScheduleType; autoReceiveIntervalSeconds: number; autoReceiveDailyTime: string; autoReceiveWeeklyDay: WeeklyScheduleDay; autoReceiveWeeklyTime: string; notifyOnAutomationStart: boolean; notifyOnAutomationProgress: boolean; notifyOnAutomationCompletion: boolean; notifyOnAutomationRelease: boolean; notifyOnAutomationMain: boolean; notifyOnAutomationFailure: boolean; notifyOnAutomationRestart: boolean; notifyOnAutomationIssueResolved: boolean; }; worklogAutomation: { autoCreateDailyWorklog: boolean; dailyCreateTime: string; repeatRequestEnabled: boolean; repeatIntervalMinutes: number; includeScreenshots: boolean; includeChangedFiles: boolean; includeCommandLogs: boolean; template: 'simple' | 'detailed'; }; planDefaults: { jangsingProcessingRequired: boolean; autoDeployToMain: boolean; openEditorAfterCreate: boolean; }; planCost: { baseCostPerMillionTokens: number; retryCostMultiplierPercent: number; hourlyCostMultiplierPercent: number; timeCostUnit: PlanCostTimeUnit; attentionCostThresholdMultiplier: number; warningCostThresholdMultiplier: number; highCostThresholdMultiplier: number; }; gestureShortcuts: { openSearch: string; openWindowSearch: string; }; }; export const DEFAULT_APP_CONFIG: AppConfig = { chat: { maxContextMessages: 12, maxContextChars: 3200, codexLiveMaxExecutionSeconds: 600, }, automation: { autoRefreshEnabled: true, autoRefreshIntervalSeconds: 5, autoReceiveScheduleType: 'interval', autoReceiveIntervalSeconds: 30, autoReceiveDailyTime: '09:00', autoReceiveWeeklyDay: 'mon', autoReceiveWeeklyTime: '09:00', notifyOnAutomationStart: true, notifyOnAutomationProgress: true, notifyOnAutomationCompletion: true, notifyOnAutomationRelease: true, notifyOnAutomationMain: true, notifyOnAutomationFailure: true, notifyOnAutomationRestart: true, notifyOnAutomationIssueResolved: true, }, worklogAutomation: { autoCreateDailyWorklog: false, dailyCreateTime: '18:00', repeatRequestEnabled: false, repeatIntervalMinutes: 60, includeScreenshots: true, includeChangedFiles: true, includeCommandLogs: true, template: 'detailed', }, planDefaults: { jangsingProcessingRequired: true, autoDeployToMain: true, openEditorAfterCreate: true, }, planCost: { baseCostPerMillionTokens: 10000, retryCostMultiplierPercent: 15, hourlyCostMultiplierPercent: 0, timeCostUnit: 'hour', attentionCostThresholdMultiplier: 1, warningCostThresholdMultiplier: 2, highCostThresholdMultiplier: 4, }, gestureShortcuts: { openSearch: 'Mod+K', openWindowSearch: 'Mod+Shift+K', }, }; const WEEKLY_DAY_LABELS: Record = { mon: '월요일', tue: '화요일', wed: '수요일', thu: '목요일', fri: '금요일', sat: '토요일', sun: '일요일', }; const AUTOMATION_NOTIFICATION_KEYS = [ 'notifyOnAutomationStart', 'notifyOnAutomationProgress', 'notifyOnAutomationCompletion', 'notifyOnAutomationRelease', 'notifyOnAutomationMain', 'notifyOnAutomationFailure', 'notifyOnAutomationRestart', 'notifyOnAutomationIssueResolved', ] as const; type AutomationNotificationSettings = Pick; function clampIntervalSeconds(value: number, fallback: number) { if (!Number.isFinite(value)) { return fallback; } return Math.min(3600, Math.max(1, Math.round(value))); } function normalizeTimeValue(value: string, fallback: string) { if (/^\d{2}:\d{2}$/.test(value)) { return value; } return fallback; } function normalizeScheduleType(value: string | undefined): AutomationScheduleType { if (value === 'daily' || value === 'weekly') { return value; } return 'interval'; } function normalizeWeeklyDay(value: string | undefined): WeeklyScheduleDay { if (value && value in WEEKLY_DAY_LABELS) { return value as WeeklyScheduleDay; } return DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyDay; } function normalizeWorklogTemplate(value: string | undefined): AppConfig['worklogAutomation']['template'] { if (value === 'simple') { return 'simple'; } return 'detailed'; } function normalizeShortcutValue(value: string | undefined, fallback: string) { const trimmed = value?.trim(); if (!trimmed) { return fallback; } return trimmed .split('+') .map((token) => token.trim()) .filter(Boolean) .join('+'); } function normalizePlanCostValue(value: number | undefined, fallback: number) { if (value === undefined || !Number.isFinite(value)) { return fallback; } return Math.min(1_000_000, Math.max(100, Math.round(value))); } function normalizePlanCostMultiplierValue(value: number | undefined, fallback: number) { if (value === undefined || !Number.isFinite(value)) { return fallback; } return Math.min(100, Math.max(0.1, Math.round(value * 10) / 10)); } function normalizePlanCostPercentValue(value: number | undefined, fallback: number) { if (value === undefined || !Number.isFinite(value)) { return fallback; } return Math.min(500, Math.max(0, Math.round(value))); } function normalizePlanCostTimeUnit(value: string | undefined): PlanCostTimeUnit { if (value === 'minute' || value === 'second') { return value; } return 'hour'; } function normalizeChatContextMessageLimit(value: number | undefined, fallback: number) { if (value === undefined || !Number.isFinite(value)) { return fallback; } return Math.min(50, Math.max(1, Math.round(value))); } function normalizeChatContextCharLimit(value: number | undefined, fallback: number) { if (value === undefined || !Number.isFinite(value)) { return fallback; } return Math.min(20_000, Math.max(500, Math.round(value))); } function normalizeCodexLiveMaxExecutionSeconds(value: number | undefined, fallback: number) { if (value === undefined || !Number.isFinite(value)) { return fallback; } return Math.min(7200, Math.max(60, Math.round(value))); } function normalizeConfig(raw?: Partial): AppConfig { const chat = raw?.chat; const automation = raw?.automation; const worklogAutomation = raw?.worklogAutomation; const planDefaults = raw?.planDefaults; const planCost = raw?.planCost; const gestureShortcuts = raw?.gestureShortcuts; return { chat: { maxContextMessages: normalizeChatContextMessageLimit( chat?.maxContextMessages, DEFAULT_APP_CONFIG.chat.maxContextMessages, ), maxContextChars: normalizeChatContextCharLimit(chat?.maxContextChars, DEFAULT_APP_CONFIG.chat.maxContextChars), codexLiveMaxExecutionSeconds: normalizeCodexLiveMaxExecutionSeconds( chat?.codexLiveMaxExecutionSeconds, DEFAULT_APP_CONFIG.chat.codexLiveMaxExecutionSeconds, ), }, automation: { autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled, autoRefreshIntervalSeconds: clampIntervalSeconds( automation?.autoRefreshIntervalSeconds ?? DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds, DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds, ), autoReceiveScheduleType: normalizeScheduleType(automation?.autoReceiveScheduleType), autoReceiveIntervalSeconds: clampIntervalSeconds( automation?.autoReceiveIntervalSeconds ?? DEFAULT_APP_CONFIG.automation.autoReceiveIntervalSeconds, DEFAULT_APP_CONFIG.automation.autoReceiveIntervalSeconds, ), autoReceiveDailyTime: normalizeTimeValue( automation?.autoReceiveDailyTime ?? DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime, DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime, ), autoReceiveWeeklyDay: normalizeWeeklyDay(automation?.autoReceiveWeeklyDay), autoReceiveWeeklyTime: normalizeTimeValue( automation?.autoReceiveWeeklyTime ?? DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime, DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime, ), notifyOnAutomationStart: automation?.notifyOnAutomationStart ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationStart, notifyOnAutomationProgress: automation?.notifyOnAutomationProgress ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationProgress, notifyOnAutomationCompletion: automation?.notifyOnAutomationCompletion ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationCompletion, notifyOnAutomationRelease: automation?.notifyOnAutomationRelease ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationRelease, notifyOnAutomationMain: automation?.notifyOnAutomationMain ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationMain, notifyOnAutomationFailure: automation?.notifyOnAutomationFailure ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationFailure, notifyOnAutomationRestart: automation?.notifyOnAutomationRestart ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationRestart, notifyOnAutomationIssueResolved: automation?.notifyOnAutomationIssueResolved ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationIssueResolved, }, worklogAutomation: { autoCreateDailyWorklog: worklogAutomation?.autoCreateDailyWorklog ?? DEFAULT_APP_CONFIG.worklogAutomation.autoCreateDailyWorklog, dailyCreateTime: normalizeTimeValue( worklogAutomation?.dailyCreateTime ?? DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime, DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime, ), repeatRequestEnabled: false, repeatIntervalMinutes: DEFAULT_APP_CONFIG.worklogAutomation.repeatIntervalMinutes, includeScreenshots: worklogAutomation?.includeScreenshots ?? DEFAULT_APP_CONFIG.worklogAutomation.includeScreenshots, includeChangedFiles: worklogAutomation?.includeChangedFiles ?? DEFAULT_APP_CONFIG.worklogAutomation.includeChangedFiles, includeCommandLogs: worklogAutomation?.includeCommandLogs ?? DEFAULT_APP_CONFIG.worklogAutomation.includeCommandLogs, template: normalizeWorklogTemplate(worklogAutomation?.template), }, planDefaults: { jangsingProcessingRequired: planDefaults?.jangsingProcessingRequired ?? DEFAULT_APP_CONFIG.planDefaults.jangsingProcessingRequired, autoDeployToMain: planDefaults?.autoDeployToMain ?? DEFAULT_APP_CONFIG.planDefaults.autoDeployToMain, openEditorAfterCreate: planDefaults?.openEditorAfterCreate ?? DEFAULT_APP_CONFIG.planDefaults.openEditorAfterCreate, }, planCost: { baseCostPerMillionTokens: normalizePlanCostValue( planCost?.baseCostPerMillionTokens, DEFAULT_APP_CONFIG.planCost.baseCostPerMillionTokens, ), retryCostMultiplierPercent: normalizePlanCostPercentValue( planCost?.retryCostMultiplierPercent, DEFAULT_APP_CONFIG.planCost.retryCostMultiplierPercent, ), hourlyCostMultiplierPercent: normalizePlanCostPercentValue( planCost?.hourlyCostMultiplierPercent, DEFAULT_APP_CONFIG.planCost.hourlyCostMultiplierPercent, ), timeCostUnit: normalizePlanCostTimeUnit(planCost?.timeCostUnit), attentionCostThresholdMultiplier: normalizePlanCostMultiplierValue( planCost?.attentionCostThresholdMultiplier, DEFAULT_APP_CONFIG.planCost.attentionCostThresholdMultiplier, ), warningCostThresholdMultiplier: normalizePlanCostMultiplierValue( planCost?.warningCostThresholdMultiplier, DEFAULT_APP_CONFIG.planCost.warningCostThresholdMultiplier, ), highCostThresholdMultiplier: normalizePlanCostMultiplierValue( planCost?.highCostThresholdMultiplier, DEFAULT_APP_CONFIG.planCost.highCostThresholdMultiplier, ), }, gestureShortcuts: { openSearch: normalizeShortcutValue( gestureShortcuts?.openSearch, DEFAULT_APP_CONFIG.gestureShortcuts.openSearch, ), openWindowSearch: normalizeShortcutValue( gestureShortcuts?.openWindowSearch, DEFAULT_APP_CONFIG.gestureShortcuts.openWindowSearch, ), }, }; } function pickAutomationNotificationSettings(automation: AppConfig['automation']): AutomationNotificationSettings { return AUTOMATION_NOTIFICATION_KEYS.reduce((picked, key) => { picked[key] = automation[key]; return picked; }, {} as AutomationNotificationSettings); } function mergeAutomationNotificationSettings( config: AppConfig, automation?: Partial | null, ) { if (!automation) { return config; } return normalizeConfig({ ...config, automation: { ...config.automation, ...automation, }, }); } function emitConfigChange() { if (typeof window === 'undefined') { return; } window.dispatchEvent(new Event(APP_CONFIG_EVENT)); } function resolveAppConfigApiBaseUrl() { if (import.meta.env.VITE_WORK_SERVER_URL) { return import.meta.env.VITE_WORK_SERVER_URL; } return '/api'; } function resolveAppConfigFallbackBaseUrl() { if (typeof window === 'undefined') { return null; } const hostname = window.location.hostname; const isLocalWorkServerHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0'; if (!isLocalWorkServerHost) { return null; } const fallbackUrl = new URL(window.location.origin); fallbackUrl.port = '3100'; fallbackUrl.pathname = '/api'; fallbackUrl.search = ''; fallbackUrl.hash = ''; return fallbackUrl.toString().replace(/\/+$/, ''); } const APP_CONFIG_API_BASE_URL = resolveAppConfigApiBaseUrl(); const APP_CONFIG_FALLBACK_BASE_URL = resolveAppConfigFallbackBaseUrl(); class AppConfigApiError extends Error { status: number; constructor(message: string, status: number) { super(message); this.name = 'AppConfigApiError'; this.status = status; } } async function requestAppConfigOnce(baseUrl: string, path: string, init?: RequestInit): Promise { const headers = appendClientIdHeader(init?.headers); const hasBody = init?.body !== undefined && init.body !== null; const method = init?.method?.toUpperCase() ?? 'GET'; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), APP_CONFIG_REQUEST_TIMEOUT_MS); if (hasBody && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } let response: Response; try { response = await fetch(`${baseUrl}${path}`, { ...init, headers, signal: controller.signal, cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined), }); } catch (error) { clearTimeout(timeoutId); if (error instanceof DOMException && error.name === 'AbortError') { throw new AppConfigApiError('서버 응답이 지연됩니다.', 408); } throw error; } clearTimeout(timeoutId); if (!response.ok) { const text = await response.text(); try { const payload = JSON.parse(text) as { message?: string }; throw new AppConfigApiError(payload.message || '요청 처리에 실패했습니다.', response.status); } catch { throw new AppConfigApiError(text || '요청 처리에 실패했습니다.', response.status); } } const contentType = response.headers.get('content-type') ?? ''; if (!contentType.toLowerCase().includes('application/json')) { const text = await response.text(); throw new AppConfigApiError(text ? '서버 응답이 JSON이 아닙니다.' : '서버 응답을 확인할 수 없습니다.', 502); } return response.json() as Promise; } async function requestAppConfig(path: string, init?: RequestInit): Promise { try { return await requestAppConfigOnce(APP_CONFIG_API_BASE_URL, path, init); } catch (error) { const shouldRetryWithFallback = APP_CONFIG_FALLBACK_BASE_URL && APP_CONFIG_FALLBACK_BASE_URL !== APP_CONFIG_API_BASE_URL && (error instanceof AppConfigApiError ? error.status === 404 || error.status === 408 || error.status === 502 : error instanceof Error && /404|not found|Failed to fetch|Load failed|NetworkError/i.test(error.message)); if (!shouldRetryWithFallback) { throw error; } return requestAppConfigOnce(APP_CONFIG_FALLBACK_BASE_URL, path, init); } } export async function fetchAppConfigFromServer() { try { const response = await requestAppConfig<{ ok: boolean; config: Partial }>(APP_CONFIG_API_PATH); const config = normalizeConfig(response.config); const preference = await fetchAutomationNotificationPreferenceFromServer(); return mergeAutomationNotificationSettings(config, preference); } catch { return null; } } async function fetchAutomationNotificationPreferenceFromServer() { try { const target = getAutomationNotificationPreferenceTarget(); const query = target ? `?${new URLSearchParams(target).toString()}` : ''; const response = await requestAppConfig<{ ok: boolean; automation?: Partial | null; }>(`${AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH}${query}`); return response.automation ?? null; } catch { return null; } } export async function saveAppConfigToServer(config: AppConfig) { const response = await requestAppConfig<{ ok: boolean; config: Partial }>(APP_CONFIG_API_PATH, { method: 'PUT', body: JSON.stringify({ config }), }); return normalizeConfig(response.config); } export async function saveAutomationNotificationPreferenceToServer(config: AppConfig) { const target = getAutomationNotificationPreferenceTarget(); const response = await requestAppConfig<{ ok: boolean; automation: Partial; }>(AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH, { method: 'PUT', body: JSON.stringify({ ...(target ?? {}), automation: pickAutomationNotificationSettings(config.automation), }), }); return mergeAutomationNotificationSettings(config, response.automation); } export async function syncAppConfigFromServer() { const config = await fetchAppConfigFromServer(); if (!config) { return false; } setStoredAppConfig(config); return true; } export function getStoredAppConfig(): AppConfig { if (typeof window === 'undefined') { return DEFAULT_APP_CONFIG; } try { const raw = window.localStorage.getItem(APP_CONFIG_STORAGE_KEY); if (!raw) { cachedConfig = DEFAULT_APP_CONFIG; cachedRawConfig = null; return DEFAULT_APP_CONFIG; } if (raw === cachedRawConfig && cachedConfig) { return cachedConfig; } const normalized = normalizeConfig(JSON.parse(raw) as Partial); cachedConfig = normalized; cachedRawConfig = raw; return normalized; } catch { cachedConfig = DEFAULT_APP_CONFIG; cachedRawConfig = null; return DEFAULT_APP_CONFIG; } } export function setStoredAppConfig(config: AppConfig) { if (typeof window === 'undefined') { return; } const normalized = normalizeConfig(config); const raw = JSON.stringify(normalized); cachedConfig = normalized; cachedRawConfig = raw; window.localStorage.setItem(APP_CONFIG_STORAGE_KEY, raw); emitConfigChange(); } export function updateStoredAppConfig(updater: (current: AppConfig) => AppConfig) { const current = getStoredAppConfig(); const next = updater(current); setStoredAppConfig(next); } function subscribeToAppConfig(callback: () => void) { if (typeof window === 'undefined') { return () => undefined; } const handleChange = () => { callback(); }; window.addEventListener(APP_CONFIG_EVENT, handleChange); window.addEventListener('storage', handleChange); return () => { window.removeEventListener(APP_CONFIG_EVENT, handleChange); window.removeEventListener('storage', handleChange); }; } export function useAppConfig() { return useSyncExternalStore(subscribeToAppConfig, getStoredAppConfig, () => DEFAULT_APP_CONFIG); } export function describeAutoReceiveSchedule(config: AppConfig) { const automation = config.automation; if (automation.autoReceiveScheduleType === 'daily') { return `매일 ${automation.autoReceiveDailyTime}`; } if (automation.autoReceiveScheduleType === 'weekly') { return `매주 ${WEEKLY_DAY_LABELS[automation.autoReceiveWeeklyDay]} ${automation.autoReceiveWeeklyTime}`; } return `${automation.autoReceiveIntervalSeconds}초마다`; } export function getWeeklyScheduleOptions() { return Object.entries(WEEKLY_DAY_LABELS).map(([value, label]) => ({ value: value as WeeklyScheduleDay, label, })); }