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

+4 -1
View File
@@ -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 });
@@ -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<ErrorResponse>(
{ error: 'Provider not found' },
{ status: 404 }
);
}
const activated = activateProvider(id);
if (!activated) {
return NextResponse.json<ErrorResponse>(
{ error: 'Failed to activate provider' },
{ status: 500 }
);
}
return NextResponse.json({ success: true, active: true });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ error: error instanceof Error ? error.message : 'Failed to activate provider' },
{ status: 500 }
);
}
}
+93
View File
@@ -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<ErrorResponse>(
{ error: 'Provider not found' },
{ status: 404 }
);
}
return NextResponse.json<ProviderResponse>({ provider: maskApiKey(provider) });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ 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<ErrorResponse>(
{ 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<ErrorResponse>(
{ error: 'Failed to update provider' },
{ status: 500 }
);
}
return NextResponse.json<ProviderResponse>({ provider: maskApiKey(updated) });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ 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<ErrorResponse>(
{ error: 'Provider not found' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ error: error instanceof Error ? error.message : 'Failed to delete provider' },
{ status: 500 }
);
}
}
+47
View File
@@ -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<ProvidersResponse>({ providers });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ 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<ErrorResponse>(
{ error: 'Missing required field: name' },
{ status: 400 }
);
}
const provider = createProvider(body);
return NextResponse.json<ProviderResponse>(
{ provider: maskApiKey(provider) },
{ status: 201 }
);
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ error: error instanceof Error ? error.message : 'Failed to create provider' },
{ status: 500 }
);
}
}
+3 -105
View File
@@ -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 (
<div className="rounded-lg border border-border/50 p-4 space-y-4">
<div>
<Label className="text-sm font-medium">API Configuration</Label>
<p className="text-xs text-muted-foreground">
Optional. Configure a custom Anthropic-compatible API for Claude Code.
Leave empty to use the default authentication (claude login / shell environment).
</p>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="api-base-url" className="text-xs text-muted-foreground">
API Base URL
</Label>
<Input
id="api-base-url"
placeholder="https://api.anthropic.com"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className="mt-1 font-mono text-sm"
/>
</div>
<div>
<Label htmlFor="api-token" className="text-xs text-muted-foreground">
API Token (ANTHROPIC_AUTH_TOKEN)
</Label>
<Input
id="api-token"
type="password"
placeholder="sk-ant-..."
value={token}
onChange={(e) => setToken(e.target.value)}
className="mt-1 font-mono text-sm"
/>
</div>
</div>
<div className="flex items-center gap-3">
<Button onClick={handleSave} disabled={saving} size="sm" className="gap-2">
{saving ? (
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
) : (
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
)}
{saving ? "Saving..." : "Save API Config"}
</Button>
{status === "saved" && (
<span className="text-sm text-green-600 dark:text-green-400">Saved</span>
)}
{status === "error" && (
<span className="text-sm text-destructive">Failed to save</span>
)}
</div>
</div>
);
}
// ApiConfigSection removed — replaced by ProviderManager component
// --- Claude CLI Settings Section (manages ~/.claude/settings.json) ---
function SettingsPageInner() {
@@ -280,7 +178,7 @@ function SettingsPageInner() {
<div className="flex-1 overflow-auto p-6">
<div className="max-w-3xl space-y-6">
<ApiConfigSection />
<ProviderManager />
{loading ? (
<div className="flex items-center justify-center py-12">
+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>
+318
View File
@@ -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>
);
}
+408
View File
@@ -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 &quot;{deleteTarget?.name}&quot;? 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>
);
}
+41 -8
View File
@@ -14,7 +14,7 @@ import type {
} from '@anthropic-ai/claude-agent-sdk';
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent } from '@/types';
import { registerPendingPermission } from './permission-registry';
import { getSetting } from './db';
import { getSetting, getActiveProvider } from './db';
import { findClaudeBinary, getExpandedPath } from './platform';
import os from 'os';
@@ -108,13 +108,46 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
// Ensure SDK subprocess has expanded PATH (consistent with Electron mode)
sdkEnv.PATH = getExpandedPath();
const appToken = getSetting('anthropic_auth_token');
const appBaseUrl = getSetting('anthropic_base_url');
if (appToken) {
sdkEnv.ANTHROPIC_AUTH_TOKEN = appToken;
}
if (appBaseUrl) {
sdkEnv.ANTHROPIC_BASE_URL = appBaseUrl;
// Try to get config from active provider first
const activeProvider = getActiveProvider();
if (activeProvider && activeProvider.api_key) {
// Clear all existing ANTHROPIC_* variables to prevent conflicts
for (const key of Object.keys(sdkEnv)) {
if (key.startsWith('ANTHROPIC_')) {
delete sdkEnv[key];
}
}
// Inject provider config — set both token variants so extra_env can clear the unwanted one
sdkEnv.ANTHROPIC_AUTH_TOKEN = activeProvider.api_key;
sdkEnv.ANTHROPIC_API_KEY = activeProvider.api_key;
if (activeProvider.base_url) {
sdkEnv.ANTHROPIC_BASE_URL = activeProvider.base_url;
}
// Inject extra environment variables
// Empty string values mean "delete this variable" (e.g. clear ANTHROPIC_API_KEY for AUTH_TOKEN-only providers)
try {
const extraEnv = JSON.parse(activeProvider.extra_env || '{}');
for (const [key, value] of Object.entries(extraEnv)) {
if (typeof value === 'string') {
if (value === '') {
delete sdkEnv[key];
} else {
sdkEnv[key] = value;
}
}
}
} catch {
// ignore malformed extra_env
}
} else {
// Fall back to legacy settings
const appToken = getSetting('anthropic_auth_token');
const appBaseUrl = getSetting('anthropic_base_url');
if (appToken) sdkEnv.ANTHROPIC_AUTH_TOKEN = appToken;
if (appBaseUrl) sdkEnv.ANTHROPIC_BASE_URL = appBaseUrl;
}
const queryOptions: Options = {
+145 -1
View File
@@ -1,7 +1,7 @@
import Database from 'better-sqlite3';
import path from 'path';
import crypto from 'crypto';
import type { ChatSession, Message, SettingsMap, TaskItem, TaskStatus } from '@/types';
import type { ChatSession, Message, SettingsMap, TaskItem, TaskStatus, ApiProvider, CreateProviderRequest, UpdateProviderRequest } from '@/types';
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(require('os').homedir(), '.codepilot');
const DB_PATH = path.join(dataDir, 'codepilot.db');
@@ -95,6 +95,20 @@ function initDb(db: Database.Database): void {
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS api_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
provider_type TEXT NOT NULL DEFAULT 'anthropic',
base_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
extra_env TEXT NOT NULL DEFAULT '{}',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON chat_sessions(updated_at);
@@ -158,6 +172,37 @@ function migrateDb(db: Database.Database): void {
);
CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);
`);
// Ensure api_providers table exists for databases created before this migration
db.exec(`
CREATE TABLE IF NOT EXISTS api_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
provider_type TEXT NOT NULL DEFAULT 'anthropic',
base_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
extra_env TEXT NOT NULL DEFAULT '{}',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Migrate existing settings to a default provider if api_providers is empty
const providerCount = db.prepare('SELECT COUNT(*) as count FROM api_providers').get() as { count: number };
if (providerCount.count === 0) {
const tokenRow = db.prepare("SELECT value FROM settings WHERE key = 'anthropic_auth_token'").get() as { value: string } | undefined;
const baseUrlRow = db.prepare("SELECT value FROM settings WHERE key = 'anthropic_base_url'").get() as { value: string } | undefined;
if (tokenRow || baseUrlRow) {
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare(
'INSERT INTO api_providers (id, name, provider_type, base_url, api_key, is_active, sort_order, extra_env, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, 'Default', 'anthropic', baseUrlRow?.value || '', tokenRow?.value || '', 1, 0, '{}', 'Migrated from settings', now, now);
}
}
}
// ==========================================
@@ -255,6 +300,13 @@ export function addMessage(
return db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as Message;
}
export function clearSessionMessages(sessionId: string): void {
const db = getDb();
db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
// Reset SDK session ID so next message starts fresh
db.prepare('UPDATE chat_sessions SET sdk_session_id = ? WHERE id = ?').run('', sessionId);
}
// ==========================================
// Settings Operations
// ==========================================
@@ -339,3 +391,95 @@ export function deleteTask(id: string): boolean {
const result = db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
return result.changes > 0;
}
// ==========================================
// API Provider Operations
// ==========================================
export function getAllProviders(): ApiProvider[] {
const db = getDb();
return db.prepare('SELECT * FROM api_providers ORDER BY sort_order ASC, created_at ASC').all() as ApiProvider[];
}
export function getProvider(id: string): ApiProvider | undefined {
const db = getDb();
return db.prepare('SELECT * FROM api_providers WHERE id = ?').get(id) as ApiProvider | undefined;
}
export function getActiveProvider(): ApiProvider | undefined {
const db = getDb();
return db.prepare('SELECT * FROM api_providers WHERE is_active = 1 LIMIT 1').get() as ApiProvider | undefined;
}
export function createProvider(data: CreateProviderRequest): ApiProvider {
const db = getDb();
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
// Get max sort_order to append at end
const maxRow = db.prepare('SELECT MAX(sort_order) as max_order FROM api_providers').get() as { max_order: number | null };
const sortOrder = (maxRow.max_order ?? -1) + 1;
db.prepare(
'INSERT INTO api_providers (id, name, provider_type, base_url, api_key, is_active, sort_order, extra_env, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
id,
data.name,
data.provider_type || 'anthropic',
data.base_url || '',
data.api_key || '',
0,
sortOrder,
data.extra_env || '{}',
data.notes || '',
now,
now,
);
return getProvider(id)!;
}
export function updateProvider(id: string, data: UpdateProviderRequest): ApiProvider | undefined {
const db = getDb();
const existing = getProvider(id);
if (!existing) return undefined;
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
const name = data.name ?? existing.name;
const providerType = data.provider_type ?? existing.provider_type;
const baseUrl = data.base_url ?? existing.base_url;
const apiKey = data.api_key ?? existing.api_key;
const extraEnv = data.extra_env ?? existing.extra_env;
const notes = data.notes ?? existing.notes;
const sortOrder = data.sort_order ?? existing.sort_order;
db.prepare(
'UPDATE api_providers SET name = ?, provider_type = ?, base_url = ?, api_key = ?, extra_env = ?, notes = ?, sort_order = ?, updated_at = ? WHERE id = ?'
).run(name, providerType, baseUrl, apiKey, extraEnv, notes, sortOrder, now, id);
return getProvider(id);
}
export function deleteProvider(id: string): boolean {
const db = getDb();
const result = db.prepare('DELETE FROM api_providers WHERE id = ?').run(id);
return result.changes > 0;
}
export function activateProvider(id: string): boolean {
const db = getDb();
const existing = getProvider(id);
if (!existing) return false;
const transaction = db.transaction(() => {
db.prepare('UPDATE api_providers SET is_active = 0').run();
db.prepare('UPDATE api_providers SET is_active = 1 WHERE id = ?').run(id);
});
transaction();
return true;
}
export function deactivateAllProviders(): void {
const db = getDb();
db.prepare('UPDATE api_providers SET is_active = 0').run();
}
+45
View File
@@ -93,6 +93,51 @@ export interface Setting {
value: string;
}
// ==========================================
// API Provider Types
// ==========================================
export interface ApiProvider {
id: string;
name: string;
provider_type: string; // 'anthropic' | 'openrouter' | 'bedrock' | 'vertex' | 'custom'
base_url: string;
api_key: string;
is_active: number; // SQLite boolean: 0 or 1
sort_order: number;
extra_env: string; // JSON string of Record<string, string>
notes: string;
created_at: string;
updated_at: string;
}
export interface CreateProviderRequest {
name: string;
provider_type?: string;
base_url?: string;
api_key?: string;
extra_env?: string;
notes?: string;
}
export interface UpdateProviderRequest {
name?: string;
provider_type?: string;
base_url?: string;
api_key?: string;
extra_env?: string;
notes?: string;
sort_order?: number;
}
export interface ProvidersResponse {
providers: ApiProvider[];
}
export interface ProviderResponse {
provider: ApiProvider;
}
// ==========================================
// Token Usage
// ==========================================