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:
7418andClaude Opus 4.6 committed 2026-02-08 20:03:18 +08:00
1 parent 21226fc010
commit 4e81eaa60b
14 files changed
+1436 -145

No files matched your search

+42 -2
View File
@@ -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]);
+235 -25
View File
@@ -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>