feat: update main chat and system chat UI

This commit is contained in:
2026-05-25 17:26:37 +09:00
parent fb5ec649cd
commit f59522ffc4
120 changed files with 43262 additions and 3325 deletions

View File

@@ -0,0 +1,371 @@
import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity';
import { getRegisteredAccessToken, isAllowedRegistrationToken } from './tokenAccess';
const UNBOUNDED_NUMERIC_LIMIT = Number.MAX_SAFE_INTEGER;
export type TokenSettingRecord = {
id: string;
name: string;
description: string;
defaultExpiresInMinutes: number;
maxExpiresInMinutes: number;
maxTokensPer30Days: number;
maxTokensPer7Days: number;
maxTokensPer5Hours: number;
oneTimeTokenLimit: number;
allowedAppIds: string[];
enabled: boolean;
updatedAt: string;
};
export type TokenSettingInput = {
originalId?: string;
id?: string;
name: string;
description?: string;
defaultExpiresInMinutes?: number;
maxExpiresInMinutes?: number;
maxTokensPer30Days?: number;
maxTokensPer7Days?: number;
maxTokensPer5Hours?: number;
oneTimeTokenLimit?: number;
allowedAppIds?: string[];
enabled?: boolean;
};
const TOKEN_SETTINGS_API_PATH = '/token-settings';
const TOKEN_SETTINGS_SYNC_EVENT = 'work-app:token-settings-changed';
const TOKEN_SETTINGS_REQUEST_TIMEOUT_MS = 8000;
type TokenSettingsRequestOptions = {
shareToken?: string | null;
};
class TokenSettingApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'TokenSettingApiError';
this.status = status;
}
}
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
function normalizeSettingId(value: string | null | undefined) {
return normalizeText(value)
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9._-]/g, '');
}
function normalizePositiveInteger(value: number | undefined, fallback: number, min: number, max: number) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.round(value)));
}
function normalizeAllowedAppIds(value: string[] | null | undefined) {
return Array.from(
new Set(
(value ?? [])
.map((item) => normalizeText(item))
.filter(Boolean),
),
).sort((left, right) => left.localeCompare(right, 'en'));
}
function normalizeTokenSetting(record: Partial<TokenSettingRecord>): TokenSettingRecord | null {
const id = normalizeSettingId(record.id);
const name = normalizeText(record.name);
if (!id || !name) {
return null;
}
const defaultExpiresInMinutes = normalizePositiveInteger(record.defaultExpiresInMinutes, 60, 0, UNBOUNDED_NUMERIC_LIMIT);
const resolvedMaxExpiresInMinutes = normalizePositiveInteger(
record.maxExpiresInMinutes,
defaultExpiresInMinutes <= 0 ? 0 : 10_080,
0,
UNBOUNDED_NUMERIC_LIMIT,
);
const maxExpiresInMinutes =
defaultExpiresInMinutes <= 0 || resolvedMaxExpiresInMinutes <= 0
? 0
: Math.max(defaultExpiresInMinutes, resolvedMaxExpiresInMinutes);
const legacyMaxTotalTokens =
'maxTotalTokens' in record
? normalizePositiveInteger((record as { maxTotalTokens?: number }).maxTotalTokens, 100_000, 0, UNBOUNDED_NUMERIC_LIMIT)
: 100_000;
return {
id,
name,
description: normalizeText(record.description),
defaultExpiresInMinutes,
maxExpiresInMinutes,
maxTokensPer30Days: normalizePositiveInteger(record.maxTokensPer30Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
maxTokensPer7Days: normalizePositiveInteger(record.maxTokensPer7Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
maxTokensPer5Hours: normalizePositiveInteger(record.maxTokensPer5Hours, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
oneTimeTokenLimit: normalizePositiveInteger(record.oneTimeTokenLimit, 0, 0, UNBOUNDED_NUMERIC_LIMIT),
allowedAppIds: normalizeAllowedAppIds(record.allowedAppIds),
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
export function sanitizeTokenSettings(items: Partial<TokenSettingRecord>[] | null | undefined) {
const byId = new Map<string, TokenSettingRecord>();
for (const item of items ?? []) {
const normalized = normalizeTokenSetting(item);
if (!normalized) {
continue;
}
const current = byId.get(normalized.id);
if (!current || Date.parse(current.updatedAt) <= Date.parse(normalized.updatedAt)) {
byId.set(normalized.id, normalized);
}
}
return Array.from(byId.values()).sort((left, right) => {
const nameCompare = left.name.localeCompare(right.name, 'ko-KR');
if (nameCompare !== 0) {
return nameCompare;
}
return left.id.localeCompare(right.id, 'en');
});
}
function emitTokenSettingsChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(TOKEN_SETTINGS_SYNC_EVENT));
}
function resolveApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const API_BASE_URL = resolveApiBaseUrl();
const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
async function requestOnce<T>(baseUrl: string, init?: RequestInit, options?: TokenSettingsRequestOptions): Promise<T> {
const headers = appendClientIdHeader(init?.headers);
const token = getRegisteredAccessToken();
const hasBody = init?.body !== undefined && init?.body !== null;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), TOKEN_SETTINGS_REQUEST_TIMEOUT_MS);
const shareToken = options?.shareToken?.trim() ?? '';
if (!shareToken && !isAllowedRegistrationToken(token)) {
throw new TokenSettingApiError('권한 토큰 등록 후에만 토큰 설정을 관리할 수 있습니다.', 403);
}
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
if (shareToken && !headers.has('X-Chat-Share-Token')) {
headers.set('X-Chat-Share-Token', shareToken);
}
try {
const response = await fetch(`${baseUrl}${TOKEN_SETTINGS_API_PATH}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
});
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new TokenSettingApiError(payload.message || '토큰 설정 요청에 실패했습니다.', response.status);
} catch {
throw new TokenSettingApiError(text || '토큰 설정 요청에 실패했습니다.', response.status);
}
}
const text = await response.text();
if (!text.trim()) {
throw new TokenSettingApiError('토큰 설정 응답이 비어 있습니다.', 502);
}
try {
return JSON.parse(text) as T;
} catch {
throw new TokenSettingApiError('토큰 설정 응답을 해석하지 못했습니다.', 502);
}
} finally {
window.clearTimeout(timeoutId);
}
}
async function requestTokenSettings<T>(init?: RequestInit, options?: TokenSettingsRequestOptions) {
try {
return await requestOnce<T>(API_BASE_URL, init, options);
} catch (error) {
const shouldRetryWithFallback =
FALLBACK_BASE_URL &&
FALLBACK_BASE_URL !== API_BASE_URL &&
(error instanceof TokenSettingApiError
? error.status === 404 || error.status === 408 || error.status === 502
: error instanceof Error && /404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message));
if (!shouldRetryWithFallback) {
throw error;
}
return requestOnce<T>(FALLBACK_BASE_URL, init, options);
}
}
async function loadTokenSettingsFromServer(options?: TokenSettingsRequestOptions) {
const response = await requestTokenSettings<{ ok: boolean; tokenSettings: Partial<TokenSettingRecord>[] | null }>({
method: 'GET',
}, options);
return sanitizeTokenSettings(response.tokenSettings);
}
async function saveTokenSettingsToServer(items: TokenSettingRecord[], options?: TokenSettingsRequestOptions) {
const resolved = sanitizeTokenSettings(items);
const response = await requestTokenSettings<{ ok: boolean; tokenSettings: Partial<TokenSettingRecord>[] }>({
method: 'PUT',
body: JSON.stringify({ tokenSettings: resolved }),
}, options);
return sanitizeTokenSettings(response.tokenSettings);
}
export function upsertTokenSetting(items: TokenSettingRecord[], input: TokenSettingInput) {
const nextItem = normalizeTokenSetting({
...input,
id: input.id,
});
if (!nextItem) {
return sanitizeTokenSettings(items);
}
const originalId = normalizeSettingId(input.originalId);
const nextItems = items.filter((item) => item.id !== nextItem.id && item.id !== originalId);
nextItems.push(nextItem);
return sanitizeTokenSettings(nextItems);
}
export function deleteTokenSetting(items: TokenSettingRecord[], tokenSettingId: string) {
return sanitizeTokenSettings(items.filter((item) => item.id !== tokenSettingId));
}
export function useTokenSettingRegistry(enabled = true, options?: TokenSettingsRequestOptions) {
const [tokenSettings, setTokenSettingsState] = useState<TokenSettingRecord[]>([]);
const [isLoading, setIsLoading] = useState(enabled);
const [errorMessage, setErrorMessage] = useState('');
const loadVersionRef = useRef(0);
const shareToken = options?.shareToken?.trim() ?? '';
useEffect(() => {
if (!enabled) {
setTokenSettingsState([]);
setIsLoading(false);
setErrorMessage('');
return undefined;
}
let mounted = true;
const load = async () => {
const loadVersion = ++loadVersionRef.current;
setIsLoading(true);
setErrorMessage('');
try {
const nextItems = await loadTokenSettingsFromServer({ shareToken });
if (!mounted || loadVersion !== loadVersionRef.current) {
return;
}
setTokenSettingsState(nextItems);
} catch (error) {
if (!mounted || loadVersion !== loadVersionRef.current) {
return;
}
setErrorMessage(error instanceof Error ? error.message : '토큰 설정을 불러오지 못했습니다.');
} finally {
if (mounted && loadVersion === loadVersionRef.current) {
setIsLoading(false);
}
}
};
void load();
const handleSync = () => {
void load();
};
window.addEventListener(TOKEN_SETTINGS_SYNC_EVENT, handleSync);
return () => {
mounted = false;
window.removeEventListener(TOKEN_SETTINGS_SYNC_EVENT, handleSync);
};
}, [enabled, shareToken]);
const setTokenSettings = async (items: TokenSettingRecord[]) => {
const savedItems = await saveTokenSettingsToServer(items, { shareToken });
setTokenSettingsState(savedItems);
emitTokenSettingsChange();
return savedItems;
};
return {
tokenSettings,
setTokenSettings,
isLoading,
errorMessage,
};
}