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:
Claude committed 2026-03-26 21:23:46 +08:00
1 parent ea2033b0fb
commit d928bb20dc
12 files changed
+199 -67

No files matched your search

+1
View File
@@ -665,6 +665,7 @@ func main() {
}
return config.AddPlatformToProject(projectName, config.PlatformConfig{Type: platType, Options: opts})
})
mgmtSrv.SetRemoveProject(config.RemoveProject)
mgmtSrv.Start()
}
+29
View File
@@ -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
View File
@@ -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 ──────────────────────────────────────────
+3
View File
@@ -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}`);
+3 -2
View File
@@ -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>;
+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -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": "認証が拒否されました。",
+5
View File
@@ -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": "授權被拒絕。",
+5
View File
@@ -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": "授权被拒绝。",
+47 -62
View File
@@ -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>
);
+69 -2
View File
@@ -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 ? (