From d928bb20dc217d5cdb7cd3e12dbfd26cc5fd7f6e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 21:23:46 +0800 Subject: [PATCH] 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 --- cmd/cc-connect/main.go | 1 + config/config.go | 29 ++++++ core/management.go | 23 ++++- web/src/api/projects.ts | 3 + web/src/hooks/useBridgeSocket.ts | 5 +- web/src/i18n/locales/en.json | 5 ++ web/src/i18n/locales/es.json | 5 ++ web/src/i18n/locales/ja.json | 5 ++ web/src/i18n/locales/zh-TW.json | 5 ++ web/src/i18n/locales/zh.json | 5 ++ web/src/pages/Chat/ChatList.tsx | 109 ++++++++++------------- web/src/pages/Projects/ProjectDetail.tsx | 71 ++++++++++++++- 12 files changed, 199 insertions(+), 67 deletions(-) diff --git a/cmd/cc-connect/main.go b/cmd/cc-connect/main.go index a27ca06..8ed6efa 100644 --- a/cmd/cc-connect/main.go +++ b/cmd/cc-connect/main.go @@ -665,6 +665,7 @@ func main() { } return config.AddPlatformToProject(projectName, config.PlatformConfig{Type: platType, Options: opts}) }) + mgmtSrv.SetRemoveProject(config.RemoveProject) mgmtSrv.Start() } diff --git a/config/config.go b/config/config.go index 67c39b6..c17fc2e 100644 --- a/config/config.go +++ b/config/config.go @@ -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 { diff --git a/core/management.go b/core/management.go index 56ab185..4be1094 100644 --- a/core/management.go +++ b/core/management.go @@ -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 ────────────────────────────────────────── diff --git a/web/src/api/projects.ts b/web/src/api/projects.ts index 3a91cd6..536f4a4 100644 --- a/web/src/api/projects.ts +++ b/web/src/api/projects.ts @@ -34,3 +34,6 @@ export const updateProject = (name: string, body: Partial }) => 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}`); diff --git a/web/src/hooks/useBridgeSocket.ts b/web/src/hooks/useBridgeSocket.ts index fd3e755..0bf0593 100644 --- a/web/src/hooks/useBridgeSocket.ts +++ b/web/src/hooks/useBridgeSocket.ts @@ -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; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a3f90e9..6994054 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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.", diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json index a7ebe94..57cc953 100644 --- a/web/src/i18n/locales/es.json +++ b/web/src/i18n/locales/es.json @@ -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.", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index ad7d3da..2548cc3 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -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": "認証が拒否されました。", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 92908e2..61f9c76 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -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": "授權被拒絕。", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index c1c2290..2dae48b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "授权被拒绝。", diff --git a/web/src/pages/Chat/ChatList.tsx b/web/src/pages/Chat/ChatList.tsx index f3087c4..4b1a56d 100644 --- a/web/src/pages/Chat/ChatList.tsx +++ b/web/src/pages/Chat/ChatList.tsx @@ -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 ( -
+
+

{t('nav.chat')}

+ {entries.length === 0 ? ( ) : ( - entries.map(({ project, latestSession }) => { - const hasLive = latestSession?.live; - const lastMsg = latestSession?.last_message; - const ts = latestSession?.updated_at || latestSession?.created_at || ''; +
+ {entries.map(({ project, latestSession }) => { + const hasLive = latestSession?.live; + const lastMsg = latestSession?.last_message; + const ts = latestSession?.updated_at || latestSession?.created_at || ''; - return ( - -
- {/* Avatar */} -
- -
- - {/* Content */} -
-
-
- - {project.name} - - {hasLive && } + return ( + + +
+
+ +

{project.name}

+ {hasLive && }
- {ts && ( - {timeAgo(ts, t)} + +
+ +
+ {lastMsg ? ( +

+ {lastMsg.role === 'user' ? ( + + ) : ( + + )} + {lastMsg.content.replace(/\n/g, ' ').slice(0, 120)} +

+ ) : ( +

+ {t('chat.noMessages')} +

)}
-
-
- {lastMsg ? ( -

- {lastMsg.role === 'user' ? ( - - ) : ( - - )} - {lastMsg.content.replace(/\n/g, ' ').slice(0, 80)} -

- ) : ( -

- {t('chat.noMessages')} -

- )} -
-
+
+
{project.agent_type} - - {project.sessions_count} - + {project.platforms?.map((pl) => {pl})} +
+
+ {project.sessions_count} {t('chat.sessions', 'sessions')} + {ts && {timeAgo(ts, t)}}
-
-
- - ); - }) +
+ + ); + })} +
)}
); diff --git a/web/src/pages/Projects/ProjectDetail.tsx b/web/src/pages/Projects/ProjectDetail.tsx index bf4557b..bbc99c5 100644 --- a/web/src/pages/Projects/ProjectDetail.tsx +++ b/web/src/pages/Projects/ProjectDetail.tsx @@ -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((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 && ( +
@@ -345,8 +384,36 @@ export default function ProjectDetail() {
+ +

{t('projects.dangerZone', 'Danger Zone')}

+
+
+

{t('projects.deleteTitle')}

+

{t('projects.deleteHint', 'Remove this project from config. Requires restart.')}

+
+ +
+
+
)} + {/* Delete confirmation */} + setShowDeleteConfirm(false)} title={t('projects.deleteTitle')}> +
+

+ {t('projects.deleteConfirm', { name })} +

+
+ + +
+
+
+ {/* Add Platform Modal */} setShowAddPlatform(false)} title={t('setup.addPlatform', 'Add platform')}> {!addPlatType ? (