feat(ui): improve CloneManager with error handling

- Add loading state during clone creation
- Add error display for failed operations
- Add retry/refresh functionality
- Improve gateway-client error handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-15 20:20:10 +08:00
parent 01737a7ef5
commit a7ae0eca7a
2 changed files with 100 additions and 46 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore'; import { useGatewayStore } from '../store/gatewayStore';
import { toChatAgent, useChatStore } from '../store/chatStore'; import { toChatAgent, useChatStore } from '../store/chatStore';
import { Bot, Plus, X, Globe, Cat, Search, BarChart2 } from 'lucide-react'; import { Bot, Plus, X, Globe, Cat, Search, BarChart2, AlertCircle, RefreshCw } from 'lucide-react';
interface CloneFormData { interface CloneFormData {
name: string; name: string;
@@ -42,10 +42,12 @@ function createFormFromDraft(quickConfig: {
} }
export function CloneManager() { export function CloneManager() {
const { clones, loadClones, createClone, deleteClone, connectionState, quickConfig, saveQuickConfig } = useGatewayStore(); const { clones, loadClones, createClone, deleteClone, connectionState, quickConfig, saveQuickConfig, error: storeError } = useGatewayStore();
const { agents, currentAgent, setCurrentAgent } = useChatStore(); const { agents, currentAgent, setCurrentAgent } = useChatStore();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<CloneFormData>(createFormFromDraft({})); const [form, setForm] = useState<CloneFormData>(createFormFromDraft({}));
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const connected = connectionState === 'connected'; const connected = connectionState === 'connected';
@@ -63,47 +65,64 @@ export function CloneManager() {
const handleCreate = async () => { const handleCreate = async () => {
if (!form.name.trim()) return; if (!form.name.trim()) return;
const scenarios = form.scenarios setCreateError(null);
? form.scenarios.split(',').map((s) => s.trim()).filter(Boolean) setIsCreating(true);
: undefined;
await saveQuickConfig({ try {
agentName: form.name, const scenarios = form.scenarios
agentRole: form.role || undefined, ? form.scenarios.split(',').map((s) => s.trim()).filter(Boolean)
agentNickname: form.nickname || undefined, : undefined;
scenarios,
workspaceDir: form.workspaceDir || undefined, await saveQuickConfig({
userName: form.userName || undefined, agentName: form.name,
userRole: form.userRole || undefined, agentRole: form.role || undefined,
restrictFiles: form.restrictFiles, agentNickname: form.nickname || undefined,
privacyOptIn: form.privacyOptIn, scenarios,
}); workspaceDir: form.workspaceDir || undefined,
const clone = await createClone({ userName: form.userName || undefined,
name: form.name, userRole: form.userRole || undefined,
role: form.role || undefined, restrictFiles: form.restrictFiles,
nickname: form.nickname || undefined, privacyOptIn: form.privacyOptIn,
scenarios, });
workspaceDir: form.workspaceDir || undefined,
userName: form.userName || undefined, const clone = await createClone({
userRole: form.userRole || undefined, name: form.name,
restrictFiles: form.restrictFiles, role: form.role || undefined,
privacyOptIn: form.privacyOptIn, nickname: form.nickname || undefined,
}); scenarios,
if (clone) { workspaceDir: form.workspaceDir || undefined,
setCurrentAgent(toChatAgent(clone)); userName: form.userName || undefined,
userRole: form.userRole || undefined,
restrictFiles: form.restrictFiles,
privacyOptIn: form.privacyOptIn,
});
if (clone) {
setCurrentAgent(toChatAgent(clone));
setForm(createFormFromDraft({
...quickConfig,
agentName: form.name,
agentRole: form.role,
agentNickname: form.nickname,
scenarios,
workspaceDir: form.workspaceDir,
userName: form.userName,
userRole: form.userRole,
restrictFiles: form.restrictFiles,
privacyOptIn: form.privacyOptIn,
}));
setShowForm(false);
} else {
// Show error from store or generic message
const errorMsg = storeError || '创建分身失败。请检查 Gateway 连接状态和后端日志。';
setCreateError(errorMsg);
}
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : String(err);
setCreateError(`创建失败: ${errorMsg}`);
} finally {
setIsCreating(false);
} }
setForm(createFormFromDraft({
...quickConfig,
agentName: form.name,
agentRole: form.role,
agentNickname: form.nickname,
scenarios,
workspaceDir: form.workspaceDir,
userName: form.userName,
userRole: form.userRole,
restrictFiles: form.restrictFiles,
privacyOptIn: form.privacyOptIn,
}));
setShowForm(false);
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
@@ -222,12 +241,28 @@ export function CloneManager() {
onChange={e => setForm({ ...form, privacyOptIn: e.target.checked })} onChange={e => setForm({ ...form, privacyOptIn: e.target.checked })}
/> />
</label> </label>
{createError && (
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 px-2 py-1.5 rounded">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
<span className="flex-1">{createError}</span>
<button onClick={() => setCreateError(null)} className="hover:text-red-800">
<X className="w-3 h-3" />
</button>
</div>
)}
<button <button
onClick={handleCreate} onClick={handleCreate}
disabled={!form.name.trim()} disabled={!form.name.trim() || isCreating}
className="w-full text-xs bg-gray-900 text-white rounded py-1.5 hover:bg-gray-800 disabled:opacity-50" className="w-full text-xs bg-gray-900 text-white rounded py-1.5 hover:bg-gray-800 disabled:opacity-50 flex items-center justify-center gap-2"
> >
{isCreating ? (
<>
<RefreshCw className="w-3 h-3 animate-spin" />
...
</>
) : (
'完成配置'
)}
</button> </button>
</div> </div>
)} )}

View File

@@ -439,8 +439,10 @@ export class GatewayClient {
// Check if OpenFang API is healthy // Check if OpenFang API is healthy
const health = await this.restGet<{ status: string; version?: string }>('/api/health'); const health = await this.restGet<{ status: string; version?: string }>('/api/health');
if (health.status === 'ok') { if (health.status === 'ok') {
this.reconnectAttempts = 0;
this.setState('connected'); this.setState('connected');
this.log('info', `Connected to OpenFang via REST API${health.version ? ` (v${health.version})` : ''}`); this.log('info', `Connected to OpenFang via REST API${health.version ? ` (v${health.version})` : ''}`);
this.emitEvent('connected', { version: health.version });
} else { } else {
throw new Error('Health check failed'); throw new Error('Health check failed');
} }
@@ -1588,15 +1590,32 @@ export class GatewayClient {
this.setState('disconnected'); this.setState('disconnected');
} }
private static readonly MAX_RECONNECT_ATTEMPTS = 10;
private scheduleReconnect() { private scheduleReconnect() {
if (this.reconnectAttempts >= GatewayClient.MAX_RECONNECT_ATTEMPTS) {
this.log('error', `Max reconnect attempts (${GatewayClient.MAX_RECONNECT_ATTEMPTS}) reached. Please reconnect manually.`);
this.setState('disconnected');
this.emitEvent('reconnect_failed', {
attempts: this.reconnectAttempts,
maxAttempts: GatewayClient.MAX_RECONNECT_ATTEMPTS
});
return;
}
this.reconnectAttempts++; this.reconnectAttempts++;
this.setState('reconnecting'); this.setState('reconnecting');
const delay = Math.min(this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), 30000); const delay = Math.min(this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), 30000);
this.log('info', `Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
this.reconnectTimer = window.setTimeout(async () => { this.reconnectTimer = window.setTimeout(async () => {
try { try {
await this.connect(); await this.connect();
} catch { /* close handler will trigger another reconnect */ } } catch {
/* close handler will trigger another reconnect */
this.log('warn', `Reconnect attempt ${this.reconnectAttempts} failed`);
}
}, delay); }, delay);
} }