提交 24c1be22 authored 作者: Will Chen's avatar Will Chen

Remove runtime mode selection & have unified setup flow for node.js + API access

上级 ba3c9f7a
import { useNavigate } from "@tanstack/react-router";
import { ChevronRight, GiftIcon, Sparkles } from "lucide-react";
import {
ChevronRight,
GiftIcon,
Sparkles,
CheckCircle,
AlertCircle,
XCircle,
Loader2,
} from "lucide-react";
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
import { useSettings } from "@/hooks/useSettings";
import { useState, useEffect, useCallback } from "react";
import { IpcClient } from "@/ipc/ipc_client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { showError } from "@/lib/toast";
export function SetupBanner() {
const navigate = useNavigate();
const { isAnyProviderSetup } = useSettings();
const [nodeVersion, setNodeVersion] = useState<string | null>(null);
const [nodeCheckError, setNodeCheckError] = useState<boolean>(false);
const [nodeInstallError, setNodeInstallError] = useState<string | null>(null);
const [nodeInstallLoading, setNodeInstallLoading] = useState<boolean>(false);
const checkNode = useCallback(async () => {
try {
setNodeCheckError(false);
const status = await IpcClient.getInstance().getNodejsStatus();
setNodeVersion(status.nodeVersion);
} catch (error) {
console.error("Failed to check Node.js status:", error);
setNodeVersion(null);
setNodeCheckError(true);
}
}, [setNodeVersion, setNodeCheckError]);
const handleSetupClick = () => {
useEffect(() => {
checkNode();
}, [checkNode]);
const handleAiSetupClick = () => {
navigate({
to: providerSettingsRoute.id,
params: { provider: "google" },
});
};
const handleNodeInstallClick = async () => {
setNodeInstallLoading(true);
try {
const result = await IpcClient.getInstance().installNode();
if (!result.success) {
showError(result.errorMessage);
setNodeInstallError(result.errorMessage || "Unknown error");
} else {
setNodeVersion(result.version);
}
} catch (error) {
showError("Failed to install Node.js. " + (error as Error).message);
setNodeInstallError(
"Failed to install Node.js. " + (error as Error).message
);
} finally {
setNodeInstallLoading(false);
}
};
const isNodeSetupComplete = !!nodeVersion;
const isAiProviderSetup = isAnyProviderSetup();
const itemsNeedAction: string[] = [];
if (!isNodeSetupComplete) itemsNeedAction.push("node-setup");
if (isNodeSetupComplete && !isAiProviderSetup)
itemsNeedAction.push("ai-setup");
if (itemsNeedAction.length === 0) {
return null;
}
const bannerClasses = cn(
"w-full mb-8 border rounded-xl shadow-sm overflow-hidden",
"border-zinc-200 dark:border-zinc-700"
);
const getStatusIcon = (isComplete: boolean, hasError: boolean = false) => {
if (hasError) {
return <XCircle className="w-5 h-5 text-red-500" />;
}
return isComplete ? (
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-500" />
) : (
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-500" />
);
};
return (
<div
className="w-full mb-8 p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-xl shadow-sm cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors"
onClick={handleSetupClick}
>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="bg-blue-100 dark:bg-blue-800 p-2 rounded-full">
<Sparkles className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="font-medium text-blue-800 dark:text-blue-300">
Setup your AI API access
</h3>
<p className="text-sm text-blue-600 dark:text-blue-400 flex items-center gap-1">
<GiftIcon className="w-3.5 h-3.5" />
Use Google Gemini for free
<div className={bannerClasses}>
<Accordion
type="multiple"
className="w-full"
defaultValue={itemsNeedAction}
>
<AccordionItem
value="node-setup"
className={cn(
nodeCheckError
? "bg-red-50 dark:bg-red-900/30"
: isNodeSetupComplete
? "bg-green-50 dark:bg-green-900/30"
: "bg-yellow-50 dark:bg-yellow-900/30"
)}
>
<AccordionTrigger className="px-4 py-3 transition-colors w-full hover:no-underline">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
{getStatusIcon(isNodeSetupComplete, nodeCheckError)}
<span className="font-medium text-sm">
1. Check Node.js Runtime
</span>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pt-2 pb-4 bg-white dark:bg-zinc-900 border-t border-inherit">
{nodeInstallError && (
<p className="text-sm text-red-600 dark:text-red-400">
{nodeInstallError}
</p>
)}
{nodeCheckError ? (
<p className="text-sm text-red-600 dark:text-red-400">
Error checking Node.js status. Please ensure node.js are
installed correctly and accessible in your system's PATH.
</p>
) : isNodeSetupComplete ? (
<p className="text-sm">Node.js ({nodeVersion}) installed.</p>
) : (
<div>
<p className="text-sm mb-3">
Node.js is required to run apps locally.
</p>
<Button
size="sm"
onClick={handleNodeInstallClick}
disabled={nodeInstallLoading}
>
{nodeInstallLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Installing...
</>
) : (
"Install Node.js Runtime"
)}
</Button>
</div>
)}
</AccordionContent>
</AccordionItem>
<AccordionItem
value="ai-setup"
disabled={!isNodeSetupComplete}
className={cn(
isAiProviderSetup
? "bg-green-50 dark:bg-green-900/30"
: "bg-yellow-50 dark:bg-yellow-900/30",
!isNodeSetupComplete ? "opacity-60" : ""
)}
>
<AccordionTrigger
className={cn(
"px-4 py-3 transition-colors w-full hover:no-underline",
!isNodeSetupComplete ? "cursor-not-allowed" : ""
)}
onClick={(e) => !isNodeSetupComplete && e.preventDefault()}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
{getStatusIcon(isAiProviderSetup)}
<span className="font-medium text-sm">
2. Setup AI Model Access
</span>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pt-2 pb-4 bg-white dark:bg-zinc-900 border-t border-inherit">
<p className="text-sm mb-3">
Connect your preferred AI provider to start generating code.
</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div
className="p-3 bg-blue-50 dark:bg-blue-900/50 border border-blue-200 dark:border-blue-700 rounded-lg cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/70 transition-colors"
onClick={handleAiSetupClick}
role="button"
tabIndex={isNodeSetupComplete ? 0 : -1}
onKeyDown={(e) =>
isNodeSetupComplete && e.key === "Enter" && handleAiSetupClick()
}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="bg-blue-100 dark:bg-blue-800 p-1.5 rounded-full">
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 className="font-medium text-sm text-blue-800 dark:text-blue-300">
Setup Google Gemini API Key
</h4>
<p className="text-xs text-blue-600 dark:text-blue-400 flex items-center gap-1">
<GiftIcon className="w-3 h-3" />
Use Google Gemini for free
</p>
</div>
</div>
<ChevronRight className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
差异被折叠。
......@@ -209,43 +209,6 @@ async function executeAppLocalNode({
});
}
function checkCommandExists(command: string): Promise<string | null> {
return new Promise((resolve) => {
let output = "";
const process = spawn(command, ["--version"], {
shell: true,
stdio: ["ignore", "pipe", "pipe"], // ignore stdin, pipe stdout/stderr
});
process.stdout?.on("data", (data) => {
output += data.toString();
});
process.stderr?.on("data", (data) => {
// Log stderr but don't treat it as a failure unless the exit code is non-zero
console.warn(
`Stderr from "${command} --version": ${data.toString().trim()}`
);
});
process.on("error", (error) => {
console.error(`Error executing command "${command}":`, error.message);
resolve(null); // Command execution failed
});
process.on("close", (code) => {
if (code === 0) {
resolve(output.trim()); // Command succeeded, return trimmed output
} else {
console.error(
`Command "${command} --version" failed with code ${code}`
);
resolve(null); // Command failed
}
});
});
}
// Helper to kill process on a specific port (cross-platform, using kill-port)
async function killProcessOnPort(port: number): Promise<void> {
try {
......@@ -256,21 +219,6 @@ async function killProcessOnPort(port: number): Promise<void> {
}
export function registerAppHandlers() {
ipcMain.handle(
"nodejs-status",
async (): Promise<{
nodeVersion: string | null;
npmVersion: string | null;
}> => {
// Run checks in parallel
const [nodeVersion, npmVersion] = await Promise.all([
checkCommandExists("node"),
checkCommandExists("npm"),
]);
return { nodeVersion, npmVersion };
}
);
ipcMain.handle(
"get-app-sandbox-config",
async (_, { appId }: { appId: number }): Promise<SandboxConfig> => {
......
import { ipcMain } from "electron";
import { spawn } from "child_process";
import { platform } from "os";
import { InstallNodeResult } from "../ipc_types";
type ShellResult =
| {
success: true;
output: string;
}
| {
success: false;
errorMessage: string;
};
function runShell(command: string): Promise<ShellResult> {
return new Promise((resolve) => {
let stdout = "";
let stderr = "";
const process = spawn(command, {
shell: true,
stdio: ["ignore", "pipe", "pipe"], // ignore stdin, pipe stdout/stderr
});
process.stdout?.on("data", (data) => {
stdout += data.toString();
});
process.stderr?.on("data", (data) => {
stderr += data.toString();
});
process.on("error", (error) => {
console.error(`Error executing command "${command}":`, error.message);
resolve({ success: false, errorMessage: error.message });
});
process.on("close", (code) => {
if (code === 0) {
resolve({ success: true, output: stdout.trim() });
} else {
const errorMessage =
stderr.trim() || `Command failed with code ${code}`;
console.error(
`Command "${command}" failed with code ${code}: ${stderr.trim()}`
);
resolve({ success: false, errorMessage });
}
});
});
}
function checkCommandExists(command: string): Promise<string | null> {
return new Promise((resolve) => {
let output = "";
const process = spawn(command, ["--version"], {
shell: true,
stdio: ["ignore", "pipe", "pipe"], // ignore stdin, pipe stdout/stderr
});
process.stdout?.on("data", (data) => {
output += data.toString();
});
process.stderr?.on("data", (data) => {
// Log stderr but don't treat it as a failure unless the exit code is non-zero
console.warn(
`Stderr from "${command} --version": ${data.toString().trim()}`
);
});
process.on("error", (error) => {
console.error(`Error executing command "${command}":`, error.message);
resolve(null); // Command execution failed
});
process.on("close", (code) => {
if (code === 0) {
resolve(output.trim()); // Command succeeded, return trimmed output
} else {
console.error(
`Command "${command} --version" failed with code ${code}`
);
resolve(null); // Command failed
}
});
});
}
export function registerNodeHandlers() {
ipcMain.handle(
"nodejs-status",
async (): Promise<{
nodeVersion: string | null;
npmVersion: string | null;
}> => {
// Run checks in parallel
const [nodeVersion, npmVersion] = await Promise.all([
checkCommandExists("node"),
checkCommandExists("npm"),
]);
return { nodeVersion, npmVersion };
}
);
ipcMain.handle("install-node", async (): Promise<InstallNodeResult> => {
console.log("Installing Node.js...");
if (platform() === "win32") {
let result = await runShell("winget install Volta.Volta");
if (!result.success) {
return { success: false, errorMessage: result.errorMessage };
}
} else {
let result = await runShell("curl https://get.volta.sh | bash");
if (!result.success) {
return { success: false, errorMessage: result.errorMessage };
}
}
console.log("Installed Volta");
process.env.PATH = ["~/.volta/bin", process.env.PATH].join(":");
console.log("Updated PATH");
let result = await runShell("volta install node");
if (!result.success) {
return { success: false, errorMessage: result.errorMessage };
}
console.log("Installed Node.js (via Volta)");
result = await runShell("node --version");
if (!result.success) {
return { success: false, errorMessage: result.errorMessage };
}
console.log("Node.js is setup with version");
return { success: true, version: result.output };
});
}
......@@ -13,6 +13,7 @@ import type {
ChatStreamParams,
CreateAppParams,
CreateAppResult,
InstallNodeResult,
ListAppsResponse,
SandboxConfig,
Version,
......@@ -520,6 +521,17 @@ export class IpcClient {
}
}
// Install Node.js and npm
public async installNode(): Promise<InstallNodeResult> {
try {
const result = await this.ipcRenderer.invoke("install-node");
return result;
} catch (error) {
showError(error);
throw error;
}
}
// --- GitHub Device Flow ---
public startGithubDeviceFlow(appId: number | null): void {
this.ipcRenderer.invoke("github:start-flow", { appId });
......
......@@ -5,6 +5,7 @@ import { registerSettingsHandlers } from "./handlers/settings_handlers";
import { registerShellHandlers } from "./handlers/shell_handler";
import { registerDependencyHandlers } from "./handlers/dependency_handlers";
import { registerGithubHandlers } from "./handlers/github_handlers";
import { registerNodeHandlers } from "./handlers/node_handlers";
export function registerIpcHandlers() {
// Register all IPC handlers by category
......@@ -15,4 +16,5 @@ export function registerIpcHandlers() {
registerShellHandlers();
registerDependencyHandlers();
registerGithubHandlers();
registerNodeHandlers();
}
......@@ -65,3 +65,13 @@ export interface SandboxConfig {
dependencies: Record<string, string>;
entry: string;
}
export type InstallNodeResult =
| {
success: true;
version: string;
}
| {
success: false;
errorMessage: string;
};
......@@ -86,9 +86,11 @@ export type GithubUser = z.infer<typeof GithubUserSchema>;
export const UserSettingsSchema = z.object({
selectedModel: LargeLanguageModelSchema,
providerSettings: z.record(z.string(), ProviderSettingSchema),
runtimeMode: RuntimeModeSchema,
githubUser: GithubUserSchema.optional(),
githubAccessToken: SecretSchema.optional(),
// DEPRECATED.
runtimeMode: RuntimeModeSchema.optional(),
});
/**
......
......@@ -12,7 +12,6 @@ const DEFAULT_SETTINGS: UserSettings = {
provider: "auto",
},
providerSettings: {},
runtimeMode: "unset",
};
const SETTINGS_FILE = "user-settings.json";
......
......@@ -11,8 +11,6 @@ import { ChatInput } from "@/components/chat/ChatInput";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import { useState, useEffect } from "react";
import { useStreamChat } from "@/hooks/useStreamChat";
import { SetupRuntimeFlow } from "@/components/SetupRuntimeFlow";
import { RuntimeMode } from "@/lib/schemas";
export default function HomePage() {
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
......@@ -83,12 +81,6 @@ export default function HomePage() {
);
}
// Runtime Setup Flow
// Render this only if runtimeMode is not set in settings
if (settings?.runtimeMode === "unset") {
return <SetupRuntimeFlow />;
}
// Main Home Page Content (Rendered only if runtimeMode is set)
return (
<div className="flex flex-col items-center justify-center max-w-3xl m-auto p-8">
......@@ -96,7 +88,7 @@ export default function HomePage() {
Build your dream app
</h1>
{!isAnyProviderSetup() && <SetupBanner />}
<SetupBanner />
<div className="w-full">
<ChatInput onSubmit={handleSubmit} />
......
差异被折叠。
......@@ -32,6 +32,7 @@ const validInvokeChannels = [
"open-external-url",
"reset-all",
"nodejs-status",
"install-node",
"github:start-flow",
"github:is-repo-available",
"github:create-repo",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论