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:
kim
2026-09-11 16:13:38 +09:00
co-authored by Claude Sonnet 5
parent aaf8dbfb33
commit c22377778e
7 changed files with 54 additions and 9 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 B

+29
View File
@@ -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)
+2 -2
View File
@@ -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>
+2
View File
@@ -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();
+6 -6
View File
@@ -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">
+15 -1
View File
@@ -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.