-
-
-
-
+
+
+ {isUpdating ? (
+
+ ) : isConnected ? (
+
+ ) : null}
+
+
);
}
diff --git a/web/src/components/layouts/components/ChatToolbar/MobileMoreMenu.tsx b/web/src/components/layouts/components/ChatToolbar/MobileMoreMenu.tsx
index 86793050..6b7b3afa 100644
--- a/web/src/components/layouts/components/ChatToolbar/MobileMoreMenu.tsx
+++ b/web/src/components/layouts/components/ChatToolbar/MobileMoreMenu.tsx
@@ -1,19 +1,26 @@
/**
* Mobile More Menu
*
- * A popup menu shown on mobile with tool selector and MCP info.
+ * A popup menu shown on mobile with tool selector and MCP management.
*/
import McpIcon from "@/assets/McpIcon";
+import { cn } from "@/lib/utils";
import type { Agent } from "@/types/agents";
+import type { McpServer } from "@/types/mcp";
+import {
+ CheckIcon,
+ ChevronDownIcon,
+ Cog6ToothIcon,
+} from "@heroicons/react/24/outline";
import { AnimatePresence, motion } from "motion/react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { ToolSelector } from "./ToolSelector";
interface McpInfo {
- servers: Array<{
- id: string;
- tools?: Array<{ name: string }>;
- }>;
+ agent: Agent;
+ servers: McpServer[];
}
interface MobileMoreMenuProps {
@@ -21,6 +28,8 @@ interface MobileMoreMenuProps {
agent: Agent | null;
onUpdateAgent: (agent: Agent) => Promise
;
mcpInfo: McpInfo | null;
+ allMcpServers?: McpServer[];
+ onOpenSettings?: () => void;
sessionKnowledgeSetId?: string | null;
onUpdateSessionKnowledge?: (knowledgeSetId: string | null) => Promise;
}
@@ -30,14 +39,61 @@ export function MobileMoreMenu({
agent,
onUpdateAgent,
mcpInfo,
+ allMcpServers = [],
+ onOpenSettings,
sessionKnowledgeSetId,
onUpdateSessionKnowledge,
}: MobileMoreMenuProps) {
+ const { t } = useTranslation();
+ const [showMcpList, setShowMcpList] = useState(false);
+ const [isUpdating, setIsUpdating] = useState(null);
+
const handleUpdateAgent = async (updatedAgent: Agent) => {
await onUpdateAgent(updatedAgent);
// Don't close on toggle - let user configure multiple tools
};
+ // Get connected server IDs from agent
+ const connectedServerIds = new Set(
+ agent?.mcp_server_ids || agent?.mcp_servers?.map((s) => s.id) || [],
+ );
+
+ // Separate servers into connected and available
+ const connectedServers = allMcpServers.filter((server) =>
+ connectedServerIds.has(server.id),
+ );
+ const availableServers = allMcpServers.filter(
+ (server) => !connectedServerIds.has(server.id),
+ );
+
+ const totalTools =
+ mcpInfo?.servers.reduce(
+ (total, server) => total + (server.tools?.length || 0),
+ 0,
+ ) || 0;
+
+ const handleMcpServerToggle = async (serverId: string, connect: boolean) => {
+ if (!agent || isUpdating) return;
+
+ setIsUpdating(serverId);
+ try {
+ const currentIds =
+ agent.mcp_server_ids || agent.mcp_servers?.map((s) => s.id) || [];
+ const newIds = connect
+ ? [...currentIds, serverId]
+ : currentIds.filter((id) => id !== serverId);
+
+ await onUpdateAgent({
+ ...agent,
+ mcp_server_ids: newIds,
+ });
+ } catch (error) {
+ console.error("Failed to update MCP server:", error);
+ } finally {
+ setIsUpdating(null);
+ }
+ };
+
return (
{isOpen && (
@@ -64,23 +120,114 @@ export function MobileMoreMenu({
)}
- {/* MCP Tool Info */}
- {mcpInfo && (
-
-
-
-
- MCP Tools
-
- {mcpInfo.servers.length > 0 && (
-
- {mcpInfo.servers.reduce(
- (total, server) => total + (server.tools?.length || 0),
- 0,
+ {/* MCP Tool Section - Expandable */}
+ {agent && (
+
+
+
+ {/* Expandable MCP Server List */}
+
+ {showMcpList && (
+
+
+ {/* Empty State */}
+ {allMcpServers.length === 0 && (
+
+
+ {t(
+ "app.toolbar.mcpNoServers",
+ "No MCP servers configured",
+ )}
+
+
+
+ )}
+
+ {/* Connected Servers */}
+ {connectedServers.length > 0 && (
+
+
+ {t("app.toolbar.mcpConnected", "Connected")}
+
+
+ {connectedServers.map((server) => (
+
+ handleMcpServerToggle(server.id, false)
+ }
+ />
+ ))}
+
+
+ )}
+
+ {/* Available Servers */}
+ {availableServers.length > 0 && (
+
+
+ {t("app.toolbar.mcpAvailable", "Available")}
+
+
+ {availableServers.map((server) => (
+
+ handleMcpServerToggle(server.id, true)
+ }
+ />
+ ))}
+
+
+ )}
+
+
)}
-
+
)}
@@ -90,4 +237,64 @@ export function MobileMoreMenu({
);
}
+/**
+ * Mobile MCP Server toggle item
+ */
+interface MobileMcpServerItemProps {
+ server: McpServer;
+ isConnected: boolean;
+ isUpdating: boolean;
+ onToggle: () => void;
+}
+
+function MobileMcpServerItem({
+ server,
+ isConnected,
+ isUpdating,
+ onToggle,
+}: MobileMcpServerItemProps) {
+ const { t } = useTranslation();
+ const isOnline = server.status === "online";
+ const isDisabled = !isOnline || isUpdating;
+
+ return (
+
+ );
+}
+
export default MobileMoreMenu;
diff --git a/web/src/i18n/locales/en/app.json b/web/src/i18n/locales/en/app.json
index 7435f459..beea8f01 100644
--- a/web/src/i18n/locales/en/app.json
+++ b/web/src/i18n/locales/en/app.json
@@ -33,6 +33,12 @@
"knowledgeConnect": "Connect Knowledge Base",
"knowledgeDisconnect": "Disconnect",
"mcpTools": "MCP Tools Connected",
+ "mcpConnected": "Connected",
+ "mcpAvailable": "Available",
+ "mcpToolsCount": "tools",
+ "mcpOffline": "offline",
+ "mcpNoServers": "No MCP servers configured",
+ "mcpOpenSettings": "Open Settings",
"searchOff": "Off",
"searchOffDesc": "Do not use search",
"searchBuiltinDesc": "Use model's native search capability",
diff --git a/web/src/i18n/locales/zh/app.json b/web/src/i18n/locales/zh/app.json
index 2ec06249..9e689381 100644
--- a/web/src/i18n/locales/zh/app.json
+++ b/web/src/i18n/locales/zh/app.json
@@ -33,6 +33,12 @@
"knowledgeConnect": "连接知识库",
"knowledgeDisconnect": "断开连接",
"mcpTools": "MCP 工具已连接",
+ "mcpConnected": "已连接",
+ "mcpAvailable": "可用",
+ "mcpToolsCount": "个工具",
+ "mcpOffline": "离线",
+ "mcpNoServers": "未配置 MCP 服务器",
+ "mcpOpenSettings": "打开设置",
"searchOff": "关闭",
"searchOffDesc": "不使用搜索功能",
"searchBuiltinDesc": "使用模型原生搜索能力",
diff --git a/web/src/lib/Markdown.tsx b/web/src/lib/Markdown.tsx
index 9f1c8f02..f3555bbe 100644
--- a/web/src/lib/Markdown.tsx
+++ b/web/src/lib/Markdown.tsx
@@ -443,9 +443,9 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const isXyzenDownloadUrl = (src: string) =>
src.includes("/xyzen/api/v1/files/") && src.includes("/download");
-const MarkdownImage: React.FC
> = (
- props,
-) => {
+const MarkdownImageComponent: React.FC<
+ React.ImgHTMLAttributes
+> = (props) => {
const { src, alt, ...rest } = props;
const backendUrl = useXyzen((state) => state.backendUrl);
const token = useXyzen((state) => state.token);
@@ -581,6 +581,7 @@ const MarkdownImage: React.FC> = (
@@ -638,6 +639,14 @@ const MarkdownImage: React.FC> = (
);
};
+// Memoize MarkdownImage to prevent re-renders during streaming
+// Only re-render when src or alt changes
+const MarkdownImage = React.memo(
+ MarkdownImageComponent,
+ (prevProps, nextProps) =>
+ prevProps.src === nextProps.src && prevProps.alt === nextProps.alt,
+);
+
// Helper component to catch Escape key for image lightbox
function ImageLightboxEscapeCatcher({ onEscape }: { onEscape: () => void }) {
useEffect(() => {
@@ -761,9 +770,7 @@ const Markdown: React.FC = function Markdown(props) {
);
},
- img(props: React.ComponentPropsWithoutRef<"img">) {
- return ;
- },
+ img: MarkdownImage,
}),
[isDark],
);
diff --git a/web/src/service/fileService.ts b/web/src/service/fileService.ts
index 8fcb0333..296d9264 100644
--- a/web/src/service/fileService.ts
+++ b/web/src/service/fileService.ts
@@ -388,7 +388,8 @@ class FileService {
}
/**
- * Generate thumbnail URL for preview
+ * Generate thumbnail URL for preview using Canvas API
+ * Resizes image to max 160px dimension and outputs as JPEG for small file size
*/
generateThumbnail(file: File): Promise {
return new Promise((resolve, reject) => {
@@ -397,16 +398,53 @@ class FileService {
return;
}
- const reader = new FileReader();
- reader.onload = (e) => {
- if (e.target?.result) {
- resolve(e.target.result as string);
+ const MAX_SIZE = 160;
+ const objectUrl = URL.createObjectURL(file);
+ const img = new Image();
+
+ img.onload = () => {
+ URL.revokeObjectURL(objectUrl);
+
+ // Calculate scaled dimensions maintaining aspect ratio
+ let width = img.width;
+ let height = img.height;
+
+ if (width > height) {
+ if (width > MAX_SIZE) {
+ height = Math.round((height * MAX_SIZE) / width);
+ width = MAX_SIZE;
+ }
} else {
- reject(new Error("Failed to read file"));
+ if (height > MAX_SIZE) {
+ width = Math.round((width * MAX_SIZE) / height);
+ height = MAX_SIZE;
+ }
+ }
+
+ // Create canvas and draw scaled image
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ reject(new Error("Failed to get canvas context"));
+ return;
}
+
+ ctx.drawImage(img, 0, 0, width, height);
+
+ // Export as JPEG with 0.8 quality for small file size
+ const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
+ resolve(thumbnailUrl);
};
- reader.onerror = () => reject(new Error("Failed to read file"));
- reader.readAsDataURL(file);
+
+ img.onerror = () => {
+ URL.revokeObjectURL(objectUrl);
+ reject(new Error("Failed to load image"));
+ };
+
+ img.src = objectUrl;
});
}
}
diff --git a/web/src/store/slices/chatSlice.ts b/web/src/store/slices/chatSlice.ts
index e59b7df4..058949c0 100644
--- a/web/src/store/slices/chatSlice.ts
+++ b/web/src/store/slices/chatSlice.ts
@@ -402,27 +402,9 @@ export const createChatSlice: StateCreator<
* - If no session exists, creates one with a default topic
*/
activateChannelForAgent: async (agentId: string) => {
- const { channels, chatHistory, backendUrl } = get();
+ const { backendUrl } = get();
- // First, check if we already have a channel for this agent
- const existingChannel = Object.values(channels).find(
- (ch) => ch.agentId === agentId,
- );
-
- if (existingChannel) {
- // Already have a channel, activate it
- await get().activateChannel(existingChannel.id);
- return;
- }
-
- // Check chat history for existing topics with this agent
- const existingHistory = chatHistory.find((h) => h.sessionId === agentId);
- if (existingHistory) {
- await get().activateChannel(existingHistory.id);
- return;
- }
-
- // No existing channel, try to find or create a session for this agent
+ // Always fetch from backend to get the most recent topic
const token = authService.getToken();
if (!token) {
console.error("No authentication token available");
@@ -446,8 +428,8 @@ export const createChatSlice: StateCreator<
// Get the most recent topic for this session, or create one
if (session.topics && session.topics.length > 0) {
- // Activate the most recent topic
- const latestTopic = session.topics[session.topics.length - 1];
+ // Activate the most recent topic (backend returns topics ordered by updated_at descending)
+ const latestTopic = session.topics[0];
// Create channel if doesn't exist
const channel: ChatChannel = {