258 lines
30 KiB
JavaScript
258 lines
30 KiB
JavaScript
'use strict';
|
|||
|
|
// 관리자 설정: 내부 호출 모델(v1.5.16, 이전 "기본 모델"), 모델 연결(서버 로그인 / OAuth 토큰 / API 키), 사용할 Claude 모델, 환율·마진. data/settings.json (0600)
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
|
||
|
|
const FILE = path.join(DATA_ROOT, 'settings.json');
|
||
|
|
|
||
|
|
const MODELS = [
|
||
|
|
{ id: 'claude-opus-5-5', label: 'Claude Opus 5.5 (기본·권장)', input: 4, output: 20, cacheRead: 0.2, note: '최고 품질, 1M 컨텍스트' },
|
||
|
|
{ id: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5', input: 2, output: 10, cacheRead: 0.2, note: '속도·비용 균형' },
|
||
|
|
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', input: 1, output: 5, cacheRead: 0.1, note: '저비용, 200K 컨텍스트' },
|
||
|
|
{ id: 'claude-fable-5-1', label: 'Claude Fable 5.1', input: 10, output: 50, cacheRead: 0.25, note: '최상위 모델, 고비용' },
|
||
|
|
{ id: 'claude-opus-5', label: 'Claude Opus 5', input: 5, output: 25, cacheRead: 0.5, note: '이전 세대 Opus' },
|
||
|
|
];
|
||
|
|
// 사용자에게 보이는 모델 등급. 실제 모델(제공자:모델)은 관리자만 지정·열람한다.
|
||
|
|
const TIERS = [
|
||
|
|
// v1.5.19 (요구 1): 등급의 "특징" 은 모델 성능이 아니라 만들 수 있는 결과물로 설명한다 (생성 탭 표·관리자 등급 지정에 같이 보임)
|
||
|
|
{ id: 'basic', label: '기본', desc: 'HTML5, CSS3, 자바스크립트 구조의 정적 웹페이지 생성' },
|
||
|
|
{ id: 'standard', label: '중급', desc: '웹페이지의 시각적 완성도(UI/UX)와 심미성 우수' },
|
||
|
|
{ id: 'premium', label: '고급', desc: '복잡한 UI/UX의 프런트엔드 아키텍쳐 구현' },
|
||
|
|
];
|
||
|
|
const DEFAULT_TIERS = { basic: 'anthropic:claude-haiku-4-5', standard: 'anthropic:claude-sonnet-5-5', premium: 'anthropic:claude-opus-5-5' };
|
||
|
|
const DEFAULTS = {
|
||
|
|
// defaultModel: 1.5.13 까지의 "Claude 기본 모델"(Claude 모델 id 하나). v1.5.16 부터 화면에서는 internalRef 를 쓰고, 이 값은
|
||
|
|
// Claude 만 쓸 수 있는 자리(연결 테스트, 옛 기록의 비용 추정)와 internalRef 가 Claude 일 때의 모델 id 로 남는다.
|
||
|
|
defaultModel: process.env.CLAUDE_MODEL || 'claude-opus-5-5',
|
||
|
|
// v1.5.16 (요구 2): 등급(기본·중급·고급)과 무관한 서비스 내부 호출(사용자 문의 AI 분석, 모델을 지정하지 않은 호출)에 쓰는 모델.
|
||
|
|
// 등급 지정과 같은 형식 "<provider>:<model>" 이며 AI 연결 탭에서 "사용" 으로 켠 모델만 고를 수 있다. 비어 있으면 anthropic:<defaultModel>.
|
||
|
|
internalRef: '',
|
||
|
|
// v1.5.16 (요구 3): Claude 모델 중 이 서비스에서 "사용" 으로 켠 것(id 배열). 비어 있으면 MODELS 전체. 등급·내부 호출에 고를 수 있는 Claude 모델이 이것으로 제한된다.
|
||
|
|
claudeModels: [],
|
||
|
|
tiers: { ...DEFAULT_TIERS },
|
||
|
|
defaultTier: 'standard',
|
||
|
|
serviceApiKey: '', // 외부 서비스가 캐시를 조정할 때 쓰는 키 (X-Service-Key)
|
||
|
|
mailSystem: null, // v1.3.0: 사이트 메일 시스템 { enabled, mxHost, spfInclude, dmarc } (null 이면 꺼짐, lib/mailsys.js)
|
||
|
|
theme: null, // v1.2.0: AI Webmaker 화면 색상·외형 (null 이면 기본). THEME_DEFAULTS 참고
|
||
|
|
backlogKey: '', // 작업 서버가 "다음 작업 반영 목록" 을 가져갈 때 쓰는 읽기 전용 키 (X-Backlog-Key, v1.1.6)
|
||
|
|
signupCredit: 0, // 신규 회원 가입 시 자동 지급하는 기본 캐시(원). 관리자 콘솔에서 설정
|
||
|
|
// v1.5.27 (요구 6): 회원가입 뒤 이용 방식 — immediate(가입 즉시 이용) | approval(관리자가 승인해야 로그인 가능). 가입 자체의 ON/OFF 는 설정 파일 service.allowSignup
|
||
|
|
signupApproval: 'immediate',
|
||
|
|
maxEdits: 10, // AI에게 맡기기: 생성 후 발행 전까지 허용하는 수정 횟수(0 = 무제한). 한도에 이르면 발행 대기
|
||
|
|
auth: { mode: 'server-login', apiKey: '', oauthToken: '' }, // server-login | oauth-token | api-key
|
||
|
|
fx: { mode: 'auto', manualRate: 0, spreadPct: 1.0 }, // auto: ECB/시장 중간환율 + 스프레드(구입가 근사) | manual: 관리자 입력
|
||
|
|
marginPct: 30,
|
||
|
|
letsEncryptEmail: '',
|
||
|
|
mail: { keyFile: '', apiKey: '', defaultDomain: '', fromName: 'Webmaster', formBase: '' },
|
||
|
|
updatedAt: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
let cache = null;
|
||
|
|
function load() {
|
||
|
|
if (cache) return cache;
|
||
|
|
try { cache = { ...DEFAULTS, ...JSON.parse(fs.readFileSync(FILE, 'utf8')) }; cache.auth = { ...DEFAULTS.auth, ...(cache.auth || {}) }; cache.fx = { ...DEFAULTS.fx, ...(cache.fx || {}) }; }
|
||
|
|
catch (_) { cache = JSON.parse(JSON.stringify(DEFAULTS)); }
|
||
|
|
return cache;
|
||
|
|
}
|
||
|
|
function save(patch) {
|
||
|
|
// v1.3.16: 사본에 적용하고 끝까지 검증을 통과했을 때만 파일·캐시에 반영 (중간에 검증 오류가 나면 아무것도 바뀌지 않음)
|
||
|
|
const s = JSON.parse(JSON.stringify(load()));
|
||
|
|
// v1.3.17 (B14): 보관소(키) 저장처럼 되돌릴 수 없는 일은 모아 두었다가 모든 검증이 끝난 뒤에 실행한다
|
||
|
|
// (예전에는 키를 먼저 보관소에 쓰고 뒤의 숫자 검증에서 실패해, 오류 응답인데 키는 바뀌어 있었다)
|
||
|
|
const after = [];
|
||
|
|
// 옛 형식(1.5.13 까지의 화면·API): Claude 모델 id 하나 → 내부 호출 모델도 그 Claude 모델로 맞춘다
|
||
|
|
if (patch.defaultModel && MODELS.some((m) => m.id === patch.defaultModel)) { s.defaultModel = patch.defaultModel; s.internalRef = 'anthropic:' + patch.defaultModel; }
|
||
|
|
// v1.5.16: 등급·내부 호출 모델은 "지금 사용 가능으로 설정된 모델" 중에서만 — 값이 바뀌는 항목만 검사한다(이미 저장된 값이 제공자를 끄면서
|
||
|
|
// 사용 불가가 되어도 다른 등급만 고쳐 저장할 수 있게). providers 는 이 모듈을 불러 쓰므로 여기서는 호출 시점에 늦게 불러온다.
|
||
|
|
const assertUsable = (ref, what) => { const list = require('./providers').availableModels(null); if (!list.some((m) => m.ref === ref)) throw Object.assign(new Error(`${what}에 지정한 모델 ${ref} 은(는) 지금 사용 가능으로 설정된 모델이 아닙니다 — AI 연결 탭에서 제공자를 켜고 모델을 "사용" 으로 고른 뒤 다시 지정하세요`), { status: 400 }); };
|
||
|
|
if (patch.tiers && typeof patch.tiers === 'object') { s.tiers = { ...DEFAULT_TIERS, ...(s.tiers || {}) }; for (const t of TIERS) if (typeof patch.tiers[t.id] === 'string' && /^[a-z0-9_-]+:.+/i.test(patch.tiers[t.id])) { const v = patch.tiers[t.id].trim(); if (v !== s.tiers[t.id]) assertUsable(v, `${t.label} 등급`); s.tiers[t.id] = v; } }
|
||
|
|
if (typeof patch.internalRef === 'string' && /^[a-z0-9_-]+:.+/i.test(patch.internalRef)) { const v = patch.internalRef.trim(); if (v !== s.internalRef) assertUsable(v, '내부 호출 모델'); s.internalRef = v; if (v.startsWith('anthropic:') && MODELS.some((m) => m.id === v.slice(10))) s.defaultModel = v.slice(10); }
|
||
|
|
// v1.5.16: 사용할 Claude 모델 (id 배열, 모르는 id 는 버림). 빈 배열 = 전체 사용
|
||
|
|
if (Array.isArray(patch.claudeModels)) s.claudeModels = patch.claudeModels.map((x) => String(x)).filter((id) => MODELS.some((m) => m.id === id));
|
||
|
|
if (patch.defaultTier && TIERS.some((t) => t.id === patch.defaultTier)) s.defaultTier = patch.defaultTier;
|
||
|
|
if (typeof patch.serviceApiKey === 'string' && patch.serviceApiKey !== '••••') s.serviceApiKey = patch.serviceApiKey.trim();
|
||
|
|
if (typeof patch.backlogKey === 'string' && patch.backlogKey !== '••••') s.backlogKey = patch.backlogKey.trim();
|
||
|
|
if (patch.theme !== undefined) s.theme = normalizeTheme(patch.theme);
|
||
|
|
if (patch.mailSystem && typeof patch.mailSystem === 'object') s.mailSystem = require('./mailsys').normalizeConfig(patch.mailSystem, s.mailSystem); // 검증 실패 시 예외(저장 안 함)
|
||
|
|
if (patch.auth) {
|
||
|
|
if (['server-login', 'oauth-token', 'api-key'].includes(patch.auth.mode)) s.auth.mode = patch.auth.mode;
|
||
|
|
// v1.3.1: Claude 키·토큰은 settings.json 이 아니라 암호화 보관소(lib/vault.js)에 저장한다
|
||
|
|
if (typeof patch.auth.apiKey === 'string' && patch.auth.apiKey.trim() && patch.auth.apiKey !== '••••') { const k = patch.auth.apiKey.trim(); after.push(() => require('./vault').set({ kind: 'anthropic-api-key', secret: k })); s.auth.apiKey = ''; }
|
||
|
|
if (typeof patch.auth.oauthToken === 'string' && patch.auth.oauthToken.trim() && patch.auth.oauthToken !== '••••') { const k = patch.auth.oauthToken.trim(); after.push(() => require('./vault').set({ kind: 'anthropic-oauth', secret: k })); s.auth.oauthToken = ''; }
|
||
|
|
}
|
||
|
|
// v1.3.16: 숫자 값 검증. 예전에는 빈 칸('')이 +'' = 0 으로 저장되어 마진·환율이 0 이 될 수 있었다 → 빈 값은 "바꾸지 않음",
|
||
|
|
// 범위를 벗어나면 저장하지 않고 이유를 알린다(관리자 화면에 그대로 표시).
|
||
|
|
const num = (v, name, lo, hi) => { if (v === undefined || v === null || String(v).trim() === '') return undefined; const n = Number(v); if (!Number.isFinite(n) || n < lo || n > hi) throw Object.assign(new Error(`${name}은(는) ${lo}~${hi} 사이 숫자로 입력하세요`), { status: 400 }); return n; };
|
||
|
|
if (patch.fx) {
|
||
|
|
if (['auto', 'manual'].includes(patch.fx.mode)) s.fx.mode = patch.fx.mode;
|
||
|
|
const mr = num(patch.fx.manualRate, '직접 입력 환율', 1, 100000); if (mr !== undefined) s.fx.manualRate = mr;
|
||
|
|
const sp = num(patch.fx.spreadPct, '스프레드 %', 0, 50); if (sp !== undefined) s.fx.spreadPct = sp;
|
||
|
|
if (s.fx.mode === 'manual' && !(s.fx.manualRate > 0)) throw Object.assign(new Error('직접 입력 방식은 1 USD 원화 가격을 입력해야 합니다'), { status: 400 });
|
||
|
|
}
|
||
|
|
// v1.3.16 서비스 화면 기본 언어 { default, force } — force 이면 지금 시각을 forcedAt 으로 남겨, 그 전에 사용자가 고른 언어보다 앞서게 한다
|
||
|
|
if (patch.uiLang && typeof patch.uiLang === 'object') { const d = String(patch.uiLang.default || 'ko').trim(); if (!/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(d)) throw Object.assign(new Error('언어 코드가 올바르지 않습니다'), { status: 400 }); s.uiLang = { default: d, forcedAt: patch.uiLang.force ? new Date().toISOString() : ((s.uiLang && s.uiLang.forcedAt) || null) }; }
|
||
|
|
{ const mg = num(patch.marginPct, '사용자 청구 마진 %', 0, 1000); if (mg !== undefined) s.marginPct = mg; }
|
||
|
|
{ const me = num(patch.maxEdits, '수정 횟수 한도', 0, 10000); if (me !== undefined) s.maxEdits = Math.round(me); }
|
||
|
|
{ const sc = num(patch.signupCredit, '신규 회원 기본 캐시', 0, 100000000); if (sc !== undefined) s.signupCredit = Math.round(sc); }
|
||
|
|
if (patch.signupApproval !== undefined) { if (!['immediate', 'approval'].includes(patch.signupApproval)) throw Object.assign(new Error('회원가입 승인 방식은 immediate 또는 approval 이어야 합니다'), { status: 400 }); s.signupApproval = patch.signupApproval; } // v1.5.27
|
||
|
|
// v1.4.1 캐시 표시 단위 (원·환·동·골드·크레딧·D·ING 등 관리자가 정하는 글자). 표시만 바뀌고 1 단위 = 1원 기준은 그대로다
|
||
|
|
if (patch.cashUnit !== undefined) { const u = String(patch.cashUnit || '').trim(); if (!u || u.length > 12 || /[<>"'&\\{}0-9]/.test(u)) throw Object.assign(new Error('캐시 표시 단위는 숫자·기호(< > " \' & { })를 뺀 1~12자로 정하세요 (예: 원, 크레딧, D)'), { status: 400 }); s.cashUnit = u; }
|
||
|
|
// v1.4.5 사용자 첫 화면 페이지 (빌더 오른쪽 미리보기에 사이트를 고르기 전 보이는 페이지) — 관리자가 만든 사이트 ID, 빈 값 = 기본 안내.
|
||
|
|
// 관리자 소유인지는 server.js 가 저장 전에 확인한다(settings 는 사이트 저장소를 모름)
|
||
|
|
if (patch.welcomeSiteId !== undefined) { const w = String(patch.welcomeSiteId || '').trim(); if (w && !/^[a-f0-9]{32}$/.test(w)) throw Object.assign(new Error('첫 화면 페이지 값이 올바르지 않습니다'), { status: 400 }); s.welcomeSiteId = w; }
|
||
|
|
if (typeof patch.letsEncryptEmail === 'string') s.letsEncryptEmail = patch.letsEncryptEmail.trim();
|
||
|
|
// v1.4.0 도메인 설정 (요율·플랫폼 도메인·DNS 방식·사이트 IP·확장자·예약 이름·새 가입자 계정 종류)
|
||
|
|
if (patch.domains && typeof patch.domains === 'object') s.domains = require('./domains').normalizeConfig(patch.domains, s.domains);
|
||
|
|
// v1.3.17 비밀번호 규칙 (lib/passpolicy.js 가 검증, 기본은 기존 규칙 6자 이상)
|
||
|
|
if (patch.passwordPolicy && typeof patch.passwordPolicy === 'object') s.passwordPolicy = require('./passpolicy').normalize({ ...(s.passwordPolicy || {}), ...patch.passwordPolicy });
|
||
|
|
// v1.3.1: 키 파일 경로가 오면 그 파일을 한 번 읽어 보관소에 암호화 저장한다(이후 평문 파일 없이 동작). 못 읽으면 저장 실패로 알린다.
|
||
|
|
if (patch.mail && typeof patch.mail === 'object' && typeof patch.mail.keyFile === 'string' && patch.mail.keyFile.trim()) { const f = patch.mail.keyFile.trim(); after.push(() => require('./vault').importFile('resend', f)); }
|
||
|
|
if (patch.mail && typeof patch.mail === 'object') { s.mail = { ...(s.mail || {}) }; for (const k of ['keyFile', 'defaultDomain', 'fromName', 'formBase']) if (typeof patch.mail[k] === 'string') s.mail[k] = patch.mail[k].trim(); if (typeof patch.mail.apiKey === 'string' && patch.mail.apiKey.trim() && patch.mail.apiKey !== '••••') { const k = patch.mail.apiKey.trim(); after.push(() => require('./vault').set({ kind: 'resend', secret: k })); s.mail.apiKey = ''; } } // v1.3.1: 메일 발송 키는 보관소에
|
||
|
|
for (const fn of after) fn(); // 검증을 모두 통과 — 이제 보관소에 쓴다 (여기서 실패하면 설정 파일도 쓰지 않는다)
|
||
|
|
s.updatedAt = new Date().toISOString();
|
||
|
|
fs.mkdirSync(DATA_ROOT, { recursive: true });
|
||
|
|
fs.writeFileSync(FILE, JSON.stringify(s, null, 2), { mode: 0o600 });
|
||
|
|
try { fs.chmodSync(FILE, 0o600); } catch (_) {}
|
||
|
|
cache = s; return s;
|
||
|
|
}
|
||
|
|
/** Claude 만 쓸 수 있는 자리(연결 테스트, 모델을 모르는 옛 기록의 비용 추정)에서 쓰는 Claude 모델 id — 내부 호출 모델이 Claude 면 그 모델 */
|
||
|
|
function getModel() { const s = load(); const r = s.internalRef || ''; if (r.startsWith('anthropic:') && MODELS.some((m) => m.id === r.slice(10))) return r.slice(10); return s.defaultModel; }
|
||
|
|
/** v1.5.16 내부 호출 모델 참조(provider:model). 저장된 값이 없으면 Claude 기본 모델. 제공자가 꺼져 쓸 수 없을 때의 대체는 providers.resolveModel 이 한다 */
|
||
|
|
function internalRef() { const s = load(); return s.internalRef || ('anthropic:' + s.defaultModel); }
|
||
|
|
/** v1.5.16 사용으로 켠 Claude 모델 목록 (빈 설정이면 전체) */
|
||
|
|
function claudeModels() { const s = load(); const ids = Array.isArray(s.claudeModels) && s.claudeModels.length ? s.claudeModels : null; return ids ? MODELS.filter((m) => ids.includes(m.id)) : MODELS; }
|
||
|
|
// 등급별 안내 문구 (사용자에게 비용 체감을 미리 알리기 위한 설명, v1.1.0)
|
||
|
|
// v1.5.19 (요구 1): summary 는 등급의 특징(결과물 기준), fit 은 적합한 경우. 비용 차이는 표의 예상 비용 열이 보여 주므로 문장에서 뺐다
|
||
|
|
const TIER_GUIDE = {
|
||
|
|
basic: { title: '기본', summary: 'HTML5, CSS3, 자바스크립트 구조의 정적 웹페이지 생성.', fit: '소개·안내 중심의 일반 사이트, 잦은 소규모 수정' },
|
||
|
|
standard: { title: '중급', summary: '웹페이지의 시각적 완성도(UI/UX)와 심미성 우수.', fit: '디자인 완성도가 중요한 사이트, 페이지가 많거나 구성이 복잡한 사이트' },
|
||
|
|
premium: { title: '고급', summary: '복잡한 UI/UX의 프런트엔드 아키텍쳐 구현.', fit: '여러 기능 모듈이 서로 연동되는 사이트, 세밀한 상호작용·품질이 필요한 경우' },
|
||
|
|
};
|
||
|
|
// 비용 추정에 쓰는 대표 토큰량 (실제 작업 기록의 중간값 근사). 생성은 파일을 많이 쓰고, 수정은 파일을 많이 읽는다.
|
||
|
|
const TYPICAL_USAGE = { create: { input: 20000, output: 30000, cacheCreate: 150000, cacheRead: 900000 }, edit: { input: 10000, output: 12000, cacheCreate: 90000, cacheRead: 350000 } };
|
||
|
|
/**
|
||
|
|
* 등급별 예상 비용(USD). 원화 환산은 호출자(server)가 환율·마진을 적용한다.
|
||
|
|
* @param {(ref:string)=>{kind,model,price}} resolve 모델 참조를 실행 정보로 바꾸는 함수(providers.resolveModel)
|
||
|
|
*/
|
||
|
|
function tierGuide(resolve) {
|
||
|
|
const s = load(); const out = [];
|
||
|
|
for (const t of TIERS) {
|
||
|
|
const ref = (s.tiers && s.tiers[t.id]) || DEFAULT_TIERS[t.id]; let create = null, edit = null;
|
||
|
|
try { const mi = resolve(ref, null); const est = (u) => (mi.kind === 'claude-cli' ? estimateUsd(u, mi.model) : ((u.input + u.cacheCreate + u.cacheRead) * mi.price.input + u.output * mi.price.output) / 1e6); create = est(TYPICAL_USAGE.create); edit = est(TYPICAL_USAGE.edit); } catch (_) {}
|
||
|
|
out.push({ id: t.id, label: t.label, desc: t.desc, ...TIER_GUIDE[t.id], createUsd: create, editUsd: edit, isDefault: (s.defaultTier || 'standard') === t.id });
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
// ---------- 화면 테마 (v1.2.0) ----------
|
||
|
|
// 색상은 style.css 의 :root 변수로, 외형은 레이아웃 변수(좌측 폭·글자 크기·모서리·간격·패널 위치)로 반영한다.
|
||
|
|
const THEME_DEFAULTS = {
|
||
|
|
colors: { accent: '#2f6fed', accent2: '#1d4fd7', bg: '#f4f5f7', panel: '#ffffff', text: '#1f2933', muted: '#6b7684', line: '#e3e6ea', ok: '#1a8f5a', warn: '#c2410c', topBg: '#fbfcfe' },
|
||
|
|
layout: { leftWidth: 460, fontSize: 14, radius: 10, density: 'normal', panelSide: 'left', headerStyle: 'light' },
|
||
|
|
};
|
||
|
|
const THEME_PRESETS = {
|
||
|
|
default: { label: '기본 (파랑)', colors: {} },
|
||
|
|
green: { label: '초록', colors: { accent: '#15803d', accent2: '#166534', topBg: '#f6fbf7' } },
|
||
|
|
purple: { label: '보라', colors: { accent: '#7c3aed', accent2: '#6d28d9', topBg: '#faf8ff' } },
|
||
|
|
orange: { label: '주황', colors: { accent: '#ea580c', accent2: '#c2410c', topBg: '#fffaf5' } },
|
||
|
|
slate: { label: '차분한 회색', colors: { accent: '#334155', accent2: '#1e293b', bg: '#eef0f3', topBg: '#f8fafc' } },
|
||
|
|
dark: { label: '어두운 화면', colors: { accent: '#60a5fa', accent2: '#3b82f6', bg: '#0f172a', panel: '#111827', text: '#e5e7eb', muted: '#9ca3af', line: '#273244', ok: '#34d399', warn: '#fb923c', topBg: '#0b1220' }, layout: { headerStyle: 'dark' } },
|
||
|
|
};
|
||
|
|
const HEX = /^#[0-9a-f]{6}$/i;
|
||
|
|
/** 저장된 테마를 기본값과 합쳐 완전한 형태로 */
|
||
|
|
/** 저장된(또는 주어진) 테마를 기본값과 합쳐 완전한 형태로. themeObj 를 주면 그 값을 쓴다(미리보기용, v1.3.2) */
|
||
|
|
function getTheme(themeObj) {
|
||
|
|
const t = (themeObj !== undefined ? themeObj : load().theme) || {}; const c = { ...THEME_DEFAULTS.colors }; const l = { ...THEME_DEFAULTS.layout };
|
||
|
|
for (const [k, v] of Object.entries(t.colors || {})) if (k in c && HEX.test(v)) c[k] = v;
|
||
|
|
for (const [k, v] of Object.entries(t.layout || {})) if (k in l) l[k] = v;
|
||
|
|
return { colors: c, layout: l, preset: t.preset || 'default' };
|
||
|
|
}
|
||
|
|
/** 관리자 입력 검증: 색은 #rrggbb, 외형 값은 허용 범위로 자른다 */
|
||
|
|
function normalizeTheme(input) {
|
||
|
|
if (!input || input.reset) return null;
|
||
|
|
const out = { colors: {}, layout: {}, preset: THEME_PRESETS[input.preset] ? input.preset : 'custom' };
|
||
|
|
for (const [k, v] of Object.entries(input.colors || {})) if (k in THEME_DEFAULTS.colors && HEX.test(v)) out.colors[k] = v.toLowerCase();
|
||
|
|
const L = input.layout || {}; const clamp = (v, a, b, d) => { const n = Number(v); return Number.isFinite(n) ? Math.min(b, Math.max(a, Math.round(n))) : d; };
|
||
|
|
out.layout.leftWidth = clamp(L.leftWidth, 360, 720, 460); out.layout.fontSize = clamp(L.fontSize, 12, 17, 14); out.layout.radius = clamp(L.radius, 0, 18, 10);
|
||
|
|
out.layout.density = ['compact', 'normal', 'comfortable'].includes(L.density) ? L.density : 'normal';
|
||
|
|
out.layout.panelSide = L.panelSide === 'right' ? 'right' : 'left'; out.layout.headerStyle = L.headerStyle === 'dark' ? 'dark' : (L.headerStyle === 'accent' ? 'accent' : 'light');
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
/** 테마 → CSS (사용자 화면·로그인·사이트 관리자 페이지가 /theme.css 로 불러온다) */
|
||
|
|
/**
|
||
|
|
* 테마 CSS.
|
||
|
|
* @param {object} [themeObj] 주면 저장하지 않은 그 테마로 만든다(관리자 화면 설정 미리보기). 없으면 저장된 테마.
|
||
|
|
*/
|
||
|
|
function themeCss(themeObj) {
|
||
|
|
const preview = themeObj !== undefined; const { colors: c, layout: l } = getTheme(preview ? normalizeTheme(themeObj) : undefined);
|
||
|
|
// 테마를 저장한 적이 없으면 변수만 내보낸다 → 기본 화면(style.css)이 그대로 유지된다 (미리보기는 항상 전체 CSS)
|
||
|
|
if (!preview && !load().theme) return `/* AI Webmaker 테마: 기본값 (관리자 콘솔 › 설정 › 화면 외형에서 변경) */\n:root { --left-w: ${l.leftWidth}px; --top-bg: ${c.topBg}; --top-text: ${c.text}; }\n`; // v1.6.40: 레일 폭(--rail-w)은 style.css 기본값을 쓴다
|
||
|
|
// 패널 색이 어두우면(밝기 0.4 미만) style.css 에 밝은 색으로 고정된 배경들을 패널 색 계열로 바꾼다
|
||
|
|
const lum = (hex) => { const n = parseInt(hex.slice(1), 16); return (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255; };
|
||
|
|
const darkPanel = lum(c.panel) < 0.4; const gap = { compact: 8, normal: 12, comfortable: 16 }[l.density]; const pad = { compact: 9, normal: 12, comfortable: 15 }[l.density];
|
||
|
|
const head = l.headerStyle === 'dark' ? { bg: '#0f172a', text: '#e5e7eb' } : l.headerStyle === 'accent' ? { bg: c.accent, text: '#ffffff' } : { bg: c.topBg, text: c.text };
|
||
|
|
return `/* AI Webmaker 테마 (관리자 콘솔 › 설정 › 화면 외형에서 변경) */
|
||
|
|
:root { --accent: ${c.accent}; --accent-2: ${c.accent2}; --bg: ${c.bg}; --panel: ${c.panel}; --text: ${c.text}; --muted: ${c.muted}; --line: ${c.line}; --ok: ${c.ok}; --warn: ${c.warn}; --top-bg: ${head.bg}; --top-text: ${head.text}; --left-w: ${l.leftWidth}px; --fs: ${l.fontSize}px; --radius: ${l.radius}px; --gap: ${gap}px; --pad: ${pad}px; }
|
||
|
|
body { font-size: var(--fs); background: var(--bg); color: var(--text); }
|
||
|
|
.app { grid-template-columns: var(--rail-w, 232px) var(--left-w) 1fr; } /* v1.6.40: 레일 + 작업 칸 + 캔버스 세 칸 */
|
||
|
|
${l.panelSide === 'right' ? '.app { grid-template-columns: 1fr var(--left-w) var(--rail-w, 232px); } .app > .rail { order: 3; border-right: 0; border-left: 1px solid var(--line); } .app > .left { order: 2; border-right: 0; border-left: 1px solid var(--line); } .app > .right { order: 1; }' : ''}
|
||
|
|
.rail { background: var(--top-bg); color: var(--top-text); } .rail .muted, .rail .rail-group, .rail .rail-label { color: ${l.headerStyle === 'light' ? 'var(--muted)' : 'rgba(255,255,255,.72)'}; }
|
||
|
|
.rail-nav button { color: ${l.headerStyle === 'light' ? 'var(--text)' : 'var(--top-text)'}; } .rail-nav button.active { background: color-mix(in srgb, var(--accent) 14%, var(--panel)); color: var(--accent-2); }
|
||
|
|
.card, .status-strip, .tier-guide, .module-row, .opt-card, .mode-opt, .dropzone { border-radius: var(--radius); background-color: var(--panel); border-color: var(--line); }
|
||
|
|
.card { padding: var(--pad); } .bottom-pane, .tabpane { gap: var(--gap); }
|
||
|
|
.left, .toolbar, .preview { background: var(--panel); }
|
||
|
|
.top-pane { background: var(--top-bg); color: var(--top-text); } .top-pane .muted, .top-pane .sub { color: ${l.headerStyle === 'light' ? 'var(--muted)' : 'rgba(255,255,255,.75)'}; }
|
||
|
|
input, textarea, select { background: var(--panel); color: var(--text); border-color: var(--line); border-radius: calc(var(--radius) - 2px); }
|
||
|
|
button, .btn { border-radius: calc(var(--radius) - 2px); background-color: var(--panel); color: var(--text); border-color: var(--line); }
|
||
|
|
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } button.primary:hover { background: var(--accent-2); }
|
||
|
|
.main-tabs button.active { background: color-mix(in srgb, var(--accent) 14%, var(--panel)); color: var(--accent-2); border-color: color-mix(in srgb, var(--accent) 35%, var(--panel)); }
|
||
|
|
.credit-pill { border-color: color-mix(in srgb, var(--accent) 35%, var(--panel)); } .credit-pill b { color: var(--accent-2); }
|
||
|
|
.ai-main-input { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: color-mix(in srgb, var(--accent) 4%, var(--panel)); }
|
||
|
|
${darkPanel ? `.ghost, code, tfoot td, .toolbar, .preview, .editor-bar, .dropzone, .pending li, .basics, .chip, .opt-card, .page-row, .progress, .tier-guide, .update-card, .update-card #fInstruction, .status-strip, .credit-footer, .usage-group td, .wm-dialog, .help-tip, .main-tabs button, .fb-env-note, .mode-opt, .card { background-color: color-mix(in srgb, var(--panel) 92%, var(--text)); color: var(--text); }
|
||
|
|
.mode-opt:hover, .opt-card.on, .main-tabs button:hover { background-color: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
|
||
|
|
.progress.running, .progress.done, .progress.error, .progress.cancelled, .progress.waiting-credit, .progress-deploy, .progress-warn { background-color: color-mix(in srgb, var(--panel) 85%, var(--accent)); color: var(--text); } .progress .pmsg, .progress #progressText { color: var(--text); } /* v1.3.2: 화면 설정 미리보기에서 발견 — 어두운 화면에서 진행 상자 글자가 안 보이던 문제 */
|
||
|
|
th { background: color-mix(in srgb, var(--panel) 85%, var(--text)); color: var(--text); }
|
||
|
|
.tier-guide tr.on td, .usage-group td, tr.on td { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); color: var(--text); } .pill, .badge { filter: saturate(.85) brightness(.95); } a { color: color-mix(in srgb, var(--accent) 80%, #fff); }` : ''}
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
/** 등급 → 모델 참조(provider:model). 등급이 없거나 모르면 기본 등급 */
|
||
|
|
function tierRef(tier) { const s = load(); const t = TIERS.some((x) => x.id === tier) ? tier : (s.defaultTier || 'standard'); return (s.tiers && s.tiers[t]) || DEFAULT_TIERS[t]; }
|
||
|
|
function validTier(tier) { return TIERS.some((x) => x.id === tier) ? tier : (load().defaultTier || 'standard'); }
|
||
|
|
/** Claude 실행 환경에 넣을 인증 변수 */
|
||
|
|
/**
|
||
|
|
* Claude 인증 값 (v1.3.1): 암호화 보관소 우선, 없으면 1.3.0 이전 settings.json 값(이전 전 호환).
|
||
|
|
* @param {'apiKey'|'oauthToken'} field
|
||
|
|
*/
|
||
|
|
function authSecret(field) { let v = ''; try { v = require('./vault').secretOf(field === 'apiKey' ? 'anthropic-api-key' : 'anthropic-oauth'); } catch (_) {} return v || (load().auth || {})[field] || ''; }
|
||
|
|
function authEnv() {
|
||
|
|
const a = load().auth; const env = {};
|
||
|
|
if (a.mode === 'api-key' && authSecret('apiKey')) env.ANTHROPIC_API_KEY = authSecret('apiKey');
|
||
|
|
else if (a.mode === 'oauth-token' && authSecret('oauthToken')) env.CLAUDE_CODE_OAUTH_TOKEN = authSecret('oauthToken');
|
||
|
|
return env;
|
||
|
|
}
|
||
|
|
/** 보관소로 옮긴 뒤 settings.json 에서 키 값을 지울 때 쓰는 내부 함수 (vault.migrate 전용) */
|
||
|
|
function replaceSecrets({ mail, auth }) { const s = load(); if (mail) s.mail = mail; if (auth) s.auth = auth; s.updatedAt = new Date().toISOString(); fs.writeFileSync(FILE, JSON.stringify(s, null, 2), { mode: 0o600 }); try { fs.chmodSync(FILE, 0o600); } catch (_) {} cache = s; }
|
||
|
|
/** 화면 표시용(비밀값 마스킹) */
|
||
|
|
function publicView() {
|
||
|
|
const s = load();
|
||
|
|
return { ...s, internalRef: internalRef(), claudeModels: Array.isArray(s.claudeModels) ? s.claudeModels : [], tiers: { ...DEFAULT_TIERS, ...(s.tiers || {}) }, tierCatalog: TIERS, serviceApiKey: s.serviceApiKey ? '••••' : '', serviceApiKeyHint: s.serviceApiKey ? s.serviceApiKey.slice(0, 6) + '…' : '', backlogKey: s.backlogKey ? '••••' : '', backlogKeyHint: s.backlogKey ? s.backlogKey.slice(0, 6) + '…' : '', mail: { ...(s.mail || {}), apiKey: s.mail && s.mail.apiKey ? '••••' : '' }, auth: { mode: s.auth.mode, apiKey: authSecret('apiKey') ? '••••' : '', oauthToken: authSecret('oauthToken') ? '••••' : '', apiKeyHint: authSecret('apiKey') ? authSecret('apiKey').slice(0, 10) + '…' + authSecret('apiKey').slice(-4) : '', oauthTokenHint: authSecret('oauthToken') ? authSecret('oauthToken').slice(0, 12) + '…' : '' }, models: MODELS, /* v1.4.0 (요구 15): AI 제공자 API 키는 암호문이라도 화면에 보내지 않는다 — 입력칸은 비어 있고, 새로 넣으면 바뀐다 */ providers: Object.fromEntries(Object.entries(s.providers || {}).map(([k, v]) => [k, { ...(v || {}), apiKey: v && v.apiKey ? '••••' : '' }])), domainsConfig: (() => { try { return require('./domains').config(); } catch (_) { return null; } })() }; // v1.4.0 도메인 설정(기본값 포함)
|
||
|
|
}
|
||
|
|
function serverLoginInfo() {
|
||
|
|
try { const j = JSON.parse(fs.readFileSync(path.join(process.env.HOME || '/home/master', '.claude.json'), 'utf8')); const o = j.oauthAccount || {}; return { email: o.emailAddress || null, org: o.organizationName || null, billing: o.billingType || null }; } catch (_) { return { email: null }; }
|
||
|
|
}
|
||
|
|
/** 사용량으로 USD 비용 추정 (실행 중 표시용). 캐시 생성은 입력 단가의 1.25배로 근사 */
|
||
|
|
/**
|
||
|
|
* 토큰 사용량 → USD (가격표 MODELS).
|
||
|
|
* v1.4.5: 캐시 쓰기 단가를 고쳤다 — Claude Code 는 1시간 캐시를 써서 입력 단가의 2배다(5분 캐시는 1.25배).
|
||
|
|
* usage 에 종류별 토큰(cacheCreate1h·cacheCreate5m)이 있으면 그대로, 없으면(실시간 추정·옛 기록) 1시간 캐시로 본다.
|
||
|
|
* 이 계산은 CLI 가 알려 주는 비용과 일치한다(2026-10-10 실측: Haiku 0.005288 = 0.005288, Opus 작업 0.388024 ≈ 0.3880244).
|
||
|
|
* 1.25배로 계산하던 1.4.4 까지는 추정이 낮게 나와 보정 배율(costcal)이 이를 메우고 있었다.
|
||
|
|
* 모델 이름에 날짜가 붙은 경우(claude-haiku-4-5-20251001)도 앞부분으로 찾는다.
|
||
|
|
*/
|
||
|
|
function estimateUsd(usage, modelId) {
|
||
|
|
const id = String(modelId || '').replace(/^[a-z]+:/, '');
|
||
|
|
const m = MODELS.find((x) => x.id === id) || MODELS.find((x) => id.startsWith(x.id)) || MODELS[0]; if (!usage) return 0;
|
||
|
|
const cc = usage.cacheCreate || 0; const c5 = Math.min(cc, usage.cacheCreate5m || 0); const c1 = cc - c5;
|
||
|
|
return ((usage.input || 0) * m.input + (usage.output || 0) * m.output + (usage.cacheRead || 0) * m.cacheRead + c1 * m.input * 2 + c5 * m.input * 1.25) / 1e6;
|
||
|
|
}
|
||
|
|
module.exports = { authSecret, replaceSecrets, THEME_DEFAULTS, THEME_PRESETS, getTheme, normalizeTheme, themeCss, MODELS, TIERS, DEFAULT_TIERS, TIER_GUIDE, TYPICAL_USAGE, tierGuide, load, save, getModel, internalRef, claudeModels, tierRef, validTier, authEnv, publicView, serverLoginInfo, estimateUsd };
|