chore: test deploy snapshot

This commit is contained in:
2026-05-29 07:57:56 +09:00
parent 1e7212b862
commit b242d91ecb
14 changed files with 753 additions and 47 deletions

View File

@@ -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(),

View File

@@ -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);
@@ -2951,6 +2952,9 @@ export async function registerChatRoutes(app: FastifyInstance) {
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,
},
);

View File

@@ -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({

View File

@@ -111,6 +111,25 @@ type ChatContext = {
customContextContent?: string | null;
};
export function sanitizeChatContextOverride(contextOverride?: Partial<ChatContext> | 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,

BIN
public/..codex? Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View File

@@ -8594,13 +8594,19 @@ export function MainChatPanel({
composerAssistActions={composerAssistActions}
composerAssistModalTitle={composerAssistModalTitle}
chatTypeOptions={chatTypeOptions}
codexModelOptions={[
{
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}

View File

@@ -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<string, unknown>;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('채팅방별 모델 설정은 JSON 객체 형태여야 합니다.');
}
return Object.entries(parsed).reduce<Record<string, string>>((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<SharedAppSettingsFormValue>();
@@ -326,6 +367,7 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [savedConfig, setSavedConfig] = useState<AppConfig>(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<Record<string, string>>({});
@@ -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
<Form.Item label="통합 검색 단축키" name={['gestureShortcuts', 'openSearch']}>
<Input />
</Form.Item>
<Form.Item label="모델 적용 범위" name={['chat', 'defaultModelScope']}>
<Radio.Group
options={[
{ label: '전체', value: 'all' },
{ label: '채팅방별', value: 'room' },
]}
/>
</Form.Item>
<Form.Item label="전체 기본 모델" name={['chat', 'defaultModelGlobal']}>
<Select
options={CODEX_MODEL_OPTIONS.map((option) => ({
label: option.label,
value: option.value,
}))}
/>
</Form.Item>
<Form.Item label="채팅방별 마지막 모델(JSON)">
<Input.TextArea
rows={8}
value={roomModelByRoomText}
onChange={(event) => setRoomModelByRoomText(event.target.value)}
placeholder='{}'
/>
</Form.Item>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useSyncExternalStore } from 'react';
import { normalizeCodexModel } from './codexModelOptions';
import { appendClientIdHeader } from './clientIdentity';
import { getAutomationNotificationPreferenceTarget } from './notificationIdentity';
import { isPreviewRuntime } from './previewRuntime';
@@ -13,10 +14,14 @@ let cachedRawConfig: string | null = null;
export type AutomationScheduleType = 'interval' | 'daily' | 'weekly';
export type WeeklyScheduleDay = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
export type CodexModelScope = 'all' | 'room';
export type PlanCostTimeUnit = 'hour' | 'minute' | 'second';
export type AppConfig = {
chat: {
defaultModelScope: CodexModelScope;
defaultModelGlobal: string;
defaultModelByRoom: Record<string, string>;
maxContextMessages: number;
maxContextChars: number;
codexLiveMaxExecutionSeconds: number;
@@ -76,6 +81,9 @@ export type AppConfig = {
export const DEFAULT_APP_CONFIG: AppConfig = {
chat: {
defaultModelScope: 'all',
defaultModelGlobal: 'gpt-5.4',
defaultModelByRoom: {},
maxContextMessages: 12,
maxContextChars: 3200,
codexLiveMaxExecutionSeconds: 600,
@@ -282,6 +290,32 @@ function normalizeRestartReservationCompletionDelaySeconds(value: number | undef
return Math.min(300, Math.max(1, Math.round(value)));
}
function normalizeCodexModelScope(value: string | undefined): AppConfig['chat']['defaultModelScope'] {
if (value === 'room') {
return 'room';
}
return 'all';
}
function normalizeChatDefaultModelByRoom(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.entries(value).reduce<Record<string, string>>((accumulator, [sessionId, modelValue]) => {
const normalizedSessionId = sessionId.trim();
const modelText = typeof modelValue === 'string' ? modelValue : '';
if (!normalizedSessionId || !modelText.trim()) {
return accumulator;
}
accumulator[normalizedSessionId] = normalizeCodexModel(modelText);
return accumulator;
}, {});
}
function normalizeBooleanValue(value: boolean | undefined, fallback: boolean) {
if (typeof value !== 'boolean') {
return fallback;
@@ -300,6 +334,9 @@ function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
return {
chat: {
defaultModelScope: normalizeCodexModelScope(chat?.defaultModelScope),
defaultModelGlobal: normalizeCodexModel(chat?.defaultModelGlobal ?? DEFAULT_APP_CONFIG.chat.defaultModelGlobal),
defaultModelByRoom: normalizeChatDefaultModelByRoom(chat?.defaultModelByRoom),
maxContextMessages: normalizeChatContextMessageLimit(
chat?.maxContextMessages,
DEFAULT_APP_CONFIG.chat.maxContextMessages,

View File

@@ -62,6 +62,7 @@ import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarke
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, sharePreviewLink } from './chatUtils';
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
import { normalizeCodexModel } from '../codexModelOptions';
import { ChatActivityChecklist, buildChatActivityChecklistEntries } from './ChatActivityChecklist';
import { describeExecutorCommand } from './executorActivitySummary';
import { buildComposerFilePickKey } from './composerFilePickKey';
@@ -105,6 +106,26 @@ type CodexModelOption = {
description: string;
};
type ChatConversationRequestWithModel = ChatConversationRequest & {
codexModel?: string | null;
};
function resolveRequestModelTokenUsage(request: ChatConversationRequest) {
return Math.max(
0,
Math.round(
Number(
request.usageSnapshot?.tokenTotals?.total ?? request.usageSnapshot?.totalTokens ?? request.totalTokens ?? 0,
) || 0,
),
);
}
function resolveRequestCodexModel(request: ChatConversationRequest) {
const requestWithModel = request as ChatConversationRequestWithModel;
return normalizeCodexModel(requestWithModel.codexModel?.trim() || '');
}
type PreviewOption = {
id: string;
label: string;
@@ -2951,6 +2972,7 @@ type ChatConversationViewProps = {
composerAssistModalTitle?: string;
chatTypeOptions: ChatTypeOption[];
codexModelOptions: CodexModelOption[];
sharedComposerModelTokenLimits?: Record<string, number | null>;
previewItems: PreviewOption[];
isResourceStripOpen: boolean;
showResourceStrip?: boolean;
@@ -3508,6 +3530,7 @@ export function ChatConversationView({
composerAssistModalTitle,
chatTypeOptions,
codexModelOptions,
sharedComposerModelTokenLimits,
previewItems,
isResourceStripOpen,
showResourceStrip = true,
@@ -3974,6 +3997,46 @@ export function ChatConversationView({
const isChatTypeReadonly = isChatTypeSelectionLocked;
const selectedChatTypeOption = chatTypeOptions.find((option) => option.value === selectedChatTypeId) ?? null;
const sharedComposerChatTypeLabel = selectedChatTypeOption?.label?.trim() || '컨텍스트 없음';
const normalizedSelectedCodexModel = normalizeCodexModel(selectedCodexModel);
const selectedCodexModelTokenLimit =
sharedComposerModelTokenLimits?.[normalizedSelectedCodexModel] ??
sharedComposerModelTokenLimits?.[selectedCodexModel] ??
null;
const normalizedSelectedCodexModelTokenLimit = Number.isFinite(selectedCodexModelTokenLimit)
? Math.max(0, Math.round(Number(selectedCodexModelTokenLimit)))
: null;
const sharedComposerModelUsedTokens = useMemo(() => {
if (!useSharedComposerChrome) {
return 0;
}
let total = 0;
requestStateMap.forEach((request) => {
if (resolveRequestCodexModel(request) !== normalizedSelectedCodexModel) {
return;
}
total += resolveRequestModelTokenUsage(request);
});
return total;
}, [requestStateMap, normalizedSelectedCodexModel, useSharedComposerChrome]);
const sharedComposerModelRemainingTokensText = useMemo(() => {
if (normalizedSelectedCodexModelTokenLimit === null) {
return `이번 세션 사용량 ${sharedComposerModelUsedTokens.toLocaleString('ko-KR')} 토큰`;
}
const remainingTokens = Math.max(0, normalizedSelectedCodexModelTokenLimit - sharedComposerModelUsedTokens);
return `잔여 ${remainingTokens.toLocaleString('ko-KR')} / ${normalizedSelectedCodexModelTokenLimit.toLocaleString(
'ko-KR',
)} 토큰`;
}, [normalizedSelectedCodexModelTokenLimit, sharedComposerModelUsedTokens]);
const sharedComposerModelTokenLimitLabel =
sharedComposerModelRemainingTokensText && sharedComposerModelRemainingTokensText.length > 0
? sharedComposerModelRemainingTokensText
: '토큰 정보 없음';
const pendingManualCompletionActionKeySet = useMemo(
() => new Set(pendingManualCompletionActionKeys),
[pendingManualCompletionActionKeys],
@@ -7973,6 +8036,26 @@ export function ChatConversationView({
disabled
/>
</div>
<div className="app-chat-panel__composer-type">
<Select
value={selectedCodexModel}
placeholder="Codex 모델 선택"
options={codexModelOptions.map((option) => ({
value: option.value,
label: (
<div className="app-chat-panel__type-option">
<span>{option.label}</span>
</div>
),
}))}
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
disabled={isComposerDisabled || codexModelOptions.length === 0}
onChange={onSelectCodexModel}
/>
</div>
<div className="app-chat-panel__composer-model-token" title={sharedComposerModelTokenLimitLabel}>
<span>{sharedComposerModelTokenLimitLabel}</span>
</div>
<div className="app-chat-panel__composer-actions app-chat-panel__composer-actions--shared">
<div className="app-chat-panel__composer-action-buttons">
<Button

View File

@@ -3020,6 +3020,7 @@ export async function submitChatShareMessage(
sessionId?: string | null;
mode?: 'queue' | 'direct';
parentRequestId?: string | null;
codexModel?: string | null;
},
) {
return requestChatApi<{ ok: boolean; queuedRequestId: string }>(
@@ -3031,6 +3032,7 @@ export async function submitChatShareMessage(
sessionId: options?.sessionId?.trim() || undefined,
mode: options?.mode === 'direct' ? 'direct' : 'queue',
parentRequestId: options?.parentRequestId?.trim() || undefined,
codexModel: options?.codexModel?.trim() || undefined,
}),
},
{

View File

@@ -230,8 +230,56 @@
padding-inline: 0;
border-radius: 999px;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-type {
flex: 1 1 180px;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-type--readonly {
flex: 0 0 180px;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-model-token {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1.25;
color: #334155;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid rgba(148, 163, 184, 0.42);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(241, 245, 249, 0.9) 100%);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.16);
}
@media (max-width: 760px) {
.app-chat-panel--rooms-shared .app-chat-panel__composer-type {
flex: 1 1 150px;
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-model-token {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1.25;
color: #334155;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid rgba(148, 163, 184, 0.42);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(241, 245, 249, 0.9) 100%);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.16);
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .ant-card-body {
border-radius: 0;
}

View File

@@ -926,6 +926,32 @@
line-height: 1.5;
}
.chat-share-page__initial-load-alert.ant-alert .ant-alert-content,
.chat-share-page__initial-load-alert.ant-alert .ant-alert-message,
.chat-share-page__initial-load-alert.ant-alert .ant-alert-description {
min-width: 0;
max-width: 100%;
word-break: break-word;
overflow-wrap: anywhere;
white-space: normal;
}
.chat-share-page__initial-load-alert.ant-alert .ant-alert-action {
margin-left: 0;
}
@media (max-width: 720px) {
.chat-share-page__initial-load-alert.ant-alert .ant-alert-content {
width: 100%;
}
.chat-share-page__initial-load-alert.ant-alert .ant-alert-action {
margin-top: 8px;
margin-left: 0;
width: 100%;
}
}
.chat-share-page__live-dot {
width: 10px;
height: 10px;
@@ -3105,6 +3131,71 @@
min-width: 0;
}
.chat-share-page__composer-utility-button.ant-btn {
width: 32px;
min-width: 32px;
height: 32px;
padding: 0;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.chat-share-page__composer-utility-button.ant-btn .anticon {
font-size: 16px;
}
.chat-share-page__composer-attachment-menu.ant-dropdown-menu {
min-width: 220px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.16);
}
.chat-share-page__composer-attachment-menu .ant-dropdown-menu-item {
margin: 0;
border-radius: 0;
}
.chat-share-page__composer-attachment-dropdown {
display: inline-flex;
}
.chat-share-page__composer-attachment-menu-model {
padding: 6px 4px;
}
.chat-share-page__composer-attachment-model-item {
display: grid;
gap: 6px;
min-width: 220px;
}
.chat-share-page__composer-attachment-model-title {
color: #0f172a;
font-size: 12px;
font-weight: 700;
}
.chat-share-page__composer-attachment-model-select {
width: 100%;
}
.chat-share-page__composer-attachment-model-select.ant-select {
width: 100%;
}
.chat-share-page__composer-attachment-model-token {
color: #334155;
font-size: 11px;
line-height: 1.35;
}
.chat-share-page__composer-attachment-model-token--session {
color: #64748b;
}
.chat-share-page__composer-entry-row {
display: flex;
align-items: stretch;

View File

@@ -58,8 +58,9 @@ import {
submitChatSharePrompt,
uploadChatShareComposerFile,
type ChatShareRoomSummary,
type ChatShareSnapshot,
type ChatShareSnapshot
} from '../mainChatPanel/chatUtils';
import { DEFAULT_CODEX_MODEL, CODEX_MODEL_OPTIONS, normalizeCodexModel } from '../codexModelOptions';
import { extractAttachmentPreviewUrls, extractChatMessageParts } from '../mainChatPanel/messageParts';
import { stripHiddenPreviewTags } from '../mainChatPanel/previewMarkers';
import { extractPreviewItems, type PreviewItem } from '../mainChatPanel/previewItems';
@@ -1891,6 +1892,10 @@ function resolveRequestUsageTokens(request: ChatConversationRequest) {
);
}
function resolveRequestCodexModel(request: ChatConversationRequest) {
return normalizeCodexModel(request.codexModel?.trim() || '');
}
function resolveTokenUsageWindowSummary(
requests: ChatConversationRequest[],
periodKey: TokenUsagePeriodKey,
@@ -3900,6 +3905,10 @@ function ShareRequestCard({
const resolvedAnswerText = answerText.trim() || resolveShareRequestFallbackAnswerText(request);
const shouldRenderQuestion = mode !== 'answer-only';
const shouldRenderFullAnswer = mode === 'full' || mode === 'answer-only';
const snapshotRefreshInFlightViewRef = useRef<'initial' | 'full' | null>(null);
const snapshotMismatchRefreshKeyRef = useRef('');
const snapshotRetryRefreshKeyRef = useRef('');
const roomSwitchPerfRunIdRef = useRef('');
const isRequestStillRunning = isRequestInFlight(request.status);
const responseMessages = useMemo(
() => relatedMessages.filter((message) => message.author !== 'user'),
@@ -4201,6 +4210,8 @@ export function ChatSharePage() {
const snapshotRefreshAbortControllerRef = useRef<AbortController | null>(null);
const snapshotRefreshInFlightRoomSessionIdRef = useRef('');
const snapshotRefreshInFlightViewRef = useRef<'initial' | 'full' | null>(null);
const snapshotMismatchRefreshKeyRef = useRef('');
const snapshotRetryRefreshKeyRef = useRef('');
const roomSwitchPerfRunIdRef = useRef('');
const pendingSnapshotPerfStageRef = useRef<{ runId: string; stage: 'cache' | 'initial' | 'full' } | null>(null);
const pendingSilentRefreshRef = useRef(false);
@@ -4233,6 +4244,7 @@ export function ChatSharePage() {
const [nowMs, setNowMs] = useState(() => Date.now());
const [expandMode, setExpandMode] = useState<ShareExpandMode>('pending');
const [latestRequestId, setLatestRequestId] = useState('');
const [sharedComposerCodexModel, setSharedComposerCodexModel] = useState(DEFAULT_CODEX_MODEL);
const [pendingPromptSelections, setPendingPromptSelections] = useState<Record<string, PendingSharePromptSelection>>({});
const [pendingPromptCompletionRequestIds, setPendingPromptCompletionRequestIds] = useState<string[]>([]);
const [pendingVerificationCompletionRequestIds, setPendingVerificationCompletionRequestIds] = useState<string[]>([]);
@@ -4501,9 +4513,13 @@ export function ChatSharePage() {
setSelectedAppEnvironment(nextEnvironment);
}
}, [selectedAppEnvironment, sortedAllowedPlayAppEntries]);
const canManageSharedAppSettings = sharePermissionSet.has('manage') && shareAllowedAppIdSet.has('app-settings');
const allowedManagementApps = useMemo(
() => SHARE_MANAGEMENT_APP_OPTIONS.filter((option) => shareAllowedAppIdSet.has(option.value)),
[shareAllowedAppIdSet],
() => SHARE_MANAGEMENT_APP_OPTIONS.filter((option) => (
shareAllowedAppIdSet.has(option.value)
&& (option.value !== 'app-settings' || canManageSharedAppSettings)
)),
[canManageSharedAppSettings, shareAllowedAppIdSet],
);
const currentShareChatTarget = useMemo(
() => buildShareChatEnvironmentTarget(snapshot?.share.sharePath ?? null, normalizedToken, selectedAppEnvironment),
@@ -5092,9 +5108,12 @@ export function ChatSharePage() {
if (matchedRequestedRoom) {
recordShareRoomSwitchPerfStep(currentPerfRunId, `${requestView}-matched-requested-room`, nextSnapshot);
snapshotMismatchRefreshKeyRef.current = '';
snapshotRetryRefreshKeyRef.current = '';
setIsRoomSwitching(false);
} else if (requestedSessionId) {
pendingSilentRefreshRef.current = true;
snapshotRetryRefreshKeyRef.current = `${requestedSessionId}:${requestView}:retry`;
}
setErrorMessage('');
@@ -5141,9 +5160,17 @@ export function ChatSharePage() {
setIsRefreshing(false);
if (pendingSilentRefreshRef.current) {
const retryKey = `${requestedSessionId}:${requestView}:retry`;
if (snapshotRetryRefreshKeyRef.current === retryKey) {
pendingSilentRefreshRef.current = false;
snapshotRetryRefreshKeyRef.current = `${requestedSessionId}:${requestView}:done`;
return;
}
snapshotRetryRefreshKeyRef.current = retryKey;
pendingSilentRefreshRef.current = false;
window.setTimeout(() => {
void refreshSnapshot({ silent: true });
void refreshSnapshot({ silent: true, view: 'full' });
}, 0);
}
}
@@ -5386,7 +5413,7 @@ export function ChatSharePage() {
} finally {
setPendingShareRuntimeRequestIds((current) => current.filter((item) => item !== normalizedRequestId));
}
}, [activeShareRoomSessionId, message, modal, normalizedToken, pendingShareRuntimeRequestIds, refreshShareRuntime, refreshSnapshot, sortedRequests]);
}, [activeShareRoomSessionId, message, modal, normalizedToken, pendingShareRuntimeRequestIds, refreshShareRuntime, refreshSnapshot, sortedRequests, sharedComposerCodexModel]);
const handleResubmitQueuedRequestDirect = useCallback(async (requestId: string) => {
const normalizedRequestId = requestId.trim();
@@ -5439,6 +5466,7 @@ export function ChatSharePage() {
sessionId: activeShareRoomSessionId,
mode: 'direct',
parentRequestId: targetRequest.parentRequestId?.trim() || '',
codexModel: sharedComposerCodexModel,
});
message.success('대기 요청을 취소하고 즉시전송했습니다.');
await Promise.all([
@@ -5455,7 +5483,7 @@ export function ChatSharePage() {
setIsSending(false);
setPendingShareRuntimeRequestIds((current) => current.filter((item) => item !== normalizedRequestId));
}
}, [activeShareRoomSessionId, isSending, message, modal, normalizedToken, pendingShareRuntimeRequestIds, refreshShareRuntime, refreshSnapshot, sortedRequests]);
}, [activeShareRoomSessionId, isSending, message, modal, normalizedToken, pendingShareRuntimeRequestIds, refreshShareRuntime, refreshSnapshot, sharedComposerCodexModel, sortedRequests]);
const handleSaveSharedRoomSettings = useCallback(async () => {
if (!snapshot?.conversation.sessionId) {
setIsRoomSettingsOpen(false);
@@ -6005,9 +6033,14 @@ export function ChatSharePage() {
if (restoredTarget) {
setProgramReloadKey(0);
if (!canLaunchShareProgram(restoredTarget.appId)) {
message.warning('이 공유 링크에서는 허용되지 않은 앱입니다.');
return;
}
setProgramTarget(restoredTarget);
}
}, []);
}, [canLaunchShareProgram, message]);
const handleReloadPage = useCallback(() => {
if (typeof window === 'undefined') {
@@ -6371,11 +6404,20 @@ export function ChatSharePage() {
const requestedSessionId = requestedRoomSessionId.trim();
const hasMatchingSnapshot = doesShareSnapshotMatchRequestedRoom(snapshot, requestedSessionId);
const mismatchRefreshKey = requestedSessionId ? `snapshot-mismatch:${requestedSessionId}` : ''
if (hasMatchingSnapshot) {
if (snapshotMismatchRefreshKeyRef.current) {
snapshotMismatchRefreshKeyRef.current = '';
}
return;
}
if (!requestedSessionId || snapshotMismatchRefreshKeyRef.current === mismatchRefreshKey) {
return;
}
snapshotMismatchRefreshKeyRef.current = mismatchRefreshKey;
void refreshSnapshot({ silent: true, view: 'initial' });
}, [normalizedToken, refreshSnapshot, requestedRoomSessionId, snapshot]);
useEffect(() => {
@@ -7039,6 +7081,81 @@ export function ChatSharePage() {
const shareKind = snapshot?.share.kind ?? 'request-bundle';
const isPromptShare = shareKind === 'prompt';
const handlePersistSharedComposerModel = useCallback(async (nextCodexModel: string) => {
const normalizedModel = normalizeCodexModel(nextCodexModel);
const normalizedScope = appConfig.chat.defaultModelScope;
const normalizedRoomSessionId = selectedShareRoomSessionId.trim();
const currentByRoom = appConfig.chat.defaultModelByRoom;
if (normalizedScope === 'room' && normalizedRoomSessionId) {
if ((currentByRoom[normalizedRoomSessionId] ?? '') === normalizedModel) {
return;
}
const nextByRoom = {
...currentByRoom,
[normalizedRoomSessionId]: normalizedModel,
};
const optimisticConfig = {
...appConfig,
chat: {
...appConfig.chat,
defaultModelByRoom: nextByRoom,
},
};
setStoredAppConfig(optimisticConfig);
if (!normalizedToken) {
return;
}
try {
const savedConfig = await saveAppConfigToServer(optimisticConfig, {
shareToken: normalizedToken,
skipAutomationNotifications: true,
});
setStoredAppConfig(savedConfig);
} catch {
// Keep local preference update even if server sync fails.
}
return;
}
if (appConfig.chat.defaultModelGlobal === normalizedModel) {
return;
}
const optimisticConfig = {
...appConfig,
chat: {
...appConfig.chat,
defaultModelGlobal: normalizedModel,
},
};
setStoredAppConfig(optimisticConfig);
if (!normalizedToken) {
return;
}
try {
const savedConfig = await saveAppConfigToServer(optimisticConfig, {
shareToken: normalizedToken,
skipAutomationNotifications: true,
});
setStoredAppConfig(savedConfig);
} catch {
// Keep local preference update even if server sync fails.
}
}, [
appConfig,
normalizedToken,
selectedShareRoomSessionId,
]);
const handleSendMessageByMode = useCallback(async (mode: 'queue' | 'direct') => {
const outgoingText = buildOutgoingShareMessageText(draftText, composerAttachments).trim();
const resolvedParentRequestId =
@@ -7050,6 +7167,7 @@ export function ChatSharePage() {
return;
}
const nextCodexModel = normalizeCodexModel(sharedComposerCodexModel);
setIsSending(true);
try {
@@ -7057,7 +7175,9 @@ export function ChatSharePage() {
sessionId: selectedShareRoomSessionId,
mode,
parentRequestId: resolvedParentRequestId,
codexModel: nextCodexModel,
});
void handlePersistSharedComposerModel(nextCodexModel);
setDraftText('');
setComposerAttachments([]);
setReplyReferenceRequestId('');
@@ -7092,6 +7212,7 @@ export function ChatSharePage() {
}, [
composerAttachments,
draftText,
handlePersistSharedComposerModel,
isSending,
isUploadingComposerAttachment,
message,
@@ -7101,6 +7222,7 @@ export function ChatSharePage() {
selectedShareRoomSessionId,
shareKind,
snapshot?.targetRequest.requestId,
sharedComposerCodexModel,
]);
const handleSendMessage = useCallback(async () => {
@@ -7499,6 +7621,24 @@ export function ChatSharePage() {
[handleUploadPromptAttachment, isUploadingComposerAttachment, message],
);
const handleOpenComposerAttachmentInput = useCallback(() => {
if (isSending || isUploadingComposerAttachment) {
return;
}
composerAttachmentInputRef.current?.click();
}, [isSending, isUploadingComposerAttachment]);
const handleShareComposerAttachmentMenuClick = useCallback(
({ key }: { key: string }) => {
if (key === 'composer-attachment-files') {
handleOpenComposerAttachmentInput();
}
},
[handleOpenComposerAttachmentInput],
);
const handleComposerPaste = useCallback(
(event: ClipboardEvent<HTMLTextAreaElement>) => {
const files = resolveShareComposerPasteFiles(event.clipboardData);
@@ -8255,6 +8395,54 @@ export function ChatSharePage() {
return candidates.find((value) => value?.trim())?.trim() || 'Codex Live';
}, [currentRequest?.chatTypeLabel, snapshot?.targetRequest?.chatTypeLabel, sortedRequests]);
const shareComposerResolvedCodexModel = useMemo(() => {
const normalizedRoomSessionId = selectedShareRoomSessionId.trim();
const chatModelScope = appConfig.chat.defaultModelScope;
const roomModelText =
chatModelScope === 'room' ? appConfig.chat.defaultModelByRoom[normalizedRoomSessionId]?.trim() ?? '' : '';
if (chatModelScope === 'room' && normalizedRoomSessionId && roomModelText) {
return normalizeCodexModel(roomModelText);
}
if (appConfig.chat.defaultModelGlobal?.trim()) {
return normalizeCodexModel(appConfig.chat.defaultModelGlobal);
}
const candidates = [
currentRequest?.codexModel,
snapshot?.targetRequest?.codexModel,
...sortedRequests.map((request) => request.codexModel),
];
return normalizeCodexModel(candidates.find((value) => value?.trim())?.trim());
}, [
appConfig.chat.defaultModelByRoom,
appConfig.chat.defaultModelGlobal,
appConfig.chat.defaultModelScope,
currentRequest?.codexModel,
selectedShareRoomSessionId,
sortedRequests,
snapshot?.targetRequest?.codexModel,
]);
useEffect(() => {
setSharedComposerCodexModel((current) => {
const nextModel = normalizeCodexModel(shareComposerResolvedCodexModel);
return normalizeCodexModel(current) === nextModel ? current : nextModel;
});
}, [shareComposerResolvedCodexModel]);
const shareComposerModelUsedTokens = useMemo(() => {
const normalizedModel = normalizeCodexModel(sharedComposerCodexModel);
return sortedRequests.reduce((accumulator, request) => {
if (resolveRequestCodexModel(request) !== normalizedModel) {
return accumulator;
}
return accumulator + resolveRequestUsageTokens(request);
}, 0);
}, [sharedComposerCodexModel, sortedRequests]);
const shareComposerModelTokenLabel = useMemo(() => '이번 세션 사용량 ' + formatTokenCount(shareComposerModelUsedTokens) + ' 토큰', [shareComposerModelUsedTokens]);
const shareMenuLabel = useMemo(() => {
const candidates = [
snapshot?.conversation.requestBadgeLabel,
@@ -8652,8 +8840,7 @@ export function ChatSharePage() {
restoreProgramReturnSnapshot(programTarget?.restoreSnapshot);
setProgramTarget(null);
}, [programTarget?.restoreSnapshot, restoreProgramReturnSnapshot]);
const canLaunchShareProgram = useCallback(
(appId?: ShareProgramTarget['appId']) => {
function canLaunchShareProgram(appId?: ShareProgramTarget['appId']) {
if (!appId) {
return true;
}
@@ -8662,10 +8849,12 @@ export function ChatSharePage() {
return true;
}
if (appId === 'app-settings') {
return canManageSharedAppSettings;
}
return shareAllowedAppIdSet.has(appId);
},
[shareAllowedAppIdSet],
);
}
const recordShareAppLaunch = useCallback((appId?: string) => {
if (!appId) {
return;
@@ -9030,6 +9219,8 @@ export function ChatSharePage() {
const sevenDayRemaining = tokenUsageSevenDaySummary.remainingTokens;
const fiveHourRemaining = tokenUsageFiveHourSummary.remainingTokens;
const currentAvailableTokens = resolveSmallestFiniteNumber(sevenDayRemaining, fiveHourRemaining);
const formatUsagePercentLabel = (value: number | null) =>
value == null ? '미표시' : String(Math.round(value)) + '%';
const axisLimit = sevenDayLimit > 0 ? sevenDayLimit : Math.max(fiveHourLimit, currentAvailableTokens ?? 0);
const overallLabel = sevenDayLimit > 0 ? `${formatTokenCount(sevenDayLimit)} 토큰` : '무제한';
const sevenDayRemainingLabel = sevenDayRemaining == null ? '무제한' : `${formatTokenCount(sevenDayRemaining)} 토큰`;
@@ -9049,6 +9240,19 @@ export function ChatSharePage() {
return {
currentAvailableLabel,
currentPercentLabel: formatUsagePercentLabel(
resolveSmallestFiniteNumber(tokenUsageSevenDaySummary.percentage, tokenUsageFiveHourSummary.percentage),
),
sevenDayUsagePercentLabel: formatUsagePercentLabel(
tokenUsageSevenDaySummary.percentage == null
? null
: Math.max(0, Math.min(100, tokenUsageSevenDaySummary.percentage)),
),
fiveHourUsagePercentLabel: formatUsagePercentLabel(
tokenUsageFiveHourSummary.percentage == null
? null
: Math.max(0, Math.min(100, tokenUsageFiveHourSummary.percentage)),
),
overallLabel,
sevenDayRemainingLabel,
fiveHourRemainingLabel,
@@ -9211,7 +9415,7 @@ export function ChatSharePage() {
</span>
<span className="chat-share-page__settings-item-description">
{selectedTokenUsageSetting
? `지금 사용 가능 ${tokenUsageOverview.currentAvailableLabel}`
? '지금 사용 가능 ' + tokenUsageOverview.currentAvailableLabel + ' (5시간 ' + tokenUsageOverview.fiveHourUsagePercentLabel + ' / 1주일 ' + tokenUsageOverview.sevenDayUsagePercentLabel + ')'
: '공유 링크 생성 시 선택된 토큰 설정이 없습니다.'}
</span>
<span className="chat-share-page__settings-item-description">
@@ -9249,7 +9453,7 @@ export function ChatSharePage() {
},
]
: []),
...(shareAllowedAppIdSet.has('app-settings')
...(canManageSharedAppSettings
? [
{
key: 'conversation-personal-settings',
@@ -9499,6 +9703,64 @@ export function ChatSharePage() {
programTarget,
sourceGroupDetail,
]);
const shareComposerModelTokenAvailableLabel = useMemo(() => {
return tokenUsageOverview.currentAvailableLabel;
}, [tokenUsageOverview.currentAvailableLabel]);
const handleSharedComposerModelChange = useCallback((nextCodexModel: string) => {
setSharedComposerCodexModel(normalizeCodexModel(nextCodexModel));
}, []);
const shareComposerAttachmentItems = useMemo((): MenuProps['items'] => [
{
key: 'composer-attachment-files',
label: (
<span className="chat-share-page__settings-item">
<span className="chat-share-page__settings-item-title"> </span>
<span className="chat-share-page__settings-item-description"> .</span>
</span>
),
icon: <FileTextOutlined />,
},
{
key: 'composer-attachment-model',
className: 'chat-share-page__composer-attachment-menu-model',
label: (
<div
className="chat-share-page__composer-attachment-model-item"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="chat-share-page__composer-attachment-model-title"> </div>
<Select
size="middle"
value={sharedComposerCodexModel}
aria-label="코덱스 모델"
options={CODEX_MODEL_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
onChange={handleSharedComposerModelChange}
disabled={isSending}
className="chat-share-page__composer-attachment-model-select"
popupMatchSelectWidth={220}
/>
<span className="chat-share-page__composer-attachment-model-token">{`잔여 ${shareComposerModelTokenAvailableLabel}`}</span>
<span className="chat-share-page__composer-attachment-model-token">{`5시간 ${tokenUsageOverview.fiveHourUsagePercentLabel} 사용`}</span>
<span className="chat-share-page__composer-attachment-model-token">{`1주일 ${tokenUsageOverview.sevenDayUsagePercentLabel} 사용`}</span>
<span className="chat-share-page__composer-attachment-model-token chat-share-page__composer-attachment-model-token--session">{shareComposerModelTokenLabel}</span>
</div>
),
},
], [
handleSharedComposerModelChange,
isSending,
sharedComposerCodexModel,
shareComposerModelTokenAvailableLabel,
shareComposerModelTokenLabel,
tokenUsageOverview.fiveHourUsagePercentLabel,
tokenUsageOverview.sevenDayUsagePercentLabel,
]);
const shareExpandModeMenuItems = useMemo<MenuProps['items']>(
() => [
{
@@ -10171,16 +10433,29 @@ export function ChatSharePage() {
/>
<div className="chat-share-page__composer-topline">
<div className="app-chat-panel__composer-utility-buttons">
<Button
icon={<PlusOutlined />}
aria-label="파일 첨부"
title="파일 첨부"
onClick={() => {
composerAttachmentInputRef.current?.click();
<Dropdown
trigger={['click']}
menu={{
items: shareComposerAttachmentItems,
onClick: handleShareComposerAttachmentMenuClick,
}}
overlayClassName="chat-share-page__composer-attachment-menu"
disabled={isSending || isUploadingComposerAttachment}
className="chat-share-page__composer-attachment-dropdown"
placement="top"
>
<Button
type="text"
icon={<PlusOutlined />}
aria-label="첨부 옵션"
title="첨부 옵션"
loading={isUploadingComposerAttachment}
className="chat-share-page__composer-utility-button"
onPointerDown={(event) => {
event.preventDefault();
}}
/>
</Dropdown>
</div>
<div className="app-chat-panel__composer-type chat-share-page__composer-type-readonly">
<Select

View File

@@ -273,6 +273,16 @@
.e-reader__panel-header-actions {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
min-width: 0;
}
.e-reader__panel-header > h2 {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
word-break: keep-all;
}
.e-reader__icon-button {
@@ -560,6 +570,7 @@
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 6px;
min-width: 0;
}
.e-reader__panel-body--locked {
@@ -618,6 +629,7 @@
display: grid;
gap: 10px;
margin-bottom: 12px;
min-width: 0;
}
.e-reader__library-sort-row {
@@ -632,12 +644,18 @@
font-size: 15px;
font-weight: 700;
color: var(--reader-ink);
min-width: 0;
overflow-wrap: anywhere;
word-break: keep-all;
}
.e-reader__library-sort-trigger {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
@@ -649,9 +667,16 @@
}
.e-reader__library-sort-trigger .anticon {
flex: 0 0 auto;
font-size: 12px;
}
.e-reader__library-sort-trigger span {
min-width: 0;
overflow-wrap: anywhere;
word-break: keep-all;
}
.e-reader__bottom-sheet {
position: absolute;
inset: 0;
@@ -984,7 +1009,8 @@
font-size: 14px;
line-height: 1.42;
text-overflow: ellipsis;
word-break: break-word;
overflow-wrap: anywhere;
word-break: keep-all;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
@@ -997,6 +1023,14 @@
font-size: 12px;
}
.e-reader__book-card > span,
.e-reader__book-card p,
.e-reader__library-status {
min-width: 0;
overflow-wrap: anywhere;
word-break: keep-all;
}
.e-reader__book-card-date {
display: inline-flex;
font-size: 11px;