diff --git a/etc/servers/work-server/src/config/env.ts b/etc/servers/work-server/src/config/env.ts index 7c7e15b..def32ba 100644 --- a/etc/servers/work-server/src/config/env.ts +++ b/etc/servers/work-server/src/config/env.ts @@ -54,6 +54,8 @@ const envSchema = z.object({ WEB_PUSH_VAPID_PUBLIC_KEY: z.string().optional(), WEB_PUSH_VAPID_PRIVATE_KEY: z.string().optional(), WEB_PUSH_SUBJECT: z.string().default('mailto:how2ice@naver.com'), + OPENAI_API_KEY: z.string().optional(), + OPENAI_ORGANIZATION_ID: z.string().optional(), APNS_KEY_ID: z.string().optional(), APNS_TEAM_ID: z.string().optional(), APNS_BUNDLE_ID: z.string().optional(), diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index 08a5ddb..8ec67f1 100644 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -2855,6 +2855,7 @@ export async function registerChatRoutes(app: FastifyInstance) { mode: z.enum(['queue', 'direct']).optional(), parentRequestId: z.string().trim().min(1).max(120).optional().nullable(), sessionId: z.string().trim().min(1).max(120).optional().nullable(), + codexModel: z.string().trim().max(120).optional().nullable(), }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); @@ -2947,11 +2948,14 @@ export async function registerChatRoutes(app: FastifyInstance) { resolvedRoomContext.activeRoom.sessionId, payload.text, { - mode: payload.mode === 'direct' ? 'direct' : 'queue', - requestOrigin: 'composer', - sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, - parentRequestId: resolvedParentRequestId, - clientId: shareSnapshot.targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null, + mode: payload.mode === 'direct' ? 'direct' : 'queue', + requestOrigin: 'composer', + sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, + parentRequestId: resolvedParentRequestId, + contextOverride: Object.prototype.hasOwnProperty.call(payload, 'codexModel') + ? { codexModel: payload.codexModel ?? null } + : undefined, + clientId: shareSnapshot.targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null, }, ); diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts index 3f3eacc..a7ca929 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -25,6 +25,7 @@ import { resolveChatContextAppOrigin, resolveChatContextAppDomain, rewriteCodexOutputWithChatResources, + sanitizeChatContextOverride, summarizeActivityProgressLine, shouldSendOfflineChatNotification, shouldUseAgenticCodexReply, @@ -127,6 +128,29 @@ test('filterInactiveOfflineNotificationClientIds excludes only actively viewing ); }); +test('sanitizeChatContextOverride drops undefined codexModel without touching explicit null', () => { + assert.deepEqual( + sanitizeChatContextOverride({ + codexModel: undefined, + chatTypeId: 'general-request', + } as any), + { + chatTypeId: 'general-request', + }, + ); + + assert.deepEqual( + sanitizeChatContextOverride({ + codexModel: null, + chatTypeId: 'general-request', + }), + { + codexModel: null, + chatTypeId: 'general-request', + }, + ); +}); + test('shouldSendOfflineChatNotification blocks chat push when app setting disables room notifications', () => { assert.equal( shouldSendOfflineChatNotification({ diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index 50b1128..b3e9a53 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -111,6 +111,25 @@ type ChatContext = { customContextContent?: string | null; }; +export function sanitizeChatContextOverride(contextOverride?: Partial | null) { + if (!contextOverride) { + return null; + } + + const normalizedOverride = { + ...contextOverride, + }; + + if ( + Object.prototype.hasOwnProperty.call(normalizedOverride, 'codexModel') + && normalizedOverride.codexModel === undefined + ) { + delete normalizedOverride.codexModel; + } + + return normalizedOverride; +} + type ChatPromptContextRef = { key: 'prompt_parent_question'; promptTitle: string; @@ -5674,6 +5693,7 @@ export class ChatService { ) { const normalizedSessionId = sessionId.trim(); const trimmedText = text.trim(); + const normalizedContextOverride = sanitizeChatContextOverride(options?.contextOverride); if (!normalizedSessionId || !trimmedText) { return null; @@ -5700,7 +5720,10 @@ export class ChatService { topMenu: '', focusedComponentId: null, pageUrl: '', - codexModel: conversation?.codexModel ?? null, + codexModel: + Object.prototype.hasOwnProperty.call(normalizedContextOverride ?? {}, 'codexModel') + ? normalizedContextOverride?.codexModel ?? null + : conversation?.codexModel ?? null, chatTypeId: parentRequest?.chatTypeId ?? conversation?.chatTypeId ?? conversation?.lastChatTypeId ?? null, chatTypeLabel: parentRequest?.chatTypeLabel ?? conversation?.contextLabel ?? '', chatTypeDescription: conversation?.contextDescription ?? '', @@ -5713,7 +5736,7 @@ export class ChatService { options?.mode === 'direct' ? 'direct' : 'queue', { ...baseContext, - ...(options?.contextOverride ?? {}), + ...(normalizedContextOverride ?? {}), }, { omitPromptHistory: options?.omitPromptHistory, diff --git a/public/..codex? b/public/..codex? new file mode 100644 index 0000000..1107ddd Binary files /dev/null and b/public/..codex? differ diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx index 72e36d3..09a8461 100644 --- a/src/app/main/MainChatPanel.tsx +++ b/src/app/main/MainChatPanel.tsx @@ -8594,13 +8594,19 @@ export function MainChatPanel({ composerAssistActions={composerAssistActions} composerAssistModalTitle={composerAssistModalTitle} chatTypeOptions={chatTypeOptions} - codexModelOptions={[ - { - value: effectiveCodexModel, - label: `${effectiveCodexParticipants[0]?.name ?? 'Codex'} · ${resolveCodexModelLabel(effectiveCodexModel)}`, - description: '채팅방 Context 참가자 기준', - }, - ]} + codexModelOptions={ + isSharedRoomsPresentation + ? CODEX_MODEL_OPTIONS.map((option) => ({ + value: option.value, + label: `${option.label} · ${option.description}`, + description: option.description, + })) + : [{ + value: effectiveCodexModel, + label: `${effectiveCodexParticipants[0]?.name ?? 'Codex'} · ${resolveCodexModelLabel(effectiveCodexModel)}`, + description: '채팅방 Context 참가자 기준', + }] + } previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))} isResourceStripOpen={isResourceStripOpen} showResourceStrip={!isRoomsMode} diff --git a/src/app/main/SharedAppSettingsPage.tsx b/src/app/main/SharedAppSettingsPage.tsx index 394a88b..41f9204 100644 --- a/src/app/main/SharedAppSettingsPage.tsx +++ b/src/app/main/SharedAppSettingsPage.tsx @@ -1,5 +1,5 @@ import { ReloadOutlined, SaveOutlined } from '@ant-design/icons'; -import { Alert, App, Button, Checkbox, Form, Input, InputNumber, Spin, Tabs, Typography } from 'antd'; +import { Alert, App, Button, Checkbox, Form, Input, InputNumber, Radio, Select, Spin, Tabs, Typography } from 'antd'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { fetchWebPushConfig } from './notificationApi'; import { NOTIFICATION_DEVICE_ID_STORAGE_KEY, getSavedNotificationDeviceId } from './notificationIdentity'; @@ -17,6 +17,7 @@ import { ensureWebPushSubscriptionRegistered, syncExistingWebPushSubscriptionRegistration, } from './webPushRegistration'; +import { CODEX_MODEL_OPTIONS, normalizeCodexModel } from './codexModelOptions'; import { isPreviewRuntime } from './previewRuntime'; import './SharedAppSettingsPage.css'; @@ -319,6 +320,46 @@ function buildStorageItemKey(entry: StorageEntry) { return `${entry.scope}:${entry.storageKey}`; } +function formatRoomModelByRoomValue(value: AppConfig['chat']['defaultModelByRoom']) { + return JSON.stringify(value || {}, null, 2); +} + +function parseRoomModelByRoomValue(raw: string) { + const trimmed = raw.trim(); + + if (!trimmed) { + return {}; + } + + const parsed = JSON.parse(trimmed) as Record; + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('채팅방별 모델 설정은 JSON 객체 형태여야 합니다.'); + } + + return Object.entries(parsed).reduce>((accumulator, [sessionId, modelValue]) => { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return accumulator; + } + + const normalizedModel = + typeof modelValue === 'string' + ? normalizeCodexModel(modelValue) + : modelValue == null + ? '' + : normalizeCodexModel(String(modelValue)); + + if (!normalizedModel) { + return accumulator; + } + + accumulator[normalizedSessionId] = normalizedModel; + return accumulator; + }, {}); +} + export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps) { const { message } = App.useApp(); const [form] = Form.useForm(); @@ -326,6 +367,7 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [savedConfig, setSavedConfig] = useState(DEFAULT_APP_CONFIG); + const [roomModelByRoomText, setRoomModelByRoomText] = useState(() => formatRoomModelByRoomValue(DEFAULT_APP_CONFIG.chat.defaultModelByRoom)); const [activeTabKey, setActiveTabKey] = useState('general'); const [storageGroupKey, setStorageGroupKey] = useState(STORAGE_GROUPS[0]?.key ?? 'share-chat'); const [storageDrafts, setStorageDrafts] = useState>({}); @@ -406,6 +448,7 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps const nextConfig = payload.config ?? DEFAULT_APP_CONFIG; setSavedConfig(nextConfig); form.setFieldsValue(nextConfig); + setRoomModelByRoomText(formatRoomModelByRoomValue(nextConfig.chat.defaultModelByRoom)); await syncWebPushStatus(); loadStorageDrafts(); } catch (error) { @@ -425,12 +468,22 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps setErrorMessage(''); try { - const saved = await saveAppConfigToServer(values, { + const normalizedValues: SharedAppSettingsFormValue = { + ...values, + chat: { + ...values.chat, + defaultModelByRoom: parseRoomModelByRoomValue(roomModelByRoomText), + defaultModelGlobal: normalizeCodexModel(values.chat.defaultModelGlobal), + }, + }; + + const saved = await saveAppConfigToServer(normalizedValues, { shareToken, skipAutomationNotifications: true, }); setSavedConfig(saved); form.setFieldsValue(saved); + setRoomModelByRoomText(formatRoomModelByRoomValue(saved.chat.defaultModelByRoom)); message.success('개인설정을 저장했습니다.'); } catch (error) { setErrorMessage(error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.'); @@ -438,7 +491,7 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps setIsSaving(false); } }, - [form, message, shareToken], + [form, message, roomModelByRoomText, shareToken], ); const handleRequestNotificationPermission = useCallback(async () => { @@ -649,6 +702,30 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps + + + + + ({ + value: option.value, + label: ( +
+ {option.label} +
+ ), + }))} + getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body} + disabled={isComposerDisabled || codexModelOptions.length === 0} + onChange={onSelectCodexModel} + /> + +
+ {sharedComposerModelTokenLimitLabel} +