Files
DB-GPT/web/pages/index.tsx
Aries-ckt ef83851b31 🎉 DB-GPT V0.8.0 - Beta Testing (#2988)
Co-authored-by: lusain <lusain1990@gmail.com>
Co-authored-by: alan.cl <1165243776@qq.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 11:55:42 +08:00

3574 lines
164 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ChatContext } from '@/app/chat-context';
import ModelSelector from '@/components/chat/header/model-selector';
import { ColumnAnalysis, PreprocessingResult, analyzeDataset } from '@/new-components/analysis';
import { ChartConfig, ChartType } from '@/new-components/charts';
import ManusLeftPanel, {
ExecutionStep as ManusExecutionStep,
StepType,
ThinkingSection,
} from '@/new-components/chat/content/ManusLeftPanel';
import ManusRightPanel, {
ActiveStepInfo,
ExecutionOutput as ManusExecutionOutput,
PanelView,
} from '@/new-components/chat/content/ManusRightPanel';
import { MessagePart, ToolPart, ToolStatus } from '@/new-components/chat/content/OpenCodeSessionTurn';
import axios from '@/utils/ctx-axios';
import { sendSpacePostRequest } from '@/utils/request';
import {
ArrowUpOutlined,
AudioOutlined,
BarChartOutlined,
BellOutlined,
BookOutlined,
CheckCircleFilled,
CloudServerOutlined,
CodeOutlined,
ConsoleSqlOutlined,
DatabaseOutlined,
FileExcelOutlined,
FileImageOutlined,
FileOutlined,
FilePptOutlined,
FileTextOutlined,
LeftOutlined,
PaperClipOutlined,
PieChartOutlined,
PlusOutlined,
ReadOutlined,
RightOutlined,
SearchOutlined,
TableOutlined,
ThunderboltOutlined,
UploadOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useRequest } from 'ahooks';
import {
Avatar,
Button,
ConfigProvider,
Dropdown,
Input,
List,
Modal,
Popover,
Tag,
Tooltip,
Upload,
message,
} from 'antd';
import { NextPage } from 'next';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
const cleanFinalContent = (text: string): string => {
let cleaned = text.replace(/\\n/g, '\n').trim();
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
cleaned = cleaned.replace(/"\s*\}\s*$/, '').trim();
// Strip raw ReAct prefixes that may leak from the backend
cleaned = cleaned.replace(/^(Thought|Action|Action Input|Observation|Phase):\s*/gm, '').trim();
return cleaned;
};
const _formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
};
const _getFileTypeLabel = (fileName: string, mimeType?: string): string => {
const ext = fileName.toLowerCase().split('.').pop() || '';
if (['xlsx', 'xls'].includes(ext) || mimeType?.includes('spreadsheet') || mimeType?.includes('excel')) {
return '电子表格';
}
if (ext === 'csv' || mimeType?.includes('csv')) {
return '电子表格';
}
if (ext === 'pdf' || mimeType?.includes('pdf')) return 'PDF';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext) || mimeType?.includes('image')) return '图片';
if (['doc', 'docx'].includes(ext) || mimeType?.includes('word')) return 'Word 文档';
if (['txt', 'md'].includes(ext) || mimeType?.includes('text')) return '文本文件';
if (['json'].includes(ext)) return 'JSON';
return '文件';
};
const _getFileIcon = (fileName: string, mimeType?: string) => {
const ext = fileName.toLowerCase().split('.').pop() || '';
if (
['xlsx', 'xls', 'csv'].includes(ext) ||
mimeType?.includes('spreadsheet') ||
mimeType?.includes('excel') ||
mimeType?.includes('csv')
) {
return <FileExcelOutlined className='text-green-600 text-lg' />;
}
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext) || mimeType?.includes('image')) {
return <FileImageOutlined className='text-pink-500 text-lg' />;
}
if (['ppt', 'pptx'].includes(ext)) {
return <FilePptOutlined className='text-orange-500 text-lg' />;
}
return <FileTextOutlined className='text-blue-500 text-lg' />;
};
interface DataSource {
id: number;
type: string;
params: Record<string, any>;
description?: string;
db_name: string; // derived from params.name
db_type: string; // alias for type
gmt_created?: string;
gmt_modified?: string;
}
// Define Knowledge Base Interface (Partial)
interface KnowledgeSpace {
id: number;
name: string;
vector_type: string;
desc?: string;
owner?: string;
}
// Define file attachment type for user messages
interface FileAttachment {
name: string;
size: number;
type: string;
}
// Define message type for chat
interface ChatMessage {
id?: string;
role: 'human' | 'view';
context: string;
model_name?: string;
order?: number;
thinking?: boolean;
attachedFile?: FileAttachment;
attachedKnowledge?: KnowledgeSpace;
attachedSkill?: { name: string; id: string };
attachedDb?: { db_name: string; db_type: string };
}
interface ExecutionStep {
id: string;
step: number;
title?: string;
detail: string;
status: 'running' | 'done' | 'failed';
action?: string;
actionInput?: string;
}
interface ExecutionOutput {
output_type: string;
content: any;
}
interface FilePreview {
kind: 'table' | 'text';
file_name?: string;
file_path?: string;
columns?: string[];
rows?: Record<string, any>[];
text?: string;
shape?: [number, number];
}
interface ChartPreview {
chartType?: ChartType;
data: Array<{ x: string | number; y: number; [key: string]: any }>;
xField: string;
yField: string;
seriesField?: string;
colorField?: string;
angleField?: string;
title?: string;
description?: string;
smooth?: boolean;
}
interface Skill {
id: string;
name: string;
description: string;
type: 'official' | 'personal';
icon?: string;
}
type ArtifactType = 'file' | 'table' | 'chart' | 'image' | 'code' | 'markdown' | 'summary' | 'html';
interface Artifact {
id: string;
type: ArtifactType;
name: string;
content: any;
createdAt: number;
messageId?: string;
stepId?: string;
downloadable?: boolean;
mimeType?: string;
size?: number;
filePath?: string;
// Chart-specific metadata
chartType?: ChartType;
chartConfig?: Partial<ChartConfig>;
}
type RightPanelTab = 'preview' | 'files' | 'charts' | 'tables' | 'analysis' | 'preprocess' | 'summary';
const _convertExecutionToMessageParts = (
execution:
| {
steps: ExecutionStep[];
outputs: Record<string, ExecutionOutput[]>;
activeStepId: string | null;
collapsed: boolean;
}
| undefined,
): MessagePart[] => {
if (!execution || !execution.steps.length) return [];
return execution.steps.map((step): ToolPart => {
const outputs = execution.outputs[step.id] || [];
const outputText = outputs
.map(o => {
if (o.output_type === 'text' || o.output_type === 'markdown') {
return String(o.content);
}
if (o.output_type === 'code') {
return `\`\`\`\n${String(o.content)}\n\`\`\``;
}
if (o.output_type === 'table' || o.output_type === 'json') {
return JSON.stringify(o.content, null, 2);
}
if (o.output_type === 'html') {
return '[HTML Report]';
}
return String(o.content);
})
.filter(Boolean)
.join('\n');
const statusMap: Record<string, ToolStatus> = {
running: 'running',
done: 'completed',
failed: 'error',
};
const actionLower = (step.action || '').toLowerCase();
const isSkill =
actionLower.includes('skill') ||
actionLower === 'execute_skill_script_file' ||
actionLower === 'get_skill_resource' ||
actionLower === 'select_skill' ||
actionLower === 'load_skill';
const toolName = isSkill
? 'skill'
: step.title?.toLowerCase().includes('skill')
? 'skill'
: step.title?.toLowerCase().includes('read')
? 'read'
: step.title?.toLowerCase().includes('write')
? 'write'
: step.title?.toLowerCase().includes('code') ||
step.title?.toLowerCase().includes('execute') ||
actionLower === 'shell_interpreter'
? 'bash'
: 'task';
return {
id: step.id,
type: 'tool',
tool: toolName,
state: {
status: statusMap[step.status] || 'completed',
input: { description: step.title || 'Step', detail: step.detail },
output: outputText || step.detail,
},
};
});
};
// Convert execution data to Manus panel format
const convertToManusFormat = (
execution:
| {
steps: ExecutionStep[];
outputs: Record<string, ExecutionOutput[]>;
activeStepId: string | null;
collapsed: boolean;
stepThoughts?: Record<string, string>;
}
| undefined,
_userQuery?: string,
t?: (key: string) => string,
): {
sections: ThinkingSection[];
activeStep: ActiveStepInfo | null;
outputs: ManusExecutionOutput[];
stepThoughts: Record<string, string>;
} => {
if (!execution || !execution.steps.length) {
return { sections: [], activeStep: null, outputs: [], stepThoughts: execution?.stepThoughts || {} };
}
// Determine step type from title
const getStepType = (title?: string, action?: string): StepType => {
// Check action name first — it's the most reliable indicator
const actionLower = (action || '').toLowerCase();
if (
actionLower.includes('skill') ||
actionLower === 'execute_skill_script_file' ||
actionLower === 'get_skill_resource' ||
actionLower === 'select_skill' ||
actionLower === 'load_skill'
)
return 'skill';
if (actionLower === 'shell_interpreter') return 'bash';
if (actionLower === 'sql_query') return 'sql';
const lower = (title || '').toLowerCase();
if (
lower.includes('load_skill') ||
lower.includes('load skill') ||
lower.includes('execute_skill_script_file') ||
lower.includes('get_skill_resource') ||
lower.includes('select_skill')
)
return 'skill';
if (lower.includes('sql_query') || lower.includes('sql query') || lower.includes('sql查询')) return 'sql';
if (lower.includes('read') || lower.includes('load')) return 'read';
if (lower.includes('edit')) return 'edit';
if (lower.includes('write') || lower.includes('save')) return 'write';
if (lower.includes('bash') || lower.includes('execute') || lower.includes('command') || lower.includes('shell'))
return 'bash';
if (lower.includes('grep') || lower.includes('search')) return 'grep';
if (lower.includes('glob') || lower.includes('find')) return 'glob';
if (lower.includes('html')) return 'html';
if (lower.includes('python') || lower.includes('code')) return 'python';
if (lower.includes('skill')) return 'skill';
if (lower.includes('task')) return 'task';
return 'other';
};
// Get step status mapping
const getStepStatus = (status: string): 'pending' | 'running' | 'completed' | 'error' => {
if (status === 'running') return 'running';
if (status === 'done') return 'completed';
if (status === 'failed') return 'error';
return 'pending';
};
// Group steps into sections (for now, create one section with all steps)
// In a more advanced version, you could group by phase/category
const steps: ManusExecutionStep[] = execution.steps
.filter(step => {
const detail = (step.detail || '').toLowerCase();
return !detail.includes('action: terminate');
})
.map(step => {
const cleanDetail = step.detail?.replace(/^Thought:.*\n?/gm, '').trim();
return {
id: step.id,
type: getStepType(step.title, step.action),
title: step.title || `Step ${step.step}`,
subtitle: cleanDetail?.split('\n')[0]?.slice(0, 80),
description: cleanDetail || undefined,
phase: (step as any).phase,
status: getStepStatus(step.status),
};
});
// Create section(s)
const sections: ThinkingSection[] = [];
// Group steps by phase (free-text from model), preserving order of first appearance
const phaseOrder: string[] = [];
const phaseGroups: Record<string, typeof steps> = {};
for (const s of steps) {
const key = s.phase || '__default__';
if (!phaseGroups[key]) {
phaseGroups[key] = [];
phaseOrder.push(key);
}
phaseGroups[key].push(s);
}
for (const key of phaseOrder) {
const group = phaseGroups[key];
sections.push({
id: `section-${key}`,
title:
key === '__default__'
? t
? t('execution_steps')
: 'Execution Steps'
: t
? (t as any)(key, { defaultValue: key })
: key,
isCompleted: group.every(s => s.status === 'completed'),
steps: group,
});
}
// Get active step info
let activeStep: ActiveStepInfo | null = null;
if (execution.activeStepId) {
const step = execution.steps.find(s => s.id === execution.activeStepId);
if (step) {
const cleanDetail = step.detail?.replace(/^Thought:.*\n?/gm, '').trim();
activeStep = {
id: step.id,
type: getStepType(step.title, step.action),
title: step.title || `Step ${step.step}`,
subtitle: cleanDetail?.split('\n')[0]?.slice(0, 80),
status: getStepStatus(step.status),
detail: cleanDetail,
action: step.action,
actionInput: step.actionInput,
};
}
}
// Get outputs for active step
const outputs: ManusExecutionOutput[] = execution.activeStepId
? (execution.outputs[execution.activeStepId] || []).map(o => ({
output_type: o.output_type as any,
content: o.content,
timestamp: Date.now(),
}))
: [];
return { sections, activeStep, outputs, stepThoughts: execution?.stepThoughts || {} };
};
const EXAMPLE_CARDS = [
{
id: 'walmart_sales',
icon: '📊',
title: '沃尔玛销售数据分析',
description: '分析沃尔玛销售CSV数据生成可视化网页报告',
query:
'请全面分析这份沃尔玛销售数据,包括各门店销售趋势、假日影响、温度与油价对销售的影响等维度,生成一份精美的交互式网页分析报告。',
fileName: 'Walmart_Sales.csv',
fileType: 'text/csv',
fileSize: 98304, // ~96 KB
color: 'from-blue-500/10 to-cyan-500/10',
borderColor: 'border-blue-200/60 dark:border-blue-800/40',
iconBg: 'bg-blue-100 dark:bg-blue-900/40',
skillName: 'csv-data-analysis',
},
{
id: 'db_profile_report',
icon: '🗄️',
title: '数据库画像与分析报告',
description: '连接数据库后,生成数据库画像并生成可视化网页报告',
query:
'请分析当前连接的数据库,生成数据库画像(包括表结构、字段信息、数据量统计等),并生成一份精美的交互式网页分析报告。',
dbName: 'Walmart_Sales',
color: 'from-emerald-500/10 to-teal-500/10',
borderColor: 'border-emerald-200/60 dark:border-emerald-800/40',
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
},
{
id: 'fin_report',
icon: '📈',
title: '金融财报深度分析',
description: '分析浙江海翔药业年度报告,生成数据可视化报告',
query:
'请深度分析这份浙江海翔药业2019年年度报告包括营收利润趋势、资产负债结构、现金流分析、关键财务指标等生成一份专业的交互式网页分析报告。',
fileName: '2020-01-23__浙江海翔药业股份有限公司__002099__海翔药业__2019年__年度报告.pdf',
fileType: 'application/pdf',
fileSize: 2621440, // ~2.5 MB
color: 'from-violet-500/10 to-purple-500/10',
borderColor: 'border-violet-200/60 dark:border-violet-800/40',
iconBg: 'bg-violet-100 dark:bg-violet-900/40',
skillName: 'financial-report-analyzer',
},
{
id: 'create_sql_skill',
icon: '🛠️',
title: '创建SQL分析技能',
description: '使用skill-creator创建一个实用的SQL数据分析技能',
query:
'请使用 skill-creator 帮我创建一个实用的SQL数据分析技能包含连接数据库、执行SQL查询和数据可视化等核心功能。',
color: 'from-amber-500/10 to-orange-500/10',
borderColor: 'border-amber-200/60 dark:border-amber-800/40',
iconBg: 'bg-amber-100 dark:bg-amber-900/40',
skillName: 'skill-creator',
},
];
const Playground: NextPage = () => {
const router = useRouter();
const { t } = useTranslation();
const { model, setModel } = useContext(ChatContext);
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false);
// Selection State
const [isDbModalOpen, setIsDbModalOpen] = useState(false);
const [isKnowledgeModalOpen, setIsKnowledgeModalOpen] = useState(false);
// Contexts
const [selectedDb, setSelectedDb] = useState<DataSource | null>(null);
const [selectedKnowledge, setSelectedKnowledge] = useState<KnowledgeSpace | null>(null);
const [uploadedFile, setUploadedFile] = useState<any | null>(null);
// Chat messages state
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [conversationId, setConversationId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [executionMap, setExecutionMap] = useState<
Record<
string,
{
steps: ExecutionStep[];
outputs: Record<string, ExecutionOutput[]>;
activeStepId: string | null;
collapsed: boolean;
stepThoughts: Record<string, string>;
}
>
>({});
const [activeMessageId, setActiveMessageId] = useState<string | null>(null);
const [uploadedFilePath, setUploadedFilePath] = useState<string | null>(null);
const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
const [_filePreviewLoading, setFilePreviewLoading] = useState(false);
const [_filePreviewError, setFilePreviewError] = useState<string | null>(null);
const [chartPreview, setChartPreview] = useState<ChartPreview | null>(null);
const lastArtifactKeyRef = useRef<string>('');
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [createdSkillNames, setCreatedSkillNames] = useState<Record<string, string>>({});
const [_rightPanelTab, setRightPanelTab] = useState<RightPanelTab>('preview');
const [streamingSummary, setStreamingSummary] = useState<string>('');
const [_summaryComplete, setSummaryComplete] = useState(false);
const [_dataAnalysis, setDataAnalysis] = useState<ColumnAnalysis[] | null>(null);
const [_analysisLoading, setAnalysisLoading] = useState(false);
const [_showProfessionalReport, _setShowProfessionalReport] = useState(false);
const [_preprocessedData, _setPreprocessedData] = useState<PreprocessingResult | null>(null);
const [isSkillPanelOpen, setIsSkillPanelOpen] = useState(false);
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
const [skillSearchQuery, setSkillSearchQuery] = useState('');
const [isKnowledgePanelOpen, setIsKnowledgePanelOpen] = useState(false);
const [knowledgeSearchQuery, setKnowledgeSearchQuery] = useState('');
const [isDbPanelOpen, setIsDbPanelOpen] = useState(false);
const [dbSearchQuery, setDbSearchQuery] = useState('');
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false);
const [rightPanelView, setRightPanelView] = useState<PanelView>('execution');
const [previewArtifact, setPreviewArtifact] = useState<Artifact | null>(null);
// Active round tracking: which view message is currently selected for the right panel
const [activeViewMsgId, setActiveViewMsgId] = useState<string | null>(null);
// Track step IDs that belong to a terminate action so we can suppress them
const terminatedStepIdsRef = useRef<Set<string>>(new Set());
const preloadedFilePathRef = useRef<string | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
// Fetch Data Sources
const { data: dataSources, loading: _loadingSources } = useRequest(async () => {
try {
const response: any = await axios.get('/api/v2/serve/datasources');
// ctx-axios interceptor returns response.data directly, so response is {success, data, ...}
const result = response?.success !== undefined ? response : response?.data;
if (result?.success) {
return (result.data || []).map((item: any) => ({
...item,
db_name: item.db_name || item.params?.name || item.params?.database || `${item.type}-${item.id}`,
db_type: item.type,
})) as DataSource[];
}
return [];
} catch (e) {
console.error('Failed to fetch datasources', e);
return [];
}
});
// Fetch Knowledge Bases
const { data: knowledgeSpaces, loading: _loadingKnowledge } = useRequest(async () => {
try {
const response = await sendSpacePostRequest('/knowledge/space/list', {});
// ctx-axios interceptor returns response.data directly, so response is {success, data, ...}
if (response?.success) {
return response.data || [];
}
return [];
} catch (e) {
console.error('Failed to fetch knowledge spaces', e);
return [];
}
});
const normalizeText = (value: unknown): string => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
const todoValue = (value as Record<string, unknown>).TODO;
if (typeof todoValue === 'string') return todoValue;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return value == null ? '' : String(value);
};
/** Extract the actual created skill name from shell_interpreter output.
* Uses priority-based matching to avoid returning 'skill-creator' (the tool path). */
const extractCreatedSkillName = (allText: string): string | null => {
// Priority 1: Skill 'xxx' initialized/packaged (quoted name from output)
const quotedSkill = allText.match(/[Ss]kill\s+['"]([\w-]+)['"]/);
if (quotedSkill) return quotedSkill[1];
// Priority 2: Initializing/Packaging skill: xxx
const colonSkill = allText.match(/(?:Initializing|Packaging)\s+skill:\s*(?:skills\/)?([\w-]+)/);
if (colonSkill) return colonSkill[1];
// Priority 3: Created skill directory: .../skills/xxx
const createdDir = allText.match(/Created skill directory:.*\/skills\/([\w-]+)/);
if (createdDir) return createdDir[1];
// Priority 4: Last skills/xxx path, filtering out 'skill-creator'
const allPaths = [...allText.matchAll(/skills\/([\w-]+)/g)].map(m => m[1]).filter(name => name !== 'skill-creator');
if (allPaths.length > 0) return allPaths[allPaths.length - 1];
return null;
};
// Fetch Skills/DBGPTs list
const { data: skillsList, loading: _loadingSkills } = useRequest(async () => {
try {
const response = await axios.get(`${process.env.API_BASE_URL ?? ''}/api/v1/skills/list`);
// ctx-axios interceptor returns response.data directly
if (response?.success && Array.isArray(response.data)) {
return response.data.map((item: any) => ({
id: String(item.id || item.name),
name: normalizeText(item.name),
description: normalizeText(item.description),
type: item.type === 'official' ? 'official' : 'personal',
icon:
item.skill_type === 'data_analysis'
? '📊'
: item.skill_type === 'coding'
? '💻'
: item.skill_type === 'web_search'
? '🔍'
: item.skill_type === 'knowledge_qa'
? '📚'
: item.skill_type === 'chat'
? '💬'
: '⚡',
})) as Skill[];
}
return [];
} catch (e) {
console.error('Failed to fetch skills', e);
return [];
}
});
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
const convId = router.query.id as string | undefined;
if (convId && convId !== conversationId) {
loadConversation(convId);
} else if (!convId && conversationId) {
// URL 中 id 消失(如点击 new_task / 探索广场),清空当前会话状态
setMessages([]);
setConversationId(null);
setQuery('');
setExecutionMap({});
setActiveMessageId(null);
setActiveViewMsgId(null);
setUploadedFilePath(null);
setFilePreview(null);
setFilePreviewError(null);
setArtifacts([]);
setRightPanelTab('preview');
setStreamingSummary('');
setSummaryComplete(false);
}
}, [router.query.id]);
useEffect(() => {
const lastView = [...messages].reverse().find(msg => msg.role === 'view');
if (lastView?.id) {
setActiveMessageId(lastView.id);
}
}, [messages]);
useEffect(() => {
const loadPreview = async () => {
if (!uploadedFilePath) return;
setFilePreviewLoading(true);
setFilePreviewError(null);
try {
const res = await axios.post(`${process.env.API_BASE_URL ?? ''}/api/v1/resource/file/read`, null, {
params: {
conv_uid: conversationId || 'preview',
file_key: uploadedFilePath,
},
});
if (res.data?.success && res.data?.data) {
let parsed: any;
try {
parsed = JSON.parse(res.data.data);
} catch {
parsed = res.data.data;
}
if (Array.isArray(parsed) && parsed.length > 0) {
const columns = Object.keys(parsed[0] || {});
setFilePreview({
kind: 'table',
file_name: uploadedFile?.name,
file_path: uploadedFilePath,
columns,
rows: parsed.slice(0, 50),
shape: [parsed.length, columns.length],
});
} else if (typeof parsed === 'string') {
setFilePreview({
kind: 'text',
file_name: uploadedFile?.name,
file_path: uploadedFilePath,
text: parsed,
});
} else {
setFilePreview({
kind: 'text',
file_name: uploadedFile?.name,
file_path: uploadedFilePath,
text: JSON.stringify(parsed, null, 2),
});
}
} else {
setFilePreviewError(res.data?.err_msg || '文件预览失败');
}
} catch (err: any) {
setFilePreviewError(err?.message || '文件预览失败');
} finally {
setFilePreviewLoading(false);
}
};
loadPreview();
}, [uploadedFilePath, conversationId, uploadedFile]);
useEffect(() => {
if (!filePreview || filePreview.kind !== 'table') {
setChartPreview(null);
return;
}
const rows = filePreview.rows || [];
const columns = filePreview.columns || [];
if (!rows.length || !columns.length) {
setChartPreview(null);
return;
}
const numericColumns = columns.filter(col => {
const sample = rows.slice(0, 20).map(row => Number(row[col]));
const numericCount = sample.filter(val => Number.isFinite(val)).length;
return numericCount >= Math.max(3, Math.floor(sample.length * 0.6));
});
if (!numericColumns.length) {
setChartPreview(null);
return;
}
const yCol = numericColumns[0];
const xCol = columns.find(col => col !== yCol) || '__index__';
const data = rows.slice(0, 60).map((row, idx) => {
const xVal = xCol === '__index__' ? idx + 1 : row[xCol];
const yVal = Number(row[yCol]);
return {
x: typeof xVal === 'string' || typeof xVal === 'number' ? xVal : String(xVal ?? idx + 1),
y: Number.isFinite(yVal) ? yVal : 0,
};
});
setChartPreview({
data,
xField: 'x',
yField: 'y',
title: `${yCol} trend`,
});
}, [filePreview]);
// Auto-analyze data when filePreview updates
useEffect(() => {
if (!filePreview || filePreview.kind !== 'table' || !filePreview.rows?.length) {
setDataAnalysis(null);
return;
}
setAnalysisLoading(true);
try {
const analysis = analyzeDataset(filePreview.rows, filePreview.columns);
setDataAnalysis(analysis);
// Auto-switch to analysis tab when data is ready
if (analysis.length > 0) {
setRightPanelTab('analysis');
}
} catch (err) {
console.error('Data analysis failed:', err);
setDataAnalysis(null);
} finally {
setAnalysisLoading(false);
}
}, [filePreview]);
useEffect(() => {
if (!activeMessageId || !filePreview) return;
const artifactKey = `${activeMessageId}:${filePreview.file_path || filePreview.file_name || ''}`;
if (artifactKey === lastArtifactKeyRef.current) return;
lastArtifactKeyRef.current = artifactKey;
const previewStepId = 'client-preview';
setExecutionMap(prev => {
const current = prev[activeMessageId] || { steps: [], outputs: {}, activeStepId: null, collapsed: false };
const hasStep = current.steps.some(step => step.id === previewStepId);
const nextSteps = hasStep
? current.steps.map(step => (step.id === previewStepId ? { ...step, status: 'done' as const } : step))
: [
...current.steps,
{
id: previewStepId,
step: current.steps.length + 1,
title: 'Preview & Visualize',
detail: 'Parsed file preview and prepared visual insights.',
status: 'done' as const,
},
];
const outputs = { ...current.outputs };
const previewOutputs: ExecutionOutput[] = [];
if (filePreview.kind === 'table') {
previewOutputs.push({
output_type: 'table',
content: {
columns: (filePreview.columns || []).map(col => ({ title: col, dataIndex: col, key: col })),
rows: filePreview.rows || [],
},
});
} else if (filePreview.kind === 'text') {
previewOutputs.push({ output_type: 'text', content: filePreview.text || '' });
}
if (chartPreview) {
previewOutputs.push({
output_type: 'chart',
content: {
data: chartPreview.data,
xField: chartPreview.xField,
yField: chartPreview.yField,
},
});
}
outputs[previewStepId] = previewOutputs;
return {
...prev,
[activeMessageId]: {
...current,
steps: nextSteps,
outputs,
activeStepId: previewStepId,
},
};
});
}, [activeMessageId, filePreview, chartPreview]);
interface Round {
humanMsg: ChatMessage | null;
viewMsg: ChatMessage | null;
}
const rounds = useMemo<Round[]>(() => {
const result: Round[] = [];
let i = 0;
while (i < messages.length) {
const msg = messages[i];
if (msg.role === 'human') {
const next = messages[i + 1];
if (next && next.role === 'view') {
result.push({ humanMsg: msg, viewMsg: next });
i += 2;
} else {
result.push({ humanMsg: msg, viewMsg: null });
i += 1;
}
} else if (msg.role === 'view') {
result.push({ humanMsg: null, viewMsg: msg });
i += 1;
} else {
i += 1;
}
}
return result;
}, [messages]);
const selectedViewMsgId = useMemo(() => {
if (activeViewMsgId) {
const exists = rounds.some(r => r.viewMsg?.id === activeViewMsgId);
if (exists) return activeViewMsgId;
}
const lastRound = rounds[rounds.length - 1];
return lastRound?.viewMsg?.id || null;
}, [activeViewMsgId, rounds]);
const parseCsvLine = (line: string) => {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result.map(val => val.trim());
};
const _parseCsvText = (text: string, fileName?: string) => {
const lines = text.split(/\r?\n/).filter(line => line.trim());
if (!lines.length) return null;
const header = parseCsvLine(lines[0]);
const rows = lines.slice(1, 51).map((line, idx) => {
const values = parseCsvLine(line);
const row: Record<string, any> = { id: idx + 1 };
header.forEach((col, i) => {
row[col || `col_${i + 1}`] = values[i] ?? '';
});
return row;
});
return {
kind: 'table' as const,
file_name: fileName,
columns: header.map(col => col || 'Column'),
rows,
shape: [lines.length - 1, header.length],
};
};
const _getArtifactName = (outputType: string, content: any): string => {
if (outputType === 'table') {
const rowCount = content?.rows?.length || 0;
const colCount = content?.columns?.length || 0;
return `Data Table (${rowCount} rows × ${colCount} cols)`;
}
if (outputType === 'chart') {
const chartType = content?.chartType || 'line';
const chartTypeNames: Record<string, string> = {
line: 'Line Chart',
column: 'Column Chart',
bar: 'Bar Chart',
pie: 'Pie Chart',
donut: 'Donut Chart',
area: 'Area Chart',
scatter: 'Scatter Plot',
'dual-axes': 'Dual Axes Chart',
};
return content?.title || chartTypeNames[chartType] || 'Chart Visualization';
}
if (outputType === 'code') {
return `Code Snippet`;
}
if (outputType === 'image') {
return content?.name || 'Image';
}
if (outputType === 'markdown') {
const preview = String(content).slice(0, 30);
return `Document: ${preview}${String(content).length > 30 ? '...' : ''}`;
}
if (outputType === 'file') {
return content?.name || content?.file_name || 'File';
}
return `${outputType} output`;
};
const extractCodeFileName = (code: string, stepLabel: string, index: number): string => {
const saveMatch = code.match(/\.to_(?:excel|csv)\s*\(\s*['"]([^'"]+)['"]/);
if (saveMatch) return saveMatch[1].split('/').pop() || saveMatch[1];
const openMatch = code.match(/open\s*\(\s*['"]([^'"]+\.(?:py|txt|json|csv|xlsx?))['"]/);
if (openMatch) return openMatch[1].split('/').pop() || openMatch[1];
const savefigMatch = code.match(/savefig\s*\(\s*['"]([^'"]+)['"]/);
if (savefigMatch) return savefigMatch[1].split('/').pop() || savefigMatch[1];
const readMatch = code.match(/pd\.read_(?:csv|excel)\s*\(\s*['"]([^'"]+)['"]/);
if (readMatch) {
const srcName = (readMatch[1].split('/').pop() || readMatch[1]).replace(/\.[^.]+$/, '');
return `analyze_${srcName}.py`;
}
const defMatch = code.match(/def\s+(\w+)\s*\(/);
if (defMatch) return `${defMatch[1]}.py`;
const classMatch = code.match(/class\s+(\w+)/);
if (classMatch) return `${classMatch[1]}.py`;
if (/import\s+matplotlib|plt\./.test(code)) return `visualization_${index + 1}.py`;
if (/sns\.|import\s+seaborn/.test(code)) return `chart_${index + 1}.py`;
if (/pd\.|import\s+pandas/.test(code)) return `data_processing_${index + 1}.py`;
const label = stepLabel.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 30);
return `${label}_${index}.py`;
};
const extractFileReferences = (
text: string,
): Array<{ name: string; downloadable: boolean; size?: number; filePath?: string }> => {
const refs: Array<{ name: string; downloadable: boolean; size?: number; filePath?: string }> = [];
const filePattern = /[/\w\-.:]+\.(?:xlsx|xls|csv|py|json|txt|pdf|png|jpg|jpeg|html|md)/gi;
const matches = text.match(filePattern) || [];
const seen = new Set<string>();
matches.forEach(m => {
const name = m.split('/').pop() || m;
const lower = name.toLowerCase();
if (!seen.has(lower)) {
seen.add(lower);
// Preserve full path if it looks like an absolute path
const filePath = m.startsWith('/') ? m : undefined;
refs.push({ name, downloadable: true, filePath });
}
});
return refs;
};
const downloadArtifact = async (artifact: Artifact) => {
const triggerBlobDownload = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
switch (artifact.type) {
case 'image': {
const imgUrl =
typeof artifact.content === 'string'
? artifact.content
: artifact.content?.url || artifact.content?.src || String(artifact.content);
const resolvedUrl = imgUrl.startsWith('/images/') ? `${process.env.API_BASE_URL || ''}${imgUrl}` : imgUrl;
try {
const resp = await fetch(resolvedUrl);
const blob = await resp.blob();
const filename = artifact.name || imgUrl.split('/').pop() || 'image.png';
triggerBlobDownload(blob, filename);
} catch {
const a = document.createElement('a');
a.href = resolvedUrl;
a.download = artifact.name || 'image.png';
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
break;
}
case 'html': {
const htmlContent =
typeof artifact.content === 'string'
? artifact.content
: artifact.content?.content || artifact.content?.html || String(artifact.content);
const blob = new Blob([htmlContent], { type: 'text/html' });
triggerBlobDownload(blob, artifact.name || 'report.html');
break;
}
case 'code': {
const blob = new Blob([String(artifact.content)], { type: 'text/plain' });
triggerBlobDownload(blob, artifact.name || 'code.py');
break;
}
case 'table': {
const rows = artifact.content?.rows || [];
const columns = artifact.content?.columns?.map((c: any) => c.dataIndex || c.key || c) || [];
const csvContent = [
columns.join(','),
...rows.map((row: any) => columns.map((col: string) => JSON.stringify(row[col] ?? '')).join(',')),
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
triggerBlobDownload(blob, artifact.name?.replace(/\.\w+$/, '.csv') || 'table.csv');
break;
}
case 'markdown':
case 'summary': {
const blob = new Blob([String(artifact.content)], { type: 'text/markdown' });
triggerBlobDownload(blob, artifact.name || `${artifact.type}.md`);
break;
}
case 'file': {
const filePath = artifact.content?.file_path || artifact.content?.path || (artifact as any).filePath;
if (filePath && filePath.includes('/images/')) {
const imgName = filePath.split('/').pop();
const resolvedUrl = `${process.env.API_BASE_URL || ''}/images/${imgName}`;
try {
const resp = await fetch(resolvedUrl);
const blob = await resp.blob();
triggerBlobDownload(blob, artifact.name || imgName || 'file');
} catch {
message.warning('文件暂不可下载');
}
} else if (filePath) {
// Download via backend file download endpoint (for agent-created files)
const downloadUrl = `${process.env.API_BASE_URL || ''}/api/v1/agent/files/download?file_path=${encodeURIComponent(filePath)}`;
try {
const resp = await fetch(downloadUrl);
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
message.warning(errData.detail || '文件暂不可下载');
break;
}
const blob = await resp.blob();
triggerBlobDownload(blob, artifact.name || filePath.split('/').pop() || 'file');
} catch {
message.warning('文件下载失败');
}
} else {
message.warning('文件暂不可下载');
}
break;
}
default: {
const blob = new Blob([JSON.stringify(artifact.content, null, 2)], { type: 'application/json' });
triggerBlobDownload(blob, artifact.name || 'artifact.json');
}
}
};
const _copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
message.success('Copied to clipboard');
});
};
const _getArtifactIcon = (type: ArtifactType, chartType?: ChartType) => {
switch (type) {
case 'table':
return <TableOutlined className='text-blue-500' />;
case 'chart':
if (chartType === 'pie' || chartType === 'donut') {
return <PieChartOutlined className='text-green-500' />;
}
return <BarChartOutlined className='text-green-500' />;
case 'code':
return <CodeOutlined className='text-purple-500' />;
case 'image':
return <FileImageOutlined className='text-pink-500' />;
case 'markdown':
return <FileTextOutlined className='text-orange-500' />;
case 'summary':
return <FileTextOutlined className='text-emerald-500' />;
case 'file':
return <FileOutlined className='text-gray-500' />;
default:
return <FileOutlined className='text-gray-500' />;
}
};
// Build artifacts from execution data — shared between live streaming and history restore
const buildArtifactsFromExecution = (
messageId: string,
execution: {
steps: ExecutionStep[];
outputs: Record<string, ExecutionOutput[]>;
},
summaryText?: string,
filePath?: string | null,
): Artifact[] => {
const finalArtifacts: Artifact[] = [];
const now = Date.now();
const seenCodeHashes = new Set<string>();
if (execution) {
const allSteps = execution.steps || [];
allSteps.forEach(step => {
const stepOutputs = execution.outputs[step.id] || [];
stepOutputs.forEach((output, oIdx) => {
if (output.output_type === 'code') {
const codeStr = String(output.content || '').trim();
const hash = codeStr.slice(0, 200);
if (codeStr && !seenCodeHashes.has(hash)) {
seenCodeHashes.add(hash);
const fileName = extractCodeFileName(codeStr, step.action || step.id, oIdx);
finalArtifacts.push({
id: `${messageId}-code-${step.id}-${oIdx}`,
type: 'code',
name: fileName,
content: codeStr,
createdAt: now,
messageId,
stepId: step.id,
downloadable: true,
});
}
} else if (output.output_type === 'file') {
finalArtifacts.push({
id: `${messageId}-file-${step.id}-${oIdx}`,
type: 'file',
name: output.content?.name || output.content?.file_name || 'File',
content: output.content,
createdAt: now,
messageId,
stepId: step.id,
downloadable: true,
size: output.content?.size,
});
} else if (output.output_type === 'html') {
const htmlContent =
typeof output.content === 'string'
? output.content
: output.content?.content || output.content?.html || String(output.content);
const htmlTitle = output.content?.title || 'Report';
finalArtifacts.push({
id: `${messageId}-html-${step.id}-${oIdx}`,
type: 'html',
name: `${htmlTitle}.html`,
content: htmlContent,
createdAt: now,
messageId,
stepId: step.id,
downloadable: true,
});
} else if (output.output_type === 'image') {
const imgUrl =
typeof output.content === 'string'
? output.content
: output.content?.url || output.content?.src || String(output.content);
const imgName = imgUrl.split('/').pop() || `image_${oIdx}.png`;
const displayName = imgName.replace(/^[a-f0-9]{8}_/, '');
finalArtifacts.push({
id: `${messageId}-img-${step.id}-${oIdx}`,
type: 'image',
name: displayName,
content: imgUrl,
createdAt: now,
messageId,
stepId: step.id,
downloadable: true,
});
}
});
// For shell_interpreter steps, extract file paths from code/text outputs
// and create downloadable file artifacts
if (step.action === 'shell_interpreter') {
// Match both absolute paths and relative filenames with extensions
const absPathPattern = /(?:\/[\w\-.]+)+\.\w{1,10}/g;
const relFilePattern = /(?:>|>>|\btee\b|\btouch\b)\s+([\w\-./ ]+\.\w{1,10})/g;
const seenFilePaths = new Set<string>();
stepOutputs.forEach(output => {
if (output.output_type === 'code' || output.output_type === 'text') {
const text = String(output.content || '');
// Look for file creation patterns
const hasFileCreation = /(?:>|>>|\btee\b|\bcat\b.*>|\bcp\b|\bmv\b|\btouch\b|\becho\b.*>)/.test(text);
if (hasFileCreation) {
const foundPaths: string[] = [];
// Extract absolute paths
const absMatches = text.match(absPathPattern) || [];
foundPaths.push(...absMatches);
// Extract relative paths after redirection operators
let relMatch;
while ((relMatch = relFilePattern.exec(text)) !== null) {
const p = relMatch[1].trim();
if (p && !p.startsWith('/')) foundPaths.push(p);
}
foundPaths.forEach(fp => {
// Normalize: strip leading ./ if present
const normalized = fp.replace(/^\.\//, '');
const fileName = normalized.split('/').pop() || normalized;
if (!seenFilePaths.has(fileName.toLowerCase())) {
seenFilePaths.add(fileName.toLowerCase());
const alreadyHasFile = finalArtifacts.some(
a => (a.type === 'file' || a.type === 'image') && a.name.toLowerCase() === fileName.toLowerCase(),
);
if (!alreadyHasFile) {
// Use the path as-is; backend resolves relative paths against pilot/tmp
finalArtifacts.push({
id: `${messageId}-shellfile-${step.id}-${fileName}`,
type: 'file',
name: fileName,
content: { name: fileName, file_path: normalized },
createdAt: now,
messageId,
stepId: step.id,
downloadable: true,
filePath: normalized,
});
}
}
});
}
}
});
}
});
}
if (summaryText) {
const fileRefs = extractFileReferences(summaryText);
fileRefs.forEach((ref, idx) => {
const alreadyExists = finalArtifacts.some(a => a.name.toLowerCase() === ref.name.toLowerCase());
if (!alreadyExists) {
finalArtifacts.push({
id: `${messageId}-fileref-${idx}`,
type: 'file',
name: ref.name,
content: { name: ref.name, file_path: ref.filePath },
createdAt: now,
messageId,
downloadable: ref.downloadable,
filePath: ref.filePath,
size: ref.size,
});
}
});
}
if (filePath) {
const uploadName = filePath.split('/').pop() || 'uploaded_file';
const alreadyExists = finalArtifacts.some(a => a.name.toLowerCase() === uploadName.toLowerCase());
if (!alreadyExists) {
finalArtifacts.push({
id: `${messageId}-upload`,
type: 'file',
name: uploadName,
content: { name: uploadName, file_path: filePath },
createdAt: now,
messageId,
downloadable: true,
});
}
}
// Deduplicate: for artifacts with the same name+type, keep only the last one
const deduped: Artifact[] = [];
const seen = new Map<string, number>();
for (let i = finalArtifacts.length - 1; i >= 0; i--) {
const key = `${finalArtifacts[i].type}:${finalArtifacts[i].name}`;
if (!seen.has(key)) {
seen.set(key, i);
deduped.unshift(finalArtifacts[i]);
}
}
return deduped;
};
const handleStart = async (
inputQuery = query,
overrideFile?: File | null,
overrideSkill?: Skill | null,
overrideDb?: DataSource | null,
) => {
const effectiveFile = overrideFile !== undefined ? overrideFile : uploadedFile;
const effectiveSkill = overrideSkill !== undefined ? overrideSkill : selectedSkill;
const effectiveDb = overrideDb !== undefined ? overrideDb : selectedDb;
if ((!inputQuery.trim() && !effectiveFile) || loading) return;
let finalQuery = inputQuery;
const appCode = 'chat_react_agent';
const chatMode = 'chat_react_agent';
let currentUploadedFilePath = null;
// Handle File Upload if present
if (preloadedFilePathRef.current) {
// Example file already copied to server - skip upload
currentUploadedFilePath = preloadedFilePathRef.current;
setUploadedFilePath(currentUploadedFilePath);
preloadedFilePathRef.current = null;
finalQuery = inputQuery || 'Analyze the uploaded file.';
} else if (effectiveFile) {
const formData = new FormData();
formData.append('file', effectiveFile);
try {
const uploadRes = await axios.post(`${process.env.API_BASE_URL ?? ''}/api/v1/python/file/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const resData = uploadRes.data;
// Handle both wrapped Result {success, data} and raw string path
if (resData?.success && resData?.data) {
currentUploadedFilePath = resData.data;
setUploadedFilePath(currentUploadedFilePath);
finalQuery = inputQuery || 'Analyze the uploaded Excel file.';
} else if (typeof resData === 'string' && resData.length > 0) {
// Backend returned the file path directly as a string
currentUploadedFilePath = resData;
setUploadedFilePath(currentUploadedFilePath);
finalQuery = inputQuery || 'Analyze the uploaded Excel file.';
} else {
const errMsg = resData?.err_msg || resData?.message || 'Unknown error';
message.error('File upload failed: ' + errMsg);
return;
}
} catch (uploadErr: any) {
console.error('[Upload] error:', uploadErr);
const errDetail =
uploadErr?.response?.data?.err_msg ||
uploadErr?.response?.data?.message ||
uploadErr?.message ||
'Network error';
message.error('File upload failed: ' + errDetail);
return;
}
} else {
if (uploadedFilePath) {
setUploadedFilePath(null);
setFilePreview(null);
}
// Construct context prefix for non-file queries
const contextParts = [];
if (effectiveDb) contextParts.push(`[Database: ${effectiveDb.db_name}]`);
if (selectedKnowledge) contextParts.push(`[Knowledge: ${selectedKnowledge.name}]`);
if (contextParts.length > 0) {
finalQuery = `${contextParts.join(' ')} ${inputQuery}`;
}
}
// Prepare conversation ID
const currentConvId = conversationId || generateUUID();
if (!conversationId) {
setConversationId(currentConvId);
}
// Calculate current order
const currentOrder = Math.floor(messages.length / 2) + 1;
const responseId = generateUUID();
const humanId = generateUUID();
// Add user message and AI placeholder message
setMessages(prev => [
...prev,
{
id: humanId,
role: 'human',
context: inputQuery,
order: currentOrder,
attachedFile: effectiveFile
? {
name: effectiveFile.name,
size: effectiveFile.size,
type: effectiveFile.type,
}
: undefined,
attachedKnowledge: selectedKnowledge ?? undefined,
attachedSkill: effectiveSkill ? { name: effectiveSkill.name, id: effectiveSkill.id } : undefined,
attachedDb: effectiveDb ? { db_name: effectiveDb.db_name, db_type: effectiveDb.db_type } : undefined,
},
{
id: responseId,
role: 'view',
context: '',
order: currentOrder,
thinking: true,
},
]);
setLoading(true);
setQuery(''); // Clear input
setStreamingSummary('');
setActiveViewMsgId(responseId); // Auto-switch right panel to new round
const controller = new AbortController();
terminatedStepIdsRef.current.clear();
setExecutionMap(prev => ({
...prev,
[responseId]: {
steps: [],
outputs: {},
activeStepId: null,
collapsed: false,
stepThoughts: {},
},
}));
setActiveMessageId(responseId);
try {
const response = await fetch(`${process.env.API_BASE_URL ?? ''}/api/v1/chat/react-agent`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
conv_uid: currentConvId,
chat_mode: chatMode,
model_name: model,
user_input: finalQuery,
temperature: 0.6,
max_new_tokens: 4000,
select_param: appCode === 'chat_react_agent' ? '' : appCode,
ext_info: {
...(currentUploadedFilePath ? { file_path: currentUploadedFilePath } : {}),
...(effectiveSkill ? { skill_id: effectiveSkill.id, skill_name: effectiveSkill.name } : {}),
...(effectiveDb ? { database_name: effectiveDb.db_name, database_type: effectiveDb.db_type } : {}),
...(selectedKnowledge
? { knowledge_space_name: selectedKnowledge.name, knowledge_space_id: selectedKnowledge.id }
: {}),
},
}),
signal: controller.signal,
});
if (!response.body) {
throw new Error('No response body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
const processEvent = (raw: string) => {
if (!raw.startsWith('data:')) return;
const data = raw.slice(5).trim();
if (!data) return;
let payload: any;
try {
payload = JSON.parse(data);
} catch (_err) {
return;
}
if (payload.type === 'step.start') {
const id = payload.id || `${payload.step}`;
if (terminatedStepIdsRef.current.has(id)) return;
setExecutionMap(prev => {
const current = prev[responseId] || {
steps: [],
outputs: {},
activeStepId: null,
collapsed: false,
stepThoughts: {},
};
const existingThoughts = current.stepThoughts || {};
const nextThoughts = existingThoughts;
// Check if step already exists - if so, update it (especially phase) instead of creating duplicate
const existingStepIndex = current.steps.findIndex(s => s.id === id);
let nextSteps;
if (existingStepIndex >= 0) {
// Update existing step with new title/phase
nextSteps = current.steps.map((step, idx) =>
idx === existingStepIndex
? {
...step,
title: payload.title,
detail: payload.detail,
phase: payload.phase,
status: 'running' as const,
}
: step.status === 'running'
? { ...step, status: 'done' }
: step,
);
} else {
// New step - mark running steps as done and add new step
nextSteps = [
...current.steps.map(item => (item.status === 'running' ? { ...item, status: 'done' } : item)),
{
id,
step: payload.step,
title: payload.title,
detail: payload.detail,
phase: payload.phase,
status: 'running' as const,
action: payload.action,
},
];
}
return {
...prev,
[responseId]: {
...current,
steps: nextSteps,
outputs: { ...current.outputs, [id]: current.outputs[id] || [] },
stepThoughts: nextThoughts,
// Only auto-focus for existing step updates (e.g., "思考中" -> "sql_query").
// New placeholder steps wait for step.meta to get real content before stealing focus.
activeStepId: existingStepIndex >= 0 ? id : current.activeStepId || id,
},
};
});
setActiveMessageId(responseId);
setRightPanelCollapsed(false);
} else if (payload.type === 'step.meta') {
if (payload.action && payload.action.toLowerCase() === 'terminate') {
terminatedStepIdsRef.current.add(payload.id);
setExecutionMap(prev => {
const current = prev[responseId];
if (!current) return prev;
const nextSteps = current.steps.filter(item => item.id !== payload.id);
const nextActiveStepId = current.activeStepId === payload.id ? null : current.activeStepId;
return {
...prev,
[responseId]: { ...current, steps: nextSteps, activeStepId: nextActiveStepId },
};
});
return;
}
// Clear manual step selection so the right panel auto-tracks this step
if (payload.action) {
setSelectedStepId(null);
}
setExecutionMap(prev => {
const current = prev[responseId];
if (!current) return prev;
// Build detail from action only (thought goes to stepThoughts)
const nextSteps = current.steps.map(item => {
if (item.id !== payload.id) return item;
const parts = [] as string[];
if (payload.action) {
parts.push(`Action: ${payload.action}`);
if (
payload.action !== 'code_interpreter' &&
payload.action !== 'shell_interpreter' &&
payload.action_input
) {
const inputStr =
typeof payload.action_input === 'string'
? payload.action_input
: JSON.stringify(payload.action_input, null, 2);
parts.push(`Action Input: ${inputStr}`);
}
}
return {
...item,
title: payload.title || item.title,
detail: parts.join('\n') || item.detail,
action: payload.action || item.action,
actionInput: payload.action_input || item.actionInput,
};
});
// Route thought to stepThoughts map for subtle display
const nextThoughts = payload.thought
? {
...current.stepThoughts,
[payload.id]: payload.thought,
}
: current.stepThoughts;
return {
...prev,
[responseId]: {
...current,
steps: nextSteps,
stepThoughts: nextThoughts,
// Focus right panel on this step when it receives action content
...(payload.action ? { activeStepId: payload.id } : {}),
},
};
});
} else if (payload.type === 'step.output') {
if (terminatedStepIdsRef.current.has(payload.id || '')) return;
setExecutionMap(prev => {
const current = prev[responseId];
if (!current) return prev;
const targetId = current.activeStepId;
if (!targetId) return prev;
const nextSteps = current.steps.map(item => {
if (item.id !== targetId) return item;
const detail = `${item.detail}\n${payload.detail}`.trim();
return { ...item, detail };
});
return { ...prev, [responseId]: { ...current, steps: nextSteps } };
});
} else if (payload.type === 'step.chunk') {
const id = payload.id;
if (terminatedStepIdsRef.current.has(id || '')) return;
setExecutionMap(prev => {
const current = prev[responseId];
if (!current) return prev;
const targetId = id || current.activeStepId;
if (!targetId) return prev;
const list = current.outputs[targetId] ? [...current.outputs[targetId]] : [];
list.push({ output_type: payload.output_type, content: payload.content });
return {
...prev,
[responseId]: {
...current,
outputs: { ...current.outputs, [targetId]: list },
},
};
});
// Artifacts are now generated at task completion (final event),
// not during streaming — to avoid showing intermediate outputs as artifacts
} else if (payload.type === 'step.done') {
const id = payload.id;
if (terminatedStepIdsRef.current.has(id || '')) return;
setExecutionMap(prev => {
const current = prev[responseId];
if (!current) return prev;
const targetId = id || current.activeStepId;
if (!targetId) return prev;
const nextSteps = current.steps.map(item =>
item.id === targetId ? { ...item, status: payload.status || 'done' } : item,
);
return { ...prev, [responseId]: { ...current, steps: nextSteps } };
});
} else if (payload.type === 'step.thought') {
const content = payload.content || '';
let normalizedThought = '';
if (typeof content === 'string') {
normalizedThought = content;
} else if (content && typeof content === 'object') {
const todoValue = (content as Record<string, unknown>).TODO;
if (typeof todoValue === 'string') {
normalizedThought = todoValue;
} else {
try {
normalizedThought = JSON.stringify(content);
} catch {
normalizedThought = String(content);
}
}
}
if (normalizedThought) {
setExecutionMap(prev => {
const current = prev[responseId];
if (!current) return prev;
const targetId = payload.id || current.activeStepId || 'initial';
return {
...prev,
[responseId]: {
...current,
stepThoughts: {
...current.stepThoughts,
[targetId]: (current.stepThoughts?.[targetId] || '') + normalizedThought,
},
},
};
});
}
} else if (payload.type === 'final') {
setExecutionMap(prev => {
const current = prev[responseId];
if (!current) return prev;
const nextSteps = current.steps.map(item =>
item.status === 'running' ? { ...item, status: 'done' } : item,
);
return { ...prev, [responseId]: { ...current, steps: nextSteps } };
});
setMessages(prev =>
prev.map(msg => {
if (msg.id !== responseId || msg.role !== 'view') return msg;
return { ...msg, context: cleanFinalContent(payload.content || ''), thinking: false };
}),
);
setActiveMessageId(responseId);
if (payload.content && payload.content.trim()) {
setStreamingSummary('');
setSummaryComplete(false);
setRightPanelTab('summary');
setRightPanelView('summary');
const summaryText = cleanFinalContent(payload.content);
const streamInterval = setInterval(() => {
setStreamingSummary(prev => {
if (prev.length >= summaryText.length) {
clearInterval(streamInterval);
setSummaryComplete(true);
setExecutionMap(currentExecMap => {
const execution = currentExecMap[responseId];
const deduped = buildArtifactsFromExecution(
responseId,
execution || { steps: [], outputs: {} },
summaryText,
uploadedFilePath,
);
setArtifacts(prevArtifacts => {
const filtered = prevArtifacts.filter(a => a.messageId !== responseId);
const newArtifacts = [...filtered, ...deduped];
// Auto-select the first HTML artifact for preview, or image if no HTML
const htmlArtifact = deduped.find(a => a.type === 'html');
if (htmlArtifact) {
setPreviewArtifact(htmlArtifact as Artifact);
setRightPanelView('html-preview');
setRightPanelCollapsed(false);
} else {
const imgArtifact = deduped.find(a => a.type === 'image');
if (imgArtifact) {
setPreviewArtifact(imgArtifact as Artifact);
setRightPanelView('image-preview');
setRightPanelCollapsed(false);
}
}
return newArtifacts;
});
// Detect skill creation from shell_interpreter steps
if (execution) {
const isSkillPackageStep = (s: ExecutionStep) => {
if (s.action !== 'shell_interpreter') return false;
// Check detail, actionInput, and outputs for package_skill/init_skill
const detailHas = s.detail?.includes('package_skill') || s.detail?.includes('init_skill');
const inputHas =
s.actionInput?.includes('package_skill') || s.actionInput?.includes('init_skill');
const outputTexts = (execution.outputs[s.id] || []).map(o => String(o.content)).join(' ');
const outputHas =
outputTexts.includes('package_skill') ||
outputTexts.includes('init_skill') ||
outputTexts.includes('Successfully packaged');
return detailHas || inputHas || outputHas;
};
const skillStep = (execution.steps || []).find(isSkillPackageStep);
if (skillStep) {
// Extract skill name from actionInput, detail, or outputs
const allText = [
skillStep.actionInput || '',
skillStep.detail || '',
...(execution.outputs[skillStep.id] || []).map(o => String(o.content)),
].join(' ');
const skillName = extractCreatedSkillName(allText);
if (skillName) {
setCreatedSkillNames(prev => ({ ...prev, [responseId]: skillName }));
setRightPanelView('skill-preview');
}
}
}
return currentExecMap;
});
return prev;
}
const chunkSize = Math.min(3, summaryText.length - prev.length);
return prev + summaryText.slice(prev.length, prev.length + chunkSize);
});
}, 15);
}
} else if (payload.type === 'done') {
setLoading(false);
}
};
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
parts.forEach(processEvent);
}
setLoading(false);
} catch (err: any) {
setLoading(false);
message.error(err?.message || 'Failed to get response');
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
if (lastMsg && lastMsg.role === 'view') {
lastMsg.context = err?.message || 'Error occurred';
lastMsg.thinking = false;
}
return newMessages;
});
}
};
const handleExampleClick = async (example: (typeof EXAMPLE_CARDS)[number]) => {
const queryKey = `example_${example.id}_query`;
const queryVal = t(queryKey) as string;
const translatedQuery = (queryVal && queryVal !== queryKey ? queryVal : example.query) as string;
if (loading) return;
try {
message.loading({ content: '正在加载示例...', key: 'example-loading', duration: 0 });
let filePath: string | null = null;
let fakeFile: File | null = null;
// If example has a file, request it from backend
if (example.fileName) {
const res = await axios.post(`${process.env.API_BASE_URL ?? ''}/api/v1/examples/use`, {
example_id: example.id,
});
if (res?.success && res?.data) {
filePath = res.data;
preloadedFilePathRef.current = filePath;
fakeFile = new File([new ArrayBuffer(example.fileSize || 0)], example.fileName, {
type: example.fileType,
});
setUploadedFile(fakeFile);
} else {
message.destroy('example-loading');
const errMsg = res?.err_msg || 'Unknown error';
message.error('加载示例失败: ' + errMsg);
return;
}
}
message.destroy('example-loading');
// Auto-select skill if example specifies one
let exampleSkill: Skill | null = null;
if (example.skillName && skillsList) {
const matched = skillsList.find(s => s.name === example.skillName);
if (matched) {
exampleSkill = matched;
setSelectedSkill(matched);
}
}
// Auto-select database if example specifies one
let matchedDb: DataSource | null = null;
if (example.dbName && dataSources) {
const found = dataSources.find((ds: DataSource) => ds.db_name === example.dbName);
if (found) {
matchedDb = found;
setSelectedDb(found);
}
}
handleStart(translatedQuery, fakeFile, exampleSkill, matchedDb);
} catch (err: unknown) {
message.destroy('example-loading');
console.error('Example click error:', err);
const errMessage = err instanceof Error ? err.message : 'Unknown error';
message.error('加载示例失败: ' + errMessage);
}
};
// Clear chat history
const handleClearChat = () => {
setMessages([]);
setConversationId(null);
setQuery('');
setExecutionMap({});
setActiveMessageId(null);
setActiveViewMsgId(null);
setUploadedFilePath(null);
setFilePreview(null);
setFilePreviewError(null);
setArtifacts([]);
setRightPanelTab('preview');
setStreamingSummary('');
setSummaryComplete(false);
router.push('/', undefined, { shallow: true });
};
const restoreFromHistory = (
historyMessages: Array<{ role: string; context: string; order?: number; model_name?: string }>,
) => {
setExecutionMap({});
setActiveMessageId(null);
setActiveViewMsgId(null);
setArtifacts([]);
setStreamingSummary('');
setSummaryComplete(false);
const newMessages: ChatMessage[] = [];
const newExecutionMap: typeof executionMap = {};
const allArtifacts: Artifact[] = [];
const restoredSkillNames: Record<string, string> = {};
historyMessages.forEach(msg => {
if (msg.role === 'human') {
newMessages.push({ id: generateUUID(), role: 'human', context: msg.context, order: msg.order });
} else if (msg.role === 'view') {
const viewId = generateUUID();
let payload: any = null;
try {
payload = JSON.parse(msg.context);
} catch {
/* ignore parse failure */
}
if (payload && payload.version === 1 && payload.type === 'react-agent') {
const steps: ExecutionStep[] = (payload.steps || []).map((s: any, idx: number) => ({
id: s.id || `history-step-${idx}`,
step: idx + 1,
title: s.title || s.action || `Step ${idx + 1}`,
detail: s.detail || '',
status: (s.status === 'failed' ? 'failed' : 'done') as 'done' | 'failed',
action: s.action,
actionInput: s.action_input || undefined,
}));
const outputs: Record<string, ExecutionOutput[]> = {};
const stepThoughts: Record<string, string> = {};
(payload.steps || []).forEach((s: any, idx: number) => {
const stepId = s.id || `history-step-${idx}`;
if (Array.isArray(s.outputs)) {
outputs[stepId] = s.outputs.map((o: any) => ({
output_type: o.output_type || 'text',
content: o.content,
}));
}
if ((s.action === 'code_interpreter' || s.action === 'shell_interpreter') && s.action_input) {
const existingOutputs = outputs[stepId] || [];
const hasCode = existingOutputs.some((o: ExecutionOutput) => o.output_type === 'code');
if (!hasCode) {
try {
const input = typeof s.action_input === 'string' ? JSON.parse(s.action_input) : s.action_input;
if (input && input.code) {
outputs[stepId] = [{ output_type: 'code', content: input.code }, ...existingOutputs];
}
} catch {
/* ignore */
}
}
}
if (s.thought) {
if (typeof s.thought === 'string') {
stepThoughts[stepId] = s.thought;
} else if (typeof s.thought === 'object') {
const todoValue = (s.thought as Record<string, unknown>).TODO;
if (typeof todoValue === 'string') {
stepThoughts[stepId] = todoValue;
} else {
try {
stepThoughts[stepId] = JSON.stringify(s.thought);
} catch {
stepThoughts[stepId] = String(s.thought);
}
}
}
}
});
newExecutionMap[viewId] = {
steps,
outputs,
activeStepId: steps.length > 0 ? steps[steps.length - 1].id : null,
collapsed: false,
stepThoughts,
};
const finalContent = cleanFinalContent(payload.final_content || '');
const restoredArtifacts = buildArtifactsFromExecution(viewId, { steps, outputs }, finalContent, null);
allArtifacts.push(...restoredArtifacts);
// Detect skill creation from restored execution
const isSkillPackageStep = (s: ExecutionStep) => {
if (s.action !== 'shell_interpreter') return false;
const detailHas = s.detail?.includes('package_skill') || s.detail?.includes('init_skill');
const inputHas = s.actionInput?.includes('package_skill') || s.actionInput?.includes('init_skill');
const outputTexts = (outputs[s.id] || []).map(o => String(o.content)).join(' ');
const outputHas =
outputTexts.includes('package_skill') ||
outputTexts.includes('init_skill') ||
outputTexts.includes('Successfully packaged');
return detailHas || inputHas || outputHas;
};
const skillStep = steps.find(isSkillPackageStep);
if (skillStep) {
const allText = [
skillStep.actionInput || '',
skillStep.detail || '',
...(outputs[skillStep.id] || []).map(o => String(o.content)),
].join(' ');
const skillName = extractCreatedSkillName(allText);
if (skillName) {
restoredSkillNames[viewId] = skillName;
}
}
newMessages.push({
id: viewId,
role: 'view',
context: finalContent,
order: msg.order,
thinking: false,
});
} else {
newMessages.push({
id: viewId,
role: 'view',
context: msg.context || '',
order: msg.order,
thinking: false,
});
}
}
});
setMessages(newMessages);
setExecutionMap(newExecutionMap);
setArtifacts(allArtifacts);
if (Object.keys(restoredSkillNames).length > 0) {
setCreatedSkillNames(prev => ({ ...prev, ...restoredSkillNames }));
}
const lastView = [...newMessages].reverse().find(m => m.role === 'view');
if (lastView?.id) {
setActiveMessageId(lastView.id);
setStreamingSummary(lastView.context || '');
setSummaryComplete(true);
}
};
const loadConversation = async (convUid: string) => {
if (historyLoading) return;
setHistoryLoading(true);
try {
const res: any = await axios.get(`/api/v1/chat/dialogue/messages/history?con_uid=${convUid}`);
let msgList: any[] | null = null;
if (res?.success && Array.isArray(res.data)) {
msgList = res.data;
} else if (Array.isArray(res?.data?.data)) {
msgList = res.data.data;
} else if (Array.isArray(res?.data)) {
msgList = res.data;
} else if (Array.isArray(res)) {
msgList = res;
}
if (msgList && msgList.length > 0) {
setConversationId(convUid);
restoreFromHistory(
msgList.map((m: any) => ({
role: m.role,
context: m.context,
order: m.order,
model_name: m.model_name,
})),
);
}
} catch (e) {
console.error('Failed to load conversation', e);
message.error('加载历史对话失败');
} finally {
setHistoryLoading(false);
}
};
// Share current conversation — create share link and copy to clipboard
const handleShare = async () => {
if (!conversationId) {
message.warning('请先开始一段对话再分享');
return;
}
try {
const res: any = await axios.post('/api/v1/chat/share', { conv_uid: conversationId });
const shareUrl = res?.data?.share_url;
if (!shareUrl) throw new Error('No share URL returned');
const fullUrl = `${window.location.origin}${shareUrl}`;
await navigator.clipboard.writeText(fullUrl);
message.success('分享链接已复制到剪贴板!');
} catch (e) {
console.error('Failed to create share link', e);
message.error('创建分享链接失败,请稍后重试');
}
};
const _QuickAction = ({ icon, text, onClick }: { icon: any; text: string; onClick?: () => void }) => (
<div
onClick={onClick}
className='flex items-center gap-2 px-4 py-2 bg-white dark:bg-[#2c2d31] border border-gray-200 dark:border-gray-700 rounded-full cursor-pointer hover:bg-gray-50 dark:hover:bg-[#35363a] transition-colors text-sm text-gray-600 dark:text-gray-300 shadow-sm'
>
{icon}
<span>{text}</span>
</div>
);
const getDbIcon = (type: string) => {
const lowerType = type.toLowerCase();
if (lowerType.includes('mysql')) return <ConsoleSqlOutlined className='text-blue-500' />;
if (lowerType.includes('postgre')) return <DatabaseOutlined className='text-blue-400' />;
if (lowerType.includes('mongo')) return <CloudServerOutlined className='text-green-500' />;
if (lowerType.includes('sqlite')) return <DatabaseOutlined className='text-amber-500' />;
return <DatabaseOutlined className='text-gray-500' />;
};
// Upload Props
const uploadProps: any = {
name: 'file',
multiple: false,
showUploadList: false,
beforeUpload: (file: any) => {
setUploadedFile(file);
parseLocalFilePreview(file as File);
message.success(`${file.name} attached successfully`);
return false; // Prevent auto upload, we just want to select it
},
};
return (
<ConfigProvider
theme={{
token: {
colorPrimary: '#000000',
},
}}
>
<div className='flex h-full w-full bg-[#f7f7f9] dark:bg-[#0f1012] text-[#1a1b1e] dark:text-gray-200 font-sans overflow-hidden'>
{/* Main Content */}
<div className='flex-1 flex flex-col relative overflow-hidden bg-white dark:bg-[#111217]'>
{/* Top Header */}
<div className='h-16 flex-shrink-0 flex items-center justify-between px-8 border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-[#111217]/80 backdrop-blur z-20'>
<div className='flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 px-2 py-1 rounded-md'>
<span>{t('home_title')}</span>
</div>
<div className='flex items-center gap-4'>
{selectedDb && (
<Tag className='flex items-center gap-1 bg-blue-50 border-blue-200 text-blue-700 px-3 py-1 rounded-full text-xs'>
{getDbIcon(selectedDb.type)} <span className='font-medium ml-1'>{selectedDb.db_name}</span>
</Tag>
)}
{messages.length > 0 && (
<Button type='text' size='small' onClick={handleClearChat} className='text-gray-500'>
Clear Chat
</Button>
)}
<BellOutlined className='text-lg text-gray-500 cursor-pointer' />
<div className='flex items-center gap-2 bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-full text-xs font-medium'>
<ThunderboltOutlined className='text-yellow-500' /> <span>300</span>
</div>
<Avatar size='small' icon={<UserOutlined />} className='bg-blue-500' />
</div>
</div>
{/* Chat Messages or Hero Section */}
{messages.length > 0 ? (
<div className={`flex-1 flex overflow-hidden ${rightPanelCollapsed ? 'justify-center' : ''}`}>
<div
className={`${rightPanelCollapsed ? 'flex-1 max-w-[800px] border-r-0' : 'flex-[2] min-w-0 border-r border-gray-200/80 dark:border-gray-800'} flex flex-col overflow-hidden bg-white dark:bg-[#111217] transition-all duration-300 relative`}
>
<div className='flex-1 min-h-0 overflow-y-auto'>
{rounds.map((round, roundIndex) => {
const isLastRound = roundIndex === rounds.length - 1;
const isSelected = round.viewMsg?.id === selectedViewMsgId;
const isCurrentRoundCollapsed = !isLastRound && !isSelected;
const execution = round.viewMsg?.id ? executionMap[round.viewMsg.id] : undefined;
const {
sections,
activeStep: _activeStep,
outputs: _outputs,
stepThoughts,
} = convertToManusFormat(execution, round.humanMsg?.context, t);
const isWorking =
(isLastRound &&
(round.viewMsg?.thinking || execution?.steps.some(s => s.status === 'running'))) ||
false;
const roundAssistantText = isLastRound
? streamingSummary || round.viewMsg?.context || undefined
: round.viewMsg?.context || undefined;
return (
<ManusLeftPanel
key={round.viewMsg?.id || round.humanMsg?.id || `round-${roundIndex}`}
sections={sections}
activeStepId={isSelected ? selectedStepId || execution?.activeStepId : undefined}
onStepClick={(stepId, _sectionId) => {
if (round.viewMsg?.id) {
setActiveViewMsgId(round.viewMsg.id);
setSelectedStepId(stepId);
setRightPanelCollapsed(false);
setExecutionMap(prev => ({
...prev,
[round.viewMsg!.id!]: {
...prev[round.viewMsg!.id!],
activeStepId: stepId,
},
}));
}
}}
isWorking={isWorking}
userQuery={round.humanMsg?.context}
attachedFile={round.humanMsg?.attachedFile}
attachedKnowledge={round.humanMsg?.attachedKnowledge}
attachedSkill={round.humanMsg?.attachedSkill}
attachedDb={round.humanMsg?.attachedDb}
assistantText={roundAssistantText}
modelName={round.viewMsg?.model_name || model}
stepThoughts={stepThoughts}
artifacts={artifacts.filter(a => a.messageId === round.viewMsg?.id)}
onArtifactClick={artifact => {
if (round.viewMsg?.id) setActiveViewMsgId(round.viewMsg.id);
setRightPanelCollapsed(false);
if (artifact.type === 'html') {
setPreviewArtifact(artifact as Artifact);
setRightPanelView('html-preview');
} else if (artifact.type === 'code' && artifact.stepId) {
setSelectedStepId(artifact.stepId);
setRightPanelView('execution');
if (round.viewMsg?.id && execution) {
setExecutionMap(prev => ({
...prev,
[round.viewMsg!.id!]: {
...prev[round.viewMsg!.id!],
activeStepId: artifact.stepId!,
},
}));
}
} else if (artifact.type === 'file') {
// Image file artifacts: preview instead of download
if (/\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(artifact.name)) {
setPreviewArtifact(artifact as Artifact);
setRightPanelView('image-preview');
} else {
downloadArtifact(artifact as Artifact);
}
} else if (artifact.type === 'image') {
setPreviewArtifact(artifact as Artifact);
setRightPanelView('image-preview');
}
}}
onArtifactDownload={artifact => downloadArtifact(artifact as Artifact)}
onViewAllFiles={() => {
if (round.viewMsg?.id) setActiveViewMsgId(round.viewMsg.id);
setRightPanelCollapsed(false);
setRightPanelView('files');
}}
isCollapsed={isCurrentRoundCollapsed}
onExpand={() => {
if (round.viewMsg?.id) setActiveViewMsgId(round.viewMsg.id);
}}
createdSkillName={createdSkillNames[round.viewMsg?.id || '']}
onSkillCardClick={_skillName => {
if (round.viewMsg?.id) setActiveViewMsgId(round.viewMsg.id);
setRightPanelCollapsed(false);
setRightPanelView('skill-preview');
// Find the package_skill step and select it so right panel shows SkillCardRenderer
if (execution) {
const skillStep = execution.steps.find((s: ExecutionStep) => {
if (s.action !== 'shell_interpreter') return false;
const detailHas = s.detail?.includes('package_skill') || s.detail?.includes('init_skill');
const inputHas =
s.actionInput?.includes('package_skill') || s.actionInput?.includes('init_skill');
const outputTexts = (execution.outputs[s.id] || []).map(o => String(o.content)).join(' ');
const outputHas =
outputTexts.includes('package_skill') ||
outputTexts.includes('init_skill') ||
outputTexts.includes('Successfully packaged');
return detailHas || inputHas || outputHas;
});
if (skillStep) {
setSelectedStepId(skillStep.id);
setExecutionMap(prev => ({
...prev,
[round.viewMsg!.id!]: { ...prev[round.viewMsg!.id!], activeStepId: skillStep.id },
}));
}
}
}}
onSkillDownload={async skillName => {
try {
const base = process.env.API_BASE_URL || '';
const res = await fetch(
`${base}/api/v1/agent/skills/download?skill_name=${encodeURIComponent(skillName)}`,
);
if (!res.ok) throw new Error('Download failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${skillName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
// Download failed silently
}
}}
/>
);
})}
</div>
{/* Input Area at Bottom for Chat Mode - Premium Layered Style */}
<div className='bg-gradient-to-t from-white via-white/95 to-white/80 dark:from-[#1a1b1e] dark:via-[#1a1b1e]/95 dark:to-[#1a1b1e]/80 p-4 md:p-6'>
<div className='max-w-[720px] mx-auto'>
{/* Context Tags Area */}
<div className='flex flex-wrap gap-2 mb-2'>
{selectedDb && (
<Tag
closable
onClose={() => setSelectedDb(null)}
className='flex items-center gap-1 bg-blue-50 border-blue-200 text-blue-700 px-3 py-1 rounded-full'
>
{getDbIcon(selectedDb.type)} <span className='font-medium ml-1'>{selectedDb.db_name}</span>
</Tag>
)}
{selectedKnowledge && (
<Tag
closable
onClose={() => setSelectedKnowledge(null)}
className='flex items-center gap-1 bg-orange-50 border-orange-200 text-orange-700 px-3 py-1 rounded-full'
>
<BookOutlined /> <span className='font-medium ml-1'>{selectedKnowledge.name}</span>
</Tag>
)}
{uploadedFile && (
<Tag
closable
onClose={() => setUploadedFile(null)}
className='flex items-center gap-1 bg-green-50 border-green-200 text-green-700 px-3 py-1 rounded-full'
>
<FileExcelOutlined /> <span className='font-medium ml-1'>{uploadedFile.name}</span>
</Tag>
)}
</div>
{/* Outer Frame - Floating Effect */}
<div className='rounded-2xl w-full relative transition-all duration-300 shadow-[0_12px_32px_rgba(0,0,0,0.1),0_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[0_20px_48px_rgba(0,0,0,0.16),0_8px_24px_rgba(0,0,0,0.08)] dark:shadow-[0_12px_32px_rgba(0,0,0,0.4)] dark:hover:shadow-[0_20px_48px_rgba(0,0,0,0.5)]'>
{/* White Inner Box - Clean Glass Card */}
<div className='bg-white/95 backdrop-blur-md dark:bg-[#1e1f24]/95 rounded-2xl border border-gray-100 dark:border-[#33353b] shadow-[inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] p-3 px-4'>
<Input.TextArea
value={query}
onChange={e => {
const newValue = e.target.value;
setQuery(newValue);
if (newValue === '/' && !isSkillPanelOpen && !selectedSkill) {
setIsSkillPanelOpen(true);
}
}}
onPressEnter={e => {
if (!e.shiftKey) {
e.preventDefault();
handleStart();
}
}}
placeholder={
t('ask_data_question') ||
'Ask a question about your database, upload a CSV, or generate a report...'
}
autoSize={{ minRows: 2, maxRows: 6 }}
className='flex-1 resize-none !border-none !shadow-none !bg-transparent px-0 py-2'
style={{ backgroundColor: 'transparent' }}
/>
{/* Toolbar Row */}
<div className='flex items-center justify-between mt-1'>
<div className='flex items-center gap-3'>
{/* Add Button */}
<Dropdown
menu={{
items: [
{
key: 'upload',
label: (
<Upload {...uploadProps}>
<div className='w-full'>Upload File</div>
</Upload>
),
icon: <UploadOutlined />,
},
{
key: 'database',
label: 'Select Data Source',
icon: <DatabaseOutlined />,
onClick: () => setIsDbModalOpen(true),
},
{
key: 'knowledge',
label: 'Select Knowledge Base',
icon: <BookOutlined />,
onClick: () => setIsKnowledgeModalOpen(true),
},
],
}}
trigger={['click']}
>
<Tooltip title={t('add_context')}>
<Button
type='text'
shape='circle'
size='small'
icon={<PlusOutlined />}
className='flex items-center justify-center text-gray-500 hover:text-violet-600 bg-gradient-to-b from-white to-gray-50 dark:from-[#2a2b2f] dark:to-[#1e1f24] dark:text-gray-300 border border-gray-200/80 dark:border-white/10 shadow-[0_1px_2px_rgba(0,0,0,0.05),inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),inset_0_1px_0_rgba(255,255,255,0.05)] hover:-translate-y-[0.5px] hover:shadow-[0_2px_4px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,1)] dark:hover:border-white/20 transition-all flex-shrink-0'
/>
</Tooltip>
</Dropdown>
{/* Skill Selector Button with Badge */}
<Popover
trigger='click'
placement='topLeft'
open={isSkillPanelOpen}
onOpenChange={setIsSkillPanelOpen}
overlayClassName='manus-skill-menu'
overlayInnerStyle={{ padding: 0, borderRadius: 12 }}
content={
<div className='w-[320px] bg-white dark:bg-[#2c2d31] rounded-xl shadow-xl overflow-hidden'>
<div className='p-3 border-b border-gray-100 dark:border-gray-700'>
<Input
placeholder={t('search_skill')}
prefix={<SearchOutlined className='text-gray-400' />}
value={skillSearchQuery}
onChange={e => setSkillSearchQuery(e.target.value)}
className='rounded-lg'
allowClear
size='small'
/>
</div>
<div className='max-h-[300px] overflow-y-auto'>
{(skillsList || [])
.filter(
skill =>
!skillSearchQuery ||
skill.name.toLowerCase().includes(skillSearchQuery.toLowerCase()) ||
skill.description.toLowerCase().includes(skillSearchQuery.toLowerCase()),
)
.map(skill => (
<div
key={skill.id}
onClick={() => {
if (selectedSkill?.id === skill.id) {
setSelectedSkill(null);
setQuery('');
} else {
setSelectedSkill(skill);
setQuery(`/${skill.name} `);
}
setIsSkillPanelOpen(false);
setSkillSearchQuery('');
}}
className={`flex items-start gap-3 px-3 py-2.5 cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-800 ${
selectedSkill?.id === skill.id ? 'bg-purple-50 dark:bg-purple-900/20' : ''
}`}
>
<div className='flex-shrink-0 w-7 h-7 rounded-lg bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center text-white text-xs'>
{skill.icon || <ThunderboltOutlined />}
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm text-gray-800 dark:text-gray-200'>
{skill.name}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded ${
skill.type === 'official'
? 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{skill.type === 'official' ? '官方' : '个人'}
</span>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2'>
{skill.description}
</p>
</div>
{selectedSkill?.id === skill.id && (
<CheckCircleFilled className='text-purple-500 flex-shrink-0 text-sm' />
)}
</div>
))}
{(skillsList || []).filter(
skill =>
!skillSearchQuery ||
skill.name.toLowerCase().includes(skillSearchQuery.toLowerCase()) ||
skill.description.toLowerCase().includes(skillSearchQuery.toLowerCase()),
).length === 0 && (
<div className='text-center py-8 text-gray-400'>
<ThunderboltOutlined className='text-2xl mb-2 opacity-50' />
<div className='text-xs'>
{skillSearchQuery ? '未找到匹配的技能' : '暂无可用技能'}
</div>
</div>
)}
</div>
<div className='border-t border-gray-100 dark:border-gray-700 px-3 py-2 flex items-center justify-between bg-gray-50/50 dark:bg-gray-900/50'>
<span className='text-[10px] text-gray-400'>
{(skillsList || []).length}
</span>
<Button
type='link'
size='small'
onClick={() => {
router.push('/construct/skills');
setIsSkillPanelOpen(false);
}}
className='text-[10px] p-0 h-auto'
>
</Button>
</div>
</div>
}
>
<Tooltip
title={
selectedSkill ? t('skill_selected', { name: selectedSkill.name }) : t('select_skill')
}
>
<Button
type='text'
shape='circle'
size='small'
className={`relative flex items-center justify-center flex-shrink-0 transition-all ${
selectedSkill
? 'bg-gradient-to-br from-[#a78bfa] to-[#7c3aed] text-white border border-transparent shadow-[0_2px_4px_rgba(139,92,246,0.3),inset_0_1px_0_rgba(255,255,255,0.3)] hover:-translate-y-[0.5px] hover:shadow-[0_4px_8px_rgba(139,92,246,0.4),inset_0_1px_0_rgba(255,255,255,0.3)]'
: 'text-gray-500 hover:text-violet-600 bg-gradient-to-b from-white to-gray-50 dark:from-[#2a2b2f] dark:to-[#1e1f24] dark:text-gray-300 border border-gray-200/80 dark:border-white/10 shadow-[0_1px_2px_rgba(0,0,0,0.05),inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),inset_0_1px_0_rgba(255,255,255,0.05)] hover:-translate-y-[0.5px] hover:shadow-[0_2px_4px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,1)] dark:hover:border-white/20'
}`}
>
<div className='relative'>
<ThunderboltOutlined className={selectedSkill ? 'text-white' : ''} />
{selectedSkill && (
<span className='absolute -top-1.5 -right-1.5 bg-white text-[#7c3aed] text-[8px] rounded-full w-3.5 h-3.5 flex items-center justify-center font-bold shadow-sm ring-1 ring-[#7c3aed]/30'>
1
</span>
)}
</div>
</Button>
</Tooltip>
</Popover>
{/* Separator dot */}
<div className='w-px h-4 bg-gray-200 dark:bg-gray-700 mx-0.5' />
{/* Model Selector with premium styling */}
<div className='model-selector-premium'>
<ModelSelector onChange={val => setModel(val)} />
</div>
<style
dangerouslySetInnerHTML={{
__html: `
.model-selector-premium .ant-select { border-radius: 8px !important; border: none !important; }
.model-selector-premium .ant-select-selector { background: linear-gradient(180deg, #ffffff 0%, #f9fafb 100%) !important; border: 1px solid rgba(0,0,0,0.12) !important; box-shadow: 0 1px 2px rgba(0,0,0,0.05), inset 0 1px 0 rgba(255,255,255,1) !important; border-radius: 8px !important; transition: all 0.2s ease !important; padding: 0 8px !important; }
.dark .model-selector-premium .ant-select-selector { background: linear-gradient(180deg, #2a2b2f 0%, #1e1f24 100%) !important; border: 1px solid rgba(255,255,255,0.1) !important; box-shadow: 0 1px 2px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.05) !important; }
.model-selector-premium .ant-select:hover .ant-select-selector { border-color: rgba(0,0,0,0.2) !important; box-shadow: 0 2px 4px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,1) !important; transform: translateY(-0.5px); }
.dark .model-selector-premium .ant-select:hover .ant-select-selector { border-color: rgba(255,255,255,0.15) !important; }
.model-selector-premium .ant-select-focused .ant-select-selector { border-color: #a78bfa !important; box-shadow: 0 0 0 2px rgba(167,139,250,0.15), inset 0 1px 0 rgba(255,255,255,1) !important; }
.dark .model-selector-premium .ant-select-focused .ant-select-selector { box-shadow: 0 0 0 2px rgba(167,139,250,0.2), inset 0 1px 0 rgba(255,255,255,0.05) !important; }
/* Global Dropdown Item Styles for Model Selectors */
.ant-select-dropdown .ant-select-item-option-selected { background-color: #f1f5f9 !important; color: #0f172a !important; font-weight: 500 !important; }
.ant-select-dropdown .ant-select-item-option-active:not(.ant-select-item-option-selected) { background-color: #f8fafc !important; }
.dark .ant-select-dropdown .ant-select-item-option-selected { background-color: rgba(255,255,255,0.08) !important; color: #e2e8f0 !important; }
.dark .ant-select-dropdown .ant-select-item-option-active:not(.ant-select-item-option-selected) { background-color: rgba(255,255,255,0.04) !important; }
`,
}}
/>
</div>
<div className='flex items-center gap-3'>
{/* Voice Button */}
<Tooltip title={t('voice_input')}>
<Button
type='text'
shape='circle'
icon={<AudioOutlined className='text-gray-500 text-[18px]' />}
onClick={() => message.info(t('voice_input_coming_soon'))}
className='flex-shrink-0 h-9 w-9 transition-all duration-200 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-800'
/>
</Tooltip>
{/* Send Button with blue gradient + gloss animation */}
<Button
type='primary'
shape='circle'
icon={<ArrowUpOutlined />}
onClick={() => handleStart()}
disabled={(!query.trim() && !uploadedFile) || loading}
loading={loading}
className={`group/send relative overflow-hidden border-none shadow-lg flex-shrink-0 h-9 w-9 transition-all duration-200 ${
query.trim() || uploadedFile
? 'bg-gradient-to-br from-[#3b82f6] to-[#2563eb] hover:shadow-blue-300/40 hover:shadow-xl hover:scale-105'
: 'bg-gray-200 text-gray-400'
}`}
style={
query.trim() || uploadedFile
? { background: 'linear-gradient(135deg, #3b82f6, #2563eb)' }
: undefined
}
>
{(query.trim() || uploadedFile) && (
<span
className='absolute inset-0 opacity-0 group-hover/send:opacity-100 transition-opacity duration-300 pointer-events-none'
style={{
background:
'linear-gradient(105deg, transparent 40%, rgba(255,255,255,0.25) 45%, rgba(255,255,255,0.35) 50%, rgba(255,255,255,0.25) 55%, transparent 60%)',
animation: 'glossSweepChat 1.8s ease-in-out infinite',
}}
/>
)}
</Button>
</div>
<style
dangerouslySetInnerHTML={{
__html: `@keyframes glossSweepChat { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }`,
}}
/>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Panel toggle handle — placed between panels to avoid overflow clipping */}
<div className='relative z-20 flex-shrink-0'>
<Tooltip title={rightPanelCollapsed ? t('expand_panel') : t('collapse_panel')} placement='left'>
<button
onClick={() => setRightPanelCollapsed(prev => !prev)}
className='absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-8 flex items-center justify-center bg-white dark:bg-[#1a1b1e] border border-gray-200 dark:border-gray-700 rounded-full shadow-sm hover:bg-gray-100 dark:hover:bg-gray-800 hover:w-5 hover:shadow-md transition-all duration-200 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
>
{rightPanelCollapsed ? (
<LeftOutlined style={{ fontSize: 10 }} />
) : (
<RightOutlined style={{ fontSize: 10 }} />
)}
</button>
</Tooltip>
</div>
<div
className={`${rightPanelCollapsed ? 'w-0 min-w-0 overflow-hidden opacity-0' : 'flex-[3] min-w-0 overflow-hidden'} bg-[#f8f8fb] dark:bg-[#0f1114] flex flex-col transition-all duration-300`}
>
{(() => {
const activeViewMsg = messages.find(m => m.id === selectedViewMsgId && m.role === 'view');
const rawExecution = activeViewMsg?.id ? executionMap[activeViewMsg.id] : undefined;
// Respect user's manual step selection for the right panel
const execution =
rawExecution && selectedStepId ? { ...rawExecution, activeStepId: selectedStepId } : rawExecution;
const {
activeStep,
outputs,
stepThoughts: _stepThoughts,
} = convertToManusFormat(execution, undefined, t);
const isRunning = execution?.steps.some(s => s.status === 'running') || false;
return (
<ManusRightPanel
activeStep={activeStep}
outputs={outputs}
databaseType={selectedDb?.db_type}
databaseName={selectedDb?.db_name}
isRunning={isRunning}
onCollapse={() => setRightPanelCollapsed(true)}
onRerun={() => {}}
onShare={!loading && !!conversationId ? handleShare : undefined}
terminalTitle={t('db_gpt_computer')}
artifacts={artifacts.filter(a => a.messageId === activeViewMsg?.id)}
onArtifactClick={artifact => {
if (artifact.type === 'html') {
setPreviewArtifact(artifact as Artifact);
setRightPanelView('html-preview');
} else if (artifact.type === 'code' && artifact.stepId) {
setSelectedStepId(artifact.stepId);
setRightPanelView('execution');
if (activeViewMsg?.id && execution) {
setExecutionMap(prev => ({
...prev,
[activeViewMsg.id!]: {
...prev[activeViewMsg.id!],
activeStepId: artifact.stepId!,
},
}));
}
} else if (artifact.type === 'file') {
if (/\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(artifact.name)) {
setPreviewArtifact(artifact as Artifact);
setRightPanelView('image-preview');
setRightPanelCollapsed(false);
}
} else if (artifact.type === 'image') {
setPreviewArtifact(artifact as Artifact);
setRightPanelView('image-preview');
setRightPanelCollapsed(false);
}
}}
panelView={rightPanelView}
onPanelViewChange={setRightPanelView}
previewArtifact={previewArtifact}
skillName={createdSkillNames[activeViewMsg?.id || ''] || null}
summaryContent={streamingSummary || activeViewMsg?.context || ''}
isSummaryStreaming={!_summaryComplete && !!streamingSummary}
/>
);
})()}
</div>
</div>
) : (
// Welcome Mode: Display Hero Section
<div className='flex-1 flex flex-col items-center justify-center px-6 py-4 pb-20 overflow-y-auto'>
<div className='w-full max-w-[860px] flex flex-col items-center animate-fade-in-up'>
<h1 className='text-4xl md:text-5xl font-serif text-gray-900 dark:text-gray-100 mb-4 text-center flex items-center gap-4'>
<div className='w-12 h-12 rounded-xl bg-white dark:bg-[#1a1b1e] shadow-md flex items-center justify-center flex-shrink-0'>
<Image src='/LOGO_SMALL.png' alt='DB-GPT' width={32} height={32} className='object-contain' />
</div>
{t('home_title')}
</h1>
<p className='text-sm md:text-base text-gray-400 dark:text-gray-500 tracking-[0.2em] font-light mb-10'>
{t('home_subtitle')}
</p>
{/* Input Box Container - Premium Layered Style */}
<div className='w-full relative'>
{/* Outer Frame - Floating Effect */}
<div className='w-full relative transition-all duration-500 rounded-[28px] shadow-[0_16px_48px_rgba(0,0,0,0.12),0_6px_20px_rgba(0,0,0,0.08)] hover:shadow-[0_24px_64px_rgba(0,0,0,0.2),0_12px_32px_rgba(0,0,0,0.1)] dark:shadow-[0_16px_48px_rgba(0,0,0,0.4)] dark:hover:shadow-[0_24px_64px_rgba(0,0,0,0.5)]'>
{/* White Inner Box - Clean Glass Card */}
<div className='bg-white/95 backdrop-blur-md dark:bg-[#1e1f24]/95 rounded-[28px] border border-gray-100 dark:border-[#33353b] shadow-[inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] p-5 relative z-10'>
{/* Uploaded File, Database, Knowledge Tags */}
{(uploadedFile || selectedDb || selectedKnowledge) && (
<div className='flex flex-wrap gap-2 mb-2'>
{uploadedFile && (
<Tag
closable
onClose={() => setUploadedFile(null)}
className='flex items-center gap-1 bg-green-50 border-green-200 text-green-700 px-3 py-1 rounded-full'
>
<FileExcelOutlined /> <span className='font-medium ml-1'>{uploadedFile.name}</span>
</Tag>
)}
{selectedDb && (
<Tag
closable
onClose={() => setSelectedDb(null)}
className='flex items-center gap-1 bg-blue-50 border-blue-200 text-blue-700 px-3 py-1 rounded-full'
>
{getDbIcon(selectedDb.type)}{' '}
<span className='font-medium ml-1'>{selectedDb.db_name}</span>
</Tag>
)}
{selectedKnowledge && (
<Tag
closable
onClose={() => setSelectedKnowledge(null)}
className='flex items-center gap-1 bg-orange-50 border-orange-200 text-orange-700 px-3 py-1 rounded-full'
>
<BookOutlined /> <span className='font-medium ml-1'>{selectedKnowledge.name}</span>
</Tag>
)}
</div>
)}
<Input.TextArea
value={query}
onChange={e => {
const newValue = e.target.value;
setQuery(newValue);
if (newValue === '/' && !isSkillPanelOpen && !selectedSkill) {
setIsSkillPanelOpen(true);
}
}}
onPressEnter={e => {
if (!e.shiftKey) {
e.preventDefault();
handleStart();
}
}}
placeholder={
t('ask_data_question') ||
'Ask a question about your database, upload a CSV, or generate a report...'
}
autoSize={{ minRows: 3, maxRows: 8 }}
className='text-lg resize-none !border-none !shadow-none !bg-transparent px-2 py-2 mb-2'
style={{ backgroundColor: 'transparent' }}
/>
{/* Input Toolbar */}
<div className='flex items-center justify-between px-1 mt-1'>
<div className='flex items-center gap-4'>
{/* Add Button with Dropdown Menu */}
<Dropdown
menu={{
items: [
{
key: 'upload',
label: (
<Upload {...uploadProps}>
<div className='w-full'>{t('add_from_local')}</div>
</Upload>
),
icon: <PaperClipOutlined />,
},
{
key: 'skill',
label: t('use_skill'),
icon: <ThunderboltOutlined />,
onClick: () => setIsSkillPanelOpen(true),
},
{
key: 'knowledge',
label: t('use_knowledge'),
icon: <BookOutlined />,
onClick: () => setIsKnowledgePanelOpen(true),
},
{
key: 'database',
label: t('use_database'),
icon: <DatabaseOutlined />,
onClick: () => setTimeout(() => setIsDbPanelOpen(true), 100),
},
],
}}
trigger={['click']}
>
<Tooltip title={t('add_context')}>
<Button
type='text'
shape='circle'
size='small'
icon={<PlusOutlined />}
className='flex items-center justify-center text-gray-500 hover:text-violet-600 bg-gradient-to-b from-white to-gray-50 dark:from-[#2a2b2f] dark:to-[#1e1f24] dark:text-gray-300 border border-gray-200/80 dark:border-white/10 shadow-[0_1px_2px_rgba(0,0,0,0.05),inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),inset_0_1px_0_rgba(255,255,255,0.05)] hover:-translate-y-[0.5px] hover:shadow-[0_2px_4px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,1)] dark:hover:border-white/20 transition-all flex-shrink-0'
/>
</Tooltip>
</Dropdown>
{/* Skill Selector Button with Badge - Purple Gradient */}
<Popover
trigger='click'
placement='topLeft'
open={isSkillPanelOpen}
onOpenChange={setIsSkillPanelOpen}
overlayClassName='manus-skill-menu'
overlayInnerStyle={{ padding: 0, borderRadius: 12 }}
content={
<div className='w-[320px] bg-white dark:bg-[#2c2d31] rounded-xl shadow-xl overflow-hidden'>
<div className='p-3 border-b border-gray-100 dark:border-gray-700'>
<Input
placeholder={t('search_skill')}
prefix={<SearchOutlined className='text-gray-400' />}
value={skillSearchQuery}
onChange={e => setSkillSearchQuery(e.target.value)}
className='rounded-lg'
allowClear
size='small'
/>
</div>
<div className='max-h-[300px] overflow-y-auto'>
{(skillsList || [])
.filter(
skill =>
!skillSearchQuery ||
skill.name.toLowerCase().includes(skillSearchQuery.toLowerCase()) ||
skill.description.toLowerCase().includes(skillSearchQuery.toLowerCase()),
)
.map(skill => (
<div
key={skill.id}
onClick={() => {
if (selectedSkill?.id === skill.id) {
setSelectedSkill(null);
setQuery('');
} else {
setSelectedSkill(skill);
setQuery(`/${skill.name} `);
}
setIsSkillPanelOpen(false);
setSkillSearchQuery('');
}}
className={`flex items-start gap-3 px-3 py-2.5 cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-800 ${
selectedSkill?.id === skill.id ? 'bg-purple-50 dark:bg-purple-900/20' : ''
}`}
>
<div className='flex-shrink-0 w-7 h-7 rounded-lg bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center text-white text-xs'>
{skill.icon || <ThunderboltOutlined />}
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm text-gray-800 dark:text-gray-200'>
{skill.name}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 rounded ${
skill.type === 'official'
? 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{skill.type === 'official' ? '官方' : '个人'}
</span>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2'>
{skill.description}
</p>
</div>
{selectedSkill?.id === skill.id && (
<CheckCircleFilled className='text-purple-500 flex-shrink-0 text-sm' />
)}
</div>
))}
{(skillsList || []).filter(
skill =>
!skillSearchQuery ||
skill.name.toLowerCase().includes(skillSearchQuery.toLowerCase()) ||
skill.description.toLowerCase().includes(skillSearchQuery.toLowerCase()),
).length === 0 && (
<div className='text-center py-8 text-gray-400'>
<ThunderboltOutlined className='text-2xl mb-2 opacity-50' />
<div className='text-xs'>
{skillSearchQuery ? '未找到匹配的技能' : '暂无可用技能'}
</div>
</div>
)}
</div>
<div className='border-t border-gray-100 dark:border-gray-700 px-3 py-2 flex items-center justify-between bg-gray-50/50 dark:bg-gray-900/50'>
<span className='text-[10px] text-gray-400'>
{(skillsList || []).length}
</span>
<Button
type='link'
size='small'
onClick={() => {
router.push('/construct/skills');
setIsSkillPanelOpen(false);
}}
className='text-[10px] p-0 h-auto'
>
</Button>
</div>
</div>
}
>
<Tooltip
title={
selectedSkill ? t('skill_selected', { name: selectedSkill.name }) : t('select_skill')
}
>
<Button
type='text'
shape='circle'
size='small'
className={`relative flex items-center justify-center flex-shrink-0 transition-all ${
selectedSkill
? 'bg-gradient-to-br from-[#a78bfa] to-[#7c3aed] text-white border border-transparent shadow-[0_2px_4px_rgba(139,92,246,0.3),inset_0_1px_0_rgba(255,255,255,0.3)] hover:-translate-y-[0.5px] hover:shadow-[0_4px_8px_rgba(139,92,246,0.4),inset_0_1px_0_rgba(255,255,255,0.3)]'
: 'text-gray-500 hover:text-violet-600 bg-gradient-to-b from-white to-gray-50 dark:from-[#2a2b2f] dark:to-[#1e1f24] dark:text-gray-300 border border-gray-200/80 dark:border-white/10 shadow-[0_1px_2px_rgba(0,0,0,0.05),inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),inset_0_1px_0_rgba(255,255,255,0.05)] hover:-translate-y-[0.5px] hover:shadow-[0_2px_4px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,1)] dark:hover:border-white/20'
}`}
>
<div className='relative'>
<ThunderboltOutlined className={selectedSkill ? 'text-white' : ''} />
{selectedSkill && (
<span className='absolute -top-1.5 -right-1.5 bg-white text-[#7c3aed] text-[8px] rounded-full w-3.5 h-3.5 flex items-center justify-center font-bold shadow-sm ring-1 ring-[#7c3aed]/30'>
1
</span>
)}
</div>
</Button>
</Tooltip>
</Popover>
{/* Database Selector Popover - Blue themed */}
<Popover
trigger='click'
placement='topLeft'
open={isDbPanelOpen}
onOpenChange={setIsDbPanelOpen}
overlayClassName='manus-database-menu'
overlayInnerStyle={{ padding: 0, borderRadius: 12 }}
content={
<div className='w-[320px] bg-white dark:bg-[#2c2d31] rounded-xl shadow-xl overflow-hidden'>
<div className='p-3 border-b border-gray-100 dark:border-gray-700'>
<Input
placeholder='搜索数据库'
prefix={<SearchOutlined className='text-gray-400' />}
value={dbSearchQuery}
onChange={e => setDbSearchQuery(e.target.value)}
className='rounded-lg'
allowClear
size='small'
/>
</div>
<div className='max-h-[300px] overflow-y-auto'>
{(dataSources || [])
.filter(
ds =>
!dbSearchQuery ||
ds.db_name.toLowerCase().includes(dbSearchQuery.toLowerCase()) ||
ds.type.toLowerCase().includes(dbSearchQuery.toLowerCase()) ||
(ds.description &&
ds.description.toLowerCase().includes(dbSearchQuery.toLowerCase())),
)
.map(ds => (
<div
key={ds.id}
onClick={() => {
setSelectedDb(ds);
setIsDbPanelOpen(false);
setDbSearchQuery('');
}}
className={`flex items-start gap-3 px-3 py-2.5 cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-800 ${
selectedDb?.id === ds.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
<div className='flex-shrink-0 w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white text-xs'>
{getDbIcon(ds.type)}
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm text-gray-800 dark:text-gray-200'>
{ds.db_name}
</span>
<span className='text-[10px] text-gray-400 bg-gray-100 dark:bg-gray-700 rounded px-1.5 py-0.5'>
{ds.type}
</span>
</div>
{ds.description && (
<p className='text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2'>
{ds.description}
</p>
)}
</div>
{selectedDb?.id === ds.id && (
<CheckCircleFilled className='text-blue-500 flex-shrink-0 text-sm' />
)}
</div>
))}
{(dataSources || []).filter(
ds =>
!dbSearchQuery ||
ds.db_name.toLowerCase().includes(dbSearchQuery.toLowerCase()) ||
ds.type.toLowerCase().includes(dbSearchQuery.toLowerCase()) ||
(ds.description &&
ds.description.toLowerCase().includes(dbSearchQuery.toLowerCase())),
).length === 0 && (
<div className='text-center py-8 text-gray-400'>
<DatabaseOutlined className='text-2xl mb-2 opacity-50' />
<div className='text-xs'>
{dbSearchQuery ? '未找到匹配的数据库' : '暂无可用数据库'}
</div>
</div>
)}
</div>
<div className='border-t border-gray-100 dark:border-gray-700 px-3 py-2 flex items-center justify-between bg-gray-50/50 dark:bg-gray-900/50'>
<span className='text-[10px] text-gray-400'>
{(dataSources || []).length}
</span>
<Button
type='link'
size='small'
onClick={() => {
router.push('/construct/database');
setIsDbPanelOpen(false);
}}
className='text-[10px] p-0 h-auto'
>
</Button>
</div>
</div>
}
>
<Tooltip
title={
selectedDb ? t('database_selected', { name: selectedDb.db_name }) : t('select_database')
}
>
<Button
type='text'
shape='circle'
size='small'
className={`relative flex items-center justify-center flex-shrink-0 transition-all ${
selectedDb
? 'bg-gradient-to-br from-blue-400 to-blue-600 text-white border border-transparent shadow-[0_2px_4px_rgba(59,130,246,0.3),inset_0_1px_0_rgba(255,255,255,0.3)] hover:-translate-y-[0.5px] hover:shadow-[0_4px_8px_rgba(59,130,246,0.4),inset_0_1px_0_rgba(255,255,255,0.3)]'
: 'text-gray-500 hover:text-blue-600 bg-gradient-to-b from-white to-gray-50 dark:from-[#2a2b2f] dark:to-[#1e1f24] dark:text-gray-300 border border-gray-200/80 dark:border-white/10 shadow-[0_1px_2px_rgba(0,0,0,0.05),inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),inset_0_1px_0_rgba(255,255,255,0.05)] hover:-translate-y-[0.5px] hover:shadow-[0_2px_4px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,1)] dark:hover:border-white/20'
}`}
>
<div className='relative'>
<DatabaseOutlined className={selectedDb ? 'text-white' : ''} />
{selectedDb && (
<span className='absolute -top-1.5 -right-1.5 bg-white text-blue-600 text-[8px] rounded-full w-3.5 h-3.5 flex items-center justify-center font-bold shadow-sm ring-1 ring-blue-400/30'>
1
</span>
)}
</div>
</Button>
</Tooltip>
</Popover>
{/* Knowledge Base Selector - Orange themed */}
<Popover
trigger='click'
placement='topLeft'
open={isKnowledgePanelOpen}
onOpenChange={setIsKnowledgePanelOpen}
overlayClassName='manus-knowledge-menu'
overlayInnerStyle={{ padding: 0, borderRadius: 12 }}
content={
<div className='w-[320px] bg-white dark:bg-[#2c2d31] rounded-xl shadow-xl overflow-hidden'>
<div className='p-3 border-b border-gray-100 dark:border-gray-700'>
<Input
placeholder='搜索知识库'
prefix={<SearchOutlined className='text-gray-400' />}
value={knowledgeSearchQuery}
onChange={e => setKnowledgeSearchQuery(e.target.value)}
className='rounded-lg'
allowClear
size='small'
/>
</div>
<div className='max-h-[300px] overflow-y-auto'>
{(knowledgeSpaces || [])
.filter(
space =>
!knowledgeSearchQuery ||
space.name.toLowerCase().includes(knowledgeSearchQuery.toLowerCase()) ||
(space.desc &&
space.desc.toLowerCase().includes(knowledgeSearchQuery.toLowerCase())),
)
.map(space => (
<div
key={space.id}
onClick={() => {
setSelectedKnowledge(space);
setIsKnowledgePanelOpen(false);
setKnowledgeSearchQuery('');
}}
className={`flex items-start gap-3 px-3 py-2.5 cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-800 ${
selectedKnowledge?.id === space.id ? 'bg-orange-50 dark:bg-orange-900/20' : ''
}`}
>
<div className='flex-shrink-0 w-7 h-7 rounded-lg bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center text-white text-xs'>
<BookOutlined />
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm text-gray-800 dark:text-gray-200'>
{space.name}
</span>
</div>
{space.desc && (
<p className='text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2'>
{space.desc}
</p>
)}
</div>
{selectedKnowledge?.id === space.id && (
<CheckCircleFilled className='text-orange-500 flex-shrink-0 text-sm' />
)}
</div>
))}
{(knowledgeSpaces || []).filter(
space =>
!knowledgeSearchQuery ||
space.name.toLowerCase().includes(knowledgeSearchQuery.toLowerCase()) ||
(space.desc &&
space.desc.toLowerCase().includes(knowledgeSearchQuery.toLowerCase())),
).length === 0 && (
<div className='text-center py-8 text-gray-400'>
<BookOutlined className='text-2xl mb-2 opacity-50' />
<div className='text-xs'>
{knowledgeSearchQuery ? '未找到匹配的知识库' : '暂无可用知识库'}
</div>
</div>
)}
</div>
<div className='border-t border-gray-100 dark:border-gray-700 px-3 py-2 flex items-center justify-between bg-gray-50/50 dark:bg-gray-900/50'>
<span className='text-[10px] text-gray-400'>
{(knowledgeSpaces || []).length}
</span>
<Button
type='link'
size='small'
onClick={() => {
router.push('/knowledge');
setIsKnowledgePanelOpen(false);
}}
className='text-[10px] p-0 h-auto'
>
</Button>
</div>
</div>
}
>
<Tooltip
title={
selectedKnowledge
? t('knowledge_selected', { name: selectedKnowledge.name })
: t('select_knowledge')
}
>
<Button
type='text'
shape='circle'
size='small'
className={`relative flex items-center justify-center flex-shrink-0 transition-all ${
selectedKnowledge
? 'bg-gradient-to-br from-orange-400 to-orange-600 text-white border border-transparent shadow-[0_2px_4px_rgba(249,115,22,0.3),inset_0_1px_0_rgba(255,255,255,0.3)] hover:-translate-y-[0.5px] hover:shadow-[0_4px_8px_rgba(249,115,22,0.4),inset_0_1px_0_rgba(255,255,255,0.3)]'
: 'text-gray-500 hover:text-orange-600 bg-gradient-to-b from-white to-gray-50 dark:from-[#2a2b2f] dark:to-[#1e1f24] dark:text-gray-300 border border-gray-200/80 dark:border-white/10 shadow-[0_1px_2px_rgba(0,0,0,0.05),inset_0_1px_0_rgba(255,255,255,1)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),inset_0_1px_0_rgba(255,255,255,0.05)] hover:-translate-y-[0.5px] hover:shadow-[0_2px_4px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,1)] dark:hover:border-white/20'
}`}
>
<div className='relative'>
<BookOutlined className={selectedKnowledge ? 'text-white' : ''} />
{selectedKnowledge && (
<span className='absolute -top-1.5 -right-1.5 bg-white text-orange-600 text-[8px] rounded-full w-3.5 h-3.5 flex items-center justify-center font-bold shadow-sm ring-1 ring-orange-400/30'>
1
</span>
)}
</div>
</Button>
</Tooltip>
</Popover>
{/* Separator */}
<div className='w-px h-4 bg-gray-200 dark:bg-gray-700 mx-0.5' />
{/* Model Selector with premium styling */}
<div className='model-selector-premium'>
<ModelSelector onChange={val => setModel(val)} />
</div>
<style
dangerouslySetInnerHTML={{
__html: `
.model-selector-premium .ant-select { border-radius: 8px !important; border: none !important; }
.model-selector-premium .ant-select-selector { background: linear-gradient(180deg, #ffffff 0%, #f9fafb 100%) !important; border: 1px solid rgba(0,0,0,0.12) !important; box-shadow: 0 1px 2px rgba(0,0,0,0.05), inset 0 1px 0 rgba(255,255,255,1) !important; border-radius: 8px !important; transition: all 0.2s ease !important; padding: 0 8px !important; }
.dark .model-selector-premium .ant-select-selector { background: linear-gradient(180deg, #2a2b2f 0%, #1e1f24 100%) !important; border: 1px solid rgba(255,255,255,0.1) !important; box-shadow: 0 1px 2px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.05) !important; }
.model-selector-premium .ant-select:hover .ant-select-selector { border-color: rgba(0,0,0,0.2) !important; box-shadow: 0 2px 4px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,1) !important; transform: translateY(-0.5px); }
.dark .model-selector-premium .ant-select:hover .ant-select-selector { border-color: rgba(255,255,255,0.15) !important; }
.model-selector-premium .ant-select-focused .ant-select-selector { border-color: #a78bfa !important; box-shadow: 0 0 0 2px rgba(167,139,250,0.15), inset 0 1px 0 rgba(255,255,255,1) !important; }
.dark .model-selector-premium .ant-select-focused .ant-select-selector { box-shadow: 0 0 0 2px rgba(167,139,250,0.2), inset 0 1px 0 rgba(255,255,255,0.05) !important; }
/* Global Dropdown Item Styles for Model Selectors */
.ant-select-dropdown .ant-select-item-option-selected { background-color: #f1f5f9 !important; color: #0f172a !important; font-weight: 500 !important; }
.ant-select-dropdown .ant-select-item-option-active:not(.ant-select-item-option-selected) { background-color: #f8fafc !important; }
.dark .ant-select-dropdown .ant-select-item-option-selected { background-color: rgba(255,255,255,0.08) !important; color: #e2e8f0 !important; }
.dark .ant-select-dropdown .ant-select-item-option-active:not(.ant-select-item-option-selected) { background-color: rgba(255,255,255,0.04) !important; }
`,
}}
/>
</div>
<div className='flex items-center gap-3'>
{/* Voice Button */}
<Tooltip title={t('voice_input')}>
<Button
type='text'
shape='circle'
size='large'
icon={<AudioOutlined className='text-gray-500 text-xl' />}
onClick={() => message.info(t('voice_input_coming_soon'))}
className='flex-shrink-0 transition-all duration-200 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-800'
/>
</Tooltip>
{/* Send Button with blue gradient + gloss */}
<Button
type='primary'
shape='circle'
size='large'
icon={<ArrowUpOutlined />}
onClick={() => handleStart()}
disabled={(!query.trim() && !uploadedFile) || loading}
loading={loading}
className={`group/send relative overflow-hidden border-none shadow-lg transition-all duration-200 ${
query.trim() || uploadedFile
? 'bg-gradient-to-br from-[#3b82f6] to-[#2563eb] hover:shadow-blue-300/40 hover:shadow-xl hover:scale-105'
: 'bg-gray-200 text-gray-400'
}`}
style={
query.trim() || uploadedFile
? { background: 'linear-gradient(135deg, #3b82f6, #2563eb)' }
: undefined
}
>
{(query.trim() || uploadedFile) && (
<span
className='absolute inset-0 opacity-0 group-hover/send:opacity-100 transition-opacity duration-300 pointer-events-none'
style={{
background:
'linear-gradient(105deg, transparent 40%, rgba(255,255,255,0.25) 45%, rgba(255,255,255,0.35) 50%, rgba(255,255,255,0.25) 55%, transparent 60%)',
animation: 'glossSweepHero 1.8s ease-in-out infinite',
}}
/>
)}
</Button>
</div>
<style
dangerouslySetInnerHTML={{
__html: `@keyframes glossSweepHero { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }`,
}}
/>
</div>
</div>
</div>
</div>
{/* Recommended Examples */}
<div className='mt-10 w-full'>
<div className='flex items-center justify-center gap-2 mb-4'>
<div className='h-px flex-1 bg-gradient-to-r from-transparent to-gray-200 dark:to-gray-700' />
<span className='text-xs font-medium text-gray-400 dark:text-gray-500 tracking-wider uppercase'>
{t('recommend_examples')}
</span>
<div className='h-px flex-1 bg-gradient-to-l from-transparent to-gray-200 dark:to-gray-700' />
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
{EXAMPLE_CARDS.map(example => (
<div
key={example.id}
onClick={() => handleExampleClick(example)}
className={`group relative bg-gradient-to-br ${example.color} border ${example.borderColor} rounded-2xl p-4 cursor-pointer hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300`}
>
<div className='flex items-start gap-3'>
<div
className={`w-10 h-10 ${example.iconBg} rounded-xl flex items-center justify-center text-xl flex-shrink-0`}
>
{example.icon}
</div>
<div className='flex-1 min-w-0'>
<h3 className='text-sm font-semibold text-gray-800 dark:text-gray-200 mb-1'>
{(() => {
const key = `example_${example.id}_title`;
const val = t(key) as string;
return val && val !== key ? val : example.title;
})()}
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 line-clamp-2'>
{(() => {
const key = `example_${example.id}_desc`;
const val = t(key) as string;
return val && val !== key ? val : example.description;
})()}
</p>
</div>
</div>
<div className='absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity'>
<RightOutlined className='text-xs text-gray-400' />
</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Footer Promo - Only show when no messages */}
{messages.length === 0 && (
<div className='absolute bottom-6 left-0 right-0 flex justify-center'>
<div className='bg-white/60 dark:bg-[#1e1f24]/60 backdrop-blur-sm px-5 py-2.5 rounded-full border border-gray-100 dark:border-gray-700/50 flex items-center gap-3 shadow-sm cursor-pointer hover:shadow-md hover:bg-white/90 dark:hover:bg-[#1e1f24]/90 transition-all duration-300'>
<Image src='/LOGO_SMALL.png' alt='DB-GPT' width={22} height={22} className='object-contain' />
<span className='text-xs font-medium text-gray-600 dark:text-gray-300 tracking-wide'>
{t('home_subtitle')}
</span>
<span className='text-[10px] text-gray-400 dark:text-gray-500'>·</span>
<span className='text-[10px] text-gray-400 dark:text-gray-500'>{t('home_title')}</span>
</div>
</div>
)}
</div>
{/* Database Selection Modal */}
<Modal
title={
<div className='flex items-center gap-2'>
<DatabaseOutlined />
Select Data Source
</div>
}
open={isDbModalOpen}
onCancel={() => setIsDbModalOpen(false)}
footer={null}
width={500}
>
<List
itemLayout='horizontal'
dataSource={dataSources || []}
renderItem={(item: DataSource) => (
<List.Item
className={`cursor-pointer hover:bg-gray-50 rounded-lg px-2 transition-colors ${selectedDb?.id === item.id ? 'bg-blue-50' : ''}`}
onClick={() => {
setSelectedDb(item);
setIsDbModalOpen(false);
}}
actions={[selectedDb?.id === item.id && <CheckCircleFilled className='text-blue-500' />]}
>
<List.Item.Meta
avatar={<div className='mt-1 bg-gray-100 p-2 rounded-lg'>{getDbIcon(item.type)}</div>}
title={item.db_name}
description={<span className='text-xs text-gray-400'>{item.type}</span>}
/>
</List.Item>
)}
locale={{ emptyText: 'No data sources found' }}
/>
<div className='mt-4 pt-4 border-t border-gray-100 text-center'>
<Button type='dashed' block icon={<PlusOutlined />} onClick={() => router.push('/construct/database')}>
Add New Data Source
</Button>
</div>
</Modal>
{/* Knowledge Base Selection Modal */}
<Modal
title={
<div className='flex items-center gap-2'>
<BookOutlined />
Select Knowledge Base
</div>
}
open={isKnowledgeModalOpen}
onCancel={() => setIsKnowledgeModalOpen(false)}
footer={null}
width={500}
>
<List
itemLayout='horizontal'
dataSource={knowledgeSpaces || []}
renderItem={(item: KnowledgeSpace) => (
<List.Item
className={`cursor-pointer hover:bg-gray-50 rounded-lg px-2 transition-colors ${selectedKnowledge?.id === item.id ? 'bg-orange-50' : ''}`}
onClick={() => {
setSelectedKnowledge(item);
setIsKnowledgeModalOpen(false);
}}
actions={[selectedKnowledge?.id === item.id && <CheckCircleFilled className='text-orange-500' />]}
>
<List.Item.Meta
avatar={
<div className='mt-1 bg-gray-100 p-2 rounded-lg'>
<ReadOutlined className='text-orange-500' />
</div>
}
title={item.name}
description={<span className='text-xs text-gray-400'>{item.vector_type}</span>}
/>
</List.Item>
)}
locale={{ emptyText: 'No knowledge bases found' }}
/>
<div className='mt-4 pt-4 border-t border-gray-100 text-center'>
<Button type='dashed' block icon={<PlusOutlined />} onClick={() => router.push('/construct/knowledge')}>
Add New Knowledge Base
</Button>
</div>
</Modal>
</div>
</ConfigProvider>
);
};
export default Playground;