Files
ai-code-app/src/app/main/appConfig.ts

669 lines
21 KiB
TypeScript
Executable File

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<WeeklyScheduleDay, string> = {
mon: '월요일',
tue: '화요일',
wed: '수요일',
thu: '목요일',
fri: '금요일',
sat: '토요일',
sun: '일요일',
};
const AUTOMATION_NOTIFICATION_KEYS = [
'notifyOnAutomationStart',
'notifyOnAutomationProgress',
'notifyOnAutomationCompletion',
'notifyOnAutomationRelease',
'notifyOnAutomationMain',
'notifyOnAutomationFailure',
'notifyOnAutomationRestart',
'notifyOnAutomationIssueResolved',
] as const;
type AutomationNotificationSettings = Pick<AppConfig['automation'], (typeof AUTOMATION_NOTIFICATION_KEYS)[number]>;
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>): 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<AutomationNotificationSettings> | 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<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
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<T>;
}
async function requestAppConfig<T>(path: string, init?: RequestInit): Promise<T> {
try {
return await requestAppConfigOnce<T>(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<T>(APP_CONFIG_FALLBACK_BASE_URL, path, init);
}
}
export async function fetchAppConfigFromServer() {
try {
const response = await requestAppConfig<{ ok: boolean; config: Partial<AppConfig> }>(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<AutomationNotificationSettings> | 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<AppConfig> }>(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<AutomationNotificationSettings>;
}>(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<AppConfig>);
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,
}));
}