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 { 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
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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 (
|
||||||
|
<HostProvider>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
</HostProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{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">
|
<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" />
|
<User className="w-4 h-4" />
|
||||||
</div>
|
</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
|
||||||
|
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>
|
||||||
|
)}
|
||||||
<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 = {
|
||||||
|
|||||||
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