feat(web): add project deletion, grid chat layout, and fix bridge WebSocket
- Add DELETE /api/v1/projects/{name} API with config.RemoveProject()
using callback pattern to keep core/ free of config/ imports
- Move delete button from project list to project detail Settings tab
under a "Danger Zone" section with confirmation dialog
- Wait for service recovery after restart before navigating to avoid
empty project list
- Change Chat list from single-column IM style to responsive grid
layout (1/2/3 cols) matching the project list design
- Fix bridge WebSocket URL to use current page host:port through the
proxy instead of directly connecting to bridge port
- Add i18n for delete/danger zone across all 5 languages
Made-with: Cursor
This commit is contained in:
1 parent
ea2033b0fb
commit
d928bb20dc
12 files changed
+199
-67
No files matched your search
@@ -665,6 +665,7 @@ func main() {
|
||||
}
|
||||
return config.AddPlatformToProject(projectName, config.PlatformConfig{Type: platType, Options: opts})
|
||||
})
|
||||
mgmtSrv.SetRemoveProject(config.RemoveProject)
|
||||
mgmtSrv.Start()
|
||||
}
|
||||
|
||||
|
||||
@@ -1732,6 +1732,35 @@ func extractLineComment(line string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// RemoveProject removes a project from the config file.
|
||||
func RemoveProject(projectName string) error {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
if ConfigPath == "" {
|
||||
return fmt.Errorf("config path not set")
|
||||
}
|
||||
data, err := os.ReadFile(ConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
cfg := &Config{}
|
||||
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||
return fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
found := false
|
||||
for i := range cfg.Projects {
|
||||
if cfg.Projects[i].Name == projectName {
|
||||
cfg.Projects = append(cfg.Projects[:i], cfg.Projects[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("project %q not found", projectName)
|
||||
}
|
||||
return saveConfig(cfg)
|
||||
}
|
||||
|
||||
// AddPlatformToProject appends a platform config to a project.
|
||||
// If the project doesn't exist, it is created with agent config cloned from the first existing project.
|
||||
func AddPlatformToProject(projectName string, platform PlatformConfig) error {
|
||||
|
||||
+22
-1
@@ -31,6 +31,7 @@ type ManagementServer struct {
|
||||
setupFeishuSave func(req FeishuSetupSaveRequest) error
|
||||
setupWeixinSave func(req WeixinSetupSaveRequest) error
|
||||
addPlatformToProject func(projectName, platType string, opts map[string]any) error
|
||||
removeProject func(projectName string) error
|
||||
}
|
||||
|
||||
// NewManagementServer creates a new management API server.
|
||||
@@ -64,6 +65,10 @@ func (m *ManagementServer) SetAddPlatformToProject(fn func(string, string, map[s
|
||||
m.addPlatformToProject = fn
|
||||
}
|
||||
|
||||
func (m *ManagementServer) SetRemoveProject(fn func(string) error) {
|
||||
m.removeProject = fn
|
||||
}
|
||||
|
||||
func (m *ManagementServer) Start() {
|
||||
mux := http.NewServeMux()
|
||||
prefix := "/api/v1"
|
||||
@@ -502,7 +507,23 @@ func (m *ManagementServer) handleProjectDetail(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
mgmtError(w, http.StatusMethodNotAllowed, "GET or PATCH only")
|
||||
if r.Method == http.MethodDelete {
|
||||
if m.removeProject == nil {
|
||||
mgmtError(w, http.StatusNotImplemented, "project removal not configured")
|
||||
return
|
||||
}
|
||||
if err := m.removeProject(name); err != nil {
|
||||
mgmtError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
mgmtJSON(w, http.StatusOK, map[string]any{
|
||||
"message": fmt.Sprintf("project %q removed from config", name),
|
||||
"restart_required": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mgmtError(w, http.StatusMethodNotAllowed, "GET, PATCH or DELETE only")
|
||||
}
|
||||
|
||||
// ── Users endpoints ──────────────────────────────────────────
|
||||
|
||||
@@ -34,3 +34,6 @@ export const updateProject = (name: string, body: Partial<ProjectDetail['setting
|
||||
|
||||
export const addPlatformToProject = (projectName: string, body: { type: string; options: Record<string, any> }) =>
|
||||
api.post<{ message: string; restart_required: boolean }>(`/projects/${projectName}/add-platform`, body);
|
||||
|
||||
export const deleteProject = (name: string) =>
|
||||
api.delete<{ message: string; restart_required: boolean }>(`/projects/${name}`);
|
||||
@@ -73,8 +73,9 @@ export function useBridgeSocket({ bridgeCfg, platformName = 'web', sessionKey, o
|
||||
if (!bridgeCfg) return;
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.hostname;
|
||||
const wsUrl = `${proto}//${host}:${bridgeCfg.port}${bridgeCfg.path}?token=${encodeURIComponent(bridgeCfg.token)}`;
|
||||
// Use current page host:port so the request goes through the Vite/nginx proxy
|
||||
// instead of directly hitting the bridge port (which may not be reachable).
|
||||
const wsUrl = `${proto}//${window.location.host}${bridgeCfg.path}?token=${encodeURIComponent(bridgeCfg.token)}`;
|
||||
|
||||
let ws: WebSocket;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
"save": "Save",
|
||||
"detail": "Details",
|
||||
"noProjects": "No projects configured",
|
||||
"deleteTitle": "Delete Project",
|
||||
"deleteConfirm": "Are you sure you want to delete project \"{{name}}\"? This will remove it from the config file.",
|
||||
"dangerZone": "Danger Zone",
|
||||
"deleteHint": "Remove this project from config. Requires restart.",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"providers": "Providers",
|
||||
@@ -172,6 +176,7 @@
|
||||
"restartRequired": "Restart required",
|
||||
"restartNow": "Restart now",
|
||||
"restarting": "Restarting service...",
|
||||
"restartAfterDelete": "Project removed. Restart service to take effect?",
|
||||
"later": "Later",
|
||||
"expired": "QR code expired.",
|
||||
"denied": "Authorization was denied.",
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
"save": "Guardar",
|
||||
"detail": "Detalles",
|
||||
"noProjects": "No hay proyectos configurados",
|
||||
"deleteTitle": "Eliminar proyecto",
|
||||
"deleteConfirm": "¿Está seguro de que desea eliminar el proyecto \"{{name}}\"? Se eliminará del archivo de configuración.",
|
||||
"dangerZone": "Zona de peligro",
|
||||
"deleteHint": "Eliminar este proyecto de la configuración. Requiere reinicio.",
|
||||
"tabs": {
|
||||
"overview": "Resumen",
|
||||
"providers": "Proveedores",
|
||||
@@ -172,6 +176,7 @@
|
||||
"restartRequired": "Reinicio necesario",
|
||||
"restartNow": "Reiniciar ahora",
|
||||
"restarting": "Reiniciando el servicio...",
|
||||
"restartAfterDelete": "Proyecto eliminado. ¿Reiniciar el servicio para aplicar?",
|
||||
"later": "Más tarde",
|
||||
"expired": "El código QR ha caducado.",
|
||||
"denied": "Se denegó la autorización.",
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
"save": "保存",
|
||||
"detail": "詳細",
|
||||
"noProjects": "プロジェクトが設定されていません",
|
||||
"deleteTitle": "プロジェクトを削除",
|
||||
"deleteConfirm": "プロジェクト「{{name}}」を削除してもよろしいですか?設定ファイルから削除されます。",
|
||||
"dangerZone": "危険ゾーン",
|
||||
"deleteHint": "このプロジェクトを設定から削除します。再起動が必要です。",
|
||||
"tabs": {
|
||||
"overview": "概要",
|
||||
"providers": "プロバイダー",
|
||||
@@ -172,6 +176,7 @@
|
||||
"restartRequired": "再起動が必要です",
|
||||
"restartNow": "今すぐ再起動",
|
||||
"restarting": "サービスを再起動中...",
|
||||
"restartAfterDelete": "プロジェクトを削除しました。サービスを再起動しますか?",
|
||||
"later": "あとで",
|
||||
"expired": "QRコードの有効期限が切れました。",
|
||||
"denied": "認証が拒否されました。",
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
"save": "儲存",
|
||||
"detail": "詳細資料",
|
||||
"noProjects": "尚未設定專案",
|
||||
"deleteTitle": "刪除專案",
|
||||
"deleteConfirm": "確定要刪除專案「{{name}}」嗎?這將從設定檔中移除該專案。",
|
||||
"dangerZone": "危險操作",
|
||||
"deleteHint": "從設定檔中移除此專案,需要重新啟動服務。",
|
||||
"tabs": {
|
||||
"overview": "總覽",
|
||||
"providers": "供應商",
|
||||
@@ -172,6 +176,7 @@
|
||||
"restartRequired": "需要重新啟動",
|
||||
"restartNow": "立即重新啟動",
|
||||
"restarting": "正在重新啟動服務...",
|
||||
"restartAfterDelete": "專案已刪除,是否重新啟動服務使其生效?",
|
||||
"later": "稍後",
|
||||
"expired": "QR 碼已過期。",
|
||||
"denied": "授權被拒絕。",
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
"save": "保存",
|
||||
"detail": "详情",
|
||||
"noProjects": "尚未配置项目",
|
||||
"deleteTitle": "删除项目",
|
||||
"deleteConfirm": "确定要删除项目「{{name}}」吗?这将从配置文件中移除该项目。",
|
||||
"dangerZone": "危险操作",
|
||||
"deleteHint": "从配置文件中移除此项目,需要重启服务。",
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"providers": "服务商",
|
||||
@@ -172,6 +176,7 @@
|
||||
"restartRequired": "需要重启",
|
||||
"restartNow": "立即重启",
|
||||
"restarting": "正在重启服务...",
|
||||
"restartAfterDelete": "项目已删除,是否重启服务使其生效?",
|
||||
"later": "稍后",
|
||||
"expired": "二维码已过期。",
|
||||
"denied": "授权被拒绝。",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MessageSquare, Bot, User, Circle, FolderKanban } from 'lucide-react';
|
||||
import { EmptyState, Badge } from '@/components/ui';
|
||||
import { MessageSquare, Bot, User, Circle, ArrowRight } from 'lucide-react';
|
||||
import { Card, EmptyState, Badge } from '@/components/ui';
|
||||
import { listProjects, type ProjectSummary } from '@/api/projects';
|
||||
import { listSessions, type Session } from '@/api/sessions';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatEntry {
|
||||
project: ProjectSummary;
|
||||
@@ -72,76 +71,62 @@ export default function ChatList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-1 animate-fade-in">
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">{t('nav.chat')}</h2>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState message={t('chat.noChats')} icon={MessageSquare} />
|
||||
) : (
|
||||
entries.map(({ project, latestSession }) => {
|
||||
const hasLive = latestSession?.live;
|
||||
const lastMsg = latestSession?.last_message;
|
||||
const ts = latestSession?.updated_at || latestSession?.created_at || '';
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{entries.map(({ project, latestSession }) => {
|
||||
const hasLive = latestSession?.live;
|
||||
const lastMsg = latestSession?.last_message;
|
||||
const ts = latestSession?.updated_at || latestSession?.created_at || '';
|
||||
|
||||
return (
|
||||
<Link key={project.name} to={`/chat/${project.name}`}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-4 px-4 py-3.5 rounded-xl transition-all duration-200 cursor-pointer',
|
||||
'hover:bg-gray-100/70 dark:hover:bg-white/[0.04]',
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-12 h-12 rounded-2xl flex items-center justify-center shrink-0',
|
||||
'bg-accent/10 ring-1 ring-accent/20',
|
||||
)}
|
||||
>
|
||||
<FolderKanban size={22} className="text-accent" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
{hasLive && <Circle size={5} className="fill-emerald-500 text-emerald-500 shrink-0" />}
|
||||
return (
|
||||
<Link key={project.name} to={`/chat/${project.name}`}>
|
||||
<Card hover className="h-full flex flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={18} className="text-accent" />
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{project.name}</h3>
|
||||
{hasLive && <Circle size={6} className="fill-emerald-500 text-emerald-500" />}
|
||||
</div>
|
||||
{ts && (
|
||||
<span className="text-[11px] text-gray-400 shrink-0">{timeAgo(ts, t)}</span>
|
||||
<ArrowRight size={16} className="text-gray-300 dark:text-gray-600" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-[2rem] mb-3">
|
||||
{lastMsg ? (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed">
|
||||
{lastMsg.role === 'user' ? (
|
||||
<User size={10} className="inline mr-1 -mt-0.5 opacity-60" />
|
||||
) : (
|
||||
<Bot size={10} className="inline mr-1 -mt-0.5 opacity-60" />
|
||||
)}
|
||||
{lastMsg.content.replace(/\n/g, ' ').slice(0, 120)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 italic">
|
||||
{t('chat.noMessages')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
{lastMsg ? (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate leading-relaxed">
|
||||
{lastMsg.role === 'user' ? (
|
||||
<User size={10} className="inline mr-1 -mt-0.5 opacity-60" />
|
||||
) : (
|
||||
<Bot size={10} className="inline mr-1 -mt-0.5 opacity-60" />
|
||||
)}
|
||||
{lastMsg.content.replace(/\n/g, ' ').slice(0, 80)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 italic">
|
||||
{t('chat.noMessages')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mt-auto pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge className="text-[9px]">{project.agent_type}</Badge>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{project.sessions_count}
|
||||
</span>
|
||||
{project.platforms?.map((pl) => <Badge key={pl}>{pl}</Badge>)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{project.sessions_count} {t('chat.sessions', 'sessions')}</span>
|
||||
{ts && <span className="text-gray-400">{timeAgo(ts, t)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Plug, Heart, Settings, Layers, Zap, Pause, Play,
|
||||
Trash2, Plus, Check, Clock,
|
||||
} from 'lucide-react';
|
||||
import { Card, Badge, Button, Input, Modal, EmptyState } from '@/components/ui';
|
||||
import { getProject, updateProject, type ProjectDetail as ProjectDetailType } from '@/api/projects';
|
||||
import { getProject, updateProject, deleteProject, type ProjectDetail as ProjectDetailType } from '@/api/projects';
|
||||
import { listProviders, addProvider, removeProvider, activateProvider, listModels, setModel, type Provider } from '@/api/providers';
|
||||
import { getHeartbeat, pauseHeartbeat, resumeHeartbeat, triggerHeartbeat, setHeartbeatInterval, type HeartbeatStatus } from '@/api/heartbeat';
|
||||
import { restartSystem } from '@/api/status';
|
||||
@@ -64,6 +64,44 @@ export default function ProjectDetail() {
|
||||
const [addPlatType, setAddPlatType] = useState('');
|
||||
const [showRestartModal, setShowRestartModal] = useState(false);
|
||||
|
||||
// Delete project
|
||||
const navigate = useNavigate();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!name) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await deleteProject(name);
|
||||
setShowDeleteConfirm(false);
|
||||
if (res.restart_required && window.confirm(t('setup.restartAfterDelete'))) {
|
||||
await restartSystem();
|
||||
// Wait for service to come back up before navigating
|
||||
await waitForService(8000);
|
||||
}
|
||||
navigate('/projects');
|
||||
} catch (e: any) {
|
||||
alert(e?.message || String(e));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const waitForService = (maxMs: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const start = Date.now();
|
||||
const poll = () => {
|
||||
fetch('/api/v1/status')
|
||||
.then((r) => { if (r.ok) resolve(); else throw new Error(); })
|
||||
.catch(() => {
|
||||
if (Date.now() - start > maxMs) { resolve(); return; }
|
||||
setTimeout(poll, 500);
|
||||
});
|
||||
};
|
||||
setTimeout(poll, 1500);
|
||||
});
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
if (!name) return;
|
||||
try {
|
||||
@@ -328,6 +366,7 @@ export default function ProjectDetail() {
|
||||
)}
|
||||
|
||||
{tab === 'settings' && project && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -345,8 +384,36 @@ export default function ProjectDetail() {
|
||||
<Button loading={saving} onClick={handleSaveSettings}>{t('common.save')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<h3 className="text-sm font-semibold text-red-600 dark:text-red-400 mb-3">{t('projects.dangerZone', 'Danger Zone')}</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{t('projects.deleteTitle')}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('projects.deleteHint', 'Remove this project from config. Requires restart.')}</p>
|
||||
</div>
|
||||
<Button variant="danger" size="sm" onClick={() => setShowDeleteConfirm(true)}>
|
||||
<Trash2 size={14} /> {t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal open={showDeleteConfirm} onClose={() => setShowDeleteConfirm(false)} title={t('projects.deleteTitle')}>
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('projects.deleteConfirm', { name })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowDeleteConfirm(false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="danger" onClick={handleDeleteProject} disabled={deleting}>
|
||||
{deleting ? t('common.deleting', 'Deleting...') : t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Add Platform Modal */}
|
||||
<Modal open={showAddPlatform} onClose={() => setShowAddPlatform(false)} title={t('setup.addPlatform', 'Add platform')}>
|
||||
{!addPlatType ? (
|
||||
|
||||
Reference in new issue
Block a user