From 4e81eaa60bb171db4320b9a71bec5a0147a19242 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Sun, 8 Feb 2026 20:03:18 +0800 Subject: [PATCH] feat: fix slash commands, add badge UI, multi-provider management Slash commands: - Fix skills not appearing (removed non-existent `enabled` filter) - Add badge/capsule UI for prompt-type commands (/compact, /review, etc.) - SDK-native commands sent directly; CLI-only commands expanded to prompts - /clear now resets SDK session and clears database messages - /cost shows actual aggregated token usage statistics - Keyboard: Backspace/Escape to remove badge, arrow keys auto-scroll popover - Add /memory command for editing CLAUDE.md Multi-provider management: - CRUD API for API providers with SQLite storage - ProviderManager UI with quick presets (GLM, Kimi, MiniMax, etc.) - Dynamic model labels based on active provider - Environment variable injection with conflict resolution Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 4 +- package.json | 2 +- src/app/api/chat/sessions/[id]/route.ts | 5 +- src/app/api/providers/[id]/activate/route.ts | 52 +++ src/app/api/providers/[id]/route.ts | 93 +++++ src/app/api/providers/route.ts | 47 +++ src/app/settings/page.tsx | 108 +---- src/components/chat/ChatView.tsx | 44 +- src/components/chat/MessageInput.tsx | 260 ++++++++++-- src/components/settings/ProviderForm.tsx | 318 +++++++++++++++ src/components/settings/ProviderManager.tsx | 408 +++++++++++++++++++ src/lib/claude-client.ts | 49 ++- src/lib/db.ts | 146 ++++++- src/types/index.ts | 45 ++ 14 files changed, 1436 insertions(+), 145 deletions(-) create mode 100644 src/app/api/providers/[id]/activate/route.ts create mode 100644 src/app/api/providers/[id]/route.ts create mode 100644 src/app/api/providers/route.ts create mode 100644 src/components/settings/ProviderForm.tsx create mode 100644 src/components/settings/ProviderManager.tsx diff --git a/package-lock.json b/package-lock.json index e0c2016..c09b6ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.2.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.2.3", + "version": "0.3.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.33", "@hugeicons/core-free-icons": "^3.1.1", diff --git a/package.json b/package.json index e09c10a..f950011 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.2.3", + "version": "0.3.0", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/src/app/api/chat/sessions/[id]/route.ts b/src/app/api/chat/sessions/[id]/route.ts index 04262e3..5729cd7 100644 --- a/src/app/api/chat/sessions/[id]/route.ts +++ b/src/app/api/chat/sessions/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server'; -import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode } from '@/lib/db'; +import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode, clearSessionMessages } from '@/lib/db'; export async function GET( _request: NextRequest, @@ -40,6 +40,9 @@ export async function PATCH( if (body.mode) { updateSessionMode(id, body.mode); } + if (body.clear_messages) { + clearSessionMessages(id); + } const updated = getSession(id); return Response.json({ session: updated }); diff --git a/src/app/api/providers/[id]/activate/route.ts b/src/app/api/providers/[id]/activate/route.ts new file mode 100644 index 0000000..2152fd6 --- /dev/null +++ b/src/app/api/providers/[id]/activate/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { activateProvider, deactivateAllProviders, getProvider } from '@/lib/db'; +import type { ErrorResponse } from '@/types'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +export async function POST(request: NextRequest, context: RouteContext) { + const { id } = await context.params; + + try { + // Parse optional body — { active: boolean }, defaults to true + let active = true; + try { + const body = await request.json(); + if (typeof body.active === 'boolean') { + active = body.active; + } + } catch { + // No body or invalid JSON — default to activate + } + + if (!active) { + deactivateAllProviders(); + return NextResponse.json({ success: true, active: false }); + } + + const existing = getProvider(id); + if (!existing) { + return NextResponse.json( + { error: 'Provider not found' }, + { status: 404 } + ); + } + + const activated = activateProvider(id); + if (!activated) { + return NextResponse.json( + { error: 'Failed to activate provider' }, + { status: 500 } + ); + } + + return NextResponse.json({ success: true, active: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to activate provider' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts new file mode 100644 index 0000000..d421959 --- /dev/null +++ b/src/app/api/providers/[id]/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getProvider, updateProvider, deleteProvider } from '@/lib/db'; +import type { ProviderResponse, ErrorResponse, UpdateProviderRequest, ApiProvider } from '@/types'; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +function maskApiKey(provider: ApiProvider): ApiProvider { + let maskedKey = provider.api_key; + if (maskedKey && maskedKey.length > 8) { + maskedKey = '***' + maskedKey.slice(-8); + } + return { ...provider, api_key: maskedKey }; +} + +export async function GET(_request: NextRequest, context: RouteContext) { + const { id } = await context.params; + + try { + const provider = getProvider(id); + if (!provider) { + return NextResponse.json( + { error: 'Provider not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ provider: maskApiKey(provider) }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get provider' }, + { status: 500 } + ); + } +} + +export async function PUT(request: NextRequest, context: RouteContext) { + const { id } = await context.params; + + try { + const body: UpdateProviderRequest = await request.json(); + + const existing = getProvider(id); + if (!existing) { + return NextResponse.json( + { error: 'Provider not found' }, + { status: 404 } + ); + } + + // If api_key starts with ***, the client sent back a masked value — don't update it + if (body.api_key && body.api_key.startsWith('***')) { + delete body.api_key; + } + + const updated = updateProvider(id, body); + if (!updated) { + return NextResponse.json( + { error: 'Failed to update provider' }, + { status: 500 } + ); + } + + return NextResponse.json({ provider: maskApiKey(updated) }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update provider' }, + { status: 500 } + ); + } +} + +export async function DELETE(_request: NextRequest, context: RouteContext) { + const { id } = await context.params; + + try { + const deleted = deleteProvider(id); + if (!deleted) { + return NextResponse.json( + { error: 'Provider not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete provider' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts new file mode 100644 index 0000000..548e516 --- /dev/null +++ b/src/app/api/providers/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAllProviders, createProvider } from '@/lib/db'; +import type { ProvidersResponse, ProviderResponse, ErrorResponse, CreateProviderRequest, ApiProvider } from '@/types'; + +function maskApiKey(provider: ApiProvider): ApiProvider { + let maskedKey = provider.api_key; + if (maskedKey && maskedKey.length > 8) { + maskedKey = '***' + maskedKey.slice(-8); + } + return { ...provider, api_key: maskedKey }; +} + +export async function GET() { + try { + const providers = getAllProviders().map(maskApiKey); + return NextResponse.json({ providers }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get providers' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body: CreateProviderRequest = await request.json(); + + if (!body.name) { + return NextResponse.json( + { error: 'Missing required field: name' }, + { status: 400 } + ); + } + + const provider = createProvider(body); + return NextResponse.json( + { provider: maskApiKey(provider) }, + { status: 201 } + ); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create provider' }, + { status: 500 } + ); + } +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index d60e5bf..bb60cac 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -25,6 +25,7 @@ import { SlidersHorizontalIcon, Loading02Icon, } from "@hugeicons/core-free-icons"; +import { ProviderManager } from "@/components/settings/ProviderManager"; interface SettingsData { [key: string]: unknown; @@ -60,110 +61,7 @@ export default function SettingsPage() { ); } -// --- API Configuration Section (CodePilot app settings, stored in SQLite) --- -function ApiConfigSection() { - const [token, setToken] = useState(""); - const [baseUrl, setBaseUrl] = useState(""); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [status, setStatus] = useState<"idle" | "saved" | "error">("idle"); - - useEffect(() => { - fetch("/api/settings/app") - .then((r) => r.json()) - .then((data) => { - const s = data.settings || {}; - setToken(s.anthropic_auth_token || ""); - setBaseUrl(s.anthropic_base_url || ""); - }) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); - - const handleSave = async () => { - setSaving(true); - setStatus("idle"); - try { - const res = await fetch("/api/settings/app", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - settings: { - anthropic_auth_token: token, - anthropic_base_url: baseUrl, - }, - }), - }); - if (res.ok) { - setStatus("saved"); - setTimeout(() => setStatus("idle"), 2000); - } else { - setStatus("error"); - } - } catch { - setStatus("error"); - } finally { - setSaving(false); - } - }; - - if (loading) return null; - - return ( -
-
- -

- Optional. Configure a custom Anthropic-compatible API for Claude Code. - Leave empty to use the default authentication (claude login / shell environment). -

-
-
-
- - setBaseUrl(e.target.value)} - className="mt-1 font-mono text-sm" - /> -
-
- - setToken(e.target.value)} - className="mt-1 font-mono text-sm" - /> -
-
-
- - {status === "saved" && ( - Saved - )} - {status === "error" && ( - Failed to save - )} -
-
- ); -} +// ApiConfigSection removed — replaced by ProviderManager component // --- Claude CLI Settings Section (manages ~/.claude/settings.json) --- function SettingsPageInner() { @@ -280,7 +178,7 @@ function SettingsPageInner() {
- + {loading ? (
diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 23c9b07..a1b54da 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -389,7 +389,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo id: 'cmd-' + Date.now(), session_id: sessionId, role: 'assistant', - content: `## Available Commands\n\n- **/help** - Show this help message\n- **/clear** - Clear conversation history\n- **/compact** - Compress conversation context\n- **/cost** - Show token usage statistics\n- **/doctor** - Check system health\n- **/init** - Initialize CLAUDE.md\n- **/review** - Start code review\n- **/terminal-setup** - Configure terminal\n\n**Tips:**\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`, + content: `## Available Commands\n\n### Instant Commands\n- **/help** — Show this help message\n- **/clear** — Clear conversation history\n- **/cost** — Show token usage statistics\n\n### Prompt Commands (shown as badge, add context then send)\n- **/compact** — Compress conversation context\n- **/doctor** — Diagnose project health\n- **/init** — Initialize CLAUDE.md for project\n- **/review** — Review code quality\n- **/terminal-setup** — Configure terminal settings\n- **/memory** — Edit project memory file\n\n### Custom Skills\nSkills from \`~/.claude/commands/\` and project \`.claude/commands/\` are also available via \`/\`.\n\n**Tips:**\n- Type \`/\` to browse commands and skills\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`, created_at: new Date().toISOString(), token_usage: null, }; @@ -398,13 +398,52 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo } case '/clear': setMessages([]); + // Also clear database messages and reset SDK session + if (sessionId) { + fetch(`/api/chat/sessions/${sessionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clear_messages: true }), + }).catch(() => { /* silent */ }); + } break; case '/cost': { + // Aggregate token usage from all messages in this session + let totalInput = 0; + let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheCreation = 0; + let totalCost = 0; + let turnCount = 0; + + for (const msg of messages) { + if (msg.token_usage) { + try { + const usage = typeof msg.token_usage === 'string' ? JSON.parse(msg.token_usage) : msg.token_usage; + totalInput += usage.input_tokens || 0; + totalOutput += usage.output_tokens || 0; + totalCacheRead += usage.cache_read_input_tokens || 0; + totalCacheCreation += usage.cache_creation_input_tokens || 0; + if (usage.cost_usd) totalCost += usage.cost_usd; + turnCount++; + } catch { /* skip */ } + } + } + + const totalTokens = totalInput + totalOutput; + let content: string; + + if (turnCount === 0) { + content = `## Token Usage\n\nNo token usage data yet. Send a message first.`; + } else { + content = `## Token Usage\n\n| Metric | Count |\n|--------|-------|\n| Input tokens | ${totalInput.toLocaleString()} |\n| Output tokens | ${totalOutput.toLocaleString()} |\n| Cache read | ${totalCacheRead.toLocaleString()} |\n| Cache creation | ${totalCacheCreation.toLocaleString()} |\n| **Total tokens** | **${totalTokens.toLocaleString()}** |\n| Turns | ${turnCount} |${totalCost > 0 ? `\n| **Estimated cost** | **$${totalCost.toFixed(4)}** |` : ''}`; + } + const costMessage: Message = { id: 'cmd-' + Date.now(), session_id: sessionId, role: 'assistant', - content: `## Token Usage\n\nToken usage tracking is available after sending messages. Check the token count displayed at the bottom of each assistant response.`, + content, created_at: new Date().toISOString(), token_usage: null, }; @@ -412,6 +451,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo break; } default: + // This shouldn't be reached since non-immediate commands are handled via badge sendMessage(command); } }, [sessionId, sendMessage]); diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index 16c1e53..6d3c6a5 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -44,19 +44,36 @@ interface PopoverItem { value: string; description?: string; builtIn?: boolean; + immediate?: boolean; +} + +interface CommandBadge { + command: string; + label: string; + description: string; + isSkill: boolean; } type PopoverMode = 'file' | 'skill' | null; +// Expansion prompts for CLI-only commands (not natively supported by SDK). +// SDK-native commands (/compact, /init, /review) are sent as-is — the SDK handles them directly. +const COMMAND_PROMPTS: Record = { + '/doctor': 'Run diagnostic checks on this project. Check system health, dependencies, configuration files, and report any issues.', + '/terminal-setup': 'Help me configure my terminal for optimal use with Claude Code. Check current setup and suggest improvements.', + '/memory': 'Show the current CLAUDE.md project memory file and help me review or edit it.', +}; + const BUILT_IN_COMMANDS: PopoverItem[] = [ - { label: 'help', value: '/help', description: 'Show help information', builtIn: true }, - { label: 'clear', value: '/clear', description: 'Clear conversation', builtIn: true }, + { label: 'help', value: '/help', description: 'Show available commands and tips', builtIn: true, immediate: true }, + { label: 'clear', value: '/clear', description: 'Clear conversation history', builtIn: true, immediate: true }, + { label: 'cost', value: '/cost', description: 'Show token usage statistics', builtIn: true, immediate: true }, { label: 'compact', value: '/compact', description: 'Compress conversation context', builtIn: true }, - { label: 'cost', value: '/cost', description: 'Show token usage', builtIn: true }, - { label: 'doctor', value: '/doctor', description: 'Check system health', builtIn: true }, - { label: 'init', value: '/init', description: 'Initialize CLAUDE.md', builtIn: true }, - { label: 'review', value: '/review', description: 'Code review', builtIn: true }, - { label: 'terminal-setup', value: '/terminal-setup', description: 'Terminal configuration', builtIn: true }, + { label: 'doctor', value: '/doctor', description: 'Diagnose project health', builtIn: true }, + { label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', builtIn: true }, + { label: 'review', value: '/review', description: 'Review code quality', builtIn: true }, + { label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', builtIn: true }, + { label: 'memory', value: '/memory', description: 'Edit project memory file', builtIn: true }, ]; interface ModeOption { @@ -72,12 +89,62 @@ const MODE_OPTIONS: ModeOption[] = [ { value: 'ask', label: 'Ask', icon: HelpCircleIcon, description: 'Answer questions only' }, ]; -const MODEL_OPTIONS = [ +// Default Claude model options — labels are dynamically overridden by active provider +const DEFAULT_MODEL_OPTIONS = [ { value: 'sonnet', label: 'Sonnet 4.5' }, { value: 'opus', label: 'Opus 4.6' }, { value: 'haiku', label: 'Haiku 4.5' }, ]; +// Provider-specific model label mappings (alias → display name) +const PROVIDER_MODEL_LABELS: Record> = { + // GLM Coding Plan (Z.AI / 智谱) + 'https://api.z.ai/api/anthropic': { + sonnet: 'GLM-4.7', + opus: 'GLM-4.7', + haiku: 'GLM-4.5-Air', + }, + 'https://open.bigmodel.cn/api/anthropic': { + sonnet: 'GLM-4.7', + opus: 'GLM-4.7', + haiku: 'GLM-4.5-Air', + }, + // Kimi Coding Plan + 'https://api.kimi.com/coding/': { + sonnet: 'Kimi K2.5', + opus: 'Kimi K2.5', + haiku: 'Kimi K2.5', + }, + // Moonshot Open Platform + 'https://api.moonshot.ai/anthropic': { + sonnet: 'Kimi K2.5', + opus: 'Kimi K2.5', + haiku: 'Kimi K2.5', + }, + 'https://api.moonshot.cn/anthropic': { + sonnet: 'Kimi K2.5', + opus: 'Kimi K2.5', + haiku: 'Kimi K2.5', + }, + // MiniMax Coding Plan + 'https://api.minimaxi.com/anthropic': { + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + haiku: 'MiniMax-M2.1', + }, + 'https://api.minimax.io/anthropic': { + sonnet: 'MiniMax-M2.1', + opus: 'MiniMax-M2.1', + haiku: 'MiniMax-M2.1', + }, + // OpenRouter — keeps Claude names, provider handles routing + 'https://openrouter.ai/api': { + sonnet: 'Sonnet 4.5', + opus: 'Opus 4.6', + haiku: 'Haiku 4.5', + }, +}; + export function MessageInput({ onSend, onCommand, @@ -106,6 +173,35 @@ export function MessageInput({ const [modeMenuOpen, setModeMenuOpen] = useState(false); const [modelMenuOpen, setModelMenuOpen] = useState(false); const [inputValue, setInputValue] = useState(''); + const [badge, setBadge] = useState(null); + const [activeProviderBaseUrl, setActiveProviderBaseUrl] = useState(null); + const [activeProviderName, setActiveProviderName] = useState(null); + + // Fetch active provider to adapt model labels + useEffect(() => { + fetch('/api/providers') + .then((r) => r.json()) + .then((data) => { + const active = (data.providers || []).find((p: { is_active: number }) => p.is_active === 1); + if (active) { + setActiveProviderBaseUrl(active.base_url || null); + setActiveProviderName(active.name || null); + } else { + setActiveProviderBaseUrl(null); + setActiveProviderName(null); + } + }) + .catch(() => {}); + }, []); + + // Compute model options based on active provider + const MODEL_OPTIONS = DEFAULT_MODEL_OPTIONS.map((opt) => { + if (activeProviderBaseUrl && PROVIDER_MODEL_LABELS[activeProviderBaseUrl]) { + const label = PROVIDER_MODEL_LABELS[activeProviderBaseUrl][opt.value]; + if (label) return { ...opt, label }; + } + return opt; + }); // Fetch files for @ mention const fetchFiles = useCallback(async (filter: string) => { @@ -144,7 +240,7 @@ export function MessageInput({ const data = await res.json(); const skills = data.skills || []; apiSkills = skills - .filter((s: { name: string; enabled: boolean }) => s.enabled && s.name.toLowerCase().includes(filter.toLowerCase())) + .filter((s: { name: string }) => s.name.toLowerCase().includes(filter.toLowerCase())) .map((s: { name: string; description: string }) => ({ label: s.name, value: `/${s.name}`, @@ -168,23 +264,44 @@ export function MessageInput({ setTriggerPos(null); }, []); + // Remove active badge + const removeBadge = useCallback(() => { + setBadge(null); + setTimeout(() => textareaRef.current?.focus(), 0); + }, []); + // Insert selected item const insertItem = useCallback((item: PopoverItem) => { if (triggerPos === null) return; - // Built-in commands: execute immediately - if (item.builtIn && onCommand) { + // Immediate built-in commands: execute right away + if (item.builtIn && item.immediate && onCommand) { setInputValue(''); closePopover(); onCommand(item.value); return; } + // Non-immediate commands (prompt-based built-ins and skills): show as badge + if (popoverMode === 'skill') { + setBadge({ + command: item.value, + label: item.label, + description: item.description || '', + isSkill: !item.builtIn, + }); + setInputValue(''); + closePopover(); + setTimeout(() => textareaRef.current?.focus(), 0); + return; + } + + // File mention: insert into text const currentVal = inputValue; const before = currentVal.slice(0, triggerPos); - const cursorEnd = triggerPos + (popoverMode === 'file' ? popoverFilter.length + 1 : popoverFilter.length + 1); + const cursorEnd = triggerPos + popoverFilter.length + 1; const after = currentVal.slice(cursorEnd); - const insertText = popoverMode === 'file' ? `@${item.value} ` : `${item.value} `; + const insertText = `@${item.value} `; setInputValue(before + insertText + after); closePopover(); @@ -234,23 +351,81 @@ export function MessageInput({ } }, [fetchFiles, fetchSkills, popoverMode, closePopover]); - const handleSubmit = useCallback((_msg: { text: string; files: unknown[] }, e: FormEvent) => { + const handleSubmit = useCallback(async (_msg: { text: string; files: unknown[] }, e: FormEvent) => { e.preventDefault(); const content = inputValue.trim(); - if (!content || disabled) return; closePopover(); - // Check if it's a built-in command - const builtInCmd = BUILT_IN_COMMANDS.find(cmd => cmd.value === content); - if (builtInCmd && onCommand) { - onCommand(content); - } else { - onSend(content); + // If badge is active, expand the command/skill and send + if (badge) { + let expandedPrompt = ''; + + if (badge.isSkill) { + // Fetch skill content from API + try { + const res = await fetch(`/api/skills/${encodeURIComponent(badge.label)}`); + if (res.ok) { + const data = await res.json(); + expandedPrompt = data.skill?.content || ''; + } + } catch { + // Fallback: use command name + } + } else { + // Built-in prompt command expansion + expandedPrompt = COMMAND_PROMPTS[badge.command] || ''; + } + + const finalPrompt = content + ? `${expandedPrompt}\n\nUser context: ${content}` + : expandedPrompt || badge.command; + + setBadge(null); + setInputValue(''); + onSend(finalPrompt); + return; } + if (!content || disabled) return; + + // Check if it's a direct slash command typed in the input + if (content.startsWith('/')) { + const cmd = BUILT_IN_COMMANDS.find(c => c.value === content); + if (cmd) { + if (cmd.immediate && onCommand) { + setInputValue(''); + onCommand(content); + return; + } + // Non-immediate: show as badge for user to add context + setBadge({ + command: cmd.value, + label: cmd.label, + description: cmd.description || '', + isSkill: false, + }); + setInputValue(''); + return; + } + + // Not a built-in command — treat as a skill + const skillName = content.slice(1); + if (skillName) { + setBadge({ + command: content, + label: skillName, + description: '', + isSkill: true, + }); + setInputValue(''); + return; + } + } + + onSend(content); setInputValue(''); - }, [inputValue, onSend, onCommand, disabled, closePopover]); + }, [inputValue, onSend, onCommand, disabled, closePopover, badge]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -279,8 +454,22 @@ export function MessageInput({ return; } } + + // Backspace removes badge when input is empty + if (e.key === 'Backspace' && badge && !inputValue) { + e.preventDefault(); + removeBadge(); + return; + } + + // Escape removes badge + if (e.key === 'Escape' && badge) { + e.preventDefault(); + removeBadge(); + return; + } }, - [popoverMode, popoverItems, selectedIndex, insertItem, closePopover] + [popoverMode, popoverItems, selectedIndex, insertItem, closePopover, badge, inputValue, removeBadge] ); // Click outside to close popover @@ -351,6 +540,7 @@ export function MessageInput({ {filteredItems.map((item, i) => ( + +
+ )} handleInputChange(e.currentTarget.value)} onKeyDown={handleKeyDown} @@ -491,7 +701,7 @@ export function MessageInput({
diff --git a/src/components/settings/ProviderForm.tsx b/src/components/settings/ProviderForm.tsx new file mode 100644 index 0000000..bbd2207 --- /dev/null +++ b/src/components/settings/ProviderForm.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Loading02Icon, ArrowDown01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons"; +import type { ApiProvider } from "@/types"; + +const PROVIDER_PRESETS: Record = { + anthropic: { base_url: "https://api.anthropic.com", extra_env: "{}" }, + openrouter: { base_url: "https://openrouter.ai/api", extra_env: '{"ANTHROPIC_API_KEY":""}' }, + bedrock: { base_url: "", extra_env: '{"CLAUDE_CODE_USE_BEDROCK":"1","AWS_REGION":"us-east-1","CLAUDE_CODE_SKIP_BEDROCK_AUTH":"1"}' }, + vertex: { base_url: "", extra_env: '{"CLAUDE_CODE_USE_VERTEX":"1","CLOUD_ML_REGION":"us-east5","CLAUDE_CODE_SKIP_VERTEX_AUTH":"1"}' }, + custom: { base_url: "", extra_env: "{}" }, +}; + +const PROVIDER_TYPES = [ + { value: "anthropic", label: "Anthropic" }, + { value: "openrouter", label: "OpenRouter" }, + { value: "bedrock", label: "AWS Bedrock" }, + { value: "vertex", label: "Google Vertex" }, + { value: "custom", label: "Custom" }, +]; + +interface ProviderFormProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "create" | "edit"; + provider?: ApiProvider | null; + onSave: (data: ProviderFormData) => Promise; + initialPreset?: { name: string; provider_type: string; base_url: string; extra_env?: string } | null; +} + +export interface ProviderFormData { + name: string; + provider_type: string; + base_url: string; + api_key: string; + extra_env: string; + notes: string; +} + +export function ProviderForm({ + open, + onOpenChange, + mode, + provider, + onSave, + initialPreset, +}: ProviderFormProps) { + const [name, setName] = useState(""); + const [providerType, setProviderType] = useState("anthropic"); + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [extraEnv, setExtraEnv] = useState("{}"); + const [notes, setNotes] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Reset form when dialog opens + useEffect(() => { + if (!open) return; + setError(null); + setSaving(false); + + if (mode === "edit" && provider) { + setName(provider.name); + setProviderType(provider.provider_type); + setBaseUrl(provider.base_url); + setApiKey(""); + setExtraEnv(provider.extra_env || "{}"); + setNotes(provider.notes || ""); + // Show advanced if extra_env has content + try { + const parsed = JSON.parse(provider.extra_env || "{}"); + setShowAdvanced(Object.keys(parsed).length > 0); + } catch { + setShowAdvanced(true); + } + } else if (initialPreset) { + setName(initialPreset.name); + setProviderType(initialPreset.provider_type); + setBaseUrl(initialPreset.base_url); + setApiKey(""); + // Use extra_env from preset if provided, otherwise look up by type + const envStr = initialPreset.extra_env || PROVIDER_PRESETS[initialPreset.provider_type]?.extra_env || "{}"; + setExtraEnv(envStr); + setNotes(""); + try { + const parsed = JSON.parse(envStr); + setShowAdvanced(Object.keys(parsed).length > 0); + } catch { + setShowAdvanced(false); + } + } else { + setName(""); + setProviderType("anthropic"); + setBaseUrl(PROVIDER_PRESETS.anthropic.base_url); + setApiKey(""); + setExtraEnv("{}"); + setNotes(""); + setShowAdvanced(false); + } + }, [open, mode, provider, initialPreset]); + + const handleTypeChange = (type: string) => { + setProviderType(type); + const preset = PROVIDER_PRESETS[type]; + if (preset) { + setBaseUrl(preset.base_url); + setExtraEnv(preset.extra_env); + try { + const parsed = JSON.parse(preset.extra_env); + setShowAdvanced(Object.keys(parsed).length > 0); + } catch { + setShowAdvanced(false); + } + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + setError("Name is required"); + return; + } + + // Validate extra_env JSON + try { + JSON.parse(extraEnv); + } catch { + setError("Extra environment variables must be valid JSON"); + return; + } + + setSaving(true); + setError(null); + try { + await onSave({ + name: name.trim(), + provider_type: providerType, + base_url: baseUrl.trim(), + api_key: apiKey, + extra_env: extraEnv, + notes: notes.trim(), + }); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save provider"); + } finally { + setSaving(false); + } + }; + + const isMaskedKey = mode === "edit" && provider?.api_key?.startsWith("***"); + + return ( + + + + + {mode === "edit" ? "Edit Provider" : "Add Provider"} + + + {mode === "edit" + ? "Update the API provider configuration." + : "Configure a new API provider for Claude Code."} + + + +
+
+ + setName(e.target.value)} + className="text-sm" + /> +
+ +
+ + +
+ +
+ + setBaseUrl(e.target.value)} + className="font-mono text-sm" + /> +
+ +
+ + setApiKey(e.target.value)} + className="font-mono text-sm" + /> +
+ + {/* Advanced options toggle */} + + + {showAdvanced && ( +
+
+ +