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">