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
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user