feat: update main chat and system chat UI
This commit is contained in:
371
src/app/main/tokenSettingAccess.ts
Normal file
371
src/app/main/tokenSettingAccess.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user