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

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;
}