diff --git a/apps/web/app/api/settings/auth/route.ts b/apps/web/app/api/settings/auth/route.ts new file mode 100644 index 000000000000..398b6e73b16a --- /dev/null +++ b/apps/web/app/api/settings/auth/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + upsertAuthProfile, + readConfig, + writeConfig, + applyAuthProfileConfig, +} from "@/lib/settings"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * Mapping from auth-method value (frontend) → { profileId, credentialProvider } + * Must exactly match what the CLI's onboard-auth.credentials.ts does for each + * provider. The gateway looks up credentials by profileId, so if they don't + * match, the key won't be found. + */ +const AUTH_METHOD_MAP: Record = { + // OpenAI + "openai-api-key": { profileId: "openai:default", provider: "openai" }, + // Anthropic + "apiKey": { profileId: "anthropic:default", provider: "anthropic" }, + // Google + "gemini-api-key": { profileId: "google:default", provider: "google" }, + // xAI + "xai-api-key": { profileId: "xai:default", provider: "xai" }, + // OpenRouter + "openrouter-api-key": { profileId: "openrouter:default", provider: "openrouter" }, + // Vercel AI Gateway + "ai-gateway-api-key": { profileId: "vercel-ai-gateway:default", provider: "vercel-ai-gateway" }, + // Moonshot + "moonshot-api-key": { profileId: "moonshot:default", provider: "moonshot" }, + "moonshot-api-key-cn": { profileId: "moonshot:default", provider: "moonshot" }, + // Kimi Coding + "kimi-code-api-key": { profileId: "kimi-coding:default", provider: "kimi-coding" }, + // Together + "together-api-key": { profileId: "together:default", provider: "together" }, + // Hugging Face + "huggingface-api-key": { profileId: "huggingface:default", provider: "huggingface" }, + // Venice + "venice-api-key": { profileId: "venice:default", provider: "venice" }, + // LiteLLM + "litellm-api-key": { profileId: "litellm:default", provider: "litellm" }, + // Synthetic + "synthetic-api-key": { profileId: "synthetic:default", provider: "synthetic" }, + // Custom + "custom-api-key": { profileId: "custom:default", provider: "custom" }, +}; + +/** + * POST /api/settings/auth + * + * Body: { provider: string, authMethod: string, apiKey: string } + * + * 1. Writes the API key to credentials.json (AuthProfileStore) – same as CLI. + * 2. Links the auth profile in openclaw.json – same as CLI. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { authMethod, apiKey } = body as { + provider?: string; + authMethod?: string; + apiKey?: string; + }; + + if (!authMethod) { + return NextResponse.json( + { error: "authMethod is required" }, + { status: 400 }, + ); + } + + if (!apiKey || apiKey.trim().length === 0) { + return NextResponse.json( + { error: "apiKey is required" }, + { status: 400 }, + ); + } + + const mapping = AUTH_METHOD_MAP[authMethod]; + if (!mapping) { + return NextResponse.json( + { error: `Unknown auth method: ${authMethod}` }, + { status: 400 }, + ); + } + + const trimmedKey = apiKey.trim(); + + // Step 1: Write credential to credentials.json (AuthProfileStore) + upsertAuthProfile({ + profileId: mapping.profileId, + credential: { + type: "api_key", + provider: mapping.provider, + key: trimmedKey, + }, + }); + + // Step 2: Link the auth profile in openclaw.json + const config = readConfig(); + const updatedConfig = applyAuthProfileConfig(config, { + profileId: mapping.profileId, + provider: mapping.provider, + mode: "api_key", + }); + writeConfig(updatedConfig); + + return NextResponse.json({ + ok: true, + profileId: mapping.profileId, + provider: mapping.provider, + }); + } catch (err) { + return NextResponse.json( + { error: `Failed to save auth config: ${String(err)}` }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/settings/config/route.ts b/apps/web/app/api/settings/config/route.ts new file mode 100644 index 000000000000..0091338800cd --- /dev/null +++ b/apps/web/app/api/settings/config/route.ts @@ -0,0 +1,98 @@ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { NextResponse } from "next/server"; +import { readAuthProfileStore } from "@/lib/settings"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** Resolve the openclaw.json config path. */ +function resolveConfigPath(): string { + const envPath = process.env.OPENCLAW_CONFIG; + if (envPath) { return envPath; } + return join(homedir(), ".openclaw", "openclaw.json"); +} + +/** Mask an API key for safe display: show first 4 + last 4 chars. */ +function maskKey(key: string | undefined): string | undefined { + if (!key) { return undefined; } + const trimmed = key.trim(); + if (trimmed.length <= 8) { return trimmed.length > 0 ? `${trimmed.slice(0, 2)}…` : undefined; } + return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}`; +} + +/** Deep-walk an object and mask any key named "apiKey", "token", "key", or "password". */ +function redactSensitiveFields(obj: unknown): unknown { + if (obj === null || obj === undefined) { return obj; } + if (typeof obj !== "object") { return obj; } + + if (Array.isArray(obj)) { + return obj.map(redactSensitiveFields); + } + + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if ( + (key === "apiKey" || key === "token" || key === "key" || key === "password" || key === "accessToken" || key === "refreshToken") && + typeof value === "string" + ) { + result[key] = maskKey(value); + } else if (typeof value === "object" && value !== null) { + result[key] = redactSensitiveFields(value); + } else { + result[key] = value; + } + } + return result; +} + +/** + * Summarize auth profiles for UI display. + * Returns a list of { profileId, provider, type, hasKey } objects. + */ +function summarizeAuthProfiles(): Array<{ + profileId: string; + provider: string; + type: string; + hasKey: boolean; +}> { + const store = readAuthProfileStore(); + return Object.entries(store.profiles).map(([profileId, profile]) => ({ + profileId, + provider: profile.provider, + type: profile.type, + hasKey: Boolean(profile.key || profile.token || profile.accessToken), + })); +} + +export async function GET() { + const configPath = resolveConfigPath(); + + if (!existsSync(configPath)) { + return NextResponse.json({ + exists: false, + config: {}, + configPath, + authProfiles: summarizeAuthProfiles(), + }); + } + + try { + const raw = readFileSync(configPath, "utf-8"); + const config = JSON.parse(raw); + const redacted = redactSensitiveFields(config); + + return NextResponse.json({ + exists: true, + config: redacted, + configPath, + authProfiles: summarizeAuthProfiles(), + }); + } catch (err) { + return NextResponse.json( + { exists: true, error: `Failed to parse config: ${String(err)}`, configPath }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/settings/copilot/poll/route.ts b/apps/web/app/api/settings/copilot/poll/route.ts new file mode 100644 index 000000000000..6896d6f18fe1 --- /dev/null +++ b/apps/web/app/api/settings/copilot/poll/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { upsertAuthProfile, applyAuthProfileConfig, readConfig, writeConfig } from "@/lib/settings"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const CLIENT_ID = "Iv1.b507a3d255788057"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { device_code } = body as { device_code?: string }; + + if (!device_code) { + return NextResponse.json({ error: "device_code is required" }, { status: 400 }); + } + + const res = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + if (!res.ok) { + const error = await res.text(); + return NextResponse.json({ error: `Polling failed: ${error}` }, { status: 500 }); + } + + const data = await res.json(); + + if (data.error) { + // data.error: authorization_pending, slow_down, expired_token, access_denied + return NextResponse.json(data); + } + + if (data.access_token) { + const profileId = "github-copilot:default"; + + // Store in credentials.json + upsertAuthProfile({ + profileId, + credential: { + type: "oauth", // CLI marks it as oauth + provider: "github-copilot", + accessToken: data.access_token, + }, + }); + + // Update openclaw.json + const config = readConfig(); + const updatedConfig = applyAuthProfileConfig(config, { + profileId, + provider: "github-copilot", + mode: "oauth", + }); + writeConfig(updatedConfig); + + return NextResponse.json({ ok: true, profileId }); + } + + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: `Polling failed: ${String(err)}` }, { status: 500 }); + } +} diff --git a/apps/web/app/api/settings/copilot/start/route.ts b/apps/web/app/api/settings/copilot/start/route.ts new file mode 100644 index 000000000000..1da9233a6db2 --- /dev/null +++ b/apps/web/app/api/settings/copilot/start/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const CLIENT_ID = "Iv1.b507a3d255788057"; // GitHub Copilot CLI Client ID + +export async function POST() { + try { + const res = await fetch("https://github.com/login/device/code", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + scope: "read:user", + }), + }); + + if (!res.ok) { + const error = await res.text(); + return NextResponse.json({ error: `Failed to start device flow: ${error}` }, { status: 500 }); + } + + const data = await res.json(); + // data: { device_code, user_code, verification_uri, expires_in, interval } + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: `Failed to start Copilot flow: ${String(err)}` }, { status: 500 }); + } +} diff --git a/apps/web/app/api/settings/model/route.ts b/apps/web/app/api/settings/model/route.ts new file mode 100644 index 000000000000..626eb7514417 --- /dev/null +++ b/apps/web/app/api/settings/model/route.ts @@ -0,0 +1,118 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function resolveConfigPath(): string { + const envPath = process.env.OPENCLAW_CONFIG; + if (envPath) {return envPath;} + return join(homedir(), ".openclaw", "openclaw.json"); +} + +function readConfig(configPath: string): Record { + if (!existsSync(configPath)) {return {};} + try { + return JSON.parse(readFileSync(configPath, "utf-8")); + } catch { + return {}; + } +} + +function writeConfig(configPath: string, config: Record): void { + const dir = dirname(configPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8"); +} + +/** + * POST /api/settings/model + * + * Body: { model: string } + * + * Updates agents.defaults.model in openclaw.json. + * This is the primary model used by the agent. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { model } = body as { model?: string }; + + if (!model || model.trim().length === 0) { + return NextResponse.json( + { error: "model is required (e.g. 'anthropic/claude-sonnet-4-5')" }, + { status: 400 }, + ); + } + + const configPath = resolveConfigPath(); + const config = readConfig(configPath); + + // Ensure agents.defaults exists + if (!config.agents || typeof config.agents !== "object") { + config.agents = {}; + } + const agents = config.agents as Record; + if (!agents.defaults || typeof agents.defaults !== "object") { + agents.defaults = {}; + } + const defaults = agents.defaults as Record; + + defaults.model = model.trim(); + + writeConfig(configPath, config); + + return NextResponse.json({ + ok: true, + model: model.trim(), + configPath, + }); + } catch (err) { + return NextResponse.json( + { error: `Failed to save model config: ${String(err)}` }, + { status: 500 }, + ); + } +} + +/** + * GET /api/settings/model + * + * Returns the current default model. + */ +export async function GET() { + const configPath = resolveConfigPath(); + + if (!existsSync(configPath)) { + return NextResponse.json({ model: null }); + } + + try { + const raw = readFileSync(configPath, "utf-8"); + const config = JSON.parse(raw); + const modelRaw = + (config as Record).agents && + typeof (config as Record).agents === "object" + ? ((config as Record).agents as Record).defaults && + typeof ((config as Record).agents as Record).defaults === "object" + ? (((config as Record).agents as Record).defaults as Record).model + : null + : null; + + // Normalize to string: could be string or { primary: string } + const modelStr = + typeof modelRaw === "string" + ? modelRaw + : (modelRaw && typeof modelRaw === "object" && "primary" in modelRaw) + ? String((modelRaw as any).primary) + : null; + + return NextResponse.json({ model: modelStr }); + } catch { + return NextResponse.json({ model: null }); + } +} diff --git a/apps/web/app/api/settings/oauth/callback/route.ts b/apps/web/app/api/settings/oauth/callback/route.ts new file mode 100644 index 000000000000..f060ba005b9b --- /dev/null +++ b/apps/web/app/api/settings/oauth/callback/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { upsertAuthProfile, applyAuthProfileConfig, readConfig, writeConfig } from "@/lib/settings"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + + const cookieStore = await cookies(); + const savedState = cookieStore.get("oauth_state")?.value; + const codeVerifier = cookieStore.get("oauth_code_verifier")?.value; + + if (!code || !state || state !== savedState || !codeVerifier) { + return new NextResponse("Invalid OAuth state or code verifier", { status: 400 }); + } + + try { + // Exchange code for tokens (Placeholder for Chutes) + const tokenRes = await fetch("https://chutes.ai/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: "ironclaw-web", + grant_type: "authorization_code", + code, + redirect_uri: "http://localhost:3000/api/settings/oauth/callback", + code_verifier: codeVerifier, + }), + }); + + if (!tokenRes.ok) { + const error = await tokenRes.text(); + return new NextResponse(`Token exchange failed: ${error}`, { status: 500 }); + } + + const tokens = await tokenRes.json(); + const profileId = "chutes:default"; + + // Store in credentials.json + upsertAuthProfile({ + profileId, + credential: { + type: "oauth", + provider: "chutes", + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + tokens.expires_in * 1000, + }, + }); + + // Update openclaw.json + const config = readConfig(); + const updatedConfig = applyAuthProfileConfig(config, { + profileId, + provider: "chutes", + mode: "oauth", + }); + writeConfig(updatedConfig); + + // Return success HTML that notifies the parent window + return new NextResponse( + ` + + +

Success! You can close this window.

+ + `, + { headers: { "Content-Type": "text/html" } } + ); + } catch (err) { + return new NextResponse(`OAuth callback failed: ${String(err)}`, { status: 500 }); + } +} diff --git a/apps/web/app/api/settings/oauth/url/route.ts b/apps/web/app/api/settings/oauth/url/route.ts new file mode 100644 index 000000000000..f69a422773a9 --- /dev/null +++ b/apps/web/app/api/settings/oauth/url/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import crypto from "node:crypto"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const REDIRECT_URI = "http://localhost:3000/api/settings/oauth/callback"; + +export async function POST(request: NextRequest) { + const body = await request.json(); + const { provider } = body as { provider?: string }; + + if (!provider) { + return NextResponse.json({ error: "Provider is required" }, { status: 400 }); + } + + // Chutes OAuth implementation example + if (provider === "chutes") { + const state = crypto.randomBytes(16).toString("hex"); + const codeVerifier = crypto.randomBytes(32).toString("hex"); + const codeChallenge = crypto + .createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + + const cookieStore = await cookies(); + cookieStore.set("oauth_state", state, { httpOnly: true, secure: process.env.NODE_ENV === "production" }); + cookieStore.set("oauth_code_verifier", codeVerifier, { httpOnly: true, secure: process.env.NODE_ENV === "production" }); + + const authUrl = new URL("https://chutes.ai/oauth/authorize"); + authUrl.searchParams.set("client_id", "ironclaw-web"); // Placeholder + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("redirect_uri", REDIRECT_URI); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set("scope", "openid profile email"); + + return NextResponse.json({ url: authUrl.toString() }); + } + + return NextResponse.json({ error: "Unsupported OAuth provider" }, { status: 400 }); +} diff --git a/apps/web/app/api/settings/providers/route.ts b/apps/web/app/api/settings/providers/route.ts new file mode 100644 index 000000000000..5406d36a3a11 --- /dev/null +++ b/apps/web/app/api/settings/providers/route.ts @@ -0,0 +1,175 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * Hardcoded provider/auth-method definitions, mirroring + * src/commands/auth-choice-options.ts :: AUTH_CHOICE_GROUP_DEFS. + * + * We duplicate the data here instead of importing because the web app + * is a separate Next.js build that cannot import from the root `src/`. + * + * Auth type guide (must match CLI behaviour): + * "api-key" – simple API key text input + * "token" – paste a setup-token (e.g. Anthropic) + * "oauth" – standard web OAuth (redirect flow) – only Chutes today + * "device-flow" – GitHub device-flow (display code, poll for auth) + * "unsupported" – requires CLI-only infra (plugin auth, custom OAuth) + */ + +export type AuthMethod = { + value: string; + label: string; + hint?: string; + type: "api-key" | "oauth" | "token" | "device-flow" | "unsupported"; + defaultModel?: string; +}; + +export type ProviderGroup = { + value: string; + label: string; + hint?: string; + methods: AuthMethod[]; +}; + +const PROVIDERS: ProviderGroup[] = [ + { + value: "openai", + label: "OpenAI", + hint: "Codex OAuth + API key", + methods: [ + // OpenAI Codex uses a custom OAuth flow (loginOpenAICodexOAuth) that + // requires a local HTTP server callback – not doable in web UI. + { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)", hint: "Requires CLI (custom OAuth flow)", type: "unsupported" }, + { value: "openai-api-key", label: "OpenAI API key", type: "api-key", defaultModel: "openai/gpt-4.1" }, + ], + }, + { + value: "anthropic", + label: "Anthropic", + hint: "setup-token + API key", + methods: [ + { value: "token", label: "Anthropic token (paste setup-token)", hint: "run `claude setup-token` elsewhere, then paste the token here", type: "token" }, + { value: "apiKey", label: "Anthropic API key", type: "api-key", defaultModel: "anthropic/claude-sonnet-4-5" }, + ], + }, + { + value: "chutes", + label: "Chutes", + hint: "OAuth", + methods: [ + { value: "chutes", label: "Chutes (OAuth)", type: "oauth" }, + ], + }, + { + value: "google", + label: "Google", + hint: "Gemini API key + OAuth", + methods: [ + { value: "gemini-api-key", label: "Google Gemini API key", type: "api-key", defaultModel: "google/gemini-2.5-pro" }, + // These use plugin-based auth (applyAuthChoicePluginProvider) which + // requires the full plugin runtime – not available in the web UI. + { value: "google-antigravity", label: "Google Antigravity OAuth", hint: "Requires CLI (plugin auth)", type: "unsupported" }, + { value: "google-gemini-cli", label: "Google Gemini CLI OAuth", hint: "Requires CLI (plugin auth)", type: "unsupported" }, + ], + }, + { + value: "xai", + label: "xAI (Grok)", + hint: "API key", + methods: [ + { value: "xai-api-key", label: "xAI (Grok) API key", type: "api-key", defaultModel: "xai/grok-3" }, + ], + }, + { + value: "openrouter", + label: "OpenRouter", + hint: "API key", + methods: [ + { value: "openrouter-api-key", label: "OpenRouter API key", type: "api-key", defaultModel: "openrouter/anthropic/claude-sonnet-4-5" }, + ], + }, + { + value: "copilot", + label: "Copilot", + hint: "GitHub + local proxy", + methods: [ + // GitHub Copilot in CLI uses githubCopilotLoginCommand (device flow). + // We replicate this with our /api/settings/copilot/start+poll endpoints. + { value: "github-copilot", label: "GitHub Copilot (GitHub device login)", hint: "Uses GitHub device flow", type: "device-flow", defaultModel: "github-copilot/gpt-4o" }, + // Copilot Proxy uses plugin-based auth – not available in web UI. + { value: "copilot-proxy", label: "Copilot Proxy (local)", hint: "Requires CLI (plugin auth)", type: "unsupported" }, + ], + }, + { + value: "ai-gateway", + label: "Vercel AI Gateway", + hint: "API key", + methods: [ + { value: "ai-gateway-api-key", label: "Vercel AI Gateway API key", type: "api-key" }, + ], + }, + { + value: "moonshot", + label: "Moonshot AI (Kimi K2.5)", + hint: "Kimi K2.5 + Kimi Coding", + methods: [ + { value: "moonshot-api-key", label: "Kimi API key (.ai)", type: "api-key" }, + { value: "moonshot-api-key-cn", label: "Kimi API key (.cn)", type: "api-key" }, + { value: "kimi-code-api-key", label: "Kimi Code API key (subscription)", type: "api-key" }, + ], + }, + { + value: "together", + label: "Together AI", + hint: "API key", + methods: [ + { value: "together-api-key", label: "Together AI API key", hint: "Access to Llama, DeepSeek, Qwen, and more open models", type: "api-key" }, + ], + }, + { + value: "huggingface", + label: "Hugging Face", + hint: "Inference API (HF token)", + methods: [ + { value: "huggingface-api-key", label: "Hugging Face API key (HF token)", hint: "Inference Providers — OpenAI-compatible chat", type: "api-key" }, + ], + }, + { + value: "venice", + label: "Venice AI", + hint: "Privacy-focused (uncensored models)", + methods: [ + { value: "venice-api-key", label: "Venice AI API key", hint: "Privacy-focused inference (uncensored models)", type: "api-key" }, + ], + }, + { + value: "litellm", + label: "LiteLLM", + hint: "Unified LLM gateway (100+ providers)", + methods: [ + { value: "litellm-api-key", label: "LiteLLM API key", hint: "Unified gateway for 100+ LLM providers", type: "api-key" }, + ], + }, + { + value: "synthetic", + label: "Synthetic", + hint: "Anthropic-compatible (multi-model)", + methods: [ + { value: "synthetic-api-key", label: "Synthetic API key", type: "api-key" }, + ], + }, + { + value: "custom", + label: "Custom Provider", + hint: "Any OpenAI or Anthropic compatible endpoint", + methods: [ + { value: "custom-api-key", label: "Custom Provider", type: "api-key" }, + ], + }, +]; + +export async function GET() { + return NextResponse.json({ providers: PROVIDERS }); +} diff --git a/apps/web/app/api/settings/token/normalize.ts b/apps/web/app/api/settings/token/normalize.ts new file mode 100644 index 000000000000..054bdb804dad --- /dev/null +++ b/apps/web/app/api/settings/token/normalize.ts @@ -0,0 +1,25 @@ +/** + * Normalize user API key/token input by trimming, removing shell syntax, and unquoting. + */ +export function normalizeApiKeyInput(raw: string): string { + const trimmed = String(raw ?? "").trim(); + if (!trimmed) { + return ""; + } + + // Handle shell-style assignments: export KEY="value" or KEY=value + const assignmentMatch = trimmed.match(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/); + const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed; + + const unquoted = + valuePart.length >= 2 && + ((valuePart.startsWith('"') && valuePart.endsWith('"')) || + (valuePart.startsWith("'") && valuePart.endsWith("'")) || + (valuePart.startsWith("`") && valuePart.endsWith("`"))) + ? valuePart.slice(1, -1) + : valuePart; + + const withoutSemicolon = unquoted.endsWith(";") ? unquoted.slice(0, -1) : unquoted; + + return withoutSemicolon.trim(); +} diff --git a/apps/web/app/api/settings/token/route.ts b/apps/web/app/api/settings/token/route.ts new file mode 100644 index 000000000000..0a3d29c80ec0 --- /dev/null +++ b/apps/web/app/api/settings/token/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { upsertAuthProfile, applyAuthProfileConfig, readConfig, writeConfig } from "@/lib/settings"; + +/** + * Normalize user API key/token input by trimming, removing shell syntax, and unquoting. + */ +function normalizeApiKeyInput(raw: string): string { + const trimmed = String(raw ?? "").trim(); + if (!trimmed) { + return ""; + } + + // Handle shell-style assignments: export KEY="value" or KEY=value + const assignmentMatch = trimmed.match(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/); + const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed; + + const unquoted = + valuePart.length >= 2 && + ((valuePart.startsWith('"') && valuePart.endsWith('"')) || + (valuePart.startsWith("'") && valuePart.endsWith("'")) || + (valuePart.startsWith("`") && valuePart.endsWith("`"))) + ? valuePart.slice(1, -1) + : valuePart; + + const withoutSemicolon = unquoted.endsWith(";") ? unquoted.slice(0, -1) : unquoted; + + return withoutSemicolon.trim(); +} + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * POST /api/settings/token + * + * Body: { provider: string, authMethod: string, token: string, name?: string } + * + * Handles setup-token or paste-token flows (e.g., Anthropic `claude setup-token`). + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { provider, authMethod, token, name } = body as { + provider?: string; + authMethod?: string; + token?: string; + name?: string; + }; + + if (!provider || !authMethod || !token) { + return NextResponse.json( + { error: "provider, authMethod, and token are required" }, + { status: 400 } + ); + } + + const normalizedToken = normalizeApiKeyInput(token); + if (!normalizedToken) { + return NextResponse.json( + { error: "Invalid token" }, + { status: 400 } + ); + } + + // Build profile ID (e.g., "anthropic:default" or "anthropic:work") + const profileName = name?.trim() || "default"; + const profileId = `${provider}:${profileName}`; + + // Upsert to credentials.json + upsertAuthProfile({ + profileId, + credential: { + type: "token", + provider, + token: normalizedToken, + }, + }); + + // Update openclaw.json to reference the profile + const config = readConfig(); + const updatedConfig = applyAuthProfileConfig(config, { + profileId, + provider, + mode: "token", + }); + writeConfig(updatedConfig); + + return NextResponse.json({ + ok: true, + profileId, + provider, + }); + } catch (err) { + return NextResponse.json( + { error: `Failed to save token: ${String(err)}` }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index e622be6d7dbd..4e9fe3d712ea 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -18,6 +18,48 @@ import { } from "./file-picker-modal"; import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor"; +declare global { + interface Window { + SpeechRecognition: new () => SpeechRecognition; + webkitSpeechRecognition: new () => SpeechRecognition; + } +} + +interface SpeechRecognitionEvent extends Event { + results: SpeechRecognitionResultList; + resultIndex: number; +} + +interface SpeechRecognitionResultList { + length: number; + item(index: number): SpeechRecognitionResult; + [index: number]: SpeechRecognitionResult; +} + +interface SpeechRecognitionResult { + length: number; + item(index: number): SpeechRecognitionAlternative; + [index: number]: SpeechRecognitionAlternative; + isFinal: boolean; +} + +interface SpeechRecognitionAlternative { + transcript: string; + confidence: number; +} + +interface SpeechRecognition extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + onstart: (() => void) | null; + onresult: ((event: SpeechRecognitionEvent) => void) | null; + onerror: ((event: Event) => void) | null; + onend: (() => void) | null; + start(): void; + stop(): void; +} + // ── Attachment types & helpers ── type AttachedFile = { @@ -503,6 +545,10 @@ export const ChatPanel = forwardRef( const [showFilePicker, setShowFilePicker] = useState(false); + // ── Voice input state ── + const [isListening, setIsListening] = useState(false); + const recognitionRef = useRef(null); + // ── Reconnection state ── const [isReconnecting, setIsReconnecting] = useState(false); const reconnectAbortRef = useRef(null); @@ -1280,6 +1326,55 @@ export const ChatPanel = forwardRef( [], ); + // ── Voice input handler ── + const toggleVoiceInput = useCallback(() => { + if (isListening) { + recognitionRef.current?.stop(); + setIsListening(false); + return; + } + + const SpeechRecognitionAPI = + window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognitionAPI) { + console.warn("Speech recognition not supported in this browser"); + return; + } + + const recognition = new SpeechRecognitionAPI(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = "en-US"; + + recognition.onstart = () => { + setIsListening(true); + }; + + recognition.onresult = (event) => { + let finalTranscript = ""; + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript; + if (event.results[i].isFinal) { + finalTranscript += transcript; + } + } + if (finalTranscript) { + editorRef.current?.insertText(finalTranscript + " "); + } + }; + + recognition.onerror = () => { + setIsListening(false); + }; + + recognition.onend = () => { + setIsListening(false); + }; + + recognitionRef.current = recognition; + recognition.start(); + }, [isListening]); + // ── Status label ── const statusLabel = loadingSession @@ -1756,6 +1851,42 @@ export const ChatPanel = forwardRef( )} + + )} +

+ Settings +

+ {selectedProvider && ( + + / {selectedProvider.label} + {selectedMethod ? ` / ${selectedMethod.label}` : ""} + + )} + + + {/* Content */} +
+
+ {loading ? ( +
+ Loading settings… +
+ ) : ( + <> + {/* Current Configuration */} + {step === "provider" && (currentModel || authProfiles.length > 0) && ( +
+
+ Current Configuration +
+
+ {currentModel && ( +
+ Model:{" "} + + {typeof currentModel === "string" ? currentModel : String((currentModel as Record)?.primary ?? "none")} + +
+ )} + {authProfiles.length > 0 && ( +
+ Auth:{" "} + + {authProfiles.map((p) => p.provider).join(", ")} + +
+ )} +
+
+ )} + + {/* Step 1: Provider Selection */} + {step === "provider" && ( +
+

+ Select Provider +

+
+ {providers.map((provider) => ( + + ))} +
+
+ )} + + {/* Step 2: Auth Method Selection (only if provider has multiple) */} + {step === "method" && selectedProvider && ( +
+

+ Choose Authentication Method +

+
+ {selectedProvider.methods.map((method) => { + const isUnsupported = method.type === "unsupported"; + return ( + + ); + })} +
+
+ )} + + {/* Step 3: Configure */} + {step === "configure" && selectedMethod && ( +
+ {/* OAuth Flow */} + {selectedMethod.type === "oauth" && ( +
+
+
+ +
+

+ Connect {selectedProvider?.label} +

+

+ {selectedMethod.hint || "Authorize OpenClaw to access your account."} +

+
+ + +
+ )} + + {/* Device Flow (GitHub Copilot) */} + {selectedMethod.type === "device-flow" && ( +
+ {!userCode ? ( + + ) : ( +
+

+ Enter this code on GitHub to authorize Copilot: +

+
+ {userCode} +
+ + Open GitHub Activation + +

+ Waiting for you to authorize in the browser... +

+
+ )} +
+ )} + + {/* Token Flow (Anthropic, etc.) */} + {selectedMethod.type === "token" && ( + <> +
+

+ Setup Token +

+