feat: 유저별 로고·브랜드명 커스터마이징 — .smallclaw/users/<user>/logo.png, brand_name.txt
헤더/환영화면/채팅 아바타·라벨을 로그인 유저별로 스왑. 파일 없으면 기본 HomeClaw 유지. 애플·자스민 계정에 기본 로고(이모지 아이콘) 적용. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5kyXurhNSboRcY3dAGceY
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 782 B |
Binary file not shown.
|
After Width: | Height: | Size: 1000 B |
@@ -2496,6 +2496,7 @@ app.get('/api/auth/status', (_req, res) => {
|
||||
authenticated: !!session,
|
||||
username: session?.username ?? null,
|
||||
role: session?.role ?? null,
|
||||
brandName: getUserBrandName(session?.username),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2836,6 +2837,34 @@ app.all('/api/localllm/:provider/{*subPath}', async (req: express.Request, res:
|
||||
}
|
||||
});
|
||||
|
||||
// Per-user brand name override, e.g. <configDir>/users/<username>/brand_name.txt
|
||||
// containing "체리클로" — falls back to "HomeClaw" when absent.
|
||||
function getUserBrandName(username: string | null | undefined): string | null {
|
||||
if (!username) return null;
|
||||
try {
|
||||
const p = path.join(getConfig().getConfigDir(), 'users', username, 'brand_name.txt');
|
||||
if (fs.existsSync(p)) {
|
||||
const name = fs.readFileSync(p, 'utf8').trim();
|
||||
if (name) return name.slice(0, 40);
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Per-user logo — falls back to the default HomeClaw logo when the user has
|
||||
// no custom logo file. Drop a PNG at <configDir>/users/<username>/logo.png
|
||||
// to override (e.g. an old family nickname logo).
|
||||
app.get('/api/branding/logo', (req, res) => {
|
||||
const username = (req as any).user?.username as string | undefined;
|
||||
const defaultLogo = path.join(webUiPath, 'homeclaw_logo.png');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
if (username) {
|
||||
const custom = path.join(getConfig().getConfigDir(), 'users', username, 'logo.png');
|
||||
if (fs.existsSync(custom)) return res.sendFile(custom);
|
||||
}
|
||||
res.sendFile(defaultLogo);
|
||||
});
|
||||
|
||||
app.use(express.static(webUiPath, { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } }));
|
||||
|
||||
// Serve code directory files for HTML preview (window.open)
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<!-- HEADER -->
|
||||
<header>
|
||||
<div class="logo">
|
||||
<img src="/homeclaw_logo.png" alt="🏠"> HomeClaw
|
||||
<img src="/homeclaw_logo.png" alt="🏠"> <span id="header-brand-name">HomeClaw</span>
|
||||
</div>
|
||||
|
||||
<div class="mode-toggle" style="margin-left:8px">
|
||||
@@ -334,7 +334,7 @@
|
||||
<div id="chat-messages">
|
||||
<div class="chat-welcome" id="chat-welcome">
|
||||
<div class="chat-welcome-icon"><img src="/homeclaw_logo.png" alt="🏠"></div>
|
||||
<h2>HomeClaw</h2>
|
||||
<h2 id="welcome-brand-name">HomeClaw</h2>
|
||||
<p>로컬 모델과 직접 대화하세요. API 키도, 클라우드도 없이 — 오직 나와 모델만.</p>
|
||||
<div class="hint">멀티스텝 에이전트 작업은 에이전트 모드로 전환하세요.</div>
|
||||
</div>
|
||||
|
||||
@@ -90,6 +90,8 @@ function setAgentSessionId(id) {
|
||||
}
|
||||
let agentSessionId = '';
|
||||
let currentUser = null;
|
||||
let userLogoUrl = '/homeclaw_logo.png';
|
||||
let brandName = 'HomeClaw';
|
||||
setAgentSessionId(generateSessionId()); // always fresh session on page load
|
||||
checkAuth(); // Check authentication on page load
|
||||
initTTSToggle();
|
||||
|
||||
@@ -418,8 +418,8 @@ function renderChatMessages() {
|
||||
if (chatHistory.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="chat-welcome" id="chat-welcome">
|
||||
<div class="chat-welcome-icon"><img src="/homeclaw_logo.png" alt="🏠"></div>
|
||||
<h2>HomeClaw</h2>
|
||||
<div class="chat-welcome-icon"><img src="${userLogoUrl}" alt="🏠"></div>
|
||||
<h2>${brandName}</h2>
|
||||
<p>Chat directly with your local model. No API keys, no cloud — just you and the model.</p>
|
||||
<div class="hint">Enable Agent mode when you want planning + tool execution in this chat.</div>
|
||||
</div>`;
|
||||
@@ -437,9 +437,9 @@ function renderChatMessages() {
|
||||
}
|
||||
return `
|
||||
<div class="msg ${msg.role}${pinClass}${selectedClass}${confirmedClass}"${clickHandler}>
|
||||
<div class="msg-avatar">${msg.role === 'user' ? 'U' : '<img src="/homeclaw_logo.png" style="width:48px;height:48px;border-radius:6px">'}</div>
|
||||
<div class="msg-avatar">${msg.role === 'user' ? 'U' : `<img src="${userLogoUrl}" style="width:48px;height:48px;border-radius:6px">`}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-role">${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? 'HomeClaw (Agent)' : 'HomeClaw')}${msg.role === 'ai' ? ` <button class="tts-btn" onclick="playTTSMsg(this,${idx})" title="음성으로 듣기">🔊</button>` : ''}</div>
|
||||
<div class="msg-role">${msg.role === 'user' ? 'You' : (msg.mode === 'agentic' ? `${brandName} (Agent)` : brandName)}${msg.role === 'ai' ? ` <button class="tts-btn" onclick="playTTSMsg(this,${idx})" title="음성으로 듣기">🔊</button>` : ''}</div>
|
||||
${msg.role === 'ai' ? renderAssistantContent(msg.content) : `<div class="msg-content">${renderMarkdown(msg.display !== undefined ? msg.display : msg.content, { hideDownload: true })}</div>`}
|
||||
${msg.role === 'ai' ? renderArtifacts(msg.artifacts) : ''}
|
||||
${msg.role === 'ai' ? renderFileDownloads(msg.steps, msg.processEntries, msg.content) : ''}
|
||||
@@ -462,9 +462,9 @@ function renderChatMessages() {
|
||||
: '';
|
||||
container.innerHTML += `
|
||||
<div class="msg ai" id="thinking-msg">
|
||||
<div class="msg-avatar"><img src="/homeclaw_logo.png" style="width:48px;height:48px;border-radius:6px"></div>
|
||||
<div class="msg-avatar"><img src="${userLogoUrl}" style="width:48px;height:48px;border-radius:6px"></div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-role">HomeClaw${useAgentMode ? ' (Agent)' : ''}</div>
|
||||
<div class="msg-role">${brandName}${useAgentMode ? ' (Agent)' : ''}</div>
|
||||
${currentPreflightStatus ? `<div class="msg-content" style="margin-bottom:6px;color:#26487e">${escHtml(currentPreflightStatus)}</div>` : ''}
|
||||
${progressHtml}
|
||||
<div class="thinking" style="align-items:center">
|
||||
|
||||
@@ -21,6 +21,19 @@ function clearLocalUserState(reason) {
|
||||
console.log(`[auth] cleared local session state (${reason})`);
|
||||
}
|
||||
|
||||
function applyUserBranding() {
|
||||
userLogoUrl = `/api/branding/logo?_=${Date.now()}`;
|
||||
brandName = (currentUser && currentUser.brandName) || 'HomeClaw';
|
||||
const headerLogo = document.querySelector('header .logo img');
|
||||
if (headerLogo) headerLogo.src = userLogoUrl;
|
||||
document.querySelectorAll('.chat-welcome-icon img').forEach((img) => { img.src = userLogoUrl; });
|
||||
const headerName = document.getElementById('header-brand-name');
|
||||
if (headerName) headerName.textContent = brandName;
|
||||
const welcomeName = document.getElementById('welcome-brand-name');
|
||||
if (welcomeName) welcomeName.textContent = brandName;
|
||||
if (typeof renderChatMessages === 'function') renderChatMessages();
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/status', { headers: authHeaders() });
|
||||
@@ -34,7 +47,8 @@ async function checkAuth() {
|
||||
return;
|
||||
}
|
||||
if (data.authenticated && data.username) {
|
||||
currentUser = { username: data.username, role: data.role };
|
||||
currentUser = { username: data.username, role: data.role, brandName: data.brandName };
|
||||
applyUserBranding();
|
||||
|
||||
// Reload chat sessions under the correct user-scoped key now that we know who is logged in.
|
||||
// This also migrates any data from the old non-namespaced key on first load after server restart.
|
||||
|
||||
Reference in New Issue
Block a user