feat: v0.1.1 迭代——人设外键重构、Chat UI优化、意识节点防幻觉、日志双视图

1. 人设外键重构:persona_template 成为 system_prompt 唯一权威来源,
   agent/系统节点通过 persona_id FK 引用,含数据迁移脚本
2. Chat UI:去掉底部AI提示、加号改为弹出菜单、新建对话乐观跳转
3. 意识节点:无可用worker时禁止编造agent_id,只能自行完成或拒绝
4. 日志页面:双tab布局(系统日志 + 工作流日志列表选择)
5. 其他:SSE流式聊天、对话删除/重命名、standalone模式修复

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 06:18:47 +00:00
parent e3b8686d45
commit 6f1bc27101
39 changed files with 2904 additions and 524 deletions
+14
View File
@@ -6,6 +6,8 @@ import { SetupGuideModal } from './components/Layout/SetupGuideModal';
import { SettingsLayout } from './components/Settings/SettingsLayout';
import { AgentLayout } from './components/Agent/AgentLayout';
import { PluginLayout } from './components/Plugin/PluginLayout';
import { WorkflowConfigSettings } from './components/Agent/WorkflowConfigSettings';
import { SystemLogsView } from './components/Agent/SystemLogsView';
import { LeftPanel } from './components/Chat/LeftPanel';
import { ChatPanel } from './components/Chat/ChatPanel';
import { RightPanel } from './components/Chat/RightPanel';
@@ -96,6 +98,18 @@ function App() {
{mode === 'agent' && agentTab === 'agents' && <AgentLayout />}
{mode === 'agent' && agentTab === 'plugin' && <PluginLayout />}
{mode === 'agent' && agentTab === 'config' && (
<div className="flex-1 overflow-y-auto p-8">
<WorkflowConfigSettings />
</div>
)}
{mode === 'agent' && agentTab === 'logs' && (
<div className="flex-1 overflow-y-auto p-8">
<SystemLogsView />
</div>
)}
</div>
</>
)}
+1 -1
View File
@@ -4,7 +4,7 @@ import axios from 'axios';
// If missing, defaulting to '' means requests will be relative to the current browser origin.
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 10000,
timeout: 120000,
headers: {
'Content-Type': 'application/json',
},
@@ -2,8 +2,6 @@ import { useTranslation } from 'react-i18next';
import { useAppStore } from '../../store/useAppStore';
import { ProvidersSettings } from './ProvidersSettings';
import { WorkerIndividualSettings } from './WorkerIndividualSettings';
import { WorkflowConfigSettings } from './WorkflowConfigSettings';
import { SystemLogsView } from './SystemLogsView';
import { PersonaTemplateSettings } from './PersonaTemplateSettings';
export function AgentLayout() {
@@ -14,8 +12,6 @@ export function AgentLayout() {
{ key: 'worker', label: t('agent.individual') },
{ key: 'persona', label: t('agent.personaManagement') },
{ key: 'providers', label: t('agent.providerManagement') },
{ key: 'config', label: t('agent.config') },
{ key: 'logs', label: t('agent.systemLogs') },
];
return (
@@ -39,8 +35,6 @@ export function AgentLayout() {
{innerAgentTab === 'worker' && <WorkerIndividualSettings />}
{innerAgentTab === 'persona' && <PersonaTemplateSettings />}
{innerAgentTab === 'providers' && <ProvidersSettings />}
{innerAgentTab === 'config' && <WorkflowConfigSettings />}
{innerAgentTab === 'logs' && <SystemLogsView />}
</div>
</div>
);
@@ -1,28 +1,18 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '../../api/client';
import { Plus, Edit2, Trash2, X, Save, Loader2, FileText, Copy } from 'lucide-react';
import type { Provider } from '../../types';
import { Plus, Edit2, Trash2, X, Save, Loader2, FileText } from 'lucide-react';
interface PersonaTemplate {
template_id: string;
name: string;
description: string;
system_prompt: string;
agent_type: string;
provider_title: string | null;
model_id: string | null;
tools: string[];
tags: string[];
is_builtin: boolean;
owner_id: string | null;
}
export function PersonaTemplateSettings() {
const { t } = useTranslation();
const [templates, setTemplates] = useState<PersonaTemplate[]>([]);
const [providers, setProviders] = useState<Provider[]>([]);
const [availableTools, setAvailableTools] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isEditing, setIsEditing] = useState(false);
@@ -34,14 +24,8 @@ export function PersonaTemplateSettings() {
const fetchData = async () => {
setLoading(true);
try {
const [tplRes, provRes, toolsRes] = await Promise.all([
apiClient.get('/api/v1/agent/template'),
apiClient.get('/api/v1/provider/list'),
apiClient.get('/api/v1/resource/tool')
]);
setTemplates(tplRes.data.templates || []);
setProviders(Object.values(provRes.data.provider_list || {}));
setAvailableTools(toolsRes.data.tools || []);
const res = await apiClient.get('/api/v1/agent/template');
setTemplates(res.data.templates || []);
} catch { setError(t('agent.loadFailed')); }
finally { setLoading(false); }
};
@@ -49,9 +33,7 @@ export function PersonaTemplateSettings() {
useEffect(() => { fetchData(); }, []);
const handleAddNew = () => {
setEditData({ name: '', description: '', system_prompt: '', agent_type: 'ordinary_individual',
provider_title: providers.length > 0 ? providers[0].provider_title : '',
model_id: '', tools: [], tags: [] });
setEditData({ name: '', system_prompt: '' });
setIsNew(true);
setIsEditing(true);
setModalMessage('');
@@ -70,25 +52,12 @@ export function PersonaTemplateSettings() {
catch { alert(t('common.deleteFailed')); }
};
const handleCreateWorker = async (id: string) => {
try {
await apiClient.post(`/api/v1/agent/worker/from-template/${id}`);
alert(t('agent.workerCreatedFromTemplate'));
} catch (err: any) {
alert(err.response?.data?.detail || t('common.saveFailed'));
}
};
const handleModalSave = async (e: React.FormEvent) => {
e.preventDefault();
setModalMessage('');
setSubmitLoading(true);
try {
const payload = { name: editData.name, description: editData.description,
system_prompt: editData.system_prompt, agent_type: editData.agent_type,
provider_title: editData.provider_title || null,
model_id: editData.model_id || null,
tools: editData.tools || [], tags: editData.tags || [] };
const payload = { name: editData.name, system_prompt: editData.system_prompt };
if (isNew) await apiClient.post('/api/v1/agent/template', payload);
else await apiClient.put(`/api/v1/agent/template/${editData.template_id}`, payload);
setIsEditing(false);
@@ -98,15 +67,6 @@ export function PersonaTemplateSettings() {
} finally { setSubmitLoading(false); }
};
const getTypeBadge = (type: string) => {
const colors: Record<string, string> = {
ordinary_individual: 'bg-bg-secondary text-text-muted',
skill_individual: 'bg-success-bg text-success',
special_individual: 'bg-warning-bg text-warning',
};
return <span className={`px-2 py-0.5 rounded-md text-[10px] font-medium ${colors[type] || colors.ordinary_individual}`}>{t(`agent.type.${type}`, type)}</span>;
};
return (
<div className="max-w-5xl space-y-6">
<div className="flex justify-between items-end">
@@ -133,133 +93,46 @@ export function PersonaTemplateSettings() {
<span className="text-sm">{t('agent.noTemplates')}</span>
</div>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="bg-bg-secondary border-b border-border-primary text-text-muted text-xs uppercase tracking-wider">
<th className="px-5 py-3 font-semibold">{t('agent.templateName')}</th>
<th className="px-5 py-3 font-semibold">{t('agent.type')}</th>
<th className="px-5 py-3 font-semibold">{t('agent.tags')}</th>
<th className="px-5 py-3 font-semibold text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-secondary">
{templates.map((tpl) => (
<tr key={tpl.template_id} className="hover:bg-bg-hover transition-colors">
<td className="px-5 py-3">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center">
<FileText size={14} className="text-text-muted" />
</div>
<div className="flex flex-col">
<span className="font-medium text-text-primary text-xs">{tpl.name}</span>
{tpl.is_builtin && <span className="text-[10px] text-accent">{t('agent.builtin')}</span>}
</div>
</div>
</td>
<td className="px-5 py-3">{getTypeBadge(tpl.agent_type)}</td>
<td className="px-5 py-3">
<div className="flex flex-wrap gap-1">
{(tpl.tags || []).map(tag => (
<span key={tag} className="px-1.5 py-0.5 rounded text-[10px] bg-bg-secondary text-text-muted">{tag}</span>
))}
</div>
</td>
<td className="px-5 py-3 text-right">
<button onClick={() => handleCreateWorker(tpl.template_id)} title={t('agent.createFromTemplate')} className="p-1.5 text-text-muted hover:text-success hover:bg-success-bg rounded-lg transition-all mr-0.5"><Copy size={14} /></button>
{!tpl.is_builtin && <button onClick={() => handleEdit(tpl)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all mr-0.5"><Edit2 size={14} /></button>}
{!tpl.is_builtin && <button onClick={() => handleDelete(tpl.template_id)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all"><Trash2 size={14} /></button>}
</td>
</tr>
))}
</tbody>
</table>
<div className="divide-y divide-border-secondary">
{templates.map((tpl) => (
<div key={tpl.template_id} className="flex items-center justify-between px-5 py-3.5 hover:bg-bg-hover transition-colors">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center shrink-0">
<FileText size={14} className="text-text-muted" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-text-primary text-sm">{tpl.name}</div>
<div className="text-xs text-text-muted truncate mt-0.5">{tpl.system_prompt || t('agent.noPrompt')}</div>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0 ml-3">
<button onClick={() => handleEdit(tpl)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"><Edit2 size={14} /></button>
<button onClick={() => handleDelete(tpl.template_id)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all"><Trash2 size={14} /></button>
</div>
</div>
))}
</div>
)}
</div>
{isEditing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto border border-border-primary animate-fade-in-scale">
<div className="flex justify-between items-center p-5 border-b border-border-primary sticky top-0 bg-bg-card z-10">
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto border border-border-primary">
<div className="flex justify-between items-center p-5 border-b border-border-primary">
<h2 className="text-base font-bold text-text-primary">{isNew ? t('agent.addTemplate') : t('agent.editTemplate')}</h2>
<button onClick={() => setIsEditing(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors"><X size={20} /></button>
</div>
<form onSubmit={handleModalSave} className="p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.templateName')}</label>
<input type="text" required value={editData.name || ''} onChange={(e) => setEditData({...editData, name: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.type')}</label>
<select value={editData.agent_type || 'ordinary_individual'} onChange={(e) => setEditData({...editData, agent_type: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="ordinary_individual">{t('agent.type.ordinary_individual')}</option>
<option value="skill_individual">{t('agent.type.skill_individual')}</option>
<option value="special_individual">{t('agent.type.special_individual')}</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.provider')}</label>
<select value={editData.provider_title || ''} onChange={(e) => setEditData({...editData, provider_title: e.target.value, model_id: ''})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="">{t('common.none')}</option>
{providers.map((p) => (<option key={p.provider_title} value={p.provider_title}>{p.provider_title}</option>))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.model')}</label>
{(() => {
const sp = providers.find(p => p.provider_title === editData.provider_title);
const models = sp?.provider_models || [];
return (
<select value={editData.model_id || ''} onChange={(e) => setEditData({...editData, model_id: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="">{t('common.none')}</option>
{models.map(m => <option key={m} value={m}>{m}</option>)}
</select>
);
})()}
</div>
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.description')}</label>
<textarea value={editData.description || ''} onChange={(e) => setEditData({...editData, description: e.target.value})} rows={2}
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.templateName')}</label>
<input type="text" required value={editData.name || ''} onChange={(e) => setEditData({...editData, name: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.systemPrompt')}</label>
<textarea value={editData.system_prompt || ''} onChange={(e) => setEditData({...editData, system_prompt: e.target.value})} rows={4}
<textarea value={editData.system_prompt || ''} onChange={(e) => setEditData({...editData, system_prompt: e.target.value})} rows={6}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.tags')}</label>
<input type="text" value={(editData.tags || []).join(', ')}
onChange={(e) => setEditData({...editData, tags: e.target.value.split(',').map(s => s.trim()).filter(Boolean)})}
placeholder={t('agent.tagsPlaceholder')}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.tools')}</label>
<div className="flex flex-wrap gap-1.5 p-3 bg-bg-input border border-border-primary rounded-xl max-h-40 overflow-y-auto">
{availableTools.map(tool => {
const currentTools: string[] = editData.tools || [];
const isSelected = currentTools.includes(tool);
return (
<button key={tool} type="button" onClick={() => {
const updated = isSelected ? currentTools.filter(x => x !== tool) : [...currentTools, tool];
setEditData({...editData, tools: updated});
}}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${isSelected ? 'bg-accent-light text-accent border border-accent/20' : 'bg-bg-secondary text-text-muted border border-border-primary hover:border-text-muted'}`}>
{tool}
</button>
);
})}
{availableTools.length === 0 && <span className="text-xs text-text-muted">{t('agent.noTools')}</span>}
</div>
</div>
{modalMessage && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{modalMessage}</div>}
<div className="pt-3 flex justify-end gap-2 border-t border-border-primary">
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">{t('common.cancel')}</button>
+241 -84
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshCw, Search } from 'lucide-react';
import { RefreshCw, Search, Server, GitBranch } from 'lucide-react';
import apiClient from '../../api/client';
interface EventLog {
id: number;
@@ -13,20 +14,52 @@ interface EventLog {
created_at: string | null;
}
interface WorkflowSummary {
trace_id: string;
title: string;
status: string;
created_at: string;
}
interface WorkflowStep {
name: string;
step: number;
node: string;
action: string;
status: string;
agent_id: string | null;
}
const LEVEL_STYLES: Record<string, { bg: string; text: string; label: string }> = {
error: { bg: 'bg-[rgba(196,145,122,0.12)]', text: 'text-[#a0705a]', label: 'ERROR' },
warn: { bg: 'bg-[rgba(196,168,130,0.15)]', text: 'text-[#9a7d5e]', label: 'WARN' },
info: { bg: 'bg-[rgba(156,175,136,0.12)]', text: 'text-[#7a8e6a]', label: 'INFO' },
};
const STATUS_STYLES: Record<string, { bg: string; text: string }> = {
completed: { bg: 'bg-success-bg', text: 'text-success' },
failed: { bg: 'bg-[rgba(196,145,122,0.12)]', text: 'text-[#a0705a]' },
working: { bg: 'bg-[rgba(156,175,136,0.12)]', text: 'text-[#7a8e6a]' },
pending: { bg: 'bg-bg-secondary', text: 'text-text-muted' },
};
export function SystemLogsView() {
const { t } = useTranslation();
const [tab, setTab] = useState<'system' | 'workflow'>('system');
// System logs state
const [logs, setLogs] = useState<EventLog[]>([]);
const [loading, setLoading] = useState(false);
const [traceFilter, setTraceFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [levelFilter, setLevelFilter] = useState('');
// Workflow logs state
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
const [selectedTrace, setSelectedTrace] = useState<string | null>(null);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
const [wfLoading, setWfLoading] = useState(false);
const fetchLogs = async () => {
setLoading(true);
try {
@@ -35,13 +68,8 @@ export function SystemLogsView() {
if (typeFilter) params.set('event_type', typeFilter);
if (levelFilter) params.set('level', levelFilter);
params.set('limit', '200');
const resp = await fetch(`/api/v1/system/logs?${params.toString()}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
});
if (!resp.ok) throw new Error('Failed to fetch logs');
const data = await resp.json();
setLogs(data.logs || []);
const resp = await apiClient.get(`/api/v1/system/logs?${params.toString()}`);
setLogs(resp.data.logs || []);
} catch (err) {
console.error(err);
} finally {
@@ -49,7 +77,36 @@ export function SystemLogsView() {
}
};
useEffect(() => { fetchLogs(); }, []);
const fetchWorkflows = async () => {
try {
const resp = await apiClient.get('/api/v1/workflow/list');
setWorkflows(resp.data.workflows || []);
} catch (err) {
console.error(err);
}
};
const fetchWorkflowDetail = async (traceId: string) => {
setWfLoading(true);
try {
const resp = await apiClient.get(`/api/v1/workflow/${traceId}`);
setWorkflowSteps(resp.data.steps || []);
} catch (err) {
console.error(err);
setWorkflowSteps([]);
} finally {
setWfLoading(false);
}
};
useEffect(() => {
if (tab === 'system') fetchLogs();
else fetchWorkflows();
}, [tab]);
useEffect(() => {
if (selectedTrace) fetchWorkflowDetail(selectedTrace);
}, [selectedTrace]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
@@ -58,87 +115,187 @@ export function SystemLogsView() {
return (
<div className="max-w-6xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-text-primary">{t('agent.systemLogs')}</h2>
<button onClick={fetchLogs} disabled={loading} className="p-2 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
{/* Tab Switcher */}
<div className="flex items-center gap-6 mb-6">
<button
onClick={() => setTab('system')}
className={`flex items-center gap-2 pb-2 text-sm font-medium border-b-2 transition-colors ${tab === 'system' ? 'border-accent text-accent' : 'border-transparent text-text-muted hover:text-text-primary'}`}
>
<Server size={14} />
{t('agent.systemLogs')}
</button>
<button
onClick={() => setTab('workflow')}
className={`flex items-center gap-2 pb-2 text-sm font-medium border-b-2 transition-colors ${tab === 'workflow' ? 'border-accent text-accent' : 'border-transparent text-text-muted hover:text-text-primary'}`}
>
<GitBranch size={14} />
{t('agent.workflowLogs')}
</button>
<div className="flex-1" />
<button
onClick={() => tab === 'system' ? fetchLogs() : fetchWorkflows()}
disabled={loading || wfLoading}
className="p-2 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"
>
<RefreshCw size={16} className={(loading || wfLoading) ? 'animate-spin' : ''} />
</button>
</div>
<form onSubmit={handleSearch} className="grid grid-cols-4 gap-3 mb-5">
<input
type="text"
value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)}
placeholder={t('agent.logFilterTraceId')}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15"
/>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
>
<option value="">{t('agent.logFilterAllTypes')}</option>
<option value="workflow_start">workflow_start</option>
<option value="step_enter">step_enter</option>
<option value="step_complete">step_complete</option>
<option value="step_error">step_error</option>
<option value="workflow_complete">workflow_complete</option>
<option value="workflow_fail">workflow_fail</option>
<option value="system">system</option>
</select>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
>
<option value="">{t('agent.logFilterAllLevels')}</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
<button type="submit" className="flex items-center justify-center gap-2 px-4 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">
<Search size={14} /> {t('agent.logSearch')}
</button>
</form>
{tab === 'system' ? (
<>
<form onSubmit={handleSearch} className="grid grid-cols-4 gap-3 mb-5">
<input
type="text"
value={traceFilter}
onChange={(e) => setTraceFilter(e.target.value)}
placeholder={t('agent.logFilterTraceId')}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15"
/>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
>
<option value="">{t('agent.logFilterAllTypes')}</option>
<option value="workflow_start">workflow_start</option>
<option value="step_enter">step_enter</option>
<option value="step_complete">step_complete</option>
<option value="step_error">step_error</option>
<option value="workflow_complete">workflow_complete</option>
<option value="workflow_fail">workflow_fail</option>
<option value="system">system</option>
</select>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="px-3 py-2 bg-bg-card border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/15"
>
<option value="">{t('agent.logFilterAllLevels')}</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
<button type="submit" className="flex items-center justify-center gap-2 px-4 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">
<Search size={14} /> {t('agent.logSearch')}
</button>
</form>
<div className="bg-bg-card border border-border-primary rounded-xl overflow-hidden">
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table className="w-full text-xs">
<thead className="bg-bg-secondary sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logLevel')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logType')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">Trace ID</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logNode')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logMessage')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logTime')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{logs.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-text-muted">{t('agent.noLogs')}</td></tr>
<div className="bg-bg-card border border-border-primary rounded-xl overflow-hidden">
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table className="w-full text-xs">
<thead className="bg-bg-secondary sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logLevel')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logType')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">Trace ID</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logNode')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logMessage')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logTime')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{logs.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-text-muted">{t('agent.noLogs')}</td></tr>
) : (
logs.map((log) => {
const style = LEVEL_STYLES[log.level] || LEVEL_STYLES.info;
return (
<tr key={log.id} className="hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-2.5">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${style.bg} ${style.text}`}>{style.label}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary font-mono">{log.event_type}</td>
<td className="px-4 py-2.5 text-text-muted font-mono">{log.trace_id.slice(-8)}</td>
<td className="px-4 py-2.5 text-text-secondary">{log.node_name || '-'}</td>
<td className="px-4 py-2.5 text-text-primary max-w-xs truncate" title={log.message}>{log.message}</td>
<td className="px-4 py-2.5 text-text-muted whitespace-nowrap">{log.created_at ? new Date(log.created_at).toLocaleString() : '-'}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</>
) : (
<div className="flex gap-4 h-[calc(100vh-220px)]">
{/* Workflow List */}
<div className="w-72 shrink-0 bg-bg-card border border-border-primary rounded-xl overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-border-primary bg-bg-secondary">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">{t('agent.workflowLogList')}</span>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border-secondary">
{workflows.length === 0 ? (
<div className="px-4 py-8 text-center text-text-muted text-xs">{t('workflow.noWorkflows')}</div>
) : (
logs.map((log) => {
const style = LEVEL_STYLES[log.level] || LEVEL_STYLES.info;
return (
<tr key={log.id} className="hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-2.5">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${style.bg} ${style.text}`}>{style.label}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary font-mono">{log.event_type}</td>
<td className="px-4 py-2.5 text-text-muted font-mono">{log.trace_id.slice(-8)}</td>
<td className="px-4 py-2.5 text-text-secondary">{log.node_name || '-'}</td>
<td className="px-4 py-2.5 text-text-primary max-w-xs truncate" title={log.message}>{log.message}</td>
<td className="px-4 py-2.5 text-text-muted whitespace-nowrap">{log.created_at ? new Date(log.created_at).toLocaleString() : '-'}</td>
</tr>
);
})
workflows.map((wf) => (
<button
key={wf.trace_id}
onClick={() => setSelectedTrace(wf.trace_id)}
className={`w-full text-left px-4 py-3 hover:bg-bg-hover transition-colors ${selectedTrace === wf.trace_id ? 'bg-accent-light border-l-2 border-accent' : ''}`}
>
<div className="text-xs font-medium text-text-primary truncate">{wf.title}</div>
<div className="flex items-center gap-2 mt-1">
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${(STATUS_STYLES[wf.status] || STATUS_STYLES.pending).bg} ${(STATUS_STYLES[wf.status] || STATUS_STYLES.pending).text}`}>
{wf.status}
</span>
<span className="text-[10px] text-text-muted">{new Date(wf.created_at).toLocaleDateString()}</span>
</div>
</button>
))
)}
</tbody>
</table>
</div>
</div>
{/* Workflow Steps Detail */}
<div className="flex-1 bg-bg-card border border-border-primary rounded-xl overflow-hidden flex flex-col">
{!selectedTrace ? (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
{t('agent.selectWorkflowToView')}
</div>
) : wfLoading ? (
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">
{t('common.loading')}
</div>
) : (
<div className="flex-1 overflow-y-auto">
<table className="w-full text-xs">
<thead className="bg-bg-secondary sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">#</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.name')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.logNode')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('common.status')}</th>
<th className="px-4 py-3 text-left font-semibold text-text-muted uppercase tracking-wider">{t('agent.workflowAction')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{workflowSteps.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-text-muted">{t('workflow.noStepsYet')}</td></tr>
) : (
workflowSteps.map((step, idx) => {
const ss = STATUS_STYLES[step.status] || STATUS_STYLES.pending;
return (
<tr key={idx} className="hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-2.5 text-text-muted">{step.step || idx + 1}</td>
<td className="px-4 py-2.5 text-text-primary font-medium">{step.name}</td>
<td className="px-4 py-2.5 text-text-secondary">{step.node}</td>
<td className="px-4 py-2.5">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold ${ss.bg} ${ss.text}`}>{step.status}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary max-w-sm truncate" title={step.action}>{step.action}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
}
@@ -11,18 +11,25 @@ interface WorkerIndividual {
description?: string;
provider_title: string;
model_id: string;
system_prompt?: string;
persona_id?: string;
output_template?: string;
bound_skill?: string;
workspace?: string;
tools?: string;
}
interface PersonaTemplate {
template_id: string;
name: string;
system_prompt: string;
}
export function WorkerIndividualSettings() {
const { t } = useTranslation();
const [providers, setProviders] = useState<Provider[]>([]);
const [workers, setWorkers] = useState<WorkerIndividual[]>([]);
const [systemNodes, setSystemNodes] = useState<any[]>([]);
const [personaTemplates, setPersonaTemplates] = useState<PersonaTemplate[]>([]);
const [availableSkills, setAvailableSkills] = useState<string[]>([]);
const [availableTools, setAvailableTools] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
@@ -36,17 +43,19 @@ export function WorkerIndividualSettings() {
const fetchData = async () => {
setLoading(true);
try {
const [provRes, workRes, sysRes, toolsRes, skillsRes] = await Promise.all([
const [provRes, workRes, sysRes, toolsRes, skillsRes, tplRes] = await Promise.all([
apiClient.get('/api/v1/provider/list'),
apiClient.get('/api/v1/agent/worker'),
apiClient.get('/api/v1/agent'),
apiClient.get('/api/v1/resource/tool'),
apiClient.get('/api/v1/resource/skill')
apiClient.get('/api/v1/resource/skill'),
apiClient.get('/api/v1/agent/template')
]);
setProviders(Object.values(provRes.data.provider_list || {}));
setWorkers(workRes.data.workers || []);
setAvailableTools(toolsRes.data.tools || []);
setAvailableSkills(Object.keys(skillsRes.data.skills || {}));
setPersonaTemplates(tplRes.data.templates || []);
const providersList = Object.values(provRes.data.provider_list || {}) as Provider[];
const defaultProvider = providersList.length > 0 ? providersList[0].provider_title : '';
@@ -61,7 +70,7 @@ export function WorkerIndividualSettings() {
provider_title: found?.provider_title || defaultProvider,
model_id: found?.model_id || '',
tools: found?.tools ? JSON.stringify(found.tools) : '[]',
custom_system_prompt: found?.custom_system_prompt || '',
persona_id: found?.persona_id || '',
is_system: true
};
}));
@@ -90,7 +99,7 @@ export function WorkerIndividualSettings() {
const handleAddNew = () => {
setEditData({ agent_name: '', agent_type: 'ordinary_individual', description: '',
provider_title: providers.length > 0 ? providers[0].provider_title : '', model_id: '',
system_prompt: '', output_template: '{}', bound_skill: '{}', workspace: '[]', tools: '[]' });
persona_id: '', output_template: '{}', bound_skill: '{}', workspace: '[]', tools: '[]' });
setIsNew(true);
setIsEditing(true);
setModalMessage('');
@@ -112,7 +121,7 @@ export function WorkerIndividualSettings() {
provider_title: editData.provider_title,
model_id: editData.model_id,
tools: JSON.parse(editData.tools || '[]'),
custom_system_prompt: (editData as any).custom_system_prompt || null,
persona_id: (editData as any).persona_id || null,
display_name: (editData as any).display_name || null
});
} else {
@@ -280,10 +289,12 @@ export function WorkerIndividualSettings() {
</div>
{(editData as any).is_system && (
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.customPrompt')}</label>
<textarea value={(editData as any).custom_system_prompt || ''} onChange={(e) => setEditData({...editData, custom_system_prompt: e.target.value} as any)} rows={3}
placeholder={t('agent.customPromptPlaceholder')}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.persona')}</label>
<select value={(editData as any).persona_id || ''} onChange={(e) => setEditData({...editData, persona_id: e.target.value || null} as any)}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="">{t('common.none')}</option>
{personaTemplates.map(p => <option key={p.template_id} value={p.template_id}>{p.name}</option>)}
</select>
</div>
)}
{!(editData as any).is_system && (
@@ -294,9 +305,12 @@ export function WorkerIndividualSettings() {
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.systemPrompt')}</label>
<textarea value={editData.system_prompt || ''} onChange={(e) => setEditData({...editData, system_prompt: e.target.value})} rows={3}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('agent.persona')}</label>
<select value={editData.persona_id || ''} onChange={(e) => setEditData({...editData, persona_id: e.target.value})} required
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="" disabled>{t('agent.selectPersona')}</option>
{personaTemplates.map(p => <option key={p.template_id} value={p.template_id}>{p.name}</option>)}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
+123 -87
View File
@@ -1,6 +1,10 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { MessageSquare, Activity, ArrowUp, Plus, Sparkles, Code, FileText, Search, User } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Activity, ArrowUp, Plus, Sparkles, Code, FileText, Search, Paperclip } from 'lucide-react';
import { useChatStore } from '../../store/useChatStore';
export function ChatPanel() {
@@ -13,7 +17,9 @@ export function ChatPanel() {
{ icon: Search, label: t('chat.quickActions.search'), prompt: '帮我搜索相关资料' },
];
const [input, setInput] = useState('');
const [showPlusMenu, setShowPlusMenu] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const plusMenuRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const {
@@ -41,22 +47,31 @@ export function ChatPanel() {
}
}, [activeSessionId]);
const handleNewChat = async () => {
await createChat(t('chat.newChat'), '你好');
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (plusMenuRef.current && !plusMenuRef.current.contains(e.target as Node)) {
setShowPlusMenu(false);
}
};
if (showPlusMenu) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPlusMenu]);
const handleSendMessage = async () => {
if (!input.trim() || !activeSessionId) return;
if (!input.trim()) return;
const text = input.trim();
setInput('');
await sendMessage(activeSessionId, text);
if (!activeSessionId) {
const title = text.slice(0, 20) + (text.length > 20 ? '...' : '');
await createChat(title, text);
} else {
await sendMessage(activeSessionId, text);
}
};
const handleQuickAction = (prompt: string) => {
if (!activeSessionId) {
createChat(prompt.slice(0, 20), prompt).then((id) => {
if (id) sendMessage(id, prompt);
});
createChat(prompt.slice(0, 20), prompt);
} else {
sendMessage(activeSessionId, prompt);
}
@@ -87,13 +102,32 @@ export function ChatPanel() {
))}
</div>
<button
onClick={handleNewChat}
className="px-5 py-2.5 bg-accent text-white rounded-xl font-medium hover:bg-accent-hover transition-all text-sm flex items-center gap-2"
>
<Plus size={16} />
{t('chat.newChat')}
</button>
<div className="w-full max-w-[480px]">
<div className="flex items-end gap-2">
<div className="flex-1 relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
placeholder={t('chat.placeholder')}
rows={1}
className="w-full bg-bg-card border border-border-primary rounded-xl pl-4 pr-4 py-3 focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_var(--accent-light),0_1px_3px_rgba(0,0,0,0.02)] transition-all text-text-primary placeholder:text-[#bbb5ae] text-[13px] resize-none min-h-[44px] max-h-[100px]"
/>
</div>
<button
onClick={handleSendMessage}
disabled={!input.trim()}
className="w-10 h-10 rounded-[10px] bg-accent text-white hover:bg-accent-hover hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(156,175,136,0.25)] transition-all disabled:opacity-30 flex items-center justify-center flex-shrink-0"
>
<ArrowUp size={16} />
</button>
</div>
</div>
</div>
</div>
);
@@ -101,85 +135,90 @@ export function ChatPanel() {
return (
<div className="flex-1 flex flex-col bg-bg-primary overflow-hidden relative">
{/* Header */}
<div className="h-12 border-b border-border-primary flex items-center px-6 gap-2.5 bg-bg-primary/60 backdrop-blur-[8px] shrink-0">
<div className="w-[26px] h-[26px] rounded-[7px] flex items-center justify-center bg-accent-light">
<MessageSquare size={12} className="text-accent" />
</div>
<span className="text-[13px] font-semibold text-text-primary">{activeSession?.title || t('chat.defaultTitle')}</span>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-8 py-6 flex flex-col gap-4.5">
{loadingMessages ? (
<div className="flex justify-center items-center h-full">
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="max-w-[720px] mx-auto flex flex-col gap-6">
{loadingMessages ? (
<div className="flex justify-center items-center py-12">
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
</div>
</div>
</div>
) : (
messages.map((msg, idx) => (
<div
key={msg.id}
className={`flex gap-2.5 max-w-[78%] ${msg.role === 'user' ? 'self-end flex-row-reverse' : ''} animate-fade-in`}
style={{ animationDelay: `${idx * 30}ms` }}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0"
style={{
background: msg.role === 'assistant'
? 'var(--accent)'
: 'linear-gradient(135deg, #6b6860, #8a8578)',
}}
>
{msg.role === 'assistant' ? (
<Activity size={12} className="text-white" />
) : (
messages.map((msg) => (
<div key={msg.id} className={msg.role === 'user' ? 'flex justify-end' : ''}>
{msg.role === 'user' ? (
<div className="max-w-[80%] px-4 py-2.5 bg-bg-card border border-border-primary rounded-2xl rounded-br-sm text-[13px] text-text-primary leading-relaxed whitespace-pre-wrap">
{msg.content}
</div>
) : (
<User size={12} className="text-white" />
<div className="prose-chat text-[13.5px] text-text-primary leading-[1.75]">
{msg.content ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const inline = !match && !String(children).includes('\n');
if (inline) {
return <code className="px-1.5 py-0.5 bg-bg-secondary rounded text-[12px] font-mono" {...props}>{children}</code>;
}
return (
<SyntaxHighlighter
style={oneDark}
language={match?.[1] || 'text'}
PreTag="div"
customStyle={{ borderRadius: '10px', fontSize: '12px', margin: '12px 0' }}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
);
},
}}
>
{msg.content}
</ReactMarkdown>
) : (
<div className="flex items-center gap-1.5 py-1">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
</div>
)}
</div>
)}
</div>
<div className={`px-4 py-3 text-[13px] leading-[1.7] whitespace-pre-wrap ${
msg.role === 'user'
? 'bg-text-primary text-white rounded-[14px] rounded-br-[3px]'
: 'bg-bg-card border border-border-primary rounded-[14px] rounded-bl-[3px] shadow-[0_1px_3px_rgba(0,0,0,0.02)]'
}`}>
{msg.content}
</div>
</div>
))
)}
{/* Typing indicator */}
{activeSession && activeSession.messages.length > 0 && activeSession.messages[activeSession.messages.length - 1].role === 'user' && (
<div className="flex gap-2.5 max-w-[78%] animate-fade-in">
<div className="w-7 h-7 rounded-full bg-accent flex items-center justify-center flex-shrink-0">
<Activity size={12} className="text-white animate-pulse" />
</div>
<div className="bg-bg-card border border-border-primary rounded-[14px] rounded-bl-[3px] px-4 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
))
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input */}
<div className="px-6 pt-3.5 pb-4.5 border-t border-border-primary bg-bg-primary/60 backdrop-blur-[8px] shrink-0">
<div className="flex items-end gap-2 max-w-[720px] mx-auto">
<input type="file" ref={fileInputRef} className="hidden" />
<button
onClick={() => fileInputRef.current?.click()}
className="w-10 h-10 rounded-xl text-text-muted hover:text-accent hover:bg-bg-hover transition-all flex items-center justify-center flex-shrink-0"
title={t('chat.addAttachment')}
>
<Plus size={18} />
</button>
<div className="relative" ref={plusMenuRef}>
<button
onClick={() => setShowPlusMenu(!showPlusMenu)}
className="w-10 h-10 rounded-xl text-text-muted hover:text-accent hover:bg-bg-hover transition-all flex items-center justify-center flex-shrink-0"
>
<Plus size={18} />
</button>
{showPlusMenu && (
<div className="absolute bottom-12 left-0 bg-bg-card border border-border-primary rounded-xl shadow-lg py-1.5 min-w-[160px] z-50">
<button
onClick={() => { fileInputRef.current?.click(); setShowPlusMenu(false); }}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-xs text-text-secondary hover:bg-bg-hover hover:text-text-primary transition-colors"
>
<Paperclip size={14} />
{t('chat.addAttachment')}
</button>
</div>
)}
</div>
<div className="flex-1 relative">
<textarea
value={input}
@@ -204,9 +243,6 @@ export function ChatPanel() {
<ArrowUp size={16} />
</button>
</div>
<p className="text-center text-[10px] text-text-muted mt-2">
{t('chat.mistakeWarning')}
</p>
</div>
</div>
);
+49 -13
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2, MessageSquare, Workflow as WorkflowIcon } from 'lucide-react';
import { Plus, Trash2, MessageSquare, Workflow as WorkflowIcon, Pencil, Check } from 'lucide-react';
import apiClient from '../../api/client';
import type { Workflow } from '../../types';
import { useChatStore } from '../../store/useChatStore';
@@ -13,15 +13,17 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
const { t } = useTranslation();
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const {
sessions,
activeSessionId,
setActiveSessionId,
removeSession,
createChat,
selectedWorkflow,
setSelectedWorkflow,
updateSessionTitle,
} = useChatStore();
useEffect(() => {
@@ -57,8 +59,8 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
};
}, [activeTab]);
const handleNewChat = async () => {
await createChat(t('chat.newChat'), '你好');
const handleNewChat = () => {
setActiveSessionId(null);
};
const handleDeleteChat = (e: React.MouseEvent, id: string) => {
@@ -66,6 +68,20 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
removeSession(id);
};
const handleStartRename = (e: React.MouseEvent, id: string, title: string) => {
e.stopPropagation();
setRenamingId(id);
setRenameValue(title);
};
const handleConfirmRename = (e: React.MouseEvent) => {
e.stopPropagation();
if (renamingId && renameValue.trim()) {
updateSessionTitle(renamingId, renameValue.trim());
}
setRenamingId(null);
};
const isChats = activeTab === 'chats';
return (
@@ -95,6 +111,7 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
) : (
sessions.map((session) => {
const isActive = activeSessionId === session.id;
const isRenaming = renamingId === session.id;
return (
<div
key={session.id}
@@ -109,16 +126,35 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
<MessageSquare size={12} className={isActive ? 'text-accent' : 'text-text-muted'} />
</div>
<div className="flex-1 min-w-0">
<h3 className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
{session.title}
</h3>
{isRenaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => { if (e.key === 'Enter') handleConfirmRename(e as any); if (e.key === 'Escape') setRenamingId(null); }}
className="w-full text-xs bg-bg-input border border-border-primary rounded px-1.5 py-0.5 text-text-primary focus:outline-none focus:border-accent"
/>
) : (
<h3 className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
{session.title}
</h3>
)}
</div>
<button
onClick={(e) => handleDeleteChat(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-1 rounded text-text-muted hover:text-danger transition-all"
>
<Trash2 size={11} />
</button>
{isRenaming ? (
<button onClick={handleConfirmRename} className="p-1 rounded text-accent hover:bg-accent-light transition-all">
<Check size={11} />
</button>
) : (
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-all">
<button onClick={(e) => handleStartRename(e, session.id, session.title)} className="p-1 rounded text-text-muted hover:text-accent transition-all">
<Pencil size={11} />
</button>
<button onClick={(e) => handleDeleteChat(e, session.id)} className="p-1 rounded text-text-muted hover:text-danger transition-all">
<Trash2 size={11} />
</button>
</div>
)}
</div>
);
})
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { MessageSquare, Workflow, Box, Bot, ChevronLeft, ChevronRight } from 'lucide-react';
import { MessageSquare, Workflow, Box, Bot, ChevronLeft, ChevronRight, Settings, ScrollText } from 'lucide-react';
import { useAppStore } from '../../store/useAppStore';
export function CollapsibleSidebar() {
@@ -22,6 +22,8 @@ export function CollapsibleSidebar() {
: [
{ key: 'plugin', label: t('nav.plugin'), icon: Box },
{ key: 'agents', label: t('nav.agents'), icon: Bot },
{ key: 'config', label: t('nav.config'), icon: Settings },
{ key: 'logs', label: t('nav.logs'), icon: ScrollText },
];
const activeTab = mode === 'work' ? workTab : agentTab;
+11 -1
View File
@@ -10,6 +10,8 @@
"workflow": "Workflow",
"plugin": "Plugin",
"agents": "Agents",
"config": "Config",
"logs": "Logs",
"settings": "Settings"
},
"auth": {
@@ -187,6 +189,10 @@
"logMessage": "Message",
"logTime": "Time",
"noLogs": "No log entries yet",
"workflowLogs": "Workflow Logs",
"workflowLogList": "Workflows",
"selectWorkflowToView": "Select a workflow to view execution logs",
"workflowAction": "Action",
"description": "Description",
"systemPrompt": "System Prompt",
"outputTemplate": "Output Template (JSON)",
@@ -205,6 +211,8 @@
"deleteTemplateConfirm": "Delete this template?",
"noTemplates": "No persona templates yet",
"templateName": "Template Name",
"selectPersona": "Select persona...",
"noPrompt": "(no prompt)",
"builtin": "Built-in",
"createFromTemplate": "Create worker from template",
"workerCreatedFromTemplate": "Worker created from template successfully",
@@ -212,7 +220,8 @@
"tagsPlaceholder": "Comma-separated, e.g.: assistant, translator, coder",
"customPrompt": "Custom Prompt",
"customPromptPlaceholder": "Additional content appended after the default system prompt...",
"displayName": "Display Name"
"displayName": "Display Name",
"persona": "Persona"
},
"plugin": {
"toolManagement": "Tool Management",
@@ -265,6 +274,7 @@
"actions": "Actions",
"cancel": "Cancel",
"skip": "Skip for now",
"status": "Status",
"reset": "Reset",
"save": "Save"
},
+11 -1
View File
@@ -10,6 +10,8 @@
"workflow": "工作流",
"plugin": "插件",
"agents": "智能体",
"config": "配置",
"logs": "日志",
"settings": "设置"
},
"auth": {
@@ -187,6 +189,10 @@
"logMessage": "消息",
"logTime": "时间",
"noLogs": "暂无日志记录",
"workflowLogs": "工作流日志",
"workflowLogList": "工作流列表",
"selectWorkflowToView": "选择左侧工作流查看执行日志",
"workflowAction": "动作",
"description": "描述",
"systemPrompt": "系统提示词",
"outputTemplate": "输出模板 (JSON)",
@@ -205,6 +211,8 @@
"deleteTemplateConfirm": "确定要删除此模板吗?",
"noTemplates": "暂无人设模板",
"templateName": "模板名称",
"selectPersona": "请选择人设",
"noPrompt": "(无提示词)",
"builtin": "内置",
"createFromTemplate": "从模板创建工作者",
"workerCreatedFromTemplate": "已从模板成功创建工作者",
@@ -212,7 +220,8 @@
"tagsPlaceholder": "用逗号分隔,如:助手, 翻译, 代码",
"customPrompt": "附加人设",
"customPromptPlaceholder": "在默认系统提示词之后追加的自定义内容...",
"displayName": "显示名称"
"displayName": "显示名称",
"persona": "人设"
},
"plugin": {
"toolManagement": "工具管理",
@@ -265,6 +274,7 @@
"actions": "操作",
"cancel": "取消",
"skip": "稍后再说",
"status": "状态",
"reset": "重置",
"save": "保存"
},
+35
View File
@@ -263,3 +263,38 @@ body::before {
opacity: 0.4;
animation: pulse-glow 2s ease-in-out infinite;
}
/* Chat markdown prose */
.prose-chat p { margin: 0.5em 0; }
.prose-chat p:first-child { margin-top: 0; }
.prose-chat p:last-child { margin-bottom: 0; }
.prose-chat ul, .prose-chat ol { margin: 0.5em 0; padding-left: 1.5em; }
.prose-chat li { margin: 0.25em 0; }
.prose-chat h1, .prose-chat h2, .prose-chat h3 {
margin: 0.75em 0 0.35em;
font-weight: 600;
line-height: 1.3;
}
.prose-chat h1 { font-size: 1.25em; }
.prose-chat h2 { font-size: 1.1em; }
.prose-chat h3 { font-size: 1em; }
.prose-chat blockquote {
margin: 0.5em 0;
padding-left: 0.75em;
border-left: 3px solid var(--border-primary);
color: var(--text-secondary);
}
.prose-chat table {
border-collapse: collapse;
margin: 0.5em 0;
font-size: 0.9em;
}
.prose-chat th, .prose-chat td {
border: 1px solid var(--border-primary);
padding: 0.35em 0.6em;
}
.prose-chat th { background: var(--bg-secondary); font-weight: 600; }
.prose-chat a { color: var(--accent); text-decoration: underline; }
.prose-chat hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.75em 0; }
.prose-chat strong { font-weight: 600; }
.prose-chat em { font-style: italic; }
+1 -1
View File
@@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware';
type AppMode = 'work' | 'agent';
type WorkTab = 'chat' | 'workflow';
type AgentTab = 'plugin' | 'agents';
type AgentTab = 'plugin' | 'agents' | 'config' | 'logs';
type ThemeMode = 'light' | 'dark' | 'system';
interface AppState {
+117 -38
View File
@@ -29,7 +29,7 @@ interface ChatState {
addSession: (session: ChatSession) => void;
updateSessionMessages: (sessionId: string, messages: Message[]) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
removeSession: (sessionId: string) => void;
removeSession: (sessionId: string) => Promise<void>;
loadSessions: () => Promise<void>;
loadMessages: (chatId: string) => Promise<void>;
@@ -72,7 +72,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
sessions: state.sessions.map((s) => (s.id === sessionId ? { ...s, title } : s)),
})),
removeSession: (sessionId) =>
removeSession: async (sessionId) => {
try {
await apiClient.delete(`/api/v1/chat/${sessionId}`);
} catch (error) {
console.error('Failed to delete chat session', error);
}
set((state) => {
const filtered = state.sessions.filter((s) => s.id !== sessionId);
return {
@@ -84,7 +89,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
: null
: state.activeSessionId,
};
}),
});
},
loadSessions: async () => {
set({ loadingSessions: true });
@@ -123,6 +129,28 @@ export const useChatStore = create<ChatState>((set, get) => ({
},
createChat: async (title = '新对话', initialMessage = '你好') => {
const tempId = `temp_${Date.now()}`;
const userMsg: Message = {
id: tempId + '_user',
role: 'user',
content: initialMessage,
timestamp: Date.now(),
};
const aiMsg: Message = {
id: tempId + '_ai',
role: 'assistant',
content: '',
timestamp: Date.now(),
};
const optimisticSession: ChatSession = {
id: tempId,
title,
messages: [userMsg, aiMsg],
updatedAt: Date.now(),
};
get().addSession(optimisticSession);
try {
const response = await apiClient.post('/api/v1/chat', {
title,
@@ -132,19 +160,28 @@ export const useChatStore = create<ChatState>((set, get) => ({
const reply: string =
response.data.reply || '你好!我是 kilostar 助手,有什么可以帮你的吗?';
const newSession: ChatSession = {
id: chatId,
title,
messages: [
{ id: chatId + '_user', role: 'user', content: initialMessage, timestamp: Date.now() },
{ id: chatId + '_ai', role: 'assistant', content: reply, timestamp: Date.now() },
],
updatedAt: Date.now(),
};
get().addSession(newSession);
set((state) => ({
sessions: state.sessions.map((s) =>
s.id === tempId
? {
...s,
id: chatId,
messages: [
{ ...userMsg, id: chatId + '_user' },
{ ...aiMsg, id: chatId + '_ai', content: reply },
],
}
: s
),
activeSessionId: state.activeSessionId === tempId ? chatId : state.activeSessionId,
}));
return chatId;
} catch (error) {
console.error('Failed to create chat session', error);
set((state) => ({
sessions: state.sessions.filter((s) => s.id !== tempId),
activeSessionId: state.activeSessionId === tempId ? null : state.activeSessionId,
}));
return null;
}
},
@@ -161,38 +198,80 @@ export const useChatStore = create<ChatState>((set, get) => ({
timestamp: Date.now(),
};
const currentMessages = [...session.messages, userMessage];
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '',
timestamp: Date.now(),
};
const currentMessages = [...session.messages, userMessage, aiMessage];
get().updateSessionMessages(chatId, currentMessages);
try {
const response = await apiClient.post(`/api/v1/chat/${chatId}/reply`, {
message: text,
});
const replyContent: string = response.data?.reply || '收到你的消息。';
if (session.messages.length <= 1 && text.length > 0) {
const newTitle = text.slice(0, 20) + (text.length > 20 ? '...' : '');
get().updateSessionTitle(chatId, newTitle);
}
// Auto-update title on first user message
if (session.messages.length <= 1 && text.length > 0) {
const newTitle = text.slice(0, 20) + (text.length > 20 ? '...' : '');
get().updateSessionTitle(chatId, newTitle);
try {
const token = localStorage.getItem('token');
const baseURL = apiClient.defaults.baseURL || '';
const response = await fetch(`${baseURL}/api/v1/chat/${chatId}/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ message: text }),
});
if (!response.ok || !response.body) {
throw new Error('Stream request failed');
}
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: replyContent,
timestamp: Date.now(),
};
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
get().updateSessionMessages(chatId, [...currentMessages, aiMessage]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const dataStr = line.slice(6).trim();
if (!dataStr) continue;
try {
const data = JSON.parse(dataStr);
if (data.done) break;
if (data.token) {
aiMessage.content += data.token;
const latestSession = get().sessions.find((s) => s.id === chatId);
if (latestSession) {
const msgs = latestSession.messages.map((m) =>
m.id === aiMessage.id ? { ...m, content: aiMessage.content } : m
);
get().updateSessionMessages(chatId, msgs);
}
}
} catch { /* skip malformed JSON */ }
}
}
} catch (error) {
console.error('Error sending message', error);
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '抱歉,与服务器通信时出错。',
timestamp: Date.now(),
};
get().updateSessionMessages(chatId, [...currentMessages, errorMessage]);
console.error('Error in streaming message', error);
const latestSession = get().sessions.find((s) => s.id === chatId);
if (latestSession) {
const msgs = latestSession.messages.map((m) =>
m.id === aiMessage.id
? { ...m, content: m.content || '抱歉,与服务器通信时出错。' }
: m
);
get().updateSessionMessages(chatId, msgs);
}
}
},
}));