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 <noreply@anthropic.com>
This commit is contained in:
1 parent
21226fc010
commit
4e81eaa60b
14 files changed
+1436
-145
No files matched your search
@@ -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]);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'/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<string, Record<string, string>> = {
|
||||
// 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<CommandBadge | null>(null);
|
||||
const [activeProviderBaseUrl, setActiveProviderBaseUrl] = useState<string | null>(null);
|
||||
const [activeProviderName, setActiveProviderName] = useState<string | null>(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<HTMLFormElement>) => {
|
||||
const handleSubmit = useCallback(async (_msg: { text: string; files: unknown[] }, e: FormEvent<HTMLFormElement>) => {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
@@ -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) => (
|
||||
<button
|
||||
key={item.value}
|
||||
ref={i === selectedIndex ? (el) => { el?.scrollIntoView({ block: 'nearest' }); } : undefined}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors",
|
||||
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
@@ -386,9 +576,29 @@ export function MessageInput({
|
||||
<PromptInput
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{/* Command badge */}
|
||||
{badge && (
|
||||
<div className="flex w-full items-center gap-1.5 px-3 pt-2.5 pb-0 order-first">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 pl-2.5 pr-1.5 py-1 text-xs font-medium border border-blue-500/20">
|
||||
<span className="font-mono">{badge.command}</span>
|
||||
{badge.description && (
|
||||
<span className="text-blue-500/60 dark:text-blue-400/60 text-[10px]">{badge.description}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeBadge}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-blue-500/20 transition-colors"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 3l6 6M9 3l-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
placeholder="Message Claude..."
|
||||
placeholder={badge ? "Add details (optional), then press Enter..." : "Message Claude..."}
|
||||
value={inputValue}
|
||||
onChange={(e) => handleInputChange(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -491,7 +701,7 @@ export function MessageInput({
|
||||
<PromptInputSubmit
|
||||
status={chatStatus}
|
||||
onStop={onStop}
|
||||
disabled={disabled || (!isStreaming && !inputValue.trim())}
|
||||
disabled={disabled || (!isStreaming && !inputValue.trim() && !badge)}
|
||||
/>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
|
||||
@@ -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<string, { base_url: string; extra_env: string }> = {
|
||||
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<void>;
|
||||
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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[28rem] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "edit" ? "Edit Provider" : "Add Provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === "edit"
|
||||
? "Update the API provider configuration."
|
||||
: "Configure a new API provider for Claude Code."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 min-w-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-name" className="text-xs text-muted-foreground">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="provider-name"
|
||||
placeholder="My API Provider"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-type" className="text-xs text-muted-foreground">
|
||||
Provider Type
|
||||
</Label>
|
||||
<Select value={providerType} onValueChange={handleTypeChange}>
|
||||
<SelectTrigger className="w-full text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDER_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-base-url" className="text-xs text-muted-foreground">
|
||||
API Base URL
|
||||
</Label>
|
||||
<Input
|
||||
id="provider-base-url"
|
||||
placeholder="https://api.anthropic.com"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-api-key" className="text-xs text-muted-foreground">
|
||||
API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="provider-api-key"
|
||||
type="password"
|
||||
placeholder={isMaskedKey ? "Leave empty to keep current key" : "sk-ant-..."}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced options toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={showAdvanced ? ArrowUp01Icon : ArrowDown01Icon}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
Advanced Options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 border-t border-border/50 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-extra-env" className="text-xs text-muted-foreground">
|
||||
Extra Environment Variables (JSON)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="provider-extra-env"
|
||||
placeholder='{"KEY": "value"}'
|
||||
value={extraEnv}
|
||||
onChange={(e) => setExtraEnv(e.target.value)}
|
||||
className="font-mono text-sm min-h-[80px]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-notes" className="text-xs text-muted-foreground">
|
||||
Notes
|
||||
</Label>
|
||||
<Textarea
|
||||
id="provider-notes"
|
||||
placeholder="Optional notes about this provider..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="text-sm"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} className="gap-2">
|
||||
{saving && (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{saving ? "Saving..." : mode === "edit" ? "Update" : "Add Provider"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
PlusSignIcon,
|
||||
Loading02Icon,
|
||||
Delete02Icon,
|
||||
PencilEdit01Icon,
|
||||
Tick01Icon,
|
||||
ServerStack01Icon,
|
||||
Cancel01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { ProviderForm } from "./ProviderForm";
|
||||
import type { ProviderFormData } from "./ProviderForm";
|
||||
import type { ApiProvider } from "@/types";
|
||||
|
||||
const QUICK_PRESETS = [
|
||||
{ name: "Anthropic", provider_type: "anthropic", base_url: "https://api.anthropic.com" },
|
||||
{ name: "OpenRouter", provider_type: "openrouter", base_url: "https://openrouter.ai/api" },
|
||||
{ name: "GLM (CN)", provider_type: "custom", base_url: "https://open.bigmodel.cn/api/anthropic", extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}' },
|
||||
{ name: "GLM (Global)", provider_type: "custom", base_url: "https://api.z.ai/api/anthropic", extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}' },
|
||||
{ name: "Kimi Coding Plan", provider_type: "custom", base_url: "https://api.kimi.com/coding/", extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}' },
|
||||
{ name: "Moonshot", provider_type: "custom", base_url: "https://api.moonshot.cn/anthropic", extra_env: '{"ANTHROPIC_API_KEY":""}' },
|
||||
{ name: "MiniMax (CN)", provider_type: "custom", base_url: "https://api.minimaxi.com/anthropic", extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}' },
|
||||
{ name: "MiniMax (Global)", provider_type: "custom", base_url: "https://api.minimax.io/anthropic", extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}' },
|
||||
{ name: "AWS Bedrock", provider_type: "bedrock", base_url: "" },
|
||||
{ name: "Google Vertex", provider_type: "vertex", base_url: "" },
|
||||
{ name: "LiteLLM", provider_type: "custom", base_url: "http://localhost:4000" },
|
||||
];
|
||||
|
||||
export function ProviderManager() {
|
||||
const [providers, setProviders] = useState<ApiProvider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Form dialog state
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"create" | "edit">("create");
|
||||
const [editingProvider, setEditingProvider] = useState<ApiProvider | null>(null);
|
||||
const [initialPreset, setInitialPreset] = useState<{ name: string; provider_type: string; base_url: string } | null>(null);
|
||||
|
||||
// Delete confirmation state
|
||||
const [deleteTarget, setDeleteTarget] = useState<ApiProvider | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Activating state
|
||||
const [activatingId, setActivatingId] = useState<string | null>(null);
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch("/api/providers");
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to load providers");
|
||||
}
|
||||
const data = await res.json();
|
||||
setProviders(data.providers || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load providers");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviders();
|
||||
}, [fetchProviders]);
|
||||
|
||||
const handleAdd = () => {
|
||||
setFormMode("create");
|
||||
setEditingProvider(null);
|
||||
setInitialPreset(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handlePresetAdd = (preset: typeof QUICK_PRESETS[number]) => {
|
||||
setFormMode("create");
|
||||
setEditingProvider(null);
|
||||
setInitialPreset(preset);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (provider: ApiProvider) => {
|
||||
setFormMode("edit");
|
||||
setEditingProvider(provider);
|
||||
setInitialPreset(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (data: ProviderFormData) => {
|
||||
if (formMode === "edit" && editingProvider) {
|
||||
const res = await fetch(`/api/providers/${editingProvider.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || "Failed to update provider");
|
||||
}
|
||||
const result = await res.json();
|
||||
setProviders((prev) =>
|
||||
prev.map((p) => (p.id === editingProvider.id ? result.provider : p))
|
||||
);
|
||||
} else {
|
||||
const res = await fetch("/api/providers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || "Failed to create provider");
|
||||
}
|
||||
const result = await res.json();
|
||||
setProviders((prev) => [...prev, result.provider]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${deleteTarget.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
setProviders((prev) => prev.filter((p) => p.id !== deleteTarget.id));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (provider: ApiProvider) => {
|
||||
setActivatingId(provider.id);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${provider.id}/activate`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (res.ok) {
|
||||
// Mark the activated provider as active, deactivate others
|
||||
setProviders((prev) =>
|
||||
prev.map((p) => ({
|
||||
...p,
|
||||
is_active: p.id === provider.id ? 1 : 0,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setActivatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (provider: ApiProvider) => {
|
||||
setActivatingId(provider.id);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${provider.id}/activate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ active: false }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setProviders((prev) =>
|
||||
prev.map((p) => ({ ...p, is_active: 0 }))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setActivatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = [...providers].sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">API Providers</h3>
|
||||
{providers.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({providers.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Manage API providers for Claude Code. The active provider will be used for all sessions.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" className="gap-1" onClick={handleAdd}>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
|
||||
Add Provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
|
||||
<p className="text-sm">Loading providers...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && providers.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-muted-foreground">
|
||||
<HugeiconsIcon icon={ServerStack01Icon} className="h-10 w-10 opacity-30" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">No providers configured</p>
|
||||
<p className="text-xs mt-0.5">
|
||||
Add a provider to use a custom API endpoint with Claude Code.
|
||||
</p>
|
||||
</div>
|
||||
{/* Quick preset buttons */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5 mt-2 max-w-md">
|
||||
{QUICK_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="gap-1"
|
||||
onClick={() => handlePresetAdd(preset)}
|
||||
>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="h-3 w-3" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider list */}
|
||||
{!loading && sorted.length > 0 && (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto min-h-0">
|
||||
{sorted.map((provider) => {
|
||||
const isActive = provider.is_active === 1;
|
||||
const isActivating = activatingId === provider.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`rounded-lg border p-3 transition-colors ${
|
||||
isActive
|
||||
? "border-border bg-green-500/5"
|
||||
: "border-border/50 hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{provider.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{provider.provider_type}
|
||||
</Badge>
|
||||
{isActive && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-green-600 dark:text-green-400 border-green-500/30">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{provider.base_url && (
|
||||
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">
|
||||
{provider.base_url}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isActive ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={isActivating}
|
||||
onClick={() => handleDeactivate(provider)}
|
||||
className="gap-1 text-muted-foreground"
|
||||
>
|
||||
{isActivating ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="h-3 w-3" />
|
||||
)}
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={isActivating}
|
||||
onClick={() => handleActivate(provider)}
|
||||
className="gap-1"
|
||||
>
|
||||
{isActivating ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Tick01Icon} className="h-3 w-3" />
|
||||
)}
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Edit"
|
||||
onClick={() => handleEdit(provider)}
|
||||
>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Delete"
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick presets row (when providers exist) */}
|
||||
{!loading && providers.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground mr-1">Quick add:</span>
|
||||
{QUICK_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="gap-1 text-[11px]"
|
||||
onClick={() => handlePresetAdd(preset)}
|
||||
>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="h-2.5 w-2.5" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form dialog */}
|
||||
<ProviderForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
mode={formMode}
|
||||
provider={editingProvider}
|
||||
onSave={handleSave}
|
||||
initialPreset={initialPreset}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Provider</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user