feat: Add host-based assistant avatars (Neko/Mochi)

- Add avatar field to hosts.json config for Neko and Mochi
- Create HostContext to provide host config to components
- Display host avatar and name in chat messages instead of generic Claude
- Add user avatar and first name to user messages
- Include avatar in /api/hosts endpoint response
- Fix appendFileSync import for debug logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-18 13:39:50 +01:00
parent 7156f1aaa0
commit 86a1d84ea1
14 changed files with 980 additions and 19 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

10
backend/public/claude.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ea580c;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<text x="50" y="68" font-family="Arial, sans-serif" font-size="50" font-weight="bold" fill="white" text-anchor="middle">C</text>
</svg>

After

Width:  |  Height:  |  Size: 510 B

14
backend/public/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/claude.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claude Web UI</title>
<script type="module" crossorigin src="/assets/index-DmNT3Myo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-4MVeF-MR.css">
</head>
<body class="bg-dark-950 text-dark-100">
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -4,7 +4,7 @@ import { createServer } from 'http';
import { spawn } from 'child_process';
import { v4 as uuidv4 } from 'uuid';
import cors from 'cors';
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, appendFileSync } from 'fs';
import { join, basename, extname } from 'path';
import multer from 'multer';
import session from 'express-session';
@@ -171,6 +171,7 @@ app.get('/api/hosts', requireAuth, (req, res) => {
id,
name: host.name,
description: host.description,
avatar: host.avatar,
color: host.color,
icon: host.icon,
connectionType: host.connection.type,
@@ -845,8 +846,7 @@ wss.on('connection', async (ws, req) => {
event
};
try {
const fs = await import('fs');
fs.appendFileSync(debugLogPath, JSON.stringify(debugEntry) + '\n');
appendFileSync(debugLogPath, JSON.stringify(debugEntry) + '\n');
} catch (e) {
// Ignore write errors
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { SessionProvider, useSessionManager } from './contexts/SessionContext';
import { HostProvider } from './contexts/HostContext';
import { Sidebar } from './components/Sidebar';
import { TabBar } from './components/TabBar';
import { ChatPanel } from './components/ChatPanel';
@@ -128,9 +129,11 @@ function AuthenticatedApp() {
}
return (
<SessionProvider>
<AppContent />
</SessionProvider>
<HostProvider>
<SessionProvider>
<AppContent />
</SessionProvider>
</HostProvider>
);
}

View File

@@ -235,6 +235,7 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
messages={session.messages || []}
isProcessing={session.isProcessing}
onSendMessage={handleSendMessage}
hostId={session.host}
/>
) : (
<WelcomeScreen session={session} onStart={start} />

View File

@@ -8,6 +8,8 @@ import {
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useAuth } from '../contexts/AuthContext';
import { useHosts } from '../contexts/HostContext';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
@@ -117,7 +119,10 @@ function SystemHints({ reminders, inline = false }) {
);
}
export const MessageList = memo(function MessageList({ messages, isProcessing, onSendMessage }) {
// Not using memo here - needs to re-render when HostContext updates
export function MessageList({ messages, isProcessing, onSendMessage, hostId }) {
const { hosts } = useHosts();
const hostConfig = hosts[hostId] || null;
const containerRef = useRef(null);
const messagesEndRef = useRef(null);
const [showScrollButton, setShowScrollButton] = useState(false);
@@ -228,7 +233,7 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
className="h-full overflow-y-auto p-4 space-y-4"
>
{processedMessages.map((message, index) => (
<Message key={`${message.type}-${message._originalIndex}-${message.timestamp || index}`} message={message} onSendMessage={onSendMessage} />
<Message key={`${message.type}-${message._originalIndex}-${message.timestamp || index}-${hostConfig?.avatar || 'no-avatar'}`} message={message} onSendMessage={onSendMessage} hostConfig={hostConfig} />
))}
{/* Processing indicator */}
@@ -260,10 +265,15 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
)}
</div>
);
});
}
const Message = memo(function Message({ message, onSendMessage }) {
function Message({ message, onSendMessage, hostConfig }) {
const { type, content, tool, input, timestamp, toolUseId, attachments } = message;
const { user } = useAuth();
// Assistant name and avatar from host config
const assistantName = hostConfig?.name || 'Claude';
const assistantAvatar = hostConfig?.avatar || '🤖';
// Memoize parsed system reminders and thinking for assistant messages
const parsedContent = useMemo(() => {
@@ -287,11 +297,19 @@ const Message = memo(function Message({ message, onSendMessage }) {
const fileCount = hasAttachments ? attachments.length - imageCount : 0;
return (
<div className="flex gap-3 message-enter">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4" />
</div>
{user?.avatar ? (
<img
src={user.avatar}
alt={user.name || 'You'}
className="w-8 h-8 rounded-lg flex-shrink-0 object-cover"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4" />
</div>
)}
<div className="max-w-[85%]">
<div className="text-xs text-dark-500 mb-1">You</div>
<div className="text-xs text-dark-500 mb-1">{user?.name?.split(' ')[0] || 'You'}</div>
<div className="bg-dark-800 rounded-lg rounded-tl-none p-3 text-dark-100 inline-block">
{/* Attachment badge */}
{hasAttachments && (
@@ -323,11 +341,19 @@ const Message = memo(function Message({ message, onSendMessage }) {
const { content: cleanContent, reminders, thinking } = parsedContent;
return (
<div className="flex gap-3 message-enter">
<div className="w-8 h-8 rounded-lg bg-orange-600 flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4" />
</div>
{assistantAvatar?.startsWith('/') ? (
<img
src={assistantAvatar}
alt={assistantName}
className="w-8 h-8 rounded-lg object-cover flex-shrink-0"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-orange-600 flex items-center justify-center flex-shrink-0 text-lg">
{assistantAvatar}
</div>
)}
<div className="max-w-[85%]">
<div className="text-xs text-dark-500 mb-1">Claude</div>
<div className="text-xs text-dark-500 mb-1">{assistantName}</div>
<div className="bg-dark-850 rounded-lg rounded-tl-none p-4 border border-dark-700 claude-message inline-block">
<ThinkingBlock thinking={thinking} />
<ReactMarkdown
@@ -408,7 +434,7 @@ const Message = memo(function Message({ message, onSendMessage }) {
};
return <div>{renderContent()}</div>;
});
}
// Tool configuration with icons, colors, and display logic
const TOOL_CONFIG = {

View File

@@ -0,0 +1,44 @@
import { createContext, useContext, useState, useEffect } from 'react';
const HostContext = createContext(null);
export function HostProvider({ children }) {
const [hosts, setHosts] = useState({});
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/hosts', { credentials: 'include' })
.then(res => res.json())
.then(data => {
const hostsMap = {};
(data.hosts || []).forEach(host => {
hostsMap[host.id] = host;
});
setHosts(hostsMap);
setLoading(false);
})
.catch(err => {
console.error('Failed to load hosts:', err);
setLoading(false);
});
}, []);
return (
<HostContext.Provider value={{ hosts, loading }}>
{children}
</HostContext.Provider>
);
}
export function useHosts() {
const context = useContext(HostContext);
if (!context) {
return { hosts: {}, loading: false };
}
return context;
}
export function useHost(hostId) {
const { hosts } = useHosts();
return hosts[hostId] || null;
}