feat(electrobun): project wizard phases 1-5 (WIP)
- sanitize.ts: input sanitization (trim, control chars, path traversal) - provider-scanner.ts: detect Claude/Codex/Ollama/Gemini availability - model-fetcher.ts: live model lists from 4 provider APIs - ModelConfigPanel.svelte: per-provider config (thinking, effort, sandbox, temperature) - WizardStep1-3.svelte: split wizard into composable steps - CustomDropdown/Checkbox/Radio: themed UI components - provider-handlers.ts: provider.scan + provider.models RPC - Wire providers into wizard step 3 (live detection + model lists) - Replace native selects in 5 settings panels with CustomDropdown
This commit is contained in:
parent
b7fc3a0f9b
commit
d4014a193d
25 changed files with 2112 additions and 759 deletions
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Git RPC handlers — branch listing, clone operations.
|
||||
* Git RPC handlers — branch listing, clone, probe, and template scaffolding.
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
|
|
@ -98,5 +98,118 @@ export function createGitHandlers() {
|
|||
});
|
||||
});
|
||||
},
|
||||
|
||||
"git.probe": async ({ url }: { url: string }) => {
|
||||
if (!url || (!url.includes("/") && !url.includes(":"))) {
|
||||
return { ok: false, branches: [], defaultBranch: '', error: "Invalid URL" };
|
||||
}
|
||||
|
||||
return new Promise<{
|
||||
ok: boolean; branches: string[]; defaultBranch: string; error?: string;
|
||||
}>((resolve) => {
|
||||
const proc = spawn("git", ["ls-remote", "--heads", "--symref", url], {
|
||||
stdio: "pipe",
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
proc.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
resolve({ ok: false, branches: [], defaultBranch: '', error: stderr.trim() || 'Probe failed' });
|
||||
return;
|
||||
}
|
||||
const branches: string[] = [];
|
||||
let defaultBranch = 'main';
|
||||
|
||||
for (const line of stdout.split("\n")) {
|
||||
// Parse symref for HEAD
|
||||
const symMatch = line.match(/^ref: refs\/heads\/(\S+)\s+HEAD/);
|
||||
if (symMatch) { defaultBranch = symMatch[1]; continue; }
|
||||
|
||||
// Parse branch refs
|
||||
const refMatch = line.match(/\trefs\/heads\/(.+)$/);
|
||||
if (refMatch && !branches.includes(refMatch[1])) {
|
||||
branches.push(refMatch[1]);
|
||||
}
|
||||
}
|
||||
resolve({ ok: true, branches, defaultBranch });
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
resolve({ ok: false, branches: [], defaultBranch: '', error: err.message });
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
"project.createFromTemplate": async ({
|
||||
templateId,
|
||||
targetDir,
|
||||
projectName,
|
||||
}: {
|
||||
templateId: string;
|
||||
targetDir: string;
|
||||
projectName: string;
|
||||
}) => {
|
||||
const resolved = path.resolve(targetDir.replace(/^~/, process.env.HOME ?? ""));
|
||||
const projectDir = path.join(resolved, projectName);
|
||||
|
||||
// Don't overwrite existing directory
|
||||
try {
|
||||
fs.statSync(projectDir);
|
||||
return { ok: false, path: '', error: "Directory already exists" };
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const scaffolds: Record<string, Record<string, string>> = {
|
||||
blank: {
|
||||
'README.md': `# ${projectName}\n`,
|
||||
'.gitignore': 'node_modules/\ndist/\n.env\n',
|
||||
},
|
||||
'web-app': {
|
||||
'index.html': `<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>${projectName}</title>\n <link rel="stylesheet" href="style.css" />\n</head>\n<body>\n <h1>${projectName}</h1>\n <script src="main.js"></script>\n</body>\n</html>\n`,
|
||||
'style.css': `body {\n font-family: system-ui, sans-serif;\n margin: 2rem;\n}\n`,
|
||||
'main.js': `console.log('${projectName} loaded');\n`,
|
||||
'README.md': `# ${projectName}\n\nA web application.\n`,
|
||||
'.gitignore': 'node_modules/\ndist/\n.env\n',
|
||||
},
|
||||
'api-server': {
|
||||
'index.ts': `const server = Bun.serve({\n port: 3000,\n fetch(req) {\n const url = new URL(req.url);\n if (url.pathname === '/health') {\n return Response.json({ status: 'ok' });\n }\n return Response.json({ message: 'Hello from ${projectName}' });\n },\n});\nconsole.log(\`Server running on \${server.url}\`);\n`,
|
||||
'README.md': `# ${projectName}\n\nA Bun HTTP API server.\n\n## Run\n\n\`\`\`bash\nbun run index.ts\n\`\`\`\n`,
|
||||
'.gitignore': 'node_modules/\ndist/\n.env\n',
|
||||
'package.json': `{\n "name": "${projectName}",\n "version": "0.1.0",\n "scripts": {\n "start": "bun run index.ts",\n "dev": "bun --watch index.ts"\n }\n}\n`,
|
||||
},
|
||||
'cli-tool': {
|
||||
'cli.ts': `#!/usr/bin/env bun\nconst args = process.argv.slice(2);\nif (args.includes('--help') || args.includes('-h')) {\n console.log('Usage: ${projectName} [options]');\n console.log(' --help, -h Show this help');\n console.log(' --version Show version');\n process.exit(0);\n}\nif (args.includes('--version')) {\n console.log('${projectName} 0.1.0');\n process.exit(0);\n}\nconsole.log('Hello from ${projectName}!');\n`,
|
||||
'README.md': `# ${projectName}\n\nA command-line tool.\n\n## Run\n\n\`\`\`bash\nbun run cli.ts --help\n\`\`\`\n`,
|
||||
'.gitignore': 'node_modules/\ndist/\n.env\n',
|
||||
},
|
||||
};
|
||||
|
||||
const files = scaffolds[templateId] ?? scaffolds['blank'];
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(projectDir, name), content);
|
||||
}
|
||||
|
||||
// Initialize git repo
|
||||
try {
|
||||
execSync('git init', { cwd: projectDir, timeout: 5000 });
|
||||
} catch {
|
||||
// Non-fatal — project created without git
|
||||
}
|
||||
|
||||
return { ok: true, path: projectDir };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, path: '', error };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
30
ui-electrobun/src/bun/handlers/provider-handlers.ts
Normal file
30
ui-electrobun/src/bun/handlers/provider-handlers.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Provider RPC handlers — scanning + model fetching.
|
||||
*/
|
||||
|
||||
import { scanAllProviders } from '../provider-scanner.ts';
|
||||
import { fetchModelsForProvider } from '../model-fetcher.ts';
|
||||
|
||||
export function createProviderHandlers() {
|
||||
return {
|
||||
'provider.scan': async () => {
|
||||
try {
|
||||
const providers = await scanAllProviders();
|
||||
return { providers };
|
||||
} catch (err) {
|
||||
console.error('[provider.scan]', err);
|
||||
return { providers: [] };
|
||||
}
|
||||
},
|
||||
|
||||
'provider.models': async ({ provider }: { provider: string }) => {
|
||||
try {
|
||||
const models = await fetchModelsForProvider(provider);
|
||||
return { models };
|
||||
} catch (err) {
|
||||
console.error('[provider.models]', err);
|
||||
return { models: [] };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ import { createSearchHandlers } from "./handlers/search-handlers.ts";
|
|||
import { createPluginHandlers } from "./handlers/plugin-handlers.ts";
|
||||
import { createRemoteHandlers } from "./handlers/remote-handlers.ts";
|
||||
import { createGitHandlers } from "./handlers/git-handlers.ts";
|
||||
import { createProviderHandlers } from "./handlers/provider-handlers.ts";
|
||||
|
||||
/** Current app version — sourced from electrobun.config.ts at build time. */
|
||||
const APP_VERSION = "0.0.1";
|
||||
|
|
@ -101,6 +102,7 @@ const searchHandlers = createSearchHandlers(searchDb);
|
|||
const pluginHandlers = createPluginHandlers();
|
||||
const remoteHandlers = createRemoteHandlers(relayClient, settingsDb);
|
||||
const gitHandlers = createGitHandlers();
|
||||
const providerHandlers = createProviderHandlers();
|
||||
|
||||
// ── RPC definition ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -127,6 +129,8 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
|
|||
...remoteHandlers,
|
||||
// Git
|
||||
...gitHandlers,
|
||||
// Providers
|
||||
...providerHandlers,
|
||||
|
||||
// Native folder picker dialog via zenity (proper GTK folder chooser)
|
||||
"files.pickDirectory": async ({ startingFolder }) => {
|
||||
|
|
|
|||
107
ui-electrobun/src/bun/model-fetcher.ts
Normal file
107
ui-electrobun/src/bun/model-fetcher.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Model fetcher — retrieves available models from each provider's API.
|
||||
*
|
||||
* Each function returns a sorted list of model IDs.
|
||||
* Network errors return empty arrays (non-fatal).
|
||||
*/
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
const TIMEOUT = 8000;
|
||||
|
||||
export async function fetchClaudeModels(): Promise<ModelInfo[]> {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
try {
|
||||
const res = await fetch('https://api.anthropic.com/v1/models', {
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { data?: Array<{ id: string; display_name?: string }> };
|
||||
return (data.data ?? [])
|
||||
.map(m => ({ id: m.id, name: m.display_name ?? m.id, provider: 'claude' }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCodexModels(): Promise<ModelInfo[]> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
try {
|
||||
const res = await fetch('https://api.openai.com/v1/models', {
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { data?: Array<{ id: string }> };
|
||||
return (data.data ?? [])
|
||||
.map(m => ({ id: m.id, name: m.id, provider: 'codex' }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOllamaModels(): Promise<ModelInfo[]> {
|
||||
try {
|
||||
const res = await fetch('http://localhost:11434/api/tags', {
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as { models?: Array<{ name: string; model?: string }> };
|
||||
return (data.models ?? [])
|
||||
.map(m => ({ id: m.name, name: m.name, provider: 'ollama' }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGeminiModels(): Promise<ModelInfo[]> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`,
|
||||
{ signal: AbortSignal.timeout(TIMEOUT) },
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as {
|
||||
models?: Array<{ name: string; displayName?: string }>;
|
||||
};
|
||||
return (data.models ?? [])
|
||||
.map(m => ({
|
||||
id: m.name.replace('models/', ''),
|
||||
name: m.displayName ?? m.name,
|
||||
provider: 'gemini',
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch models for a specific provider.
|
||||
*/
|
||||
export async function fetchModelsForProvider(
|
||||
provider: string,
|
||||
): Promise<ModelInfo[]> {
|
||||
switch (provider) {
|
||||
case 'claude': return fetchClaudeModels();
|
||||
case 'codex': return fetchCodexModels();
|
||||
case 'ollama': return fetchOllamaModels();
|
||||
case 'gemini': return fetchGeminiModels();
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
107
ui-electrobun/src/bun/provider-scanner.ts
Normal file
107
ui-electrobun/src/bun/provider-scanner.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Provider scanner — detects which AI providers are available on this machine.
|
||||
*
|
||||
* Checks environment variables and CLI tool availability.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export interface ProviderScanResult {
|
||||
id: string;
|
||||
available: boolean;
|
||||
hasApiKey: boolean;
|
||||
hasCli: boolean;
|
||||
cliPath: string | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
function whichSync(bin: string): string | null {
|
||||
try {
|
||||
return execSync(`which ${bin}`, { encoding: 'utf8', timeout: 3000 }).trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getVersion(bin: string): string | null {
|
||||
try {
|
||||
const out = execSync(`${bin} --version`, { encoding: 'utf8', timeout: 5000 }).trim();
|
||||
// Extract first version-like string
|
||||
const match = out.match(/(\d+\.\d+[\w.-]*)/);
|
||||
return match ? match[1] : out.slice(0, 40);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanClaude(): Promise<ProviderScanResult> {
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
const cliPath = whichSync('claude');
|
||||
return {
|
||||
id: 'claude',
|
||||
available: hasApiKey || !!cliPath,
|
||||
hasApiKey,
|
||||
hasCli: !!cliPath,
|
||||
cliPath,
|
||||
version: cliPath ? getVersion('claude') : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanCodex(): Promise<ProviderScanResult> {
|
||||
const hasApiKey = !!process.env.OPENAI_API_KEY;
|
||||
const cliPath = whichSync('codex');
|
||||
return {
|
||||
id: 'codex',
|
||||
available: hasApiKey || !!cliPath,
|
||||
hasApiKey,
|
||||
hasCli: !!cliPath,
|
||||
cliPath,
|
||||
version: cliPath ? getVersion('codex') : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanOllama(): Promise<ProviderScanResult> {
|
||||
const cliPath = whichSync('ollama');
|
||||
let serverUp = false;
|
||||
try {
|
||||
const res = await fetch('http://localhost:11434/api/version', {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
serverUp = res.ok;
|
||||
} catch {
|
||||
// ECONNREFUSED or timeout — server not running
|
||||
}
|
||||
return {
|
||||
id: 'ollama',
|
||||
available: serverUp || !!cliPath,
|
||||
hasApiKey: false,
|
||||
hasCli: !!cliPath,
|
||||
cliPath,
|
||||
version: cliPath ? getVersion('ollama') : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanGemini(): Promise<ProviderScanResult> {
|
||||
const hasApiKey = !!process.env.GEMINI_API_KEY;
|
||||
return {
|
||||
id: 'gemini',
|
||||
available: hasApiKey,
|
||||
hasApiKey,
|
||||
hasCli: false,
|
||||
cliPath: null,
|
||||
version: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all known providers. Returns results for each.
|
||||
*/
|
||||
export async function scanAllProviders(): Promise<ProviderScanResult[]> {
|
||||
const [claude, codex, ollama, gemini] = await Promise.all([
|
||||
scanClaude(),
|
||||
scanCodex(),
|
||||
scanOllama(),
|
||||
scanGemini(),
|
||||
]);
|
||||
return [claude, codex, ollama, gemini];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue