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 { spawn } from 'child_process';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import cors from 'cors'; 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 { join, basename, extname } from 'path';
import multer from 'multer'; import multer from 'multer';
import session from 'express-session'; import session from 'express-session';
@@ -171,6 +171,7 @@ app.get('/api/hosts', requireAuth, (req, res) => {
id, id,
name: host.name, name: host.name,
description: host.description, description: host.description,
avatar: host.avatar,
color: host.color, color: host.color,
icon: host.icon, icon: host.icon,
connectionType: host.connection.type, connectionType: host.connection.type,
@@ -845,8 +846,7 @@ wss.on('connection', async (ws, req) => {
event event
}; };
try { try {
const fs = await import('fs'); appendFileSync(debugLogPath, JSON.stringify(debugEntry) + '\n');
fs.appendFileSync(debugLogPath, JSON.stringify(debugEntry) + '\n');
} catch (e) { } catch (e) {
// Ignore write errors // 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 { useState, useCallback, useEffect } from 'react';
import { AuthProvider, useAuth } from './contexts/AuthContext'; import { AuthProvider, useAuth } from './contexts/AuthContext';
import { SessionProvider, useSessionManager } from './contexts/SessionContext'; import { SessionProvider, useSessionManager } from './contexts/SessionContext';
import { HostProvider } from './contexts/HostContext';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { TabBar } from './components/TabBar'; import { TabBar } from './components/TabBar';
import { ChatPanel } from './components/ChatPanel'; import { ChatPanel } from './components/ChatPanel';
@@ -128,9 +129,11 @@ function AuthenticatedApp() {
} }
return ( return (
<SessionProvider> <HostProvider>
<AppContent /> <SessionProvider>
</SessionProvider> <AppContent />
</SessionProvider>
</HostProvider>
); );
} }

View File

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

View File

@@ -8,6 +8,8 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { useAuth } from '../contexts/AuthContext';
import { useHosts } from '../contexts/HostContext';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; 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 containerRef = useRef(null);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const [showScrollButton, setShowScrollButton] = useState(false); 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" className="h-full overflow-y-auto p-4 space-y-4"
> >
{processedMessages.map((message, index) => ( {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 */} {/* Processing indicator */}
@@ -260,10 +265,15 @@ export const MessageList = memo(function MessageList({ messages, isProcessing, o
)} )}
</div> </div>
); );
}); }
const Message = memo(function Message({ message, onSendMessage }) { function Message({ message, onSendMessage, hostConfig }) {
const { type, content, tool, input, timestamp, toolUseId, attachments } = message; 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 // Memoize parsed system reminders and thinking for assistant messages
const parsedContent = useMemo(() => { const parsedContent = useMemo(() => {
@@ -287,11 +297,19 @@ const Message = memo(function Message({ message, onSendMessage }) {
const fileCount = hasAttachments ? attachments.length - imageCount : 0; const fileCount = hasAttachments ? attachments.length - imageCount : 0;
return ( return (
<div className="flex gap-3 message-enter"> <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?.avatar ? (
<User className="w-4 h-4" /> <img
</div> 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="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"> <div className="bg-dark-800 rounded-lg rounded-tl-none p-3 text-dark-100 inline-block">
{/* Attachment badge */} {/* Attachment badge */}
{hasAttachments && ( {hasAttachments && (
@@ -323,11 +341,19 @@ const Message = memo(function Message({ message, onSendMessage }) {
const { content: cleanContent, reminders, thinking } = parsedContent; const { content: cleanContent, reminders, thinking } = parsedContent;
return ( return (
<div className="flex gap-3 message-enter"> <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"> {assistantAvatar?.startsWith('/') ? (
<Bot className="w-4 h-4" /> <img
</div> 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="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"> <div className="bg-dark-850 rounded-lg rounded-tl-none p-4 border border-dark-700 claude-message inline-block">
<ThinkingBlock thinking={thinking} /> <ThinkingBlock thinking={thinking} />
<ReactMarkdown <ReactMarkdown
@@ -408,7 +434,7 @@ const Message = memo(function Message({ message, onSendMessage }) {
}; };
return <div>{renderContent()}</div>; return <div>{renderContent()}</div>;
}); }
// Tool configuration with icons, colors, and display logic // Tool configuration with icons, colors, and display logic
const TOOL_CONFIG = { 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;
}