mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 16:17:25 +00:00
feat(setup): add back navigation to onboard wizard
Steps 2-4 now have a Back button. Steps with DB resources (provider, agent) support edit mode — pre-fill form and use UPDATE instead of INSERT on re-submit.
This commit is contained in:
1 parent
dcf9ec4d49
commit
5ab4af415d
6 files changed
+144
-65
No files matched your search
@@ -75,6 +75,20 @@ export function useAgents() {
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const updateAgent = useCallback(
|
||||
async (id: string, data: Partial<AgentData>) => {
|
||||
try {
|
||||
await http.put(`/v1/agents/${id}`, data);
|
||||
await invalidate();
|
||||
toast.success("Agent updated", `${data.display_name || data.agent_key || "Agent"} has been updated`);
|
||||
} catch (err) {
|
||||
toast.error("Failed to update agent", err instanceof Error ? err.message : "Unknown error");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, invalidate],
|
||||
);
|
||||
|
||||
const deleteAgent = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
@@ -96,5 +110,5 @@ export function useAgents() {
|
||||
[http],
|
||||
);
|
||||
|
||||
return { agents, loading, error, refresh: invalidate, createAgent, deleteAgent, resummonAgent };
|
||||
return { agents, loading, error, refresh: invalidate, createAgent, updateAgent, deleteAgent, resummonAgent };
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export function SetupPage() {
|
||||
|
||||
{step === 1 && (
|
||||
<StepProvider
|
||||
existingProvider={createdProvider}
|
||||
onComplete={(provider) => {
|
||||
setCreatedProvider(provider);
|
||||
setStep(2);
|
||||
@@ -74,6 +75,8 @@ export function SetupPage() {
|
||||
{step === 2 && activeProvider && (
|
||||
<StepModel
|
||||
provider={activeProvider}
|
||||
initialModel={selectedModel}
|
||||
onBack={() => setStep(1)}
|
||||
onComplete={(model) => {
|
||||
setSelectedModel(model);
|
||||
setStep(3);
|
||||
@@ -85,6 +88,8 @@ export function SetupPage() {
|
||||
<StepAgent
|
||||
provider={activeProvider}
|
||||
model={selectedModel}
|
||||
existingAgent={createdAgent}
|
||||
onBack={() => setStep(2)}
|
||||
onComplete={(agent) => {
|
||||
setCreatedAgent(agent);
|
||||
setStep(4);
|
||||
@@ -95,6 +100,7 @@ export function SetupPage() {
|
||||
{step === 4 && (
|
||||
<StepChannel
|
||||
agent={activeAgent}
|
||||
onBack={() => setStep(3)}
|
||||
onComplete={handleFinish}
|
||||
onSkip={handleFinish}
|
||||
/>
|
||||
|
||||
@@ -23,17 +23,25 @@ interface StepAgentProps {
|
||||
provider: ProviderData | null;
|
||||
model: string | null;
|
||||
onComplete: (agent: AgentData) => void;
|
||||
onBack?: () => void;
|
||||
existingAgent?: AgentData | null;
|
||||
}
|
||||
|
||||
export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
|
||||
export function StepAgent({ provider, model, onComplete, onBack, existingAgent }: StepAgentProps) {
|
||||
const { t } = useTranslation("setup");
|
||||
const { createAgent, deleteAgent, resummonAgent } = useAgents();
|
||||
const { createAgent, updateAgent, deleteAgent, resummonAgent } = useAgents();
|
||||
|
||||
const [displayName, setDisplayName] = useState("GoClaw");
|
||||
const [agentKey, setAgentKey] = useState("goclaw");
|
||||
const [keyTouched, setKeyTouched] = useState(false);
|
||||
const [description, setDescription] = useState(DEFAULT_PROMPT);
|
||||
const [selfEvolve, setSelfEvolve] = useState(false);
|
||||
const isEditing = !!existingAgent;
|
||||
|
||||
const [displayName, setDisplayName] = useState(existingAgent?.display_name ?? "GoClaw");
|
||||
const [agentKey, setAgentKey] = useState(existingAgent?.agent_key ?? "goclaw");
|
||||
const [keyTouched, setKeyTouched] = useState(isEditing);
|
||||
const [description, setDescription] = useState(
|
||||
existingAgent?.other_config?.description as string ?? DEFAULT_PROMPT,
|
||||
);
|
||||
const [selfEvolve, setSelfEvolve] = useState(
|
||||
!!(existingAgent?.other_config?.self_evolve),
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -72,7 +80,7 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
|
||||
if (agentResult) onComplete(agentResult);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!agentKey.trim() || !isValidSlug(agentKey)) return;
|
||||
if (!provider) { setError(t("agent.errors.noProvider")); return; }
|
||||
|
||||
@@ -83,21 +91,34 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
|
||||
const otherConfig: Record<string, unknown> = {};
|
||||
if (description.trim()) otherConfig.description = description.trim();
|
||||
if (selfEvolve) otherConfig.self_evolve = true;
|
||||
const data: Partial<AgentData> = {
|
||||
agent_key: agentKey.trim(),
|
||||
display_name: displayName.trim() || undefined,
|
||||
provider: provider.name,
|
||||
model: model || "",
|
||||
agent_type: "predefined",
|
||||
is_default: true,
|
||||
other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
|
||||
};
|
||||
|
||||
const result = await createAgent(data) as AgentData;
|
||||
setAgentResult(result);
|
||||
setSummoningOutcome("pending");
|
||||
setCreatedAgent({ id: result.id, name: displayName.trim() || agentKey });
|
||||
setSummoningOpen(true);
|
||||
if (isEditing) {
|
||||
// Update existing agent — skip summoning
|
||||
const patch: Partial<AgentData> = {
|
||||
display_name: displayName.trim() || undefined,
|
||||
provider: provider.name,
|
||||
model: model || "",
|
||||
other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
|
||||
};
|
||||
await updateAgent(existingAgent!.id, patch);
|
||||
onComplete({ ...existingAgent!, ...patch } as AgentData);
|
||||
} else {
|
||||
const data: Partial<AgentData> = {
|
||||
agent_key: agentKey.trim(),
|
||||
display_name: displayName.trim() || undefined,
|
||||
provider: provider.name,
|
||||
model: model || "",
|
||||
agent_type: "predefined",
|
||||
is_default: true,
|
||||
other_config: Object.keys(otherConfig).length > 0 ? otherConfig : undefined,
|
||||
};
|
||||
|
||||
const result = await createAgent(data) as AgentData;
|
||||
setAgentResult(result);
|
||||
setSummoningOutcome("pending");
|
||||
setCreatedAgent({ id: result.id, name: displayName.trim() || agentKey });
|
||||
setSummoningOpen(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("agent.errors.failedCreate"));
|
||||
} finally {
|
||||
@@ -172,6 +193,7 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
|
||||
onChange={(e) => { setKeyTouched(true); setAgentKey(e.target.value); }}
|
||||
onBlur={() => setAgentKey(slugify(agentKey))}
|
||||
placeholder={t("agent.agentKeyPlaceholder")}
|
||||
disabled={isEditing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,12 +236,17 @@ export function StepAgent({ provider, model, onComplete }: StepAgentProps) {
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-between">
|
||||
{onBack ? (
|
||||
<Button variant="ghost" onClick={onBack}>{t("common:back")}</Button>
|
||||
) : <span />}
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !agentKey.trim() || !isValidSlug(agentKey) || !description.trim()}
|
||||
>
|
||||
{loading ? t("agent.creating") : t("agent.create")}
|
||||
{loading
|
||||
? isEditing ? t("agent.updating", "Updating...") : t("agent.creating")
|
||||
: isEditing ? t("agent.update", "Update") : t("agent.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -25,9 +25,10 @@ interface StepChannelProps {
|
||||
agent: AgentData | null;
|
||||
onComplete: () => void;
|
||||
onSkip: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function StepChannel({ agent, onComplete, onSkip }: StepChannelProps) {
|
||||
export function StepChannel({ agent, onComplete, onSkip, onBack }: StepChannelProps) {
|
||||
const { t } = useTranslation("setup");
|
||||
const { createInstance } = useChannelInstances();
|
||||
|
||||
@@ -158,13 +159,18 @@ export function StepChannel({ agent, onComplete, onSkip }: StepChannelProps) {
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onSkip} disabled={loading}>
|
||||
{t("channel.skipFinish")}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? t("channel.creating") : t("channel.create")}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
{onBack ? (
|
||||
<Button variant="ghost" onClick={onBack}>{t("common:back")}</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onSkip} disabled={loading}>
|
||||
{t("channel.skipFinish")}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? t("channel.creating") : t("channel.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
|
||||
@@ -15,14 +15,16 @@ import type { ProviderData } from "@/types/provider";
|
||||
interface StepModelProps {
|
||||
provider: ProviderData;
|
||||
onComplete: (model: string) => void;
|
||||
onBack?: () => void;
|
||||
initialModel?: string | null;
|
||||
}
|
||||
|
||||
export function StepModel({ provider, onComplete }: StepModelProps) {
|
||||
export function StepModel({ provider, onComplete, onBack, initialModel }: StepModelProps) {
|
||||
const { t } = useTranslation("setup");
|
||||
const { models, loading: modelsLoading } = useProviderModels(provider.id, provider.provider_type);
|
||||
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
|
||||
|
||||
const [model, setModel] = useState("");
|
||||
const [model, setModel] = useState(initialModel ?? "");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Reset verification when model changes
|
||||
@@ -90,17 +92,22 @@ export function StepModel({ provider, onComplete }: StepModelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleVerify}
|
||||
disabled={!model.trim() || verifying || isVerified}
|
||||
>
|
||||
{verifying ? t("model.verifying") : isVerified ? t("model.verified") : t("model.verify")}
|
||||
</Button>
|
||||
<Button onClick={() => onComplete(model.trim())} disabled={!isVerified}>
|
||||
{t("model.continue")}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between">
|
||||
{onBack ? (
|
||||
<Button variant="ghost" onClick={onBack}>{t("common:back")}</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleVerify}
|
||||
disabled={!model.trim() || verifying || isVerified}
|
||||
>
|
||||
{verifying ? t("model.verifying") : isVerified ? t("model.verified") : t("model.verify")}
|
||||
</Button>
|
||||
<Button onClick={() => onComplete(model.trim())} disabled={!isVerified}>
|
||||
{t("model.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
|
||||
@@ -17,20 +17,25 @@ import { PROVIDER_TYPES } from "@/constants/providers";
|
||||
import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { CLISection } from "@/pages/providers/provider-cli-section";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import type { ProviderData } from "@/types/provider";
|
||||
import type { ProviderData, ProviderInput } from "@/types/provider";
|
||||
|
||||
interface StepProviderProps {
|
||||
onComplete: (provider: ProviderData) => void;
|
||||
existingProvider?: ProviderData | null;
|
||||
}
|
||||
|
||||
export function StepProvider({ onComplete }: StepProviderProps) {
|
||||
export function StepProvider({ onComplete, existingProvider }: StepProviderProps) {
|
||||
const { t } = useTranslation("setup");
|
||||
const { createProvider } = useProviders();
|
||||
const { createProvider, updateProvider } = useProviders();
|
||||
|
||||
const [providerType, setProviderType] = useState("openrouter");
|
||||
const [name, setName] = useState("openrouter");
|
||||
const isEditing = !!existingProvider;
|
||||
|
||||
const [providerType, setProviderType] = useState(existingProvider?.provider_type ?? "openrouter");
|
||||
const [name, setName] = useState(existingProvider?.name ?? "openrouter");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [apiBase, setApiBase] = useState("https://openrouter.ai/api/v1");
|
||||
const [apiBase, setApiBase] = useState(
|
||||
existingProvider?.api_base ?? "https://openrouter.ai/api/v1",
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -54,19 +59,31 @@ export function StepProvider({ onComplete }: StepProviderProps) {
|
||||
[providerType],
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!isCLI && !isOllama && !apiKey.trim()) { setError(t("provider.errors.apiKeyRequired")); return; }
|
||||
const handleSubmit = async () => {
|
||||
if (!isEditing && !isCLI && !isOllama && !apiKey.trim()) { setError(t("provider.errors.apiKeyRequired")); return; }
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const provider = await createProvider({
|
||||
name: name.trim(),
|
||||
provider_type: providerType,
|
||||
api_base: apiBase.trim() || undefined,
|
||||
api_key: isCLI || isOllama ? undefined : apiKey.trim(),
|
||||
enabled: true,
|
||||
}) as ProviderData;
|
||||
onComplete(provider);
|
||||
if (isEditing) {
|
||||
const patch: Record<string, unknown> = {
|
||||
name: name.trim(),
|
||||
provider_type: providerType,
|
||||
api_base: apiBase.trim() || undefined,
|
||||
};
|
||||
// Only include api_key if user entered a new one
|
||||
if (apiKey.trim()) patch.api_key = apiKey.trim();
|
||||
await updateProvider(existingProvider!.id, patch as Partial<ProviderInput>);
|
||||
onComplete({ ...existingProvider!, ...patch } as ProviderData);
|
||||
} else {
|
||||
const provider = await createProvider({
|
||||
name: name.trim(),
|
||||
provider_type: providerType,
|
||||
api_base: apiBase.trim() || undefined,
|
||||
api_key: isCLI || isOllama ? undefined : apiKey.trim(),
|
||||
enabled: true,
|
||||
}) as ProviderData;
|
||||
onComplete(provider);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("provider.errors.failedCreate"));
|
||||
} finally {
|
||||
@@ -145,8 +162,10 @@ export function StepProvider({ onComplete }: StepProviderProps) {
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleCreate} disabled={loading || (!isCLI && !isOllama && !apiKey.trim())}>
|
||||
{loading ? t("provider.creating") : t("provider.create")}
|
||||
<Button onClick={handleSubmit} disabled={loading || (!isEditing && !isCLI && !isOllama && !apiKey.trim())}>
|
||||
{loading
|
||||
? isEditing ? t("provider.updating", "Updating...") : t("provider.creating")
|
||||
: isEditing ? t("provider.update", "Update") : t("provider.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
Reference in new issue
Block a user