mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 03:22:40 +08:00
feat: Implement AI Agent with enhanced tool processing capabilities (#89)
* feat: Implement AI Agent with tool processing capabilities - Added tools for listing and running processors in the agent. - Created data models for agent chat requests and tool calls. - Developed API integration for agent chat and streaming responses. - Built the AI Agent widget with a user interface for interaction. - Styled the agent components for better user experience. * feat: 增强 AI 助手工具功能,添加文件操作和搜索功能,更新界面显示 * feat: 更新 AI 助手组件 * feat: 更新 AiAgentWidget 组件样式,调整背景和边距以提升界面一致性
This commit is contained in:
121
web/src/api/agent.ts
Normal file
121
web/src/api/agent.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import request, { API_BASE_URL } from './client';
|
||||
|
||||
export type AgentChatMessage = Record<string, any>;
|
||||
|
||||
export interface AgentChatContext {
|
||||
current_path?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentChatRequest {
|
||||
messages: AgentChatMessage[];
|
||||
auto_execute?: boolean;
|
||||
approved_tool_call_ids?: string[];
|
||||
rejected_tool_call_ids?: string[];
|
||||
context?: AgentChatContext;
|
||||
}
|
||||
|
||||
export interface PendingToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
requires_confirmation: boolean;
|
||||
}
|
||||
|
||||
export interface AgentChatResponse {
|
||||
messages: AgentChatMessage[];
|
||||
pending_tool_calls?: PendingToolCall[];
|
||||
}
|
||||
|
||||
export type AgentSseEvent =
|
||||
| { event: 'assistant_start'; data: { id: string } }
|
||||
| { event: 'assistant_delta'; data: { id: string; delta: string } }
|
||||
| { event: 'assistant_end'; data: { id: string; message: AgentChatMessage } }
|
||||
| { event: 'tool_start'; data: { tool_call_id: string; name: string } }
|
||||
| { event: 'tool_end'; data: { tool_call_id: string; name: string; message: AgentChatMessage } }
|
||||
| { event: 'pending'; data: { pending_tool_calls: PendingToolCall[] } }
|
||||
| { event: 'done'; data: AgentChatResponse };
|
||||
|
||||
export const agentApi = {
|
||||
chat: (payload: AgentChatRequest) =>
|
||||
request<AgentChatResponse>('/agent/chat', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
}),
|
||||
chatStream: async (
|
||||
payload: AgentChatRequest,
|
||||
onEvent: (evt: AgentSseEvent) => void,
|
||||
options?: { signal?: AbortSignal }
|
||||
) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
};
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const resp = await fetch(`${API_BASE_URL}/agent/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
let errMsg = resp.statusText;
|
||||
try {
|
||||
const data = await resp.json();
|
||||
if (Array.isArray((data as any)?.detail)) {
|
||||
errMsg = (data as any).detail.map((e: any) => e.msg || JSON.stringify(e)).join('; ');
|
||||
} else {
|
||||
errMsg = (typeof (data as any)?.detail === 'string') ? (data as any).detail : JSON.stringify(data);
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
errMsg = await resp.text();
|
||||
} catch { void 0; }
|
||||
}
|
||||
throw new Error(errMsg || `Request failed: ${resp.status}`);
|
||||
}
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) throw new Error('Stream not supported');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const flush = (raw: string) => {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
}
|
||||
const dataStr = dataLines.join('\n').trim();
|
||||
if (!eventName || !dataStr) return;
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
onEvent({ event: eventName as any, data } as any);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
while (true) {
|
||||
const idx = buffer.indexOf('\n\n');
|
||||
if (idx === -1) break;
|
||||
const chunk = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
if (chunk.trim()) flush(chunk);
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) flush(buffer);
|
||||
},
|
||||
};
|
||||
932
web/src/components/AiAgentWidget.tsx
Normal file
932
web/src/components/AiAgentWidget.tsx
Normal file
@@ -0,0 +1,932 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Avatar, Button, Divider, Drawer, Flex, Input, List, Space, Switch, Tag, Typography, message, theme } from 'antd';
|
||||
import { RobotOutlined, SendOutlined, FolderOpenOutlined, DeleteOutlined, ToolOutlined, DownOutlined, UpOutlined, CodeOutlined, CopyOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import PathSelectorModal from './PathSelectorModal';
|
||||
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)}`;
|
||||
}
|
||||
|
||||
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 [pathModalOpen, setPathModalOpen] = useState(false);
|
||||
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 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(() => {
|
||||
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 handlePathSelected = useCallback((path: string) => {
|
||||
const p = normalizePath(path) || '/';
|
||||
setInput((prev) => (prev.trim() ? `${prev.trim()} ${p}` : p));
|
||||
setPathModalOpen(false);
|
||||
}, []);
|
||||
|
||||
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((toolName: string, rawContent: string, toolArgs?: Record<string, any> | null) => {
|
||||
const data = tryParseJson<Record<string, any>>(rawContent);
|
||||
if (!data) return '';
|
||||
|
||||
if (data.canceled) return t('Canceled');
|
||||
if (data.error) return `${t('Error')}: ${String(data.error)}`;
|
||||
|
||||
if (toolName === 'processors_list') {
|
||||
const processors = Array.isArray(data.processors) ? data.processors : [];
|
||||
return `${t('Processors')}: ${processors.length}`;
|
||||
}
|
||||
if (toolName === 'processors_run') {
|
||||
const ctx = (() => {
|
||||
const processorType = typeof toolArgs?.processor_type === 'string' ? toolArgs.processor_type.trim() : '';
|
||||
const path = typeof toolArgs?.path === 'string' ? toolArgs.path.trim() : '';
|
||||
const parts = [processorType, path].filter(Boolean);
|
||||
return parts.length ? parts.join(' · ') : '';
|
||||
})();
|
||||
if (typeof data.task_id === 'string') {
|
||||
return ctx ? `${t('Task submitted')}: ${ctx} · ${shortId(data.task_id)}` : `${t('Task submitted')}: ${shortId(data.task_id)}`;
|
||||
}
|
||||
const taskIds = Array.isArray(data.task_ids) ? data.task_ids : [];
|
||||
const scheduled = typeof data.scheduled === 'number' ? data.scheduled : taskIds.length;
|
||||
if (scheduled) return ctx ? `${t('Tasks submitted')}: ${ctx} · ${scheduled}` : `${t('Tasks submitted')}: ${scheduled}`;
|
||||
return t('Task submitted');
|
||||
}
|
||||
if (toolName === 'vfs_list_dir') {
|
||||
const path = typeof data.path === 'string' ? data.path : '';
|
||||
const entries = Array.isArray(data.entries) ? data.entries : [];
|
||||
const names = entries
|
||||
.map((it: any) => String(it?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const head = `${t('Directory')}: ${path || '/'}`;
|
||||
const tail = `${entries.length} ${t('items')}`;
|
||||
const sample = names.length ? ` · ${names.join(', ')}` : '';
|
||||
return `${head} · ${tail}${sample}`;
|
||||
}
|
||||
if (toolName === 'vfs_search') {
|
||||
const query = typeof data.query === 'string' ? data.query : '';
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
return `${t('Search')}: ${query || '-'} · ${items.length} ${t('results')}`;
|
||||
}
|
||||
if (toolName === 'vfs_stat') {
|
||||
const isDir = Boolean(data.is_dir);
|
||||
const path = typeof data.path === 'string' ? data.path : '';
|
||||
return `${t('Info')}: ${path || '-'} · ${isDir ? t('Folder') : t('File')}`;
|
||||
}
|
||||
if (toolName === 'vfs_read_text') {
|
||||
const path = typeof data.path === 'string' ? data.path : '';
|
||||
const length = typeof data.length === 'number' ? data.length : undefined;
|
||||
const truncated = Boolean(data.truncated);
|
||||
const tail = length != null ? ` · ${length} ${t('chars')}${truncated ? `(${t('Truncated')})` : ''}` : '';
|
||||
return `${t('Read')}: ${path || '-'}${tail}`;
|
||||
}
|
||||
if (toolName === 'vfs_write_text') {
|
||||
const path = typeof data.path === 'string' ? data.path : '';
|
||||
const bytes = typeof data.bytes === 'number' ? data.bytes : undefined;
|
||||
return `${t('Write')}: ${path || '-'}${bytes != null ? ` · ${bytes} bytes` : ''}`;
|
||||
}
|
||||
if (toolName === 'vfs_mkdir') {
|
||||
const path = typeof data.path === 'string' ? data.path : '';
|
||||
return `${t('Created')}: ${path || '-'}`;
|
||||
}
|
||||
if (toolName === 'vfs_delete') {
|
||||
const path = typeof data.path === 'string' ? data.path : '';
|
||||
return `${t('Deleted')}: ${path || '-'}`;
|
||||
}
|
||||
if (toolName === 'vfs_move') {
|
||||
const src = typeof data.src === 'string' ? data.src : '';
|
||||
const dst = typeof data.dst === 'string' ? data.dst : '';
|
||||
return `${t('Moved')}: ${src || '-'} → ${dst || '-'}`;
|
||||
}
|
||||
if (toolName === 'vfs_copy') {
|
||||
const src = typeof data.src === 'string' ? data.src : '';
|
||||
const dst = typeof data.dst === 'string' ? data.dst : '';
|
||||
return `${t('Copied')}: ${src || '-'} → ${dst || '-'}`;
|
||||
}
|
||||
if (toolName === 'vfs_rename') {
|
||||
const src = typeof data.src === 'string' ? data.src : '';
|
||||
const dst = typeof data.dst === 'string' ? data.dst : '';
|
||||
return `${t('Renamed')}: ${src || '-'} → ${dst || '-'}`;
|
||||
}
|
||||
return '';
|
||||
}, [t]);
|
||||
|
||||
const renderToolDetails = useCallback((toolKey: string, toolName: string, rawContent: string) => {
|
||||
const data = tryParseJson<Record<string, any>>(rawContent);
|
||||
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>
|
||||
);
|
||||
|
||||
if (toolName === 'processors_list') {
|
||||
const processors = Array.isArray(data?.processors) ? data!.processors : [];
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<List
|
||||
size="small"
|
||||
dataSource={processors}
|
||||
locale={{ emptyText: t('No results') }}
|
||||
renderItem={(item: any) => (
|
||||
<List.Item>
|
||||
<Space size={10} wrap>
|
||||
<Text code style={{ fontVariantNumeric: 'tabular-nums' }}>{String(item?.type || '')}</Text>
|
||||
<Text>{String(item?.name || '')}</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === 'vfs_list_dir') {
|
||||
const path = typeof data?.path === 'string' ? data!.path : '/';
|
||||
const entries = Array.isArray(data?.entries) ? data!.entries : [];
|
||||
const pagination = data?.pagination && typeof data.pagination === 'object' ? data.pagination : null;
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('Directory')}: {path}</Text>
|
||||
{pagination?.total != null ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('Total')}: {String(pagination.total)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<List
|
||||
size="small"
|
||||
dataSource={entries}
|
||||
locale={{ emptyText: t('No results') }}
|
||||
renderItem={(item: any) => {
|
||||
const name = String(item?.name || '');
|
||||
const type = String(item?.type || (item?.is_dir ? 'dir' : 'file'));
|
||||
return (
|
||||
<List.Item>
|
||||
<Space size={10} wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space size={10} wrap>
|
||||
<Text code style={{ fontVariantNumeric: 'tabular-nums' }}>{type}</Text>
|
||||
<Text>{name}</Text>
|
||||
</Space>
|
||||
{!item?.is_dir && typeof item?.size === 'number' ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{item.size} bytes</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === 'vfs_search') {
|
||||
const query = typeof data?.query === 'string' ? data!.query : '';
|
||||
const mode = typeof data?.mode === 'string' ? data!.mode : '';
|
||||
const items = Array.isArray(data?.items) ? data!.items : [];
|
||||
const pagination = data?.pagination && typeof data.pagination === 'object' ? data.pagination : null;
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('Search')}: {query || '-'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('Mode')}: {mode || '-'}</Text>
|
||||
{pagination?.has_more != null ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('Page')}: {String(pagination.page)} · {t('Has more')}: {String(Boolean(pagination.has_more))}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
locale={{ emptyText: t('No results') }}
|
||||
renderItem={(item: any) => {
|
||||
const type = String(item?.source_type || item?.mime || '');
|
||||
const path = String(item?.path || '');
|
||||
const score = item?.score != null ? Number(item.score) : null;
|
||||
return (
|
||||
<List.Item>
|
||||
<Space size={10} wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space size={10} wrap>
|
||||
{type ? <Text code style={{ fontVariantNumeric: 'tabular-nums' }}>{type}</Text> : null}
|
||||
<Text>{path}</Text>
|
||||
</Space>
|
||||
{score != null && !Number.isNaN(score) ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{score.toFixed(3)}</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === 'vfs_read_text') {
|
||||
const path = typeof data?.path === 'string' ? data!.path : '';
|
||||
const content = typeof data?.content === 'string' ? data!.content : '';
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('File')}: {path || '-'}</Text>
|
||||
<pre className="fx-agent-pre" style={{ marginTop: 10 }}>{content || ''}</pre>
|
||||
{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(data ?? rawContent) || <Text type="secondary">{t('No content')}</Text>}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [copyToClipboard, expandedRaw, t]);
|
||||
|
||||
const renderToolArgsSummary = useCallback((toolName: string, args?: Record<string, any> | null) => {
|
||||
const a = args || {};
|
||||
if (toolName === 'processors_run') {
|
||||
const path = typeof a.path === 'string' ? a.path : '';
|
||||
return path ? `${t('Path')}: ${path}` : '';
|
||||
}
|
||||
if (toolName === 'vfs_read_text' || toolName === 'vfs_list_dir' || toolName === 'vfs_stat' || toolName === 'vfs_delete' || toolName === 'vfs_mkdir') {
|
||||
const path = typeof a.path === 'string' ? a.path : '';
|
||||
return path ? `${t('Path')}: ${path}` : '';
|
||||
}
|
||||
if (toolName === 'vfs_search') {
|
||||
const query = typeof a.query === 'string' ? a.query : '';
|
||||
return query ? `${t('Search')}: ${query}` : '';
|
||||
}
|
||||
if (toolName === 'vfs_write_text') {
|
||||
const path = typeof a.path === 'string' ? a.path : '';
|
||||
return path ? `${t('Path')}: ${path}` : '';
|
||||
}
|
||||
if (toolName === 'vfs_move' || toolName === 'vfs_copy' || toolName === 'vfs_rename') {
|
||||
const src = typeof a.src === 'string' ? a.src : '';
|
||||
const dst = typeof a.dst === 'string' ? a.dst : '';
|
||||
if (src && dst) return `${src} → ${dst}`;
|
||||
if (src) return src;
|
||||
if (dst) return dst;
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
title={t('AI Agent')}
|
||||
open={open}
|
||||
onClose={() => { streamControllerRef.current?.abort(); onOpenChange(false); }}
|
||||
width={520}
|
||||
mask={false}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
body: {
|
||||
padding: 8,
|
||||
background: token.colorBgContainer,
|
||||
},
|
||||
}}
|
||||
extra={
|
||||
<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 vertical gap={0} style={{ height: '100%' }} 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 = toolName ? renderToolResultSummary(toolName, rawContent, toolInfo?.args || null) : '';
|
||||
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, toolName || t('Tool'), 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(p.name, 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>
|
||||
<Button size="small" icon={<FolderOpenOutlined />} onClick={() => setPathModalOpen(true)} disabled={loading}>
|
||||
{t('Select Path')}
|
||||
</Button>
|
||||
{effectivePath && (
|
||||
<Tag bordered={false} color="blue">{t('Current')}: {effectivePath}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Input.TextArea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t('Type a message')}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
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>
|
||||
</Drawer>
|
||||
|
||||
<PathSelectorModal
|
||||
open={pathModalOpen}
|
||||
mode="any"
|
||||
initialPath={effectivePath || '/'}
|
||||
onOk={handlePathSelected}
|
||||
onCancel={() => setPathModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AiAgentWidget;
|
||||
@@ -690,5 +690,40 @@
|
||||
"App \"{key}\" not found.": "App \"{key}\" not found.",
|
||||
"Open with {app}": "Open with {app}",
|
||||
"Set as default for .{ext}": "Set as default for .{ext}",
|
||||
"Advanced tokens must be valid JSON": "Advanced tokens must be valid JSON"
|
||||
"AI Agent": "AI Agent",
|
||||
"Auto execute": "Auto execute",
|
||||
"Start a conversation": "Start a conversation",
|
||||
"No content": "No content",
|
||||
"Pending actions": "Pending actions",
|
||||
"Execute": "Execute",
|
||||
"Execute all": "Execute all",
|
||||
"Cancel all": "Cancel all",
|
||||
"Type a message": "Type a message",
|
||||
"Send": "Send",
|
||||
"Please confirm pending actions first": "Please confirm pending actions first",
|
||||
"You": "You",
|
||||
"Tool": "Tool",
|
||||
"MCP Tool": "MCP Tool",
|
||||
"Arguments": "Arguments",
|
||||
"Raw JSON": "Raw JSON",
|
||||
"Collapse": "Collapse",
|
||||
"Copied": "Copied",
|
||||
"Canceled": "Canceled",
|
||||
"Tasks submitted": "Tasks submitted",
|
||||
"Calling tools": "Calling tools",
|
||||
"Advanced tokens must be valid JSON": "Advanced tokens must be valid JSON",
|
||||
"Search": "Search",
|
||||
"Total": "Total",
|
||||
"Mode": "Mode",
|
||||
"Has more": "Has more",
|
||||
"Page": "Page",
|
||||
"results": "results",
|
||||
"chars": "chars",
|
||||
"Truncated": "Truncated",
|
||||
"Write": "Write",
|
||||
"Read": "Read",
|
||||
"Created": "Created",
|
||||
"Moved": "Moved",
|
||||
"Renamed": "Renamed",
|
||||
"Info": "Info"
|
||||
}
|
||||
|
||||
@@ -683,5 +683,40 @@
|
||||
"App \"{key}\" not found.": "应用 \"{key}\" 不存在。",
|
||||
"Open with {app}": "使用 {app} 打开",
|
||||
"Set as default for .{ext}": "设为该类型(.{ext})默认应用",
|
||||
"Advanced tokens must be valid JSON": "高级 Token 需为合法 JSON"
|
||||
"AI Agent": "AI 助手",
|
||||
"Auto execute": "自动执行",
|
||||
"Start a conversation": "开始对话",
|
||||
"No content": "无内容",
|
||||
"Pending actions": "待确认操作",
|
||||
"Execute": "执行",
|
||||
"Execute all": "全部执行",
|
||||
"Cancel all": "全部取消",
|
||||
"Type a message": "输入消息",
|
||||
"Send": "发送",
|
||||
"Please confirm pending actions first": "请先确认待执行操作",
|
||||
"You": "你",
|
||||
"Tool": "工具",
|
||||
"MCP Tool": "MCP 工具",
|
||||
"Arguments": "参数",
|
||||
"Raw JSON": "原始 JSON",
|
||||
"Collapse": "收起",
|
||||
"Copied": "已复制",
|
||||
"Canceled": "已取消",
|
||||
"Tasks submitted": "已提交任务",
|
||||
"Calling tools": "正在调用工具",
|
||||
"Advanced tokens must be valid JSON": "高级 Token 需为合法 JSON",
|
||||
"Search": "搜索",
|
||||
"Total": "总计",
|
||||
"Mode": "模式",
|
||||
"Has more": "更多",
|
||||
"Page": "页",
|
||||
"results": "条结果",
|
||||
"chars": "字符",
|
||||
"Truncated": "已截断",
|
||||
"Write": "写入",
|
||||
"Read": "读取",
|
||||
"Created": "已创建",
|
||||
"Moved": "已移动",
|
||||
"Renamed": "已重命名",
|
||||
"Info": "信息"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography } from 'antd';
|
||||
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip } from 'antd';
|
||||
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { memo, useState } from 'react';
|
||||
import SearchDialog from './SearchDialog.tsx';
|
||||
import { authApi } from '../api/auth.ts';
|
||||
@@ -14,9 +14,10 @@ const { Header } = Layout;
|
||||
export interface TopHeaderProps {
|
||||
collapsed: boolean;
|
||||
onToggle(): void;
|
||||
onOpenAiAgent(): void;
|
||||
}
|
||||
|
||||
const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProps) {
|
||||
const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }: TopHeaderProps) {
|
||||
const { token } = theme.useToken();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
@@ -50,6 +51,15 @@ const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProp
|
||||
</Button>
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
|
||||
<Tooltip title={t('AI Agent')}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RobotOutlined />}
|
||||
aria-label={t('AI Agent')}
|
||||
onClick={onOpenAiAgent}
|
||||
style={{ paddingInline: 8, height: 40 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<LanguageSwitcher />
|
||||
<Dropdown
|
||||
menu={{
|
||||
|
||||
@@ -16,6 +16,7 @@ import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
|
||||
import PluginsPage from '../pages/PluginsPage.tsx';
|
||||
import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext';
|
||||
import { AppWindowsLayer } from '../apps/AppWindowsLayer';
|
||||
import AiAgentWidget from '../components/AiAgentWidget';
|
||||
|
||||
const ShellBody = memo(function ShellBody() {
|
||||
const params = useParams<{ navKey?: string; '*': string }>();
|
||||
@@ -24,11 +25,13 @@ const ShellBody = memo(function ShellBody() {
|
||||
const navigate = useNavigate();
|
||||
const COLLAPSED_KEY = 'layout.siderCollapsed';
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(COLLAPSED_KEY) === '1');
|
||||
const [agentOpen, setAgentOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
localStorage.setItem(COLLAPSED_KEY, collapsed ? '1' : '0');
|
||||
}, [collapsed]);
|
||||
const { windows, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows();
|
||||
const settingsTab = navKey === 'settings' ? (subPath.split('/')[0] || undefined) : undefined;
|
||||
const agentCurrentPath = navKey === 'files' ? ('/' + subPath).replace(/\/+/g, '/').replace(/\/+$/, '') || '/' : null;
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', background: 'var(--ant-color-bg-layout)' }}>
|
||||
<SideNav
|
||||
@@ -44,7 +47,7 @@ const ShellBody = memo(function ShellBody() {
|
||||
}}
|
||||
/>
|
||||
<Layout style={{ background: 'var(--ant-color-bg-layout)' }}>
|
||||
<TopHeader collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} />
|
||||
<TopHeader collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} onOpenAiAgent={() => setAgentOpen(true)} />
|
||||
<Layout.Content style={{ padding: 16, background: 'var(--ant-color-bg-layout)' }}>
|
||||
<div style={{ minHeight: 'calc(100vh - 56px - 32px)', background: 'var(--ant-color-bg-layout)' }}>
|
||||
<Flex vertical gap={16}>
|
||||
@@ -76,6 +79,7 @@ const ShellBody = memo(function ShellBody() {
|
||||
onBringToFront={bringToFront}
|
||||
onUpdateWindow={updateWindow}
|
||||
/>
|
||||
<AiAgentWidget currentPath={agentCurrentPath} open={agentOpen} onOpenChange={setAgentOpen} />
|
||||
</Layout>
|
||||
);
|
||||
});
|
||||
|
||||
244
web/src/styles/ai-agent.css
Normal file
244
web/src/styles/ai-agent.css
Normal file
@@ -0,0 +1,244 @@
|
||||
.fx-agent-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fx-agent-chat-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.fx-agent-empty {
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fx-agent-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.fx-agent-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fx-agent-msg-user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.fx-agent-msg-assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fx-agent-msg-tool {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.fx-agent-user-block {
|
||||
max-width: 85%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.fx-agent-assistant-block {
|
||||
max-width: 100%;
|
||||
padding: 2px 2px;
|
||||
}
|
||||
|
||||
.fx-agent-tool-block {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
background: var(--ant-color-bg-container);
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.fx-agent-tool-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fx-agent-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.fx-agent-tool-pills .ant-tag {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.fx-agent-pill {
|
||||
border-radius: 999px;
|
||||
padding-inline: 10px;
|
||||
padding-block: 2px;
|
||||
border: 0;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.fx-agent-pill-strong {
|
||||
background: var(--ant-color-primary-bg);
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.fx-agent-pill-warn {
|
||||
background: var(--ant-color-warning-bg);
|
||||
color: var(--ant-color-warning);
|
||||
}
|
||||
|
||||
.fx-agent-tool-summary-line {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.fx-agent-tool-expanded {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.fx-agent-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.fx-agent-md {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.fx-agent-md p {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.fx-agent-md p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fx-agent-md ul,
|
||||
.fx-agent-md ol {
|
||||
margin: 0 0 0.5em;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.fx-agent-md code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.fx-agent-md pre {
|
||||
margin: 0 0 0.5em;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--ant-color-bg-container);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fx-agent-md pre code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.fx-agent-md blockquote {
|
||||
margin: 0 0 0.65em;
|
||||
padding: 0 0 0 10px;
|
||||
border-left: 3px solid var(--ant-color-border);
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.fx-agent-md a {
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.fx-agent-tool-details {
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.fx-agent-pre {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--ant-color-bg-container);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.fx-agent-pre.fx-agent-pre-compact {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.fx-agent-pending-group {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fx-agent-pending-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.fx-agent-pending-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fx-agent-composer {
|
||||
padding: 8px 0 0;
|
||||
background: transparent;
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.fx-agent-composer .ant-input {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.fx-agent-running {
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px dashed var(--ant-color-border-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
Reference in New Issue
Block a user