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:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user