mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 00:48:07 +00:00
client refactoring
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { themes } from './theme';
|
||||
import { useAppSelector } from './store/hooks';
|
||||
import App from './App.tsx';
|
||||
|
||||
export default function ThemedApp() {
|
||||
const themeId = useAppSelector((s) => s.theme.themeId);
|
||||
return (
|
||||
<ThemeProvider theme={themes[themeId]}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './store/store';
|
||||
import ThemedApp from './ThemedApp.tsx';
|
||||
import { AppProviders } from './providers';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Provider store={store}>
|
||||
<ThemedApp />
|
||||
</Provider>
|
||||
<AppProviders>
|
||||
<App />
|
||||
</AppProviders>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { store } from '../store/store';
|
||||
import { themes } from '../theme';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
|
||||
function ThemeBridge({ children }: { children: ReactNode }) {
|
||||
const themeId = useAppSelector((s) => s.theme.themeId);
|
||||
return (
|
||||
<ThemeProvider theme={themes[themeId]}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ThemeBridge>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</ThemeBridge>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AppProviders } from './AppProviders';
|
||||
@@ -3,23 +3,3 @@ import type { AppDispatch, RootState } from './store';
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
||||
|
||||
/**
|
||||
* Hook to extract validation errors from RTK Query error
|
||||
* Returns typed field errors or null
|
||||
*/
|
||||
export function useValidationErrors(error: unknown): Record<string, string[]> | null {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'status' in error &&
|
||||
typeof (error as { status: unknown }).status === 'number' &&
|
||||
'data' in error &&
|
||||
typeof (error as { data: unknown }).data === 'object' &&
|
||||
(error as { data: unknown }).data !== null &&
|
||||
error.status === 422
|
||||
) {
|
||||
return (error as { data: Record<string, string[]> }).data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
export { store } from './store';
|
||||
export type { RootState, AppDispatch } from './store';
|
||||
export { useAppDispatch, useAppSelector } from './hooks';
|
||||
export * from '../../features/auth/api';
|
||||
export * from '../../entities/user/api';
|
||||
export * from '../../entities/agent/api';
|
||||
export * from '../../entities/conversation/api';
|
||||
export * from '../../entities/message/api';
|
||||
export { logout } from '../../features/auth/slice';
|
||||
export { setTheme } from '../../features/theme/slice';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { createLogger } from 'redux-logger';
|
||||
import { baseApi } from '../../shared/api/baseApi';
|
||||
import authReducer from '../../features/auth/slice';
|
||||
import themeReducer from '../../features/theme/slice';
|
||||
import { baseApi } from '../../shared/api';
|
||||
import { authReducer } from '../../features/auth';
|
||||
import { themeReducer } from '../../features/theme';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './api';
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './api';
|
||||
export { default as ChatRow } from './ui/ChatRow';
|
||||
export { default as AuthRow } from './ui/AuthRow';
|
||||
export { default as providerEmoji } from './ui/providerEmoji';
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Box, Typography, Chip } from '@mui/material';
|
||||
import type { ChannelAuth } from '../../entities/channel/api';
|
||||
import type { ChannelAuth } from '../api';
|
||||
import providerEmoji from './providerEmoji';
|
||||
|
||||
export default function AuthRow({ profile }: { profile: ChannelAuth }) {
|
||||
+5
-6
@@ -1,15 +1,14 @@
|
||||
import { Box, Typography, Chip, IconButton } from '@mui/material';
|
||||
import { Delete } from '@mui/icons-material';
|
||||
import type { ChannelChat } from '../../entities/channel/api';
|
||||
import type { ChannelChat } from '../api';
|
||||
import providerEmoji from './providerEmoji';
|
||||
|
||||
export default function ChatRow({
|
||||
channel,
|
||||
onRemove,
|
||||
}: {
|
||||
interface ChatRowProps {
|
||||
channel: ChannelChat;
|
||||
onRemove: (name: string) => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
export default function ChatRow({ channel, onRemove }: ChatRowProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
export { default as ConversationItem } from './ui/ConversationItem';
|
||||
+2
-5
@@ -11,11 +11,8 @@ import {
|
||||
} from '@mui/material';
|
||||
import { ChatBubbleOutline, DeleteOutline, Edit, Check } from '@mui/icons-material';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import {
|
||||
useUpdateConversationMutation,
|
||||
useDeleteConversationMutation,
|
||||
} from '../../entities/conversation/api';
|
||||
import DeleteButton from '../../shared/ui/DeleteButton';
|
||||
import { DeleteButton } from '../../../shared/ui';
|
||||
import { useUpdateConversationMutation, useDeleteConversationMutation } from '../api';
|
||||
|
||||
interface ConversationItemProps {
|
||||
agentId: string;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
export { default as CronRow } from './ui/CronRow';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Box, Typography, Chip, CircularProgress, Switch, IconButton, Tooltip } from '@mui/material';
|
||||
import { Delete, Error as ErrorIcon } from '@mui/icons-material';
|
||||
import { useToggleCronJobMutation, type CronJob } from '../../entities/cron/api';
|
||||
import { useToggleCronJobMutation, type CronJob } from '../api';
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
if (ms >= 86400000) return `${Math.round(ms / 86400000)}d`;
|
||||
@@ -32,13 +32,12 @@ function relativeTime(ms: number): string {
|
||||
return `${Math.floor(diff / 86400000)}d ago`;
|
||||
}
|
||||
|
||||
export default function CronRow({
|
||||
job,
|
||||
onRemove,
|
||||
}: {
|
||||
interface CronRowProps {
|
||||
job: CronJob;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
export default function CronRow({ job, onRemove }: CronRowProps) {
|
||||
const [toggleCron] = useToggleCronJobMutation();
|
||||
const [localEnabled, setLocalEnabled] = useState(job.enabled);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './api';
|
||||
export { default as MessageBubble } from './ui/MessageBubble';
|
||||
export { default as ThinkingBlock } from './ui/ThinkingBlock';
|
||||
export { default as FileAttachments } from './ui/FileAttachments';
|
||||
export type { MessageLike } from './ui/MessageBubble';
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { Box, Chip, useTheme } from '@mui/material';
|
||||
import { InsertDriveFileOutlined } from '@mui/icons-material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import type { MessageFile } from '../../entities/message/api';
|
||||
import { API_BASE_URL } from '../../shared/api/baseApi';
|
||||
import { API_BASE_URL } from '../../../shared/api';
|
||||
import type { MessageFile } from '../api';
|
||||
|
||||
function formatFileSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
+2
-4
@@ -1,12 +1,10 @@
|
||||
import { useState, memo, useCallback } from 'react';
|
||||
import { Box, Paper, Typography, IconButton, useTheme } from '@mui/material';
|
||||
import { DeleteOutline, ContentCopy, Done } from '@mui/icons-material';
|
||||
import { useDeleteMessageMutation } from '../../entities/message/api';
|
||||
import DeleteButton from '../../shared/ui/DeleteButton';
|
||||
import MarkdownContent from '../../shared/ui/MarkdownContent';
|
||||
import { DeleteButton, MarkdownContent } from '../../../shared/ui';
|
||||
import ThinkingBlock from './ThinkingBlock';
|
||||
import FileAttachments from './FileAttachments';
|
||||
import type { Message, MessageFile } from '../../entities/message/api';
|
||||
import { useDeleteMessageMutation, type Message, type MessageFile } from '../api';
|
||||
|
||||
export type MessageLike =
|
||||
| Message
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Collapse, Typography } from '@mui/material';
|
||||
import { ExpandMore } from '@mui/icons-material';
|
||||
import MarkdownContent from '../../shared/ui/MarkdownContent';
|
||||
import { MarkdownContent } from '../../../shared/ui';
|
||||
|
||||
interface ThinkingBlockProps {
|
||||
text: string;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
export { default as PluginRow } from './ui/PluginRow';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Box, Typography, Chip, CircularProgress, Switch } from '@mui/material';
|
||||
import { useTogglePluginMutation, type PluginInfo } from '../../entities/plugin/api';
|
||||
import { useTogglePluginMutation, type PluginInfo } from '../api';
|
||||
|
||||
function originLabel(origin: string): string {
|
||||
if (origin === 'bundled') return 'built-in';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
export { default as SkillRow } from './ui/SkillRow';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Typography, Chip, Tooltip } from '@mui/material';
|
||||
import type { SkillInfo } from '../../entities/skill/api';
|
||||
import type { SkillInfo } from '../api';
|
||||
|
||||
function sourceLabel(source: string, bundled: boolean): string {
|
||||
if (bundled) return 'built-in';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './api';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
export { default as UserRow } from './ui/UserRow';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Typography, IconButton, Chip } from '@mui/material';
|
||||
import { Edit, Delete } from '@mui/icons-material';
|
||||
import type { User } from '../../entities/user/api';
|
||||
import type { User } from '../api';
|
||||
|
||||
function relativeDate(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
@@ -10,15 +10,13 @@ function relativeDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
export default function UserRow({
|
||||
user,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
interface UserRowProps {
|
||||
user: User;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
export default function UserRow({ user, onEdit, onDelete }: UserRowProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CreateAgentForm } from './ui/CreateAgentForm';
|
||||
export type { CreatedAgent } from './ui/CreateAgentForm';
|
||||
+12
-11
@@ -9,21 +9,22 @@ import {
|
||||
Divider,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { useCreateAgentMutation } from '../../../../entities/agent';
|
||||
|
||||
interface CreateAgentFormProps {
|
||||
onCreated: (agent: { slug: string; dbId: string; interactive: boolean }) => void;
|
||||
onCancel: () => void;
|
||||
createAgent: (args: { name: string; interactive: boolean }) => { unwrap: () => Promise<any> };
|
||||
isCreating: boolean;
|
||||
export interface CreatedAgent {
|
||||
slug: string;
|
||||
dbId: string;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
export default function CreateAgentForm({
|
||||
onCreated,
|
||||
onCancel,
|
||||
createAgent,
|
||||
isCreating,
|
||||
}: CreateAgentFormProps) {
|
||||
interface CreateAgentFormProps {
|
||||
onCreated: (agent: CreatedAgent) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function CreateAgentForm({ onCreated, onCancel }: CreateAgentFormProps) {
|
||||
const { sidebar } = useTheme().palette;
|
||||
const [createAgent, { isLoading: isCreating }] = useCreateAgentMutation();
|
||||
const [name, setName] = useState('');
|
||||
const [mode, setMode] = useState<'quick' | 'configure'>('quick');
|
||||
const [error, setError] = useState('');
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TerminalPanel } from './ui/TerminalPanel';
|
||||
+2
-2
@@ -12,8 +12,8 @@ import { Close, CheckCircle } from '@mui/icons-material';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { API_BASE_URL } from '../../shared/api/baseApi';
|
||||
import { useSyncAgentsMutation, useDeleteAgentMutation } from '../../entities/agent/api';
|
||||
import { API_BASE_URL } from '../../../../shared/api';
|
||||
import { useSyncAgentsMutation, useDeleteAgentMutation } from '../../../../entities/agent';
|
||||
|
||||
interface TerminalPanelProps {
|
||||
agentName: string;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './api';
|
||||
export { logout } from './slice';
|
||||
export { default as authReducer } from './slice';
|
||||
export { default as PrivateRoute } from './PrivateRoute';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AddChannelForm } from './ui/AddChannelForm';
|
||||
+2
-2
@@ -17,9 +17,9 @@ import {
|
||||
useAddChannelMutation,
|
||||
CHANNEL_PROVIDERS,
|
||||
CHANNEL_FIELDS,
|
||||
providerEmoji,
|
||||
type ChannelProvider,
|
||||
} from '../../entities/channel/api';
|
||||
import providerEmoji from './providerEmoji';
|
||||
} from '../../../../entities/channel';
|
||||
|
||||
const inputSx = {
|
||||
mb: 1.5,
|
||||
@@ -1,297 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { MessageFile, Message } from '../../entities/message/api';
|
||||
import { API_BASE_URL, baseApi } from '../../shared/api/baseApi';
|
||||
import { useAppDispatch } from '../../app/store/hooks';
|
||||
import { useGetMessagesQuery, usePollMessagesQuery, messagesApi } from '../../entities/message/api';
|
||||
import type { MessagesResponse } from '../../entities/message/api';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
export interface ChatState {
|
||||
messages: ReturnType<typeof useGetMessagesQuery>['data'] extends infer D
|
||||
? D extends { items: infer I }
|
||||
? I extends (infer M)[]
|
||||
? M[]
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
hasMore: boolean;
|
||||
isStreaming: boolean;
|
||||
streamingText: string;
|
||||
streamingThinking: string;
|
||||
pendingUserText: string;
|
||||
pendingFilesPreviews: MessageFile[];
|
||||
send: (text: string, files: File[]) => Promise<void>;
|
||||
loadMore: () => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
handleScroll: () => void;
|
||||
}
|
||||
|
||||
export default function useChat(conversationId: string | undefined) {
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [streamingThinking, setStreamingThinking] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [pendingUserText, setPendingUserText] = useState('');
|
||||
const [pendingFilesPreviews, setPendingFilesPreviews] = useState<MessageFile[]>([]);
|
||||
const [loadMoreCursor, setLoadMoreCursor] = useState<string | undefined>();
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isLoadingMore = useRef(false);
|
||||
const prevScrollHeight = useRef(0);
|
||||
const initialScrollDone = useRef(false);
|
||||
const lastConvId = useRef(conversationId);
|
||||
const scrollTickRef = useRef(0);
|
||||
const lastMergedPollTs = useRef<string | undefined>(undefined);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data, isLoading, isFetching, refetch } = useGetMessagesQuery(
|
||||
{ conversationId: conversationId!, before: loadMoreCursor },
|
||||
{ skip: !conversationId }
|
||||
);
|
||||
|
||||
const messages = useMemo(() => data?.items ?? [], [data?.items]);
|
||||
const hasMore = (data as MessagesResponse | undefined)?.hasMore ?? false;
|
||||
|
||||
// Polling: only fetch messages that are newer than the latest one we have.
|
||||
// Skip while streaming so the SSE flow owns the update.
|
||||
const lastMessageTs = messages.length > 0 ? messages[messages.length - 1].createdAt : undefined;
|
||||
|
||||
const { data: pollData } = usePollMessagesQuery(
|
||||
{ conversationId: conversationId!, after: lastMessageTs },
|
||||
{
|
||||
skip: !conversationId || isStreaming || isLoading,
|
||||
pollingInterval: POLL_INTERVAL_MS,
|
||||
refetchOnMountOrArgChange: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Merge new polled items into the messages cache + trigger auto-scroll.
|
||||
useEffect(() => {
|
||||
if (!conversationId || !pollData || pollData.items.length === 0) return;
|
||||
|
||||
// Dedup by the timestamp of the newest polled message: avoids re-merging
|
||||
// the same response until new data arrives.
|
||||
const newestTs = pollData.items[pollData.items.length - 1].createdAt;
|
||||
if (lastMergedPollTs.current === newestTs) return;
|
||||
lastMergedPollTs.current = newestTs;
|
||||
|
||||
dispatch(
|
||||
messagesApi.util.updateQueryData(
|
||||
'getMessages',
|
||||
{ conversationId, before: undefined },
|
||||
(draft) => {
|
||||
const existing = new Set(draft.items.map((m: Message) => m._id));
|
||||
const additions = pollData.items.filter((m) => !existing.has(m._id));
|
||||
if (additions.length === 0) return;
|
||||
draft.items = [...draft.items, ...additions];
|
||||
draft.total = draft.items.length;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
if (distanceFromBottom < 200) {
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 50);
|
||||
}
|
||||
}, [pollData, conversationId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastConvId.current !== conversationId) {
|
||||
lastConvId.current = conversationId;
|
||||
initialScrollDone.current = false;
|
||||
lastMergedPollTs.current = undefined;
|
||||
}
|
||||
if (isLoadingMore.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight - prevScrollHeight.current;
|
||||
prevScrollHeight.current = 0;
|
||||
}
|
||||
isLoadingMore.current = false;
|
||||
return;
|
||||
}
|
||||
if (!initialScrollDone.current && messages.length > 0) {
|
||||
initialScrollDone.current = true;
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'instant' });
|
||||
}, 80);
|
||||
return;
|
||||
}
|
||||
if (pendingUserText || pendingFilesPreviews.length > 0) {
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 150);
|
||||
}
|
||||
}, [messages, conversationId, pendingUserText, pendingFilesPreviews]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamingText && !streamingThinking) return;
|
||||
const now = Date.now();
|
||||
if (now - scrollTickRef.current < 200) return;
|
||||
scrollTickRef.current = now;
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [streamingText, streamingThinking]);
|
||||
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort();
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
setStreamingThinking('');
|
||||
setPendingUserText('');
|
||||
setPendingFilesPreviews([]);
|
||||
setLoadMoreCursor(undefined);
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || isLoadingMore.current || isFetching || !hasMore || isStreaming) return;
|
||||
if (container.scrollTop < 150 && messages.length > 0) {
|
||||
isLoadingMore.current = true;
|
||||
prevScrollHeight.current = container.scrollHeight;
|
||||
setLoadMoreCursor(messages[0].createdAt);
|
||||
}
|
||||
}, [isFetching, hasMore, isStreaming, messages]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, files: File[]) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && files.length === 0) || !conversationId || isStreaming) return;
|
||||
|
||||
const previews: MessageFile[] = files.map((f) => ({
|
||||
filename: f.name,
|
||||
originalName: f.name,
|
||||
mimetype: f.type,
|
||||
size: f.size,
|
||||
url: URL.createObjectURL(f),
|
||||
}));
|
||||
|
||||
setPendingUserText(trimmed);
|
||||
setPendingFilesPreviews(previews);
|
||||
setStreamingText('');
|
||||
setStreamingThinking('');
|
||||
setIsStreaming(true);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('conversationId', conversationId);
|
||||
if (trimmed) form.append('text', trimmed);
|
||||
files.forEach((f) => form.append('files', f));
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/message/chat`, {
|
||||
method: 'POST',
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: form,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
console.error('Chat request failed:', res.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let lineBuf = '';
|
||||
let accText = '';
|
||||
let accThinking = '';
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (!line.startsWith('data: ')) return;
|
||||
const jsonStr = line.slice(6).trim();
|
||||
if (!jsonStr || jsonStr === '[DONE]') return;
|
||||
try {
|
||||
const event = JSON.parse(jsonStr);
|
||||
if (event.type === 'response.output_text.delta' && event.delta) {
|
||||
accText += event.delta;
|
||||
setStreamingText(accText);
|
||||
} else if (event.type === 'response.thinking.delta' && event.delta) {
|
||||
accThinking += event.delta;
|
||||
setStreamingThinking(accThinking);
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
if (lineBuf.trim()) processLine(lineBuf);
|
||||
break;
|
||||
}
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
lineBuf += chunk;
|
||||
const parts = lineBuf.split('\n');
|
||||
lineBuf = parts.pop()!;
|
||||
parts.forEach(processLine);
|
||||
}
|
||||
|
||||
await refetch();
|
||||
if (messages.length === 0) {
|
||||
dispatch(baseApi.util.invalidateTags(['Conversation']));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
console.error('Stream error:', err);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
setStreamingThinking('');
|
||||
setPendingUserText('');
|
||||
setPendingFilesPreviews((prev) => {
|
||||
prev.forEach((f) => URL.revokeObjectURL(f.url));
|
||||
return [];
|
||||
});
|
||||
abortRef.current = null;
|
||||
}
|
||||
},
|
||||
[conversationId, isStreaming, refetch, dispatch, messages.length]
|
||||
);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (messages.length > 0) {
|
||||
isLoadingMore.current = true;
|
||||
prevScrollHeight.current = scrollContainerRef.current?.scrollHeight ?? 0;
|
||||
setLoadMoreCursor(messages[0].createdAt);
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
isFetching,
|
||||
hasMore,
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
pendingUserText,
|
||||
pendingFilesPreviews,
|
||||
send,
|
||||
loadMore,
|
||||
scrollContainerRef,
|
||||
messagesEndRef,
|
||||
handleScroll,
|
||||
loadMoreCursor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AddCronForm } from './ui/AddCronForm';
|
||||
+3
-3
@@ -14,9 +14,9 @@ import {
|
||||
Autocomplete,
|
||||
} from '@mui/material';
|
||||
import { Close } from '@mui/icons-material';
|
||||
import { useAddCronJobMutation } from '../../entities/cron/api';
|
||||
import { useGetAgentsQuery } from '../../entities/agent/api';
|
||||
import { useGetConversationsQuery } from '../../entities/conversation/api';
|
||||
import { useAddCronJobMutation } from '../../../../entities/cron';
|
||||
import { useGetAgentsQuery } from '../../../../entities/agent';
|
||||
import { useGetConversationsQuery } from '../../../../entities/conversation';
|
||||
|
||||
type ScheduleKind = 'cron' | 'every' | 'at';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useSendMessage } from './model/useSendMessage';
|
||||
export type { SendMessageState } from './model/useSendMessage';
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useAppDispatch } from '../../../../app/store/hooks';
|
||||
import { API_BASE_URL, baseApi } from '../../../../shared/api';
|
||||
import { useGetMessagesQuery, type MessageFile } from '../../../../entities/message';
|
||||
|
||||
interface UseSendMessageArgs {
|
||||
conversationId: string | undefined;
|
||||
refetch: ReturnType<typeof useGetMessagesQuery>['refetch'];
|
||||
hasMessages: boolean;
|
||||
}
|
||||
|
||||
export interface SendMessageState {
|
||||
isStreaming: boolean;
|
||||
streamingText: string;
|
||||
streamingThinking: string;
|
||||
pendingUserText: string;
|
||||
pendingFilesPreviews: MessageFile[];
|
||||
send: (text: string, files: File[]) => Promise<void>;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the fetch/stream lifecycle for sending a chat message.
|
||||
* Keeps UI-facing state (streaming text, pending previews) local.
|
||||
*/
|
||||
export function useSendMessage({
|
||||
conversationId,
|
||||
refetch,
|
||||
hasMessages,
|
||||
}: UseSendMessageArgs): SendMessageState {
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [streamingThinking, setStreamingThinking] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [pendingUserText, setPendingUserText] = useState('');
|
||||
const [pendingFilesPreviews, setPendingFilesPreviews] = useState<MessageFile[]>([]);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const abort = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, files: File[]) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && files.length === 0) || !conversationId || isStreaming) return;
|
||||
|
||||
const previews: MessageFile[] = files.map((f) => ({
|
||||
filename: f.name,
|
||||
originalName: f.name,
|
||||
mimetype: f.type,
|
||||
size: f.size,
|
||||
url: URL.createObjectURL(f),
|
||||
}));
|
||||
|
||||
setPendingUserText(trimmed);
|
||||
setPendingFilesPreviews(previews);
|
||||
setStreamingText('');
|
||||
setStreamingThinking('');
|
||||
setIsStreaming(true);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('conversationId', conversationId);
|
||||
if (trimmed) form.append('text', trimmed);
|
||||
files.forEach((f) => form.append('files', f));
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/message/chat`, {
|
||||
method: 'POST',
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: form,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
console.error('Chat request failed:', res.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let lineBuf = '';
|
||||
let accText = '';
|
||||
let accThinking = '';
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (!line.startsWith('data: ')) return;
|
||||
const jsonStr = line.slice(6).trim();
|
||||
if (!jsonStr || jsonStr === '[DONE]') return;
|
||||
try {
|
||||
const event = JSON.parse(jsonStr);
|
||||
if (event.type === 'response.output_text.delta' && event.delta) {
|
||||
accText += event.delta;
|
||||
setStreamingText(accText);
|
||||
} else if (event.type === 'response.thinking.delta' && event.delta) {
|
||||
accThinking += event.delta;
|
||||
setStreamingThinking(accThinking);
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
if (lineBuf.trim()) processLine(lineBuf);
|
||||
break;
|
||||
}
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
lineBuf += chunk;
|
||||
const parts = lineBuf.split('\n');
|
||||
lineBuf = parts.pop()!;
|
||||
parts.forEach(processLine);
|
||||
}
|
||||
|
||||
await refetch();
|
||||
if (!hasMessages) {
|
||||
dispatch(baseApi.util.invalidateTags(['Conversation']));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
console.error('Stream error:', err);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingText('');
|
||||
setStreamingThinking('');
|
||||
setPendingUserText('');
|
||||
setPendingFilesPreviews((prev) => {
|
||||
prev.forEach((f) => URL.revokeObjectURL(f.url));
|
||||
return [];
|
||||
});
|
||||
abortRef.current = null;
|
||||
}
|
||||
},
|
||||
[conversationId, isStreaming, refetch, dispatch, hasMessages]
|
||||
);
|
||||
|
||||
return {
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
pendingUserText,
|
||||
pendingFilesPreviews,
|
||||
send,
|
||||
abort,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} from '@mui/material';
|
||||
import { Palette } from '@mui/icons-material';
|
||||
import { themeConfigs, type ThemeId } from '../../app/theme';
|
||||
import { useAppDispatch, useAppSelector, setTheme } from '../../app/store';
|
||||
import { useAppDispatch, useAppSelector } from '../../app/store';
|
||||
import { setTheme } from './slice';
|
||||
|
||||
const themeEntries = Object.entries(themeConfigs) as [ThemeId, typeof themeConfigs[ThemeId]][];
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { setTheme } from './slice';
|
||||
export { default as themeReducer } from './slice';
|
||||
export { default as ThemePicker } from './ThemePicker';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as UpdateBanner } from './ui/UpdateBanner';
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Box, CircularProgress, useTheme } from '@mui/material';
|
||||
import { SystemUpdateAlt } from '@mui/icons-material';
|
||||
import { useCheckUpdateQuery, useApplyUpdateMutation } from '../../entities/update/api';
|
||||
import { API_BASE_URL } from '../../shared/api/baseApi';
|
||||
import { API_BASE_URL } from '../../../../shared/api';
|
||||
import { useCheckUpdateQuery, useApplyUpdateMutation } from '../../../../entities/update';
|
||||
|
||||
export default function UpdateBanner() {
|
||||
const theme = useTheme();
|
||||
@@ -0,0 +1 @@
|
||||
export { default as UserForm } from './ui/UserForm';
|
||||
+5
-6
@@ -6,7 +6,7 @@ import {
|
||||
useGetUserQuery,
|
||||
useCreateUserMutation,
|
||||
useUpdateUserMutation,
|
||||
} from '../../entities/user/api';
|
||||
} from '../../../../entities/user';
|
||||
|
||||
type FieldErrors = Record<string, string[]>;
|
||||
|
||||
@@ -28,13 +28,12 @@ const inputSx = {
|
||||
|
||||
const emptyValues = { name: '', lastName: '', email: '', password: '', phone: '' };
|
||||
|
||||
export default function UserForm({
|
||||
userId,
|
||||
onDone,
|
||||
}: {
|
||||
interface UserFormProps {
|
||||
userId: string | null;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
export default function UserForm({ userId, onDone }: UserFormProps) {
|
||||
const isEdit = Boolean(userId);
|
||||
const { data: existing, isLoading: loadingUser } = useGetUserQuery(userId!, { skip: !userId });
|
||||
const [createUser, { isLoading: isCreating }] = useCreateUserMutation();
|
||||
@@ -1,86 +1,8 @@
|
||||
import { Link, useParams, useSearchParams } from 'react-router';
|
||||
import { Box, IconButton, Typography, CircularProgress } from '@mui/material';
|
||||
import { ArrowBack } from '@mui/icons-material';
|
||||
import { useGetAgentQuery } from '../../app/store';
|
||||
import WorkspaceFileTabs from './WorkspaceFileTabs';
|
||||
import { useParams } from 'react-router';
|
||||
import { Workspace } from '../../widgets/workspace';
|
||||
|
||||
export default function AgentWorkspacePage() {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnConv = searchParams.get('return');
|
||||
const { data: agent, isLoading } = useGetAgentQuery(agentId!, { skip: !agentId });
|
||||
|
||||
const backHref = agentId && returnConv ? `/agent/${agentId}/chat/${returnConv}` : '/';
|
||||
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading && !agent) {
|
||||
return (
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}
|
||||
>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: { xs: '100vh', md: 'calc(100vh - 48px)' },
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: { xs: 1.5, md: 2 },
|
||||
py: 1.5,
|
||||
pl: { xs: 7, md: 2 },
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
component={Link}
|
||||
to={backHref}
|
||||
size="small"
|
||||
aria-label="Back"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<ArrowBack sx={{ fontSize: 22 }} />
|
||||
</IconButton>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="h6" fontWeight={600} noWrap>
|
||||
{agent?.name ?? 'Agent'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Workspace files
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'auto',
|
||||
px: { xs: 2, md: 3 },
|
||||
py: 2,
|
||||
}}
|
||||
>
|
||||
<WorkspaceFileTabs agentId={agentId} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
if (!agentId) return null;
|
||||
return <Workspace agentId={agentId} />;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import useChat from '../../features/chat/useChat';
|
||||
import ChatHeader from '../../widgets/chat/ChatHeader';
|
||||
import SessionSettingsBar from '../../widgets/chat/SessionSettingsBar';
|
||||
import MessageList from '../../widgets/chat/MessageList';
|
||||
import ChatInput from '../../widgets/chat/ChatInput';
|
||||
import { Chat } from '../../widgets/chat';
|
||||
|
||||
export default function AgentChat() {
|
||||
export default function AgentChatPage() {
|
||||
const { agentId, conversationId } = useParams<{ agentId: string; conversationId: string }>();
|
||||
const [showSessionSettings, setShowSessionSettings] = useState(false);
|
||||
const chat = useChat(conversationId);
|
||||
|
||||
if (!conversationId) {
|
||||
if (!agentId || !conversationId) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Typography color="text.secondary">Select a conversation to start chatting</Typography>
|
||||
@@ -20,33 +13,5 @@ export default function AgentChat() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: {
|
||||
xs: '100vh',
|
||||
md: 'calc(100vh - 48px)',
|
||||
},
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
{agentId && (
|
||||
<ChatHeader
|
||||
agentId={agentId}
|
||||
conversationId={conversationId}
|
||||
showSessionSettings={showSessionSettings}
|
||||
onToggleSessionSettings={() => setShowSessionSettings((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
{showSessionSettings && agentId && (
|
||||
<SessionSettingsBar agentId={agentId} conversationId={conversationId} />
|
||||
)}
|
||||
<MessageList chat={chat} />
|
||||
<ChatInput onSend={chat.send} isStreaming={chat.isStreaming} />
|
||||
</Box>
|
||||
);
|
||||
return <Chat agentId={agentId} conversationId={conversationId} />;
|
||||
}
|
||||
|
||||
@@ -1,106 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Typography, CircularProgress, IconButton, Collapse } from '@mui/material';
|
||||
import { Add } from '@mui/icons-material';
|
||||
import { useListChannelsQuery, useRemoveChannelMutation } from '../../entities/channel/api';
|
||||
import ChatRow from '../../widgets/channels/ChatRow';
|
||||
import AuthRow from '../../widgets/channels/AuthRow';
|
||||
import AddChannelForm from '../../widgets/channels/AddChannelForm';
|
||||
import ChannelsPanel from '../../widgets/channels';
|
||||
|
||||
export default function ChannelsPage() {
|
||||
const { data, isLoading, isFetching } = useListChannelsQuery();
|
||||
const [removeChannel] = useRemoveChannelMutation();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [pendingOp, setPendingOp] = useState(false);
|
||||
|
||||
const busy = isLoading || pendingOp || isFetching;
|
||||
|
||||
const chat = data?.chat ?? [];
|
||||
const auth = data?.auth ?? [];
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
setPendingOp(true);
|
||||
try {
|
||||
await removeChannel({ name }).unwrap();
|
||||
} catch {
|
||||
/* handled by RTK */
|
||||
} finally {
|
||||
setPendingOp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdded = () => {
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Channels
|
||||
</Typography>
|
||||
{!busy && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{chat.length} chat · {auth.length} auth
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowAdd((prev) => !prev)}
|
||||
sx={{
|
||||
bgcolor: showAdd ? 'primary.main' : 'action.hover',
|
||||
color: showAdd ? 'primary.contrastText' : 'text.primary',
|
||||
'&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' },
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showAdd}>
|
||||
<AddChannelForm onDone={handleAdded} />
|
||||
</Collapse>
|
||||
|
||||
{busy ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{chat.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ px: 2, color: 'text.secondary', fontWeight: 700 }}
|
||||
>
|
||||
Chat Channels
|
||||
</Typography>
|
||||
{chat.map((c) => (
|
||||
<ChatRow key={c.id} channel={c} onRemove={handleRemove} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{auth.length > 0 && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ px: 2, color: 'text.secondary', fontWeight: 700 }}
|
||||
>
|
||||
Auth Profiles
|
||||
</Typography>
|
||||
{auth.map((a) => (
|
||||
<AuthRow key={a.id} profile={a} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{chat.length === 0 && auth.length === 0 && (
|
||||
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
|
||||
No channels configured
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
return <ChannelsPanel />;
|
||||
}
|
||||
|
||||
@@ -1,78 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Typography, CircularProgress, IconButton, Collapse } from '@mui/material';
|
||||
import { Add } from '@mui/icons-material';
|
||||
import { useListCronJobsQuery, useRemoveCronJobMutation } from '../../entities/cron/api';
|
||||
import CronRow from '../../widgets/cron/CronRow';
|
||||
import AddCronForm from '../../widgets/cron/AddCronForm';
|
||||
import CronPanel from '../../widgets/cron';
|
||||
|
||||
export default function CronPage() {
|
||||
const { data, isLoading, isFetching } = useListCronJobsQuery();
|
||||
const [removeCron] = useRemoveCronJobMutation();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [pendingOp, setPendingOp] = useState(false);
|
||||
|
||||
const busy = isLoading || pendingOp || isFetching;
|
||||
const jobs = data?.jobs ?? [];
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
setPendingOp(true);
|
||||
try {
|
||||
await removeCron({ id }).unwrap();
|
||||
} catch {
|
||||
/* handled by RTK */
|
||||
} finally {
|
||||
setPendingOp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdded = () => {
|
||||
setShowAdd(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Cron Jobs
|
||||
</Typography>
|
||||
{!busy && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{jobs.length} job{jobs.length !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowAdd((prev) => !prev)}
|
||||
sx={{
|
||||
bgcolor: showAdd ? 'primary.main' : 'action.hover',
|
||||
color: showAdd ? 'primary.contrastText' : 'text.primary',
|
||||
'&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' },
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showAdd}>
|
||||
<AddCronForm onDone={handleAdded} />
|
||||
</Collapse>
|
||||
|
||||
{busy ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{jobs.map((job) => (
|
||||
<CronRow key={job.id} job={job} onRemove={handleRemove} />
|
||||
))}
|
||||
{jobs.length === 0 && (
|
||||
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
|
||||
No cron jobs configured
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
return <CronPanel />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
|
||||
import { Button, TextField, Card, Typography, Box, CircularProgress, Alert } from '@mui/material';
|
||||
import { useFormik, FormikProvider, Form } from 'formik';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useLoginMutation } from '../../app/store';
|
||||
import { useLoginMutation } from '../../features/auth';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -1,79 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Typography, TextField, CircularProgress, InputAdornment } from '@mui/material';
|
||||
import { Search } from '@mui/icons-material';
|
||||
import { useListPluginsQuery } from '../../entities/plugin/api';
|
||||
import PluginRow from '../../widgets/plugins/PluginRow';
|
||||
import PluginsPanel from '../../widgets/plugins';
|
||||
|
||||
export default function PluginsPage() {
|
||||
const { data: plugins, isLoading } = useListPluginsQuery();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const all = plugins ?? [];
|
||||
const filtered = all.filter((p) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.id.toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const enabledCount = all.filter((p) => p.enabled).length;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Plugins
|
||||
</Typography>
|
||||
{!isLoading && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{enabledCount} of {all.length} enabled
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search plugins..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 1.5,
|
||||
'& input': { fontSize: '0.85rem', py: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
{filtered.map((p) => (
|
||||
<PluginRow key={p.id} plugin={p} />
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
|
||||
{search ? 'No plugins match your search' : 'No plugins found'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
return <PluginsPanel />;
|
||||
}
|
||||
|
||||
@@ -1,99 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Typography, TextField, Chip, CircularProgress, InputAdornment } from '@mui/material';
|
||||
import { Search } from '@mui/icons-material';
|
||||
import { useListSkillsQuery } from '../../entities/skill/api';
|
||||
import SkillRow from '../../widgets/skills/SkillRow';
|
||||
import SkillsPanel from '../../widgets/skills';
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { data: skills, isLoading } = useListSkillsQuery();
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<'all' | 'eligible' | 'missing'>('all');
|
||||
|
||||
const all = skills ?? [];
|
||||
const filtered = all.filter((s) => {
|
||||
if (filter === 'eligible' && !s.eligible) return false;
|
||||
if (filter === 'missing' && s.eligible) return false;
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const eligibleCount = all.filter((s) => s.eligible).length;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Skills
|
||||
</Typography>
|
||||
{!isLoading && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{eligibleCount} of {all.length} eligible
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search skills..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 1.5,
|
||||
'& input': { fontSize: '0.85rem', py: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{(['all', 'eligible', 'missing'] as const).map((f) => (
|
||||
<Chip
|
||||
key={f}
|
||||
label={f}
|
||||
size="small"
|
||||
variant="filled"
|
||||
onClick={() => setFilter(f)}
|
||||
sx={{
|
||||
height: 32,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
bgcolor: filter === f ? 'primary.main' : 'transparent',
|
||||
color: filter === f ? 'primary.contrastText' : 'text.secondary',
|
||||
'&:hover': {
|
||||
bgcolor: filter === f ? 'primary.dark' : 'action.hover',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
{filtered.map((s) => (
|
||||
<SkillRow key={s.name} skill={s} />
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
|
||||
{search ? 'No skills match your search' : 'No skills found'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
return <SkillsPanel />;
|
||||
}
|
||||
|
||||
@@ -1,131 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import { Search, Add } from '@mui/icons-material';
|
||||
import { useGetUsersQuery, useDeleteUserMutation } from '../../entities/user/api';
|
||||
import UserRow from '../../widgets/users/UserRow';
|
||||
import UserForm from '../../widgets/users/UserForm';
|
||||
import UsersPanel from '../../widgets/users';
|
||||
|
||||
export default function UsersPage() {
|
||||
const { data, isLoading, isFetching } = useGetUsersQuery();
|
||||
const [deleteUser] = useDeleteUserMutation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [formMode, setFormMode] = useState<'closed' | 'add' | string>('closed');
|
||||
const [pendingOp, setPendingOp] = useState(false);
|
||||
|
||||
const busy = isLoading || pendingOp || isFetching;
|
||||
const users = data?.items ?? [];
|
||||
|
||||
const filtered = users.filter((u) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
u.name.toLowerCase().includes(q) ||
|
||||
u.lastName.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.phone && u.phone.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setPendingOp(true);
|
||||
try {
|
||||
await deleteUser(id).unwrap();
|
||||
} catch {
|
||||
/* handled by RTK */
|
||||
} finally {
|
||||
setPendingOp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormDone = () => {
|
||||
setFormMode('closed');
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
setFormMode(id);
|
||||
};
|
||||
|
||||
const showForm = formMode !== 'closed';
|
||||
const editUserId = formMode !== 'closed' && formMode !== 'add' ? formMode : null;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Users
|
||||
</Typography>
|
||||
{!busy && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{users.length} user{users.length !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setFormMode((prev) => (prev === 'add' ? 'closed' : 'add'))}
|
||||
sx={{
|
||||
bgcolor: formMode === 'add' ? 'primary.main' : 'action.hover',
|
||||
color: formMode === 'add' ? 'primary.contrastText' : 'text.primary',
|
||||
'&:hover': {
|
||||
bgcolor: formMode === 'add' ? 'primary.dark' : 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showForm}>
|
||||
<UserForm userId={editUserId} onDone={handleFormDone} />
|
||||
</Collapse>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search users..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 1.5,
|
||||
'& input': { fontSize: '0.85rem', py: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{busy ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
{filtered.map((user) => (
|
||||
<UserRow key={user._id} user={user} onEdit={handleEdit} onDelete={handleDelete} />
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
|
||||
{search ? 'No users match your search' : 'No users found'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
return <UsersPanel />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { API_BASE_URL, baseApi } from './baseApi';
|
||||
@@ -0,0 +1 @@
|
||||
export { useValidationErrors } from './useValidationErrors';
|
||||
@@ -0,0 +1,15 @@
|
||||
export function useValidationErrors(error: unknown): Record<string, string[]> | null {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'status' in error &&
|
||||
typeof (error as { status: unknown }).status === 'number' &&
|
||||
'data' in error &&
|
||||
typeof (error as { data: unknown }).data === 'object' &&
|
||||
(error as { data: unknown }).data !== null &&
|
||||
(error as { status: number }).status === 422
|
||||
) {
|
||||
return (error as { data: Record<string, string[]> }).data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as MarkdownContent } from './MarkdownContent';
|
||||
export { default as DeleteButton } from './DeleteButton';
|
||||
export { default as ProviderLogo } from './ProviderLogo';
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, CircularProgress, Collapse, IconButton, Typography } from '@mui/material';
|
||||
import { Add } from '@mui/icons-material';
|
||||
import {
|
||||
AuthRow,
|
||||
ChatRow,
|
||||
useListChannelsQuery,
|
||||
useRemoveChannelMutation,
|
||||
} from '../../entities/channel';
|
||||
import { AddChannelForm } from '../../features/channel/add';
|
||||
|
||||
export default function ChannelsPanel() {
|
||||
const { data, isLoading, isFetching } = useListChannelsQuery();
|
||||
const [removeChannel] = useRemoveChannelMutation();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [pendingOp, setPendingOp] = useState(false);
|
||||
|
||||
const busy = isLoading || pendingOp || isFetching;
|
||||
|
||||
const chat = data?.chat ?? [];
|
||||
const auth = data?.auth ?? [];
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
setPendingOp(true);
|
||||
try {
|
||||
await removeChannel({ name }).unwrap();
|
||||
} catch {
|
||||
/* handled by RTK */
|
||||
} finally {
|
||||
setPendingOp(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Channels
|
||||
</Typography>
|
||||
{!busy && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{chat.length} chat · {auth.length} auth
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowAdd((prev) => !prev)}
|
||||
sx={{
|
||||
bgcolor: showAdd ? 'primary.main' : 'action.hover',
|
||||
color: showAdd ? 'primary.contrastText' : 'text.primary',
|
||||
'&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' },
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showAdd}>
|
||||
<AddChannelForm onDone={() => setShowAdd(false)} />
|
||||
</Collapse>
|
||||
|
||||
{busy ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{chat.length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ px: 2, color: 'text.secondary', fontWeight: 700 }}
|
||||
>
|
||||
Chat Channels
|
||||
</Typography>
|
||||
{chat.map((c) => (
|
||||
<ChatRow key={c.id} channel={c} onRemove={handleRemove} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{auth.length > 0 && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ px: 2, color: 'text.secondary', fontWeight: 700 }}
|
||||
>
|
||||
Auth Profiles
|
||||
</Typography>
|
||||
{auth.map((a) => (
|
||||
<AuthRow key={a.id} profile={a} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{chat.length === 0 && auth.length === 0 && (
|
||||
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
|
||||
No channels configured
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as Chat } from './ui/Chat';
|
||||
export { default as ChatHeader } from './ui/ChatHeader';
|
||||
export { default as ChatInput } from './ui/ChatInput';
|
||||
export { default as MessageList } from './ui/MessageList';
|
||||
export { default as SessionSettingsBar } from './ui/SessionSettingsBar';
|
||||
export { default as SettingChip } from './ui/SettingChip';
|
||||
export { useChat } from './model/useChat';
|
||||
export type { ChatState } from './model/types';
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RefObject } from 'react';
|
||||
import type { Message, MessageFile } from '../../../entities/message';
|
||||
|
||||
export interface ChatState {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
hasMore: boolean;
|
||||
loadMoreCursor: string | undefined;
|
||||
|
||||
isStreaming: boolean;
|
||||
streamingText: string;
|
||||
streamingThinking: string;
|
||||
pendingUserText: string;
|
||||
pendingFilesPreviews: MessageFile[];
|
||||
|
||||
send: (text: string, files: File[]) => Promise<void>;
|
||||
loadMore: () => void;
|
||||
handleScroll: () => void;
|
||||
|
||||
scrollContainerRef: RefObject<HTMLDivElement | null>;
|
||||
messagesEndRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAppDispatch } from '../../../app/store/hooks';
|
||||
import {
|
||||
messagesApi,
|
||||
useGetMessagesQuery,
|
||||
usePollMessagesQuery,
|
||||
type Message,
|
||||
type MessagesResponse,
|
||||
} from '../../../entities/message';
|
||||
import { useSendMessage } from '../../../features/message/send';
|
||||
import type { ChatState } from './types';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Composes message querying, polling, scroll behavior, and send-message state
|
||||
* into a single `ChatState` consumed by the chat widget.
|
||||
*/
|
||||
export function useChat(conversationId: string | undefined): ChatState {
|
||||
const [loadMoreCursor, setLoadMoreCursor] = useState<string | undefined>();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isLoadingMore = useRef(false);
|
||||
const prevScrollHeight = useRef(0);
|
||||
const initialScrollDone = useRef(false);
|
||||
const lastConvId = useRef(conversationId);
|
||||
const scrollTickRef = useRef(0);
|
||||
const lastMergedPollTs = useRef<string | undefined>(undefined);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data, isLoading, isFetching, refetch } = useGetMessagesQuery(
|
||||
{ conversationId: conversationId!, before: loadMoreCursor },
|
||||
{ skip: !conversationId }
|
||||
);
|
||||
|
||||
const messages = useMemo<Message[]>(() => data?.items ?? [], [data?.items]);
|
||||
const hasMore = (data as MessagesResponse | undefined)?.hasMore ?? false;
|
||||
|
||||
const {
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
pendingUserText,
|
||||
pendingFilesPreviews,
|
||||
send,
|
||||
abort,
|
||||
} = useSendMessage({
|
||||
conversationId,
|
||||
refetch,
|
||||
hasMessages: messages.length > 0,
|
||||
});
|
||||
|
||||
// Polling: only fetch messages newer than the latest one we have.
|
||||
// Skip while streaming so SSE flow owns the update.
|
||||
const lastMessageTs = messages.length > 0 ? messages[messages.length - 1].createdAt : undefined;
|
||||
|
||||
const { data: pollData } = usePollMessagesQuery(
|
||||
{ conversationId: conversationId!, after: lastMessageTs },
|
||||
{
|
||||
skip: !conversationId || isStreaming || isLoading,
|
||||
pollingInterval: POLL_INTERVAL_MS,
|
||||
refetchOnMountOrArgChange: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Merge new polled items into the messages cache + trigger auto-scroll.
|
||||
useEffect(() => {
|
||||
if (!conversationId || !pollData || pollData.items.length === 0) return;
|
||||
|
||||
// Dedup by newest polled timestamp: avoids re-merging the same data.
|
||||
const newestTs = pollData.items[pollData.items.length - 1].createdAt;
|
||||
if (lastMergedPollTs.current === newestTs) return;
|
||||
lastMergedPollTs.current = newestTs;
|
||||
|
||||
dispatch(
|
||||
messagesApi.util.updateQueryData(
|
||||
'getMessages',
|
||||
{ conversationId, before: undefined },
|
||||
(draft) => {
|
||||
const existing = new Set(draft.items.map((m) => m._id));
|
||||
const additions = pollData.items.filter((m) => !existing.has(m._id));
|
||||
if (additions.length === 0) return;
|
||||
draft.items = [...draft.items, ...additions];
|
||||
draft.total = draft.items.length;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
if (distanceFromBottom < 200) {
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 50);
|
||||
}
|
||||
}, [pollData, conversationId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastConvId.current !== conversationId) {
|
||||
lastConvId.current = conversationId;
|
||||
initialScrollDone.current = false;
|
||||
lastMergedPollTs.current = undefined;
|
||||
}
|
||||
if (isLoadingMore.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight - prevScrollHeight.current;
|
||||
prevScrollHeight.current = 0;
|
||||
}
|
||||
isLoadingMore.current = false;
|
||||
return;
|
||||
}
|
||||
if (!initialScrollDone.current && messages.length > 0) {
|
||||
initialScrollDone.current = true;
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'instant' });
|
||||
}, 80);
|
||||
return;
|
||||
}
|
||||
if (pendingUserText || pendingFilesPreviews.length > 0) {
|
||||
setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 150);
|
||||
}
|
||||
}, [messages, conversationId, pendingUserText, pendingFilesPreviews]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamingText && !streamingThinking) return;
|
||||
const now = Date.now();
|
||||
if (now - scrollTickRef.current < 200) return;
|
||||
scrollTickRef.current = now;
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [streamingText, streamingThinking]);
|
||||
|
||||
const [prevConvId, setPrevConvId] = useState(conversationId);
|
||||
if (prevConvId !== conversationId) {
|
||||
setPrevConvId(conversationId);
|
||||
abort();
|
||||
if (loadMoreCursor !== undefined) setLoadMoreCursor(undefined);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abort();
|
||||
};
|
||||
}, [abort]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || isLoadingMore.current || isFetching || !hasMore || isStreaming) return;
|
||||
if (container.scrollTop < 150 && messages.length > 0) {
|
||||
isLoadingMore.current = true;
|
||||
prevScrollHeight.current = container.scrollHeight;
|
||||
setLoadMoreCursor(messages[0].createdAt);
|
||||
}
|
||||
}, [isFetching, hasMore, isStreaming, messages]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (messages.length > 0) {
|
||||
isLoadingMore.current = true;
|
||||
prevScrollHeight.current = scrollContainerRef.current?.scrollHeight ?? 0;
|
||||
setLoadMoreCursor(messages[0].createdAt);
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
isFetching,
|
||||
hasMore,
|
||||
loadMoreCursor,
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
pendingUserText,
|
||||
pendingFilesPreviews,
|
||||
send,
|
||||
loadMore,
|
||||
handleScroll,
|
||||
scrollContainerRef,
|
||||
messagesEndRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { useChat } from '../model/useChat';
|
||||
import ChatHeader from './ChatHeader';
|
||||
import SessionSettingsBar from './SessionSettingsBar';
|
||||
import MessageList from './MessageList';
|
||||
import ChatInput from './ChatInput';
|
||||
|
||||
interface ChatProps {
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full chat experience for a given agent/conversation: header,
|
||||
* optional session-settings bar, message list and input.
|
||||
*/
|
||||
export default function Chat({ agentId, conversationId }: ChatProps) {
|
||||
const [showSessionSettings, setShowSessionSettings] = useState(false);
|
||||
const chat = useChat(conversationId);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: { xs: '100vh', md: 'calc(100vh - 48px)' },
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ChatHeader
|
||||
agentId={agentId}
|
||||
conversationId={conversationId}
|
||||
showSessionSettings={showSessionSettings}
|
||||
onToggleSessionSettings={() => setShowSessionSettings((v) => !v)}
|
||||
/>
|
||||
{showSessionSettings && (
|
||||
<SessionSettingsBar agentId={agentId} conversationId={conversationId} />
|
||||
)}
|
||||
<MessageList chat={chat} />
|
||||
<ChatInput onSend={chat.send} isStreaming={chat.isStreaming} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { Box, TextField, IconButton, Typography, CircularProgress } from '@mui/material';
|
||||
import { Edit, Check, Settings, TuneOutlined } from '@mui/icons-material';
|
||||
import { Link } from 'react-router';
|
||||
import { useGetAgentQuery, useUpdateAgentMutation } from '../../entities/agent/api';
|
||||
import { useGetAgentQuery, useUpdateAgentMutation } from '../../../entities/agent';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
agentId: string;
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import { Box, Typography, CircularProgress } from '@mui/material';
|
||||
import type useChat from '../../features/chat/useChat';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import { MessageBubble } from '../../../entities/message';
|
||||
import type { ChatState } from '../model/types';
|
||||
|
||||
interface MessageListProps {
|
||||
chat: ReturnType<typeof useChat>;
|
||||
chat: ChatState;
|
||||
}
|
||||
|
||||
export default function MessageList({ chat }: MessageListProps) {
|
||||
+1
-4
@@ -1,9 +1,6 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import {
|
||||
useGetSessionSettingsQuery,
|
||||
usePatchSessionSettingsMutation,
|
||||
} from '../../entities/agent/api';
|
||||
import { useGetSessionSettingsQuery, usePatchSessionSettingsMutation } from '../../../entities/agent';
|
||||
import SettingChip from './SettingChip';
|
||||
|
||||
const THINKING_OPTIONS = ['inherit', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, CircularProgress, Collapse, IconButton, Typography } from '@mui/material';
|
||||
import { Add } from '@mui/icons-material';
|
||||
import {
|
||||
CronRow,
|
||||
useListCronJobsQuery,
|
||||
useRemoveCronJobMutation,
|
||||
} from '../../entities/cron';
|
||||
import { AddCronForm } from '../../features/cron/add';
|
||||
|
||||
export default function CronPanel() {
|
||||
const { data, isLoading, isFetching } = useListCronJobsQuery();
|
||||
const [removeCron] = useRemoveCronJobMutation();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [pendingOp, setPendingOp] = useState(false);
|
||||
|
||||
const busy = isLoading || pendingOp || isFetching;
|
||||
const jobs = data?.jobs ?? [];
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
setPendingOp(true);
|
||||
try {
|
||||
await removeCron({ id }).unwrap();
|
||||
} catch {
|
||||
/* handled by RTK */
|
||||
} finally {
|
||||
setPendingOp(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Cron Jobs
|
||||
</Typography>
|
||||
{!busy && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{jobs.length} job{jobs.length !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowAdd((prev) => !prev)}
|
||||
sx={{
|
||||
bgcolor: showAdd ? 'primary.main' : 'action.hover',
|
||||
color: showAdd ? 'primary.contrastText' : 'text.primary',
|
||||
'&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' },
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showAdd}>
|
||||
<AddCronForm onDone={() => setShowAdd(false)} />
|
||||
</Collapse>
|
||||
|
||||
{busy ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{jobs.map((job) => (
|
||||
<CronRow key={job.id} job={job} onRemove={handleRemove} />
|
||||
))}
|
||||
{jobs.length === 0 && (
|
||||
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
|
||||
No cron jobs configured
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Box, CircularProgress, InputAdornment, TextField, Typography } from '@mui/material';
|
||||
import { Search } from '@mui/icons-material';
|
||||
import { PluginRow, useListPluginsQuery } from '../../entities/plugin';
|
||||
|
||||
export default function PluginsPanel() {
|
||||
const { data: plugins, isLoading } = useListPluginsQuery();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const all = useMemo(() => plugins ?? [], [plugins]);
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return all;
|
||||
const q = search.toLowerCase();
|
||||
return all.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.id.toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q)
|
||||
);
|
||||
}, [all, search]);
|
||||
|
||||
const enabledCount = useMemo(() => all.filter((p) => p.enabled).length, [all]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Plugins
|
||||
</Typography>
|
||||
{!isLoading && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{enabledCount} of {all.length} enabled
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search plugins..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 1.5,
|
||||
'& input': { fontSize: '0.85rem', py: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
{filtered.map((p) => (
|
||||
<PluginRow key={p.id} plugin={p} />
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
|
||||
{search ? 'No plugins match your search' : 'No plugins found'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +1,21 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
useTheme,
|
||||
TextField,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
People,
|
||||
Extension,
|
||||
Psychology,
|
||||
Forum,
|
||||
Schedule,
|
||||
Add,
|
||||
Search,
|
||||
KeyboardDoubleArrowUp,
|
||||
SwapVert,
|
||||
} from '@mui/icons-material';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import {
|
||||
useGetAgentsQuery,
|
||||
useCreateAgentMutation,
|
||||
useSyncAgentsMutation,
|
||||
} from '../../entities/agent/api';
|
||||
import { useGetAllConversationsQuery } from '../../entities/conversation/api';
|
||||
import ThemePicker from '../../features/theme/ThemePicker';
|
||||
import UpdateBanner from './UpdateBanner';
|
||||
import AgentSection from './AgentSection';
|
||||
import TerminalPanel from './TerminalPanel';
|
||||
import SyncProgressBar from './SyncProgressBar';
|
||||
import CreateAgentForm from './CreateAgentForm';
|
||||
import { useState } from 'react';
|
||||
import { Box, useTheme } from '@mui/material';
|
||||
import { UpdateBanner } from '../../features/update/install';
|
||||
import { ThemePicker } from '../../features/theme';
|
||||
import SidebarHeader from './ui/SidebarHeader';
|
||||
import SidebarSearch from './ui/SidebarSearch';
|
||||
import SidebarMenu from './ui/SidebarMenu';
|
||||
import AgentsPanel from './ui/AgentsPanel';
|
||||
|
||||
export const SIDEBAR_WIDTH = 240;
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'USERS', icon: <People sx={{ fontSize: 18 }} />, path: '/users' },
|
||||
{ text: 'PLUGINS', icon: <Extension sx={{ fontSize: 18 }} />, path: '/plugins' },
|
||||
{ text: 'SKILLS', icon: <Psychology sx={{ fontSize: 18 }} />, path: '/skills' },
|
||||
{ text: 'CHANNELS', icon: <Forum sx={{ fontSize: 18 }} />, path: '/channels' },
|
||||
{ text: 'CRON', icon: <Schedule sx={{ fontSize: 18 }} />, path: '/cron' },
|
||||
];
|
||||
interface SidebarProps {
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
const { sidebar } = theme.palette;
|
||||
|
||||
const [showNewAgent, setShowNewAgent] = useState(false);
|
||||
export default function Sidebar({ onNavigate }: SidebarProps) {
|
||||
const { sidebar } = useTheme().palette;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [collapseKey, setCollapseKey] = useState(0);
|
||||
const [sortAlpha, setSortAlpha] = useState(false);
|
||||
const [terminalAgent, setTerminalAgent] = useState<{ slug: string; dbId: string } | null>(null);
|
||||
const [deletingAgentId, setDeletingAgentId] = useState<string | null>(null);
|
||||
|
||||
const { data: agentsData, isLoading: agentsLoading } = useGetAgentsQuery();
|
||||
const { data: convData } = useGetAllConversationsQuery();
|
||||
const [createAgent, { isLoading: isCreating }] = useCreateAgentMutation();
|
||||
const [syncAgents, { isLoading: isSyncing }] = useSyncAgentsMutation();
|
||||
const [syncDone, setSyncDone] = useState(false);
|
||||
const syncDoneTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const syncCalled = useRef(false);
|
||||
|
||||
const allConversations = convData?.items ?? [];
|
||||
const agents = sortAlpha
|
||||
? [...(agentsData?.items ?? [])].sort((a, b) => a.name.localeCompare(b.name))
|
||||
: (agentsData?.items ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncCalled.current) return;
|
||||
if (agentsLoading || !agentsData) return;
|
||||
syncCalled.current = true;
|
||||
syncAgents().then(() => {
|
||||
setSyncDone(true);
|
||||
syncDoneTimer.current = setTimeout(() => setSyncDone(false), 2500);
|
||||
});
|
||||
return () => {
|
||||
if (syncDoneTimer.current) clearTimeout(syncDoneTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentsLoading]);
|
||||
|
||||
const handleAgentCreated = (result: { slug: string; dbId: string; interactive: boolean }) => {
|
||||
setShowNewAgent(false);
|
||||
if (result.interactive) {
|
||||
setTerminalAgent({ slug: result.slug, dbId: result.dbId });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -107,258 +31,12 @@ export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box component="img" src="/logo_128.png" alt="OpenClaw" sx={{ width: 16, height: 16 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, letterSpacing: '1px', color: 'error.main' }}
|
||||
>
|
||||
OpenClaw
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, letterSpacing: '1px', color: sidebar.selectedText }}
|
||||
>
|
||||
Client
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2, mb: 1, flexShrink: 0 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: <Search sx={{ fontSize: 16, color: sidebar.text, mr: 0.5 }} />,
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: sidebar.hover,
|
||||
borderRadius: 1.5,
|
||||
'& fieldset': { borderColor: 'transparent' },
|
||||
'&:hover fieldset': { borderColor: sidebar.text },
|
||||
'&.Mui-focused fieldset': { borderColor: sidebar.selectedBorder },
|
||||
'& input': { color: sidebar.selectedText, fontSize: '0.78rem', py: 0.7, px: 0.5 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<List sx={{ px: 2, py: 0, flexShrink: 0 }}>
|
||||
{menuItems.map((item) => {
|
||||
const isSelected =
|
||||
location.pathname === item.path || location.pathname.startsWith(item.path + '/');
|
||||
return (
|
||||
<ListItem key={item.text} disablePadding sx={{ mb: 0.2 }}>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
to={item.path}
|
||||
onClick={onNavigate}
|
||||
selected={isSelected}
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
py: 0.6,
|
||||
px: 1.5,
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
bgcolor: sidebar.hover,
|
||||
'& .MuiListItemText-primary': { color: sidebar.selectedText },
|
||||
'& .MuiListItemIcon-root': { color: sidebar.selectedText },
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
bgcolor: sidebar.selectedBg,
|
||||
boxShadow: '0 2px 8px rgba(44, 44, 40, 0.06)',
|
||||
'&:hover': { bgcolor: sidebar.selectedBg },
|
||||
'& .MuiListItemText-primary': { color: sidebar.selectedText },
|
||||
'& .MuiListItemIcon-root': { color: sidebar.selectedBorder },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 28, color: sidebar.text }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.text}
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: sidebar.text,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '1px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: sidebar.selectedBorder,
|
||||
ml: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
mt: 3,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: sidebar.text,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '1.5px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Agents
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
{isSyncing && <CircularProgress size={11} sx={{ color: sidebar.text, opacity: 0.6 }} />}
|
||||
{!isSyncing && syncDone && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'success.main',
|
||||
opacity: 1,
|
||||
animation: 'fadeOut 2.5s ease-in forwards',
|
||||
'@keyframes fadeOut': {
|
||||
'0%': { opacity: 1 },
|
||||
'60%': { opacity: 1 },
|
||||
'100%': { opacity: 0 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSortAlpha((v) => !v)}
|
||||
title={sortAlpha ? 'Unsort' : 'Sort A–Z'}
|
||||
sx={{
|
||||
color: sortAlpha ? 'primary.main' : sidebar.text,
|
||||
p: 0.3,
|
||||
'&:hover': { color: 'primary.main' },
|
||||
}}
|
||||
>
|
||||
<SwapVert sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setCollapseKey((k) => k + 1)}
|
||||
title="Collapse all"
|
||||
sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: sidebar.selectedText } }}
|
||||
>
|
||||
<KeyboardDoubleArrowUp sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowNewAgent((prev) => !prev)}
|
||||
sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: 'success.main' } }}
|
||||
>
|
||||
<Add sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isSyncing && <SyncProgressBar />}
|
||||
|
||||
{showNewAgent && (
|
||||
<CreateAgentForm
|
||||
onCreated={handleAgentCreated}
|
||||
onCancel={() => setShowNewAgent(false)}
|
||||
createAgent={createAgent}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
'&::-webkit-scrollbar': { width: 4 },
|
||||
'&::-webkit-scrollbar-track': { bgcolor: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
bgcolor: sidebar.border,
|
||||
borderRadius: 2,
|
||||
'&:hover': { bgcolor: sidebar.text },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{agentsLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={18} sx={{ color: sidebar.text }} />
|
||||
</Box>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{agents.map((agent) => (
|
||||
<AgentSection
|
||||
key={agent._id}
|
||||
agent={agent}
|
||||
conversations={allConversations.filter((c) => c.agentId === agent._id)}
|
||||
searchQuery={searchQuery || undefined}
|
||||
collapseKey={collapseKey}
|
||||
onNavigate={onNavigate}
|
||||
disabled={deletingAgentId === agent._id}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<SidebarHeader />
|
||||
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
|
||||
<SidebarMenu onNavigate={onNavigate} />
|
||||
<AgentsPanel searchQuery={searchQuery} onNavigate={onNavigate} />
|
||||
<UpdateBanner />
|
||||
|
||||
<ThemePicker />
|
||||
|
||||
{terminalAgent && (
|
||||
<TerminalPanel
|
||||
agentName={terminalAgent.slug}
|
||||
agentDbId={terminalAgent.dbId}
|
||||
open
|
||||
onClose={() => setTerminalAgent(null)}
|
||||
onDeleting={setDeletingAgentId}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-5
@@ -12,11 +12,9 @@ import {
|
||||
} from '@mui/material';
|
||||
import { Add, ExpandMore, ExpandLess, SmartToy, DeleteOutline } from '@mui/icons-material';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useDeleteAgentMutation } from '../../entities/agent/api';
|
||||
import { useCreateConversationMutation } from '../../entities/conversation/api';
|
||||
import DeleteButton from '../../shared/ui/DeleteButton';
|
||||
import ProviderLogo from '../../shared/ui/ProviderLogo';
|
||||
import ConversationItem from './ConversationItem';
|
||||
import { useDeleteAgentMutation } from '../../../entities/agent';
|
||||
import { useCreateConversationMutation, ConversationItem } from '../../../entities/conversation';
|
||||
import { DeleteButton, ProviderLogo } from '../../../shared/ui';
|
||||
|
||||
interface AgentSectionProps {
|
||||
agent: { _id: string; name: string; model?: string | null };
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Box, CircularProgress, IconButton, List, Typography, useTheme } from '@mui/material';
|
||||
import { Add, KeyboardDoubleArrowUp, SwapVert } from '@mui/icons-material';
|
||||
import { useGetAgentsQuery, useSyncAgentsMutation } from '../../../entities/agent';
|
||||
import { useGetAllConversationsQuery } from '../../../entities/conversation';
|
||||
import { CreateAgentForm, type CreatedAgent } from '../../../features/agent/create';
|
||||
import { TerminalPanel } from '../../../features/agent/setup-terminal';
|
||||
import AgentSection from './AgentSection';
|
||||
import SyncProgressBar from './SyncProgressBar';
|
||||
|
||||
interface AgentsPanelProps {
|
||||
searchQuery: string;
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export default function AgentsPanel({ searchQuery, onNavigate }: AgentsPanelProps) {
|
||||
const { sidebar } = useTheme().palette;
|
||||
|
||||
const [showNewAgent, setShowNewAgent] = useState(false);
|
||||
const [collapseKey, setCollapseKey] = useState(0);
|
||||
const [sortAlpha, setSortAlpha] = useState(false);
|
||||
const [terminalAgent, setTerminalAgent] = useState<{ slug: string; dbId: string } | null>(null);
|
||||
const [deletingAgentId, setDeletingAgentId] = useState<string | null>(null);
|
||||
|
||||
const { data: agentsData, isLoading: agentsLoading } = useGetAgentsQuery();
|
||||
const { data: convData } = useGetAllConversationsQuery();
|
||||
const [syncAgents, { isLoading: isSyncing }] = useSyncAgentsMutation();
|
||||
|
||||
const [syncDone, setSyncDone] = useState(false);
|
||||
const syncDoneTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const syncCalled = useRef(false);
|
||||
|
||||
const allConversations = convData?.items ?? [];
|
||||
const agents = sortAlpha
|
||||
? [...(agentsData?.items ?? [])].sort((a, b) => a.name.localeCompare(b.name))
|
||||
: (agentsData?.items ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (syncCalled.current) return;
|
||||
if (agentsLoading || !agentsData) return;
|
||||
syncCalled.current = true;
|
||||
syncAgents().then(() => {
|
||||
setSyncDone(true);
|
||||
syncDoneTimer.current = setTimeout(() => setSyncDone(false), 2500);
|
||||
});
|
||||
return () => {
|
||||
if (syncDoneTimer.current) clearTimeout(syncDoneTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentsLoading]);
|
||||
|
||||
const handleAgentCreated = (result: CreatedAgent) => {
|
||||
setShowNewAgent(false);
|
||||
if (result.interactive) {
|
||||
setTerminalAgent({ slug: result.slug, dbId: result.dbId });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
mt: 3,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: sidebar.text,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '1.5px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Agents
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
{isSyncing && <CircularProgress size={11} sx={{ color: sidebar.text, opacity: 0.6 }} />}
|
||||
{!isSyncing && syncDone && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'success.main',
|
||||
opacity: 1,
|
||||
animation: 'fadeOut 2.5s ease-in forwards',
|
||||
'@keyframes fadeOut': {
|
||||
'0%': { opacity: 1 },
|
||||
'60%': { opacity: 1 },
|
||||
'100%': { opacity: 0 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSortAlpha((v) => !v)}
|
||||
title={sortAlpha ? 'Unsort' : 'Sort A–Z'}
|
||||
sx={{
|
||||
color: sortAlpha ? 'primary.main' : sidebar.text,
|
||||
p: 0.3,
|
||||
'&:hover': { color: 'primary.main' },
|
||||
}}
|
||||
>
|
||||
<SwapVert sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setCollapseKey((k) => k + 1)}
|
||||
title="Collapse all"
|
||||
sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: sidebar.selectedText } }}
|
||||
>
|
||||
<KeyboardDoubleArrowUp sx={{ fontSize: 15 }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowNewAgent((prev) => !prev)}
|
||||
sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: 'success.main' } }}
|
||||
>
|
||||
<Add sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isSyncing && <SyncProgressBar />}
|
||||
|
||||
{showNewAgent && (
|
||||
<CreateAgentForm
|
||||
onCreated={handleAgentCreated}
|
||||
onCancel={() => setShowNewAgent(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
'&::-webkit-scrollbar': { width: 4 },
|
||||
'&::-webkit-scrollbar-track': { bgcolor: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
bgcolor: sidebar.border,
|
||||
borderRadius: 2,
|
||||
'&:hover': { bgcolor: sidebar.text },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{agentsLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={18} sx={{ color: sidebar.text }} />
|
||||
</Box>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{agents.map((agent) => (
|
||||
<AgentSection
|
||||
key={agent._id}
|
||||
agent={agent}
|
||||
conversations={allConversations.filter((c) => c.agentId === agent._id)}
|
||||
searchQuery={searchQuery || undefined}
|
||||
collapseKey={collapseKey}
|
||||
onNavigate={onNavigate}
|
||||
disabled={deletingAgentId === agent._id}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{terminalAgent && (
|
||||
<TerminalPanel
|
||||
agentName={terminalAgent.slug}
|
||||
agentDbId={terminalAgent.dbId}
|
||||
open
|
||||
onClose={() => setTerminalAgent(null)}
|
||||
onDeleting={setDeletingAgentId}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Box, Typography, useTheme } from '@mui/material';
|
||||
|
||||
export default function SidebarHeader() {
|
||||
const { sidebar } = useTheme().palette;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box component="img" src="/logo_128.png" alt="OpenClaw" sx={{ width: 16, height: 16 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, letterSpacing: '1px', color: 'error.main' }}
|
||||
>
|
||||
OpenClaw
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, letterSpacing: '1px', color: sidebar.selectedText }}
|
||||
>
|
||||
Client
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, useTheme } from '@mui/material';
|
||||
import { People, Extension, Psychology, Forum, Schedule } from '@mui/icons-material';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
|
||||
interface MenuItem {
|
||||
text: string;
|
||||
icon: ReactNode;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const MENU_ITEMS: MenuItem[] = [
|
||||
{ text: 'USERS', icon: <People sx={{ fontSize: 18 }} />, path: '/users' },
|
||||
{ text: 'PLUGINS', icon: <Extension sx={{ fontSize: 18 }} />, path: '/plugins' },
|
||||
{ text: 'SKILLS', icon: <Psychology sx={{ fontSize: 18 }} />, path: '/skills' },
|
||||
{ text: 'CHANNELS', icon: <Forum sx={{ fontSize: 18 }} />, path: '/channels' },
|
||||
{ text: 'CRON', icon: <Schedule sx={{ fontSize: 18 }} />, path: '/cron' },
|
||||
];
|
||||
|
||||
interface SidebarMenuProps {
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
export default function SidebarMenu({ onNavigate }: SidebarMenuProps) {
|
||||
const location = useLocation();
|
||||
const { sidebar } = useTheme().palette;
|
||||
|
||||
return (
|
||||
<List sx={{ px: 2, py: 0, flexShrink: 0 }}>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
const isSelected =
|
||||
location.pathname === item.path || location.pathname.startsWith(item.path + '/');
|
||||
return (
|
||||
<ListItem key={item.text} disablePadding sx={{ mb: 0.2 }}>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
to={item.path}
|
||||
onClick={onNavigate}
|
||||
selected={isSelected}
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
py: 0.6,
|
||||
px: 1.5,
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
bgcolor: sidebar.hover,
|
||||
'& .MuiListItemText-primary': { color: sidebar.selectedText },
|
||||
'& .MuiListItemIcon-root': { color: sidebar.selectedText },
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
bgcolor: sidebar.selectedBg,
|
||||
boxShadow: '0 2px 8px rgba(44, 44, 40, 0.06)',
|
||||
'&:hover': { bgcolor: sidebar.selectedBg },
|
||||
'& .MuiListItemText-primary': { color: sidebar.selectedText },
|
||||
'& .MuiListItemIcon-root': { color: sidebar.selectedBorder },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 28, color: sidebar.text }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.text}
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: sidebar.text,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '1px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: sidebar.selectedBorder,
|
||||
ml: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Box, TextField, useTheme } from '@mui/material';
|
||||
import { Search } from '@mui/icons-material';
|
||||
|
||||
interface SidebarSearchProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
export default function SidebarSearch({ value, onChange }: SidebarSearchProps) {
|
||||
const { sidebar } = useTheme().palette;
|
||||
return (
|
||||
<Box sx={{ px: 2, mb: 1, flexShrink: 0 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search..."
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: <Search sx={{ fontSize: 16, color: sidebar.text, mr: 0.5 }} />,
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: sidebar.hover,
|
||||
borderRadius: 1.5,
|
||||
'& fieldset': { borderColor: 'transparent' },
|
||||
'&:hover fieldset': { borderColor: sidebar.text },
|
||||
'&.Mui-focused fieldset': { borderColor: sidebar.selectedBorder },
|
||||
'& input': { color: sidebar.selectedText, fontSize: '0.78rem', py: 0.7, px: 0.5 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Search } from '@mui/icons-material';
|
||||
import { SkillRow, useListSkillsQuery } from '../../entities/skill';
|
||||
|
||||
type SkillFilter = 'all' | 'eligible' | 'missing';
|
||||
|
||||
export default function SkillsPanel() {
|
||||
const { data: skills, isLoading } = useListSkillsQuery();
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<SkillFilter>('all');
|
||||
|
||||
const all = useMemo(() => skills ?? [], [skills]);
|
||||
const filtered = useMemo(() => {
|
||||
return all.filter((s) => {
|
||||
if (filter === 'eligible' && !s.eligible) return false;
|
||||
if (filter === 'missing' && s.eligible) return false;
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q);
|
||||
});
|
||||
}, [all, filter, search]);
|
||||
|
||||
const eligibleCount = useMemo(() => all.filter((s) => s.eligible).length, [all]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Skills
|
||||
</Typography>
|
||||
{!isLoading && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{eligibleCount} of {all.length} eligible
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search skills..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 1.5,
|
||||
'& input': { fontSize: '0.85rem', py: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{(['all', 'eligible', 'missing'] as const).map((f) => (
|
||||
<Chip
|
||||
key={f}
|
||||
label={f}
|
||||
size="small"
|
||||
variant="filled"
|
||||
onClick={() => setFilter(f)}
|
||||
sx={{
|
||||
height: 32,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
bgcolor: filter === f ? 'primary.main' : 'transparent',
|
||||
color: filter === f ? 'primary.contrastText' : 'text.secondary',
|
||||
'&:hover': {
|
||||
bgcolor: filter === f ? 'primary.dark' : 'action.hover',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
{filtered.map((s) => (
|
||||
<SkillRow key={s.name} skill={s} />
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
|
||||
{search ? 'No skills match your search' : 'No skills found'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Add, Search } from '@mui/icons-material';
|
||||
import {
|
||||
UserRow,
|
||||
useDeleteUserMutation,
|
||||
useGetUsersQuery,
|
||||
} from '../../entities/user';
|
||||
import { UserForm } from '../../features/user/edit';
|
||||
|
||||
type FormMode = 'closed' | 'add' | string;
|
||||
|
||||
export default function UsersPanel() {
|
||||
const { data, isLoading, isFetching } = useGetUsersQuery();
|
||||
const [deleteUser] = useDeleteUserMutation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [formMode, setFormMode] = useState<FormMode>('closed');
|
||||
const [pendingOp, setPendingOp] = useState(false);
|
||||
|
||||
const busy = isLoading || pendingOp || isFetching;
|
||||
const users = useMemo(() => data?.items ?? [], [data?.items]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return users;
|
||||
const q = search.toLowerCase();
|
||||
return users.filter(
|
||||
(u) =>
|
||||
u.name.toLowerCase().includes(q) ||
|
||||
u.lastName.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.phone && u.phone.toLowerCase().includes(q))
|
||||
);
|
||||
}, [users, search]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setPendingOp(true);
|
||||
try {
|
||||
await deleteUser(id).unwrap();
|
||||
} catch {
|
||||
/* handled by RTK */
|
||||
} finally {
|
||||
setPendingOp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showForm = formMode !== 'closed';
|
||||
const editUserId = formMode !== 'closed' && formMode !== 'add' ? formMode : null;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
Users
|
||||
</Typography>
|
||||
{!busy && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{users.length} user{users.length !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setFormMode((prev) => (prev === 'add' ? 'closed' : 'add'))}
|
||||
sx={{
|
||||
bgcolor: formMode === 'add' ? 'primary.main' : 'action.hover',
|
||||
color: formMode === 'add' ? 'primary.contrastText' : 'text.primary',
|
||||
'&:hover': {
|
||||
bgcolor: formMode === 'add' ? 'primary.dark' : 'action.selected',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showForm}>
|
||||
<UserForm userId={editUserId} onDone={() => setFormMode('closed')} />
|
||||
</Collapse>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search users..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 1.5,
|
||||
'& input': { fontSize: '0.85rem', py: 1 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{busy ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
{filtered.map((user) => (
|
||||
<UserRow
|
||||
key={user._id}
|
||||
user={user}
|
||||
onEdit={(id) => setFormMode(id)}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
|
||||
{search ? 'No users match your search' : 'No users found'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Workspace } from './ui/Workspace';
|
||||
export { default as WorkspaceFileTabs } from './ui/WorkspaceFileTabs';
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Box, IconButton, Typography, CircularProgress } from '@mui/material';
|
||||
import { ArrowBack } from '@mui/icons-material';
|
||||
import { useGetAgentQuery } from '../../../entities/agent';
|
||||
import WorkspaceFileTabs from './WorkspaceFileTabs';
|
||||
|
||||
interface WorkspaceProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export default function Workspace({ agentId }: WorkspaceProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnConv = searchParams.get('return');
|
||||
const { data: agent, isLoading } = useGetAgentQuery(agentId, { skip: !agentId });
|
||||
|
||||
const backHref = returnConv ? `/agent/${agentId}/chat/${returnConv}` : '/';
|
||||
|
||||
if (isLoading && !agent) {
|
||||
return (
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}
|
||||
>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: { xs: '100vh', md: 'calc(100vh - 48px)' },
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: { xs: 1.5, md: 2 },
|
||||
py: 1.5,
|
||||
pl: { xs: 7, md: 2 },
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
component={Link}
|
||||
to={backHref}
|
||||
size="small"
|
||||
aria-label="Back"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<ArrowBack sx={{ fontSize: 22 }} />
|
||||
</IconButton>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="h6" fontWeight={600} noWrap>
|
||||
{agent?.name ?? 'Agent'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Workspace files
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'auto',
|
||||
px: { xs: 2, md: 3 },
|
||||
py: 2,
|
||||
}}
|
||||
>
|
||||
<WorkspaceFileTabs agentId={agentId} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -10,13 +10,13 @@ import {
|
||||
} from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { Visibility, PostAdd } from '@mui/icons-material';
|
||||
import MarkdownContent from '../../shared/ui/MarkdownContent';
|
||||
import { MarkdownContent } from '../../../shared/ui';
|
||||
import {
|
||||
useGetWorkspaceMetaQuery,
|
||||
useGetWorkspaceFileQuery,
|
||||
useSaveWorkspaceFileMutation,
|
||||
WORKSPACE_TAB_FILES,
|
||||
} from '../../entities/agent/api';
|
||||
} from '../../../entities/agent';
|
||||
|
||||
export default function WorkspaceFileTabs({ agentId }: { agentId: string }) {
|
||||
const { data: meta } = useGetWorkspaceMetaQuery(agentId);
|
||||
Reference in New Issue
Block a user