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:
1
backend/public/assets/index-4MVeF-MR.css
Normal file
1
backend/public/assets/index-4MVeF-MR.css
Normal file
File diff suppressed because one or more lines are too long
431
backend/public/assets/index-D5ZTFezh.js
Normal file
431
backend/public/assets/index-D5ZTFezh.js
Normal file
File diff suppressed because one or more lines are too long
431
backend/public/assets/index-DmNT3Myo.js
Normal file
431
backend/public/assets/index-DmNT3Myo.js
Normal file
File diff suppressed because one or more lines are too long
10
backend/public/claude.svg
Normal file
10
backend/public/claude.svg
Normal 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
14
backend/public/index.html
Normal 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>
|
||||
BIN
backend/public/mochi-avatar.png
Normal file
BIN
backend/public/mochi-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
backend/public/neko-avatar.png
Normal file
BIN
backend/public/neko-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -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
|
||||
}
|
||||
|
||||
BIN
frontend/public/mochi-avatar.png
Normal file
BIN
frontend/public/mochi-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
frontend/public/neko-avatar.png
Normal file
BIN
frontend/public/neko-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -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 (
|
||||
<HostProvider>
|
||||
<SessionProvider>
|
||||
<AppContent />
|
||||
</SessionProvider>
|
||||
</HostProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
{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" />
|
||||
{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 = {
|
||||
|
||||
44
frontend/src/contexts/HostContext.jsx
Normal file
44
frontend/src/contexts/HostContext.jsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user