mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-31 13:10:55 +08:00
Initial commit
This commit is contained in:
891
web/src/components/AiAgentWidget.tsx
Normal file
891
web/src/components/AiAgentWidget.tsx
Normal file
@@ -0,0 +1,891 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Avatar, Button, Divider, Flex, Input, List, Modal, Space, Switch, Tag, Typography, message, theme } from 'antd';
|
||||
import { RobotOutlined, SendOutlined, DeleteOutlined, ToolOutlined, DownOutlined, UpOutlined, CodeOutlined, CopyOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import { agentApi, type AgentChatMessage, type PendingToolCall } from '../api/agent';
|
||||
import { useI18n } from '../i18n';
|
||||
import '../styles/ai-agent.css';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
function normalizePath(p?: string | null): string | null {
|
||||
if (!p) return null;
|
||||
const s = ('/' + p).replace(/\/+/, '/').replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||
return s;
|
||||
}
|
||||
|
||||
function extractTextContent(content: any): string {
|
||||
if (content == null) return '';
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (typeof item === 'string') {
|
||||
if (item.trim()) parts.push(item);
|
||||
continue;
|
||||
}
|
||||
const text = typeof item?.text === 'string' ? item.text : '';
|
||||
if (text.trim()) parts.push(text);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content, null, 2);
|
||||
} catch {
|
||||
return String(content);
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseJson<T = any>(raw: string): T | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
return JSON.parse(s) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shortId(id: string, keep: number = 6): string {
|
||||
const s = String(id || '');
|
||||
if (s.length <= keep * 2 + 3) return s;
|
||||
return `${s.slice(0, keep)}…${s.slice(-keep)}`;
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLen: number): string {
|
||||
if (value.length <= maxLen) return value;
|
||||
return `${value.slice(0, maxLen)}…`;
|
||||
}
|
||||
|
||||
function formatDisplayValue(value: any, maxLen: number = 120): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return clampText(value, maxLen);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try {
|
||||
return clampText(JSON.stringify(value), maxLen);
|
||||
} catch {
|
||||
return clampText(String(value), maxLen);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: any): value is Record<string, any> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
type ToolPayload = {
|
||||
ok?: boolean;
|
||||
summary?: string;
|
||||
view?: {
|
||||
type?: string;
|
||||
title?: string;
|
||||
meta?: Record<string, any>;
|
||||
items?: any[];
|
||||
text?: string;
|
||||
message?: string;
|
||||
};
|
||||
data?: any;
|
||||
error?: any;
|
||||
};
|
||||
|
||||
function parseToolPayload(raw: string): ToolPayload | null {
|
||||
const parsed = tryParseJson<ToolPayload>(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
interface AiAgentWidgetProps {
|
||||
currentPath?: string | null;
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
}
|
||||
|
||||
const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenChange }: AiAgentWidgetProps) {
|
||||
const { t } = useI18n();
|
||||
const { token } = theme.useToken();
|
||||
const [autoExecute, setAutoExecute] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<AgentChatMessage[]>([]);
|
||||
const [pending, setPending] = useState<PendingToolCall[]>([]);
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
||||
const [expandedRaw, setExpandedRaw] = useState<Record<string, boolean>>({});
|
||||
const [runningTools, setRunningTools] = useState<Record<string, string>>({});
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<TextAreaRef | null>(null);
|
||||
const streamControllerRef = useRef<AbortController | null>(null);
|
||||
const streamSeqRef = useRef(0);
|
||||
const baseMessagesRef = useRef<AgentChatMessage[]>([]);
|
||||
const assistantIndexRef = useRef<Record<string, number>>({});
|
||||
const toolNameByIdRef = useRef<Record<string, string>>({});
|
||||
|
||||
const effectivePath = useMemo(() => normalizePath(currentPath), [currentPath]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = window.setTimeout(scrollToBottom, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [messages, open, pending, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || loading || pending.length > 0) return;
|
||||
const t = window.setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [open, loading, messages.length, pending.length]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toolCallsById = useMemo(() => {
|
||||
const map = new Map<string, { name: string; args: Record<string, any> }>();
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== 'object') continue;
|
||||
if (msg.role !== 'assistant') continue;
|
||||
const toolCalls = (msg as any).tool_calls;
|
||||
if (!Array.isArray(toolCalls)) continue;
|
||||
for (const call of toolCalls) {
|
||||
const id = typeof call?.id === 'string' ? call.id : '';
|
||||
const fn = call?.function;
|
||||
const name = typeof fn?.name === 'string' ? fn.name : '';
|
||||
const rawArgs = typeof fn?.arguments === 'string' ? fn.arguments : '';
|
||||
if (!id || !name) continue;
|
||||
const parsedArgs = tryParseJson<Record<string, any>>(rawArgs) || {};
|
||||
map.set(id, { name, args: parsedArgs });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
const runStream = useCallback(async (payload: Partial<Parameters<typeof agentApi.chat>[0]> & { messages: AgentChatMessage[] }) => {
|
||||
streamControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
streamControllerRef.current = controller;
|
||||
streamSeqRef.current += 1;
|
||||
const seq = streamSeqRef.current;
|
||||
|
||||
baseMessagesRef.current = payload.messages;
|
||||
assistantIndexRef.current = {};
|
||||
|
||||
setLoading(true);
|
||||
const approvedIds = payload.approved_tool_call_ids || [];
|
||||
if (Array.isArray(approvedIds) && approvedIds.length > 0) {
|
||||
const preRunning: Record<string, string> = {};
|
||||
approvedIds.forEach((id) => {
|
||||
if (typeof id === 'string' && id.trim()) preRunning[id] = '';
|
||||
});
|
||||
setRunningTools(preRunning);
|
||||
} else {
|
||||
setRunningTools({});
|
||||
}
|
||||
|
||||
try {
|
||||
await agentApi.chatStream(
|
||||
{
|
||||
messages: payload.messages,
|
||||
auto_execute: autoExecute,
|
||||
context: effectivePath ? { current_path: effectivePath } : undefined,
|
||||
approved_tool_call_ids: payload.approved_tool_call_ids,
|
||||
rejected_tool_call_ids: payload.rejected_tool_call_ids,
|
||||
},
|
||||
(evt) => {
|
||||
if (seq !== streamSeqRef.current) return;
|
||||
switch (evt.event) {
|
||||
case 'assistant_start': {
|
||||
const id = String((evt.data as any)?.id || '');
|
||||
if (!id) return;
|
||||
setMessages((prev) => {
|
||||
const idx = prev.length;
|
||||
assistantIndexRef.current[id] = idx;
|
||||
return [...prev, { role: 'assistant', content: '' }];
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'assistant_delta': {
|
||||
const id = String((evt.data as any)?.id || '');
|
||||
const delta = String((evt.data as any)?.delta || '');
|
||||
if (!id || !delta) return;
|
||||
setMessages((prev) => {
|
||||
const idx = assistantIndexRef.current[id];
|
||||
if (idx === undefined || idx < 0 || idx >= prev.length) return prev;
|
||||
const cur = prev[idx] as any;
|
||||
const curContent = typeof cur?.content === 'string' ? cur.content : extractTextContent(cur?.content);
|
||||
const next = prev.slice();
|
||||
next[idx] = { ...cur, content: (curContent || '') + delta };
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'assistant_end': {
|
||||
const id = String((evt.data as any)?.id || '');
|
||||
const msg = (evt.data as any)?.message;
|
||||
if (!id || !msg || typeof msg !== 'object') return;
|
||||
setMessages((prev) => {
|
||||
const idx = assistantIndexRef.current[id];
|
||||
if (idx === undefined || idx < 0 || idx >= prev.length) return prev;
|
||||
const next = prev.slice();
|
||||
next[idx] = msg;
|
||||
return next;
|
||||
});
|
||||
delete assistantIndexRef.current[id];
|
||||
return;
|
||||
}
|
||||
case 'tool_start': {
|
||||
const toolCallId = String((evt.data as any)?.tool_call_id || '');
|
||||
const name = String((evt.data as any)?.name || '');
|
||||
if (!toolCallId) return;
|
||||
if (name) toolNameByIdRef.current[toolCallId] = name;
|
||||
setRunningTools((prev) => ({ ...prev, [toolCallId]: name || prev[toolCallId] || '' }));
|
||||
return;
|
||||
}
|
||||
case 'tool_end': {
|
||||
const toolCallId = String((evt.data as any)?.tool_call_id || '');
|
||||
const name = String((evt.data as any)?.name || '');
|
||||
const msg = (evt.data as any)?.message;
|
||||
if (toolCallId && name) toolNameByIdRef.current[toolCallId] = name;
|
||||
if (toolCallId) {
|
||||
setRunningTools((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[toolCallId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (msg && typeof msg === 'object') {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'pending': {
|
||||
const items = Array.isArray((evt.data as any)?.pending_tool_calls) ? (evt.data as any).pending_tool_calls : [];
|
||||
setPending(items);
|
||||
return;
|
||||
}
|
||||
case 'done': {
|
||||
const base = baseMessagesRef.current || [];
|
||||
const newMessages = Array.isArray((evt.data as any)?.messages) ? (evt.data as any).messages : [];
|
||||
const nextPending = Array.isArray((evt.data as any)?.pending_tool_calls) ? (evt.data as any).pending_tool_calls : [];
|
||||
setMessages([...base, ...newMessages]);
|
||||
setPending(nextPending);
|
||||
setRunningTools({});
|
||||
assistantIndexRef.current = {};
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (controller.signal.aborted) return;
|
||||
message.error(err?.message || t('Operation failed'));
|
||||
} finally {
|
||||
if (seq === streamSeqRef.current) {
|
||||
setLoading(false);
|
||||
if (controller.signal.aborted) {
|
||||
setRunningTools({});
|
||||
assistantIndexRef.current = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [autoExecute, effectivePath, t]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
if (pending.length > 0) {
|
||||
message.warning(t('Please confirm pending actions first'));
|
||||
return;
|
||||
}
|
||||
const nextUserMsg: AgentChatMessage = { role: 'user', content: text };
|
||||
setInput('');
|
||||
const base = [...messages, nextUserMsg];
|
||||
setMessages(base);
|
||||
setPending([]);
|
||||
await runStream({ messages: base });
|
||||
}, [input, messages, pending.length, runStream, t]);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
streamControllerRef.current?.abort();
|
||||
setMessages([]);
|
||||
setPending([]);
|
||||
setExpandedTools({});
|
||||
setExpandedRaw({});
|
||||
setRunningTools({});
|
||||
}, []);
|
||||
|
||||
const approveOne = useCallback(async (id: string) => {
|
||||
await runStream({ messages, approved_tool_call_ids: [id] });
|
||||
}, [messages, runStream]);
|
||||
|
||||
const rejectOne = useCallback(async (id: string) => {
|
||||
await runStream({ messages, rejected_tool_call_ids: [id] });
|
||||
}, [messages, runStream]);
|
||||
|
||||
const approveAll = useCallback(async () => {
|
||||
const ids = pending.map((p) => p.id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
await runStream({ messages, approved_tool_call_ids: ids });
|
||||
}, [messages, pending, runStream]);
|
||||
|
||||
const rejectAll = useCallback(async () => {
|
||||
const ids = pending.map((p) => p.id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
await runStream({ messages, rejected_tool_call_ids: ids });
|
||||
}, [messages, pending, runStream]);
|
||||
|
||||
const messageItems = useMemo(() => {
|
||||
return messages.filter((m) => {
|
||||
if (!m || typeof m !== 'object') return false;
|
||||
const role = typeof (m as any).role === 'string' ? String((m as any).role) : '';
|
||||
if (!role || role === 'system') return false;
|
||||
if (role === 'assistant') {
|
||||
const text = extractTextContent((m as any).content);
|
||||
return !!text.trim();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const runningToolEntries = useMemo(() => Object.entries(runningTools).filter(([id]) => !!id), [runningTools]);
|
||||
const runningToolCount = runningToolEntries.length;
|
||||
|
||||
const copyToClipboard = useCallback(async (raw: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
message.success(t('Copied'));
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Operation failed'));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const renderToolResultSummary = useCallback((rawContent: string) => {
|
||||
const payload = parseToolPayload(rawContent);
|
||||
if (!payload) return '';
|
||||
const summary = typeof payload.summary === 'string' ? payload.summary.trim() : '';
|
||||
if (summary) return summary;
|
||||
|
||||
if (payload.ok === false) {
|
||||
const message = typeof payload.error?.message === 'string' ? payload.error.message : '';
|
||||
return message ? `${t('Error')}: ${message}` : t('Error');
|
||||
}
|
||||
|
||||
const view = payload.view || {};
|
||||
const viewType = typeof view.type === 'string' ? view.type : '';
|
||||
if (viewType === 'text') {
|
||||
const text = typeof view.text === 'string' ? view.text : '';
|
||||
return text ? `${text.length} ${t('chars')}` : '';
|
||||
}
|
||||
if (viewType === 'list') {
|
||||
const items = Array.isArray(view.items) ? view.items : [];
|
||||
return `${items.length} ${t('items')}`;
|
||||
}
|
||||
if (viewType === 'kv') {
|
||||
const items = Array.isArray(view.items) ? view.items : [];
|
||||
return `${items.length} ${t('items')}`;
|
||||
}
|
||||
return '';
|
||||
}, [t]);
|
||||
|
||||
const renderToolDetails = useCallback((toolKey: string, rawContent: string) => {
|
||||
const payload = parseToolPayload(rawContent);
|
||||
const view = payload?.view;
|
||||
const showRaw = !!expandedRaw[toolKey];
|
||||
const toggleRaw = () => setExpandedRaw((prev) => ({ ...prev, [toolKey]: !prev[toolKey] }));
|
||||
|
||||
const rawJson = (() => {
|
||||
if (!rawContent?.trim()) return '';
|
||||
const parsed = tryParseJson<any>(rawContent);
|
||||
if (!parsed) return rawContent;
|
||||
try {
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return rawContent;
|
||||
}
|
||||
})();
|
||||
|
||||
const header = (
|
||||
<Space size={10} wrap>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CodeOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); toggleRaw(); }}
|
||||
>
|
||||
{t('Raw JSON')}
|
||||
</Button>
|
||||
{showRaw && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); void copyToClipboard(rawJson); }}
|
||||
>
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
const viewType = typeof view?.type === 'string' ? view.type : '';
|
||||
const title = typeof view?.title === 'string' ? view.title : '';
|
||||
const metaEntries = isPlainObject(view?.meta) ? Object.entries(view!.meta) : [];
|
||||
|
||||
const renderMeta = () => {
|
||||
if (metaEntries.length === 0 && !title) return null;
|
||||
return (
|
||||
<>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
{title ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{title}</Text>
|
||||
) : null}
|
||||
{metaEntries.slice(0, 6).map(([key, value]) => (
|
||||
<Text key={key} type="secondary" style={{ fontSize: 12 }}>
|
||||
{key}: {formatDisplayValue(value, 180) || '-'}
|
||||
</Text>
|
||||
))}
|
||||
</Space>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (viewType === 'error') {
|
||||
const message = typeof view?.message === 'string'
|
||||
? view.message
|
||||
: (typeof payload?.error?.message === 'string' ? payload.error.message : t('Error'));
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{message || t('Error')}
|
||||
</Paragraph>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewType === 'text') {
|
||||
const text = typeof view?.text === 'string' ? view.text : '';
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{renderMeta()}
|
||||
<pre className="fx-agent-pre" style={{ marginTop: metaEntries.length || title ? 0 : 10 }}>{text || ''}</pre>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewType === 'kv') {
|
||||
const items = Array.isArray(view?.items) ? view!.items : [];
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{renderMeta()}
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
locale={{ emptyText: t('No results') }}
|
||||
renderItem={(item: any, idx) => {
|
||||
const key = typeof item?.key === 'string' ? item.key : (typeof item?.label === 'string' ? item.label : String(idx));
|
||||
const value = typeof item?.value === 'string' ? item.value : formatDisplayValue(item?.value, 200);
|
||||
return (
|
||||
<List.Item>
|
||||
<Space size={10} wrap>
|
||||
<Text code style={{ fontVariantNumeric: 'tabular-nums' }}>{key || '-'}</Text>
|
||||
<Text>{value || '-'}</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewType === 'list') {
|
||||
const items = Array.isArray(view?.items) ? view!.items : [];
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{renderMeta()}
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
locale={{ emptyText: t('No results') }}
|
||||
renderItem={(item: any) => {
|
||||
if (isPlainObject(item)) {
|
||||
const entries = Object.entries(item);
|
||||
const shown = entries.slice(0, 4);
|
||||
const extra = entries.length - shown.length;
|
||||
return (
|
||||
<List.Item>
|
||||
<Space size={10} wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space size={10} wrap>
|
||||
{shown.map(([key, value]) => (
|
||||
<Text key={key}>
|
||||
<Text type="secondary">{key}</Text>: {formatDisplayValue(value, 160) || '-'}
|
||||
</Text>
|
||||
))}
|
||||
{extra > 0 ? <Text type="secondary">+{extra}</Text> : null}
|
||||
</Space>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<List.Item>
|
||||
<Text>{formatDisplayValue(item, 200) || '-'}</Text>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{showRaw ? (
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
) : (
|
||||
<Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{extractTextContent(payload ?? rawContent) || <Text type="secondary">{t('No content')}</Text>}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [copyToClipboard, expandedRaw, t]);
|
||||
|
||||
const renderToolArgsSummary = useCallback((args?: Record<string, any> | null) => {
|
||||
const entries = Object.entries(args || {})
|
||||
.filter(([, value]) => value != null && String(value).trim() !== '');
|
||||
if (entries.length === 0) return '';
|
||||
return entries.slice(0, 2)
|
||||
.map(([key, value]) => `${key}: ${formatDisplayValue(value, 60)}`)
|
||||
.join(' · ');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={(
|
||||
<Flex align="center" justify="space-between" gap={12} wrap>
|
||||
<Text strong>{t('AI Agent')}</Text>
|
||||
<Space align="center">
|
||||
<Text type="secondary">{t('Auto execute')}</Text>
|
||||
<Switch size="small" checked={autoExecute} onChange={setAutoExecute} />
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={clearChat}
|
||||
disabled={loading || messageItems.length === 0}
|
||||
>
|
||||
{t('Clear')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
)}
|
||||
open={open}
|
||||
onCancel={() => { streamControllerRef.current?.abort(); onOpenChange(false); }}
|
||||
width={720}
|
||||
centered
|
||||
closable={false}
|
||||
destroyOnHidden
|
||||
footer={null}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 8,
|
||||
background: token.colorBgContainer,
|
||||
height: '70vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex vertical gap={0} style={{ flex: 1, minHeight: 0 }} className="fx-agent-container">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="fx-agent-chat-scroll"
|
||||
>
|
||||
{messageItems.length === 0 ? (
|
||||
<div className="fx-agent-empty">
|
||||
<Avatar size={36} icon={<RobotOutlined />} style={{ background: token.colorPrimary }} />
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary">{t('Start a conversation')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fx-agent-messages">
|
||||
{messageItems.map((m, idx) => {
|
||||
const role = String((m as any).role);
|
||||
const isUser = role === 'user';
|
||||
const isTool = role === 'tool';
|
||||
const toolCallId = typeof (m as any).tool_call_id === 'string' ? String((m as any).tool_call_id) : '';
|
||||
const toolInfo = toolCallId ? toolCallsById.get(toolCallId) : null;
|
||||
const toolName = toolInfo?.name || (toolCallId ? toolNameByIdRef.current[toolCallId] : '') || '';
|
||||
const msgKey = toolCallId ? `tool:${toolCallId}` : `${role}:${idx}`;
|
||||
|
||||
if (isTool) {
|
||||
const rawContent = extractTextContent((m as any).content);
|
||||
const expanded = !!expandedTools[msgKey];
|
||||
const summary = rawContent ? renderToolResultSummary(rawContent) : '';
|
||||
return (
|
||||
<div key={msgKey} className="fx-agent-msg fx-agent-msg-tool">
|
||||
<div className="fx-agent-tool-block">
|
||||
<div className="fx-agent-tool-bar">
|
||||
<Space size={6} wrap className="fx-agent-tool-pills">
|
||||
<Tag className="fx-agent-pill" bordered={false} icon={<ToolOutlined />}>
|
||||
{t('MCP Tool')}
|
||||
</Tag>
|
||||
<Tag className="fx-agent-pill fx-agent-pill-strong" bordered={false} icon={<CodeOutlined />}>
|
||||
{toolName || t('Tool')}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={expanded ? <UpOutlined /> : <DownOutlined />}
|
||||
onClick={() => setExpandedTools((prev) => ({ ...prev, [msgKey]: !prev[msgKey] }))}
|
||||
>
|
||||
{expanded ? t('Collapse') : t('Expand')}
|
||||
</Button>
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="fx-agent-tool-summary-line">
|
||||
<Text type="secondary">{summary}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
{expanded && (
|
||||
<div className="fx-agent-tool-expanded">
|
||||
{toolInfo?.args && Object.keys(toolInfo.args).length > 0 && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('Arguments')}</Text>
|
||||
<pre className="fx-agent-pre fx-agent-pre-compact">
|
||||
{JSON.stringify(toolInfo.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{renderToolDetails(msgKey, rawContent)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const text = extractTextContent((m as any).content);
|
||||
if (isUser) {
|
||||
return (
|
||||
<div key={msgKey} className="fx-agent-msg fx-agent-msg-user">
|
||||
<div className="fx-agent-user-block fx-agent-content">
|
||||
{text.trim() ? <div className="fx-agent-text">{text}</div> : <Text type="secondary">{t('No content')}</Text>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msgKey} className="fx-agent-msg fx-agent-msg-assistant">
|
||||
<div className="fx-agent-assistant-block fx-agent-content">
|
||||
{text.trim() ? (
|
||||
<div className="fx-agent-md">
|
||||
<ReactMarkdown>{text}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">{t('No content')}</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{runningToolCount > 0 && (
|
||||
<div className="fx-agent-running">
|
||||
<LoadingOutlined spin />
|
||||
<Text type="secondary">{t('Calling tools')}</Text>
|
||||
<Space size={6} wrap>
|
||||
{runningToolEntries.slice(0, 2).map(([id, name]) => (
|
||||
<Tag key={id} bordered={false} color="blue">
|
||||
{(name || t('Tool'))} #{shortId(id, 4)}
|
||||
</Tag>
|
||||
))}
|
||||
{runningToolCount > 2 && (
|
||||
<Text type="secondary">+{runningToolCount - 2}</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{pending.length > 0 && (
|
||||
<div className="fx-agent-pending-group">
|
||||
<div className="fx-agent-pending-head">
|
||||
<Space size={8} wrap>
|
||||
<Tag className="fx-agent-pill fx-agent-pill-warn" bordered={false}>
|
||||
{t('Pending actions')}
|
||||
</Tag>
|
||||
<Text type="secondary">{pending.length}</Text>
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Button size="small" type="primary" onClick={approveAll} loading={loading}>
|
||||
{t('Execute all')}
|
||||
</Button>
|
||||
<Button size="small" onClick={rejectAll} disabled={loading}>
|
||||
{t('Cancel all')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="fx-agent-pending-list">
|
||||
{pending.map((p) => {
|
||||
const args = p.arguments || {};
|
||||
const key = `pending:${p.id}`;
|
||||
const expanded = !!expandedTools[key];
|
||||
const running = Object.prototype.hasOwnProperty.call(runningTools, p.id);
|
||||
const summary = renderToolArgsSummary(args);
|
||||
return (
|
||||
<div key={p.id} className="fx-agent-tool-block fx-agent-pending-item">
|
||||
<div className="fx-agent-tool-bar">
|
||||
<Space size={6} wrap className="fx-agent-tool-pills">
|
||||
<Tag className="fx-agent-pill" bordered={false} icon={<ToolOutlined />}>
|
||||
{t('MCP Tool')}
|
||||
</Tag>
|
||||
<Tag className="fx-agent-pill fx-agent-pill-strong" bordered={false} icon={<CodeOutlined />}>
|
||||
{p.name}
|
||||
</Tag>
|
||||
{running ? <LoadingOutlined spin style={{ color: token.colorPrimary }} /> : null}
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => void approveOne(p.id)}
|
||||
loading={loading && running}
|
||||
disabled={loading && !running}
|
||||
>
|
||||
{t('Execute')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => void rejectOne(p.id)}
|
||||
disabled={loading && !running}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={expanded ? <UpOutlined /> : <DownOutlined />}
|
||||
onClick={() => setExpandedTools((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="fx-agent-tool-summary-line">
|
||||
<Text type="secondary">{summary}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
{expanded && (
|
||||
<div className="fx-agent-tool-expanded">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('Arguments')}</Text>
|
||||
<pre className="fx-agent-pre">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fx-agent-composer">
|
||||
<Flex vertical gap={8}>
|
||||
<Space wrap>
|
||||
{effectivePath && (
|
||||
<Tag bordered={false} color="blue">{t('Current')}: {effectivePath}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Input.TextArea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t('Type a message')}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
autoFocus
|
||||
disabled={loading || pending.length > 0}
|
||||
variant="borderless"
|
||||
onPressEnter={(e) => {
|
||||
if (e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSend}
|
||||
loading={loading}
|
||||
disabled={loading || pending.length > 0 || !input.trim()}
|
||||
>
|
||||
{t('Send')}
|
||||
</Button>
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
</Flex>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AiAgentWidget;
|
||||
20
web/src/components/LanguageSwitcher.tsx
Normal file
20
web/src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Dropdown, Button } from 'antd';
|
||||
import { GlobalOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { memo } from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
const LanguageSwitcher = memo(function LanguageSwitcher() {
|
||||
const { lang, setLang, t } = useI18n();
|
||||
const items = [
|
||||
{ key: 'zh', label: t('Chinese'), icon: lang === 'zh' ? <CheckOutlined /> : undefined, onClick: () => setLang('zh') },
|
||||
{ key: 'en', label: t('English'), icon: lang === 'en' ? <CheckOutlined /> : undefined, onClick: () => setLang('en') },
|
||||
];
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['click']}>
|
||||
<Button icon={<GlobalOutlined />}>{t('Language')}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
export default LanguageSwitcher;
|
||||
|
||||
184
web/src/components/NoticesModal.tsx
Normal file
184
web/src/components/NoticesModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, List, Typography, theme, Flex, Button, Empty, message, Divider, Spin } from 'antd';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { format } from 'date-fns';
|
||||
import { noticesApi, type NoticeItem } from '../api/notices';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface NoticesModalProps {
|
||||
open: boolean;
|
||||
version: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NoticesModal = memo(function NoticesModal({ open, version, onClose }: NoticesModalProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<NoticeItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
const selected = useMemo(() => items.find(i => i.id === selectedId) ?? null, [items, selectedId]);
|
||||
const hasMore = items.length < total;
|
||||
|
||||
const loadPage = async (targetPage: number, mode: 'replace' | 'append') => {
|
||||
if (mode === 'replace') setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
try {
|
||||
const resp = await noticesApi.list({ version, page: targetPage });
|
||||
setPage(resp.page ?? targetPage);
|
||||
setTotal(resp.total ?? 0);
|
||||
setItems(prev => mode === 'replace' ? resp.items : [...prev, ...resp.items]);
|
||||
if (mode === 'replace') {
|
||||
setSelectedId(resp.items[0]?.id ?? null);
|
||||
} else {
|
||||
setSelectedId(prev => prev ?? resp.items[0]?.id ?? null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error(e.message || t('Error'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setItems([]);
|
||||
setPage(1);
|
||||
setTotal(0);
|
||||
setSelectedId(null);
|
||||
loadPage(1, 'replace');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, version]);
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
try {
|
||||
return format(new Date(ts), 'yyyy-MM-dd HH:mm');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Notices')}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={980}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
height: '70vh',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex style={{ height: '70vh', minHeight: 0 }}>
|
||||
<div style={{
|
||||
width: 320,
|
||||
minWidth: 280,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
}}>
|
||||
<Typography.Text type="secondary">{t('Total')}: {total}</Typography.Text>
|
||||
<Typography.Text type="secondary">{items.length}/{total}</Typography.Text>
|
||||
</div>
|
||||
<List
|
||||
size="small"
|
||||
loading={loading && items.length === 0}
|
||||
dataSource={items}
|
||||
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
||||
renderItem={(item) => {
|
||||
const isSelected = item.id === selectedId;
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? 'rgba(22,119,255,0.08)' : undefined,
|
||||
borderInlineStart: isSelected ? `3px solid ${token.colorPrimary}` : '3px solid transparent',
|
||||
paddingInlineStart: 10,
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={<Typography.Text strong={isSelected}>{item.title}</Typography.Text>}
|
||||
description={<Typography.Text type="secondary">{formatTime(item.createdAt)}</Typography.Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
padding: 12,
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}>
|
||||
<Button
|
||||
block
|
||||
loading={loadingMore}
|
||||
disabled={!hasMore}
|
||||
onClick={() => loadPage(page + 1, 'append')}
|
||||
>
|
||||
{t('Load more')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, padding: 16, overflow: 'auto' }}>
|
||||
{selected ? (
|
||||
<>
|
||||
<Typography.Title level={4} style={{ marginTop: 0, marginBottom: 6 }}>
|
||||
{selected.title}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">{formatTime(selected.createdAt)}</Typography.Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
{selected.contentMd?.trim() ? (
|
||||
<div style={{ color: token.colorText, lineHeight: 1.7 }}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
||||
ul: ({ ...props }) => <ul style={{ paddingLeft: 20, marginBottom: 12 }} {...props} />,
|
||||
li: ({ ...props }) => <li style={{ marginBottom: 6 }} {...props} />,
|
||||
p: ({ ...props }) => <p style={{ marginBottom: 12 }} {...props} />,
|
||||
}}
|
||||
>
|
||||
{selected.contentMd}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description={t('No content')} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<Empty description={t('No notices')} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default NoticesModal;
|
||||
|
||||
8
web/src/components/PageCard.tsx
Normal file
8
web/src/components/PageCard.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Card, type CardProps } from 'antd';
|
||||
import { memo } from 'react';
|
||||
|
||||
const PageCard = memo((props: CardProps) => {
|
||||
return <Card styles={{ body: { overflowY: 'auto', height: 'calc(100vh - 145px)' } }} {...props} />;
|
||||
});
|
||||
|
||||
export default PageCard;
|
||||
143
web/src/components/PathSelectorModal.tsx
Normal file
143
web/src/components/PathSelectorModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Button, List, Typography, Space, Input, message } from 'antd';
|
||||
import { FolderOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../i18n';
|
||||
import { vfsApi, type VfsEntry } from '../api/client';
|
||||
import { getFileIcon } from '../pages/FileExplorerPage/components/FileIcons';
|
||||
|
||||
export type PathSelectorMode = 'directory' | 'file' | 'any';
|
||||
|
||||
interface PathSelectorModalProps {
|
||||
open: boolean;
|
||||
mode?: PathSelectorMode;
|
||||
initialPath?: string;
|
||||
onOk: (path: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
if (!p) return '/';
|
||||
const s = ('/' + p).replace(/\/+/, '/');
|
||||
return s.replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
const base = normalizePath(dir);
|
||||
if (base === '/') return `/${name}`;
|
||||
return `${base}/${name}`.replace(/\/+/, '/');
|
||||
}
|
||||
|
||||
const PathSelectorModal = memo(function PathSelectorModal({ open, mode = 'directory', initialPath = '/', onOk, onCancel }: PathSelectorModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [path, setPath] = useState<string>(normalizePath(initialPath));
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null); // selected file name within current folder
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (mode === 'file') return t('Select File');
|
||||
if (mode === 'any') return t('Select Path');
|
||||
return t('Select Folder');
|
||||
}, [mode, t]);
|
||||
|
||||
const load = async (p: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const listing = await vfsApi.list(p, 1, 500, 'name', 'asc');
|
||||
setEntries(listing.entries);
|
||||
setPath(listing.path || p);
|
||||
setSelected(null);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
load(normalizePath(initialPath));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialPath]);
|
||||
|
||||
const canOk = useMemo(() => {
|
||||
if (mode === 'file') return !!selected;
|
||||
return true;
|
||||
}, [mode, selected]);
|
||||
|
||||
const handleOk = () => {
|
||||
if (mode === 'directory') {
|
||||
onOk(normalizePath(path));
|
||||
return;
|
||||
}
|
||||
if (mode === 'file') {
|
||||
if (!selected) {
|
||||
message.warning(t('Please select a file'));
|
||||
return;
|
||||
}
|
||||
onOk(joinPath(path, selected));
|
||||
return;
|
||||
}
|
||||
// any
|
||||
if (selected) onOk(joinPath(path, selected));
|
||||
else onOk(normalizePath(path));
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
const cur = normalizePath(path);
|
||||
if (cur === '/') return;
|
||||
const parent = cur.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||||
load(parent);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleOk}
|
||||
okButtonProps={{ disabled: !canOk }}
|
||||
width={720}
|
||||
>
|
||||
<Space style={{ width: '100%', marginBottom: 12 }} align="center">
|
||||
<Typography.Text type="secondary">{t('Current')}</Typography.Text>
|
||||
<Input value={path} readOnly />
|
||||
<Button onClick={goUp} icon={<ArrowUpOutlined />} disabled={path === '/'}>{t('Up')}</Button>
|
||||
{mode !== 'file' && (
|
||||
<Button type="primary" onClick={() => onOk(normalizePath(path))}>{t('Select Current Folder')}</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<List
|
||||
bordered
|
||||
loading={loading}
|
||||
dataSource={entries}
|
||||
style={{ maxHeight: 420, overflow: 'auto' }}
|
||||
renderItem={(item) => {
|
||||
const isSelected = selected === item.name && !item.is_dir;
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => {
|
||||
if (item.is_dir) {
|
||||
load(joinPath(path, item.name));
|
||||
} else {
|
||||
setSelected((prev) => (prev === item.name ? null : item.name));
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer', background: isSelected ? 'rgba(22,119,255,0.08)' : undefined }}
|
||||
>
|
||||
<Space>
|
||||
{item.is_dir ? <FolderOutlined /> : getFileIcon(item.name)}
|
||||
<Typography.Text strong={item.is_dir}>{item.name}</Typography.Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default PathSelectorModal;
|
||||
|
||||
63
web/src/components/ProcessorConfigForm.tsx
Normal file
63
web/src/components/ProcessorConfigForm.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Select, Typography } from 'antd';
|
||||
import type { ProcessorTypeMeta } from '../api/processors';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
interface ProcessorConfigFormProps {
|
||||
processorMeta: ProcessorTypeMeta | undefined;
|
||||
form: any;
|
||||
configPath: string[];
|
||||
}
|
||||
|
||||
export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ processorMeta, configPath }) => {
|
||||
const { t } = useI18n();
|
||||
if (!processorMeta) {
|
||||
return <Typography.Text type="secondary">{t('Please select a processor')}</Typography.Text>;
|
||||
}
|
||||
if (!processorMeta.config_schema?.length) {
|
||||
return <Typography.Text type="secondary">{t('No config fields')}</Typography.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{processorMeta.config_schema.map(field => {
|
||||
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
|
||||
let inputNode: React.ReactNode;
|
||||
|
||||
switch (field.type) {
|
||||
case 'password':
|
||||
inputNode = <Input.Password placeholder={field.placeholder} />;
|
||||
break;
|
||||
case 'number':
|
||||
inputNode = <Input type="number" placeholder={field.placeholder} />;
|
||||
break;
|
||||
case 'select':
|
||||
inputNode = (
|
||||
<Select placeholder={field.placeholder || t('Please select')}>
|
||||
{field.options?.map((opt: any) => (
|
||||
<Select.Option key={String(opt.value)} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
inputNode = <Input placeholder={field.placeholder} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={[...configPath, field.key]}
|
||||
label={t(field.label)}
|
||||
rules={rules}
|
||||
initialValue={field.default}
|
||||
>
|
||||
{inputNode}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
122
web/src/components/ProfileModal.tsx
Normal file
122
web/src/components/ProfileModal.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Modal, Form, Input, message, Collapse } from 'antd';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface ProfileModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ProfileModal = memo(function ProfileModal({ open, onClose }: ProfileModalProps) {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
form.setFieldsValue({
|
||||
username: user.username || '',
|
||||
full_name: user.full_name || '',
|
||||
email: user.email || '',
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
});
|
||||
} else if (!open) {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, user, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload: any = {};
|
||||
if (values.full_name !== (user?.full_name || '')) payload.full_name = values.full_name || null;
|
||||
if (values.email !== (user?.email || '')) payload.email = values.email || null;
|
||||
if (values.old_password || values.new_password) {
|
||||
payload.old_password = values.old_password || '';
|
||||
payload.new_password = values.new_password || '';
|
||||
}
|
||||
setLoading(true);
|
||||
await authApi.updateMe(payload);
|
||||
await refreshUser();
|
||||
message.success(t('Saved successfully'));
|
||||
onClose();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error(e.message || t('Save failed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Profile')}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText={t('Save')}
|
||||
cancelText={t('Cancel')}
|
||||
forceRender
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="username" label={t('Username')}>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name="full_name" label={t('Full Name')}>
|
||||
<Input placeholder={t('Full Name')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label={t('Email')}>
|
||||
<Input placeholder={t('Email')} type="email" />
|
||||
</Form.Item>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[{
|
||||
key: 'pwd',
|
||||
label: t('Change Password'),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="old_password"
|
||||
label={t('Old Password')}
|
||||
dependencies={["new_password"]}
|
||||
rules={[{
|
||||
validator: async (_, value) => {
|
||||
const newPwd = form.getFieldValue('new_password');
|
||||
if ((value && !newPwd) || (!value && newPwd)) {
|
||||
throw new Error(t('Please fill both old and new password'));
|
||||
}
|
||||
}
|
||||
}]}
|
||||
>
|
||||
<Input.Password placeholder={t('Old Password')} autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="new_password"
|
||||
label={t('New Password')}
|
||||
dependencies={["old_password"]}
|
||||
rules={[{
|
||||
validator: async (_, value) => {
|
||||
const oldPwd = form.getFieldValue('old_password');
|
||||
if ((value && !oldPwd) || (!value && oldPwd)) {
|
||||
throw new Error(t('Please fill both old and new password'));
|
||||
}
|
||||
}
|
||||
}]}
|
||||
>
|
||||
<Input.Password placeholder={t('New Password')} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}]} />
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileModal;
|
||||
26
web/src/components/WeChatModal.tsx
Normal file
26
web/src/components/WeChatModal.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Modal, theme } from 'antd';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface WeChatModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function WeChatModal({ open, onClose }: WeChatModalProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Modal open={open} onCancel={onClose} title={t('Join Community')} footer={null} width={320}>
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<img src="https://foxel.cc/image/wechat.png" width={200} alt="wechat" />
|
||||
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>
|
||||
{t('Scan to join WeChat group')}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: token.colorTextTertiary }}>
|
||||
{t('If QR expires, add drizzle2001 to join')}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user