diff --git a/.claude/workflows/cli-templates/protocols/write-protocol.md b/.claude/workflows/cli-templates/protocols/write-protocol.md index cdda0163..c14da608 100644 --- a/.claude/workflows/cli-templates/protocols/write-protocol.md +++ b/.claude/workflows/cli-templates/protocols/write-protocol.md @@ -49,17 +49,6 @@ RULES: [templates | additional constraints] - Break backward compatibility - Exceed 3 failed attempts without stopping -## Multi-Task Execution (Resume) - -**First subtask**: Standard execution flow -**Subsequent subtasks** (via `resume`): -- Recall context from previous subtasks -- Build on previous work -- Maintain consistency -- Test integration -- Report context for next subtask - -## Error Handling **Three-Attempt Rule**: On 3rd failure, stop and report what attempted, what failed, root cause @@ -80,7 +69,7 @@ RULES: [templates | additional constraints] **If template has no format** → Use default format below -### Single Task Implementation +### Task Implementation ```markdown # Implementation: [TASK Title] @@ -112,48 +101,6 @@ RULES: [templates | additional constraints] [Recommendations if any] ``` -### Multi-Task (First Subtask) - -```markdown -# Subtask 1/N: [TASK Title] - -## Changes -[List of file changes] - -## Implementation -[Details with code references] - -## Testing -✅ Tests: X passing - -## Context for Next Subtask -- Key decisions: [established patterns] -- Files created: [paths and purposes] -- Integration points: [where next subtask should connect] -``` - -### Multi-Task (Subsequent Subtasks) - -```markdown -# Subtask N/M: [TASK Title] - -## Changes -[List of file changes] - -## Integration Notes -✅ Compatible with previous subtask -✅ Maintains established patterns - -## Implementation -[Details with code references] - -## Testing -✅ Tests: X passing - -## Context for Next Subtask -[If not final, provide context] -``` - ### Partial Completion ```markdown diff --git a/.claude/workflows/cli-tools-usage.md b/.claude/workflows/cli-tools-usage.md index 63b6bb3d..6113f635 100644 --- a/.claude/workflows/cli-tools-usage.md +++ b/.claude/workflows/cli-tools-usage.md @@ -362,10 +362,6 @@ ccw cli -p "RULES: \$(cat ~/.claude/workflows/cli-templates/protocols/analysis-p - Description: Additional directories (comma-separated) - Default: none -- **`--timeout `** - - Description: Timeout in milliseconds - - Default: 300000 - - **`--resume [id]`** - Description: Resume previous session - Default: - @@ -423,73 +419,80 @@ CCW automatically maps to tool-specific syntax: **Analysis Task** (Security Audit): ```bash -ccw cli -p " +timeout 600 ccw cli -p " PURPOSE: Identify OWASP Top 10 vulnerabilities in authentication module to pass security audit; success = all critical/high issues documented with remediation TASK: • Scan for injection flaws (SQL, command, LDAP) • Check authentication bypass vectors • Evaluate session management • Assess sensitive data exposure MODE: analysis CONTEXT: @src/auth/**/* @src/middleware/auth.ts | Memory: Using bcrypt for passwords, JWT for sessions EXPECTED: Security report with: severity matrix, file:line references, CVE mappings where applicable, remediation code snippets prioritized by risk RULES: $(cat ~/.claude/workflows/cli-templates/protocols/analysis-protocol.md) $(cat ~/.claude/workflows/cli-templates/prompts/analysis/03-assess-security-risks.txt) | Focus on authentication | Ignore test files -" --tool gemini --cd src/auth --timeout 600000 +" --tool gemini --mode analysis --cd src/auth ``` **Implementation Task** (New Feature): ```bash -ccw cli -p " +timeout 1800 ccw cli -p " PURPOSE: Implement rate limiting for API endpoints to prevent abuse; must be configurable per-endpoint; backward compatible with existing clients TASK: • Create rate limiter middleware with sliding window • Implement per-route configuration • Add Redis backend for distributed state • Include bypass for internal services MODE: write CONTEXT: @src/middleware/**/* @src/config/**/* | Memory: Using Express.js, Redis already configured, existing middleware pattern in auth.ts EXPECTED: Production-ready code with: TypeScript types, unit tests, integration test, configuration example, migration guide RULES: $(cat ~/.claude/workflows/cli-templates/protocols/write-protocol.md) $(cat ~/.claude/workflows/cli-templates/prompts/development/02-implement-feature.txt) | Follow existing middleware patterns | No breaking changes -" --tool codex --mode write --timeout 1800000 +" --tool codex --mode write ``` **Bug Fix Task**: ```bash -ccw cli -p " +timeout 900 ccw cli -p " PURPOSE: Fix memory leak in WebSocket connection handler causing server OOM after 24h; root cause must be identified before any fix TASK: • Trace connection lifecycle from open to close • Identify event listener accumulation • Check cleanup on disconnect • Verify garbage collection eligibility MODE: analysis CONTEXT: @src/websocket/**/* @src/services/connection-manager.ts | Memory: Using ws library, ~5000 concurrent connections in production EXPECTED: Root cause analysis with: memory profile, leak source (file:line), fix recommendation with code, verification steps RULES: $(cat ~/.claude/workflows/cli-templates/protocols/analysis-protocol.md) $(cat ~/.claude/workflows/cli-templates/prompts/analysis/01-diagnose-bug-root-cause.txt) | Focus on resource cleanup -" --tool gemini --cd src --timeout 900000 +" --tool gemini --mode analysis --cd src ``` **Refactoring Task**: ```bash -ccw cli -p " +timeout 1200 ccw cli -p " PURPOSE: Refactor payment processing to use strategy pattern for multi-gateway support; no functional changes; all existing tests must pass TASK: • Extract gateway interface from current implementation • Create strategy classes for Stripe, PayPal • Implement factory for gateway selection • Migrate existing code to use strategies MODE: write CONTEXT: @src/payments/**/* @src/types/payment.ts | Memory: Currently only Stripe, adding PayPal next sprint, must support future gateways EXPECTED: Refactored code with: strategy interface, concrete implementations, factory class, updated tests, migration checklist RULES: $(cat ~/.claude/workflows/cli-templates/protocols/write-protocol.md) $(cat ~/.claude/workflows/cli-templates/prompts/development/02-refactor-codebase.txt) | Preserve all existing behavior | Tests must pass -" --tool gemini --mode write --timeout 1200000 +" --tool gemini --mode write ``` --- ## Configuration -### Timeout Allocation +### Timeout Allocation (Bash) -**Minimum**: 5 minutes (300000ms) +CLI internal timeout is disabled; controlled by external bash `timeout` command: -- **Simple**: 5-10min (300000-600000ms) - - Examples: Analysis, search +```bash +# Syntax: timeout ccw cli ... +timeout 600 ccw cli -p "..." --tool gemini --mode analysis # 10 minutes +timeout 1800 ccw cli -p "..." --tool codex --mode write # 30 minutes +``` -- **Medium**: 10-20min (600000-1200000ms) - - Examples: Refactoring, documentation +**Recommended Time Allocation**: -- **Complex**: 20-60min (1200000-3600000ms) - - Examples: Implementation, migration +- **Simple** (5-10min): Analysis, search + - `timeout 300` ~ `timeout 600` -- **Heavy**: 60-120min (3600000-7200000ms) - - Examples: Large codebase, multi-file +- **Medium** (10-20min): Refactoring, documentation + - `timeout 600` ~ `timeout 1200` -**Codex Multiplier**: 3x allocated time (minimum 15min / 900000ms) +- **Complex** (20-60min): Implementation, migration + - `timeout 1200` ~ `timeout 3600` +- **Heavy** (60-120min): Large codebase, multi-file + - `timeout 3600` ~ `timeout 7200` + +**Codex Multiplier**: 3x allocated time (minimum 15min / 900s) ### Permission Framework @@ -523,4 +526,3 @@ RULES: $(cat ~/.claude/workflows/cli-templates/protocols/write-protocol.md) $(ca - [ ] **Tool selected** - `--tool gemini|qwen|codex` - [ ] **Template applied (REQUIRED)** - Use specific or universal fallback template - [ ] **Constraints specified** - Scope, requirements -- [ ] **Timeout configured** - Based on complexity diff --git a/.codex/AGENTS.md b/.codex/AGENTS.md index ed9f50c4..e95edc3c 100644 --- a/.codex/AGENTS.md +++ b/.codex/AGENTS.md @@ -21,8 +21,11 @@ - Graceful degradation - Don't expose sensitive info + + ## Core Principles + **Incremental Progress**: - Small, testable changes - Commit working code frequently @@ -43,11 +46,58 @@ - Maintain established patterns - Test integration between subtasks + +## System Optimization + +**Direct Binary Calls**: Always call binaries directly in `functions.shell`, set `workdir`, avoid shell wrappers (`bash -lc`, `cmd /c`, etc.) + +**Text Editing Priority**: +1. Use `apply_patch` tool for all routine text edits +2. Fall back to `sed` for single-line substitutions if unavailable +3. Avoid Python editing scripts unless both fail + +**apply_patch invocation**: +```json +{ + "command": ["apply_patch", "*** Begin Patch\n*** Update File: path/to/file\n@@\n- old\n+ new\n*** End Patch\n"], + "workdir": "", + "justification": "Brief reason" +} +``` + +**Windows UTF-8 Encoding** (before commands): +```powershell +[Console]::InputEncoding = [Text.UTF8Encoding]::new($false) +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +chcp 65001 > $null +``` + +## Context Acquisition (MCP Tools Priority) + +**For task context gathering and analysis, ALWAYS prefer MCP tools**: + +1. **smart_search** - First choice for code discovery + - Use `smart_search(query="...")` for semantic/keyword search + - Use `smart_search(action="find_files", pattern="*.ts")` for file discovery + - Supports modes: `auto`, `hybrid`, `exact`, `ripgrep` + +2. **read_file** - Batch file reading + - Read multiple files in parallel: `read_file(path="file1.ts")`, `read_file(path="file2.ts")` + - Supports glob patterns: `read_file(path="src/**/*.config.ts")` + +**Priority Order**: +``` +smart_search (discovery) → read_file (batch read) → shell commands (fallback) +``` + +**NEVER** use shell commands (`cat`, `find`, `grep`) when MCP tools are available. + ## Execution Checklist **Before**: - [ ] Understand PURPOSE and TASK clearly -- [ ] Review CONTEXT files, find 3+ patterns +- [ ] Use smart_search to discover relevant files +- [ ] Use read_file to batch read context files, find 3+ patterns - [ ] Check RULES templates and constraints **During**: diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md index d52e227e..768dc0ac 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -1,25 +1,62 @@ -# Gemini Code Guidelines + +## Code Quality Standards + +### Code Quality +- Follow project's existing patterns +- Match import style and naming conventions +- Single responsibility per function/class +- DRY (Don't Repeat Yourself) +- YAGNI (You Aren't Gonna Need It) + +### Testing +- Test all public functions +- Test edge cases and error conditions +- Mock external dependencies +- Target 80%+ coverage + +### Error Handling +- Proper try-catch blocks +- Clear error messages +- Graceful degradation +- Don't expose sensitive info ## Core Principles -**Thoroughness**: -- Analyze ALL CONTEXT files completely -- Check cross-file patterns and dependencies -- Identify edge cases and quantify metrics +**Incremental Progress**: +- Small, testable changes +- Commit working code frequently +- Build on previous work (subtasks) **Evidence-Based**: -- Quote relevant code with `file:line` references -- Link related patterns across files -- Support all claims with concrete examples +- Study 3+ similar patterns before implementing +- Match project style exactly +- Verify with existing code -**Actionable**: -- Clear, specific recommendations (not vague) -- Prioritized by impact -- Incremental changes over big rewrites +**Pragmatic**: +- Boring solutions over clever code +- Simple over complex +- Adapt to project reality -**Philosophy**: -- **Simple over complex** - Avoid over-engineering -- **Clear over clever** - Prefer obvious solutions -- **Learn from existing** - Reference project patterns -- **Pragmatic over dogmatic** - Adapt to project reality -- **Incremental progress** - Small, testable changes +**Context Continuity** (Multi-Task): +- Leverage resume for consistency +- Maintain established patterns +- Test integration between subtasks + +## Execution Checklist + +**Before**: +- [ ] Understand PURPOSE and TASK clearly +- [ ] Review CONTEXT files, find 3+ patterns +- [ ] Check RULES templates and constraints + +**During**: +- [ ] Follow existing patterns exactly +- [ ] Write tests alongside code +- [ ] Run tests after every change +- [ ] Commit working code incrementally + +**After**: +- [ ] All tests pass +- [ ] Coverage meets target +- [ ] Build succeeds +- [ ] All EXPECTED deliverables met diff --git a/API_SETTINGS_IMPLEMENTATION.md b/API_SETTINGS_IMPLEMENTATION.md new file mode 100644 index 00000000..047a980c --- /dev/null +++ b/API_SETTINGS_IMPLEMENTATION.md @@ -0,0 +1,196 @@ +# API Settings 页面实现完成 + +## 创建的文件 + +### 1. JavaScript 文件 +**位置**: `ccw/src/templates/dashboard-js/views/api-settings.js` (28KB) + +**主要功能**: +- ✅ Provider Management (提供商管理) + - 添加/编辑/删除提供商 + - 支持 OpenAI, Anthropic, Google, Ollama, Azure, Mistral, DeepSeek, Custom + - API Key 管理(支持环境变量) + - 连接测试功能 + +- ✅ Endpoint Management (端点管理) + - 创建自定义端点 + - 关联提供商和模型 + - 缓存策略配置 + - 显示 CLI 使用示例 + +- ✅ Cache Management (缓存管理) + - 全局缓存开关 + - 缓存统计显示 + - 清除缓存功能 + +### 2. CSS 样式文件 +**位置**: `ccw/src/templates/dashboard-css/31-api-settings.css` (6.8KB) + +**样式包括**: +- 卡片式布局 +- 表单样式 +- 进度条 +- 响应式设计 +- 空状态显示 + +### 3. 国际化支持 +**位置**: `ccw/src/templates/dashboard-js/i18n.js` + +**添加的翻译**: +- 英文:54 个翻译键 +- 中文:54 个翻译键 +- 包含所有 UI 文本、提示信息、错误消息 + +### 4. 配置更新 + +#### dashboard-generator.ts +- ✅ 添加 `31-api-settings.css` 到 CSS 模块列表 +- ✅ 添加 `views/api-settings.js` 到 JS 模块列表 + +#### navigation.js +- ✅ 添加 `api-settings` 路由处理 +- ✅ 添加标题更新逻辑 + +#### dashboard.html +- ✅ 添加导航菜单项 (Settings 图标) + +## API 端点使用 + +该页面使用以下后端 API(已存在): + +### Provider APIs +- `GET /api/litellm-api/providers` - 获取所有提供商 +- `POST /api/litellm-api/providers` - 创建提供商 +- `PUT /api/litellm-api/providers/:id` - 更新提供商 +- `DELETE /api/litellm-api/providers/:id` - 删除提供商 +- `POST /api/litellm-api/providers/:id/test` - 测试连接 + +### Endpoint APIs +- `GET /api/litellm-api/endpoints` - 获取所有端点 +- `POST /api/litellm-api/endpoints` - 创建端点 +- `PUT /api/litellm-api/endpoints/:id` - 更新端点 +- `DELETE /api/litellm-api/endpoints/:id` - 删除端点 + +### Model Discovery +- `GET /api/litellm-api/models/:providerType` - 获取提供商支持的模型列表 + +### Cache APIs +- `GET /api/litellm-api/cache/stats` - 获取缓存统计 +- `POST /api/litellm-api/cache/clear` - 清除缓存 + +### Config APIs +- `GET /api/litellm-api/config` - 获取完整配置 +- `PUT /api/litellm-api/config/cache` - 更新全局缓存设置 + +## 页面特性 + +### Provider 管理 +``` ++-- Provider Card ------------------------+ +| OpenAI Production [Edit] [Del] | +| Type: openai | +| Key: sk-...abc | +| URL: https://api.openai.com/v1 | +| Status: ✓ Enabled | ++-----------------------------------------+ +``` + +### Endpoint 管理 +``` ++-- Endpoint Card ------------------------+ +| GPT-4o Code Review [Edit] [Del]| +| ID: my-gpt4o | +| Provider: OpenAI Production | +| Model: gpt-4-turbo | +| Cache: Enabled (60 min) | +| Usage: ccw cli -p "..." --model my-gpt4o| ++-----------------------------------------+ +``` + +### 表单功能 +- **Provider Form**: + - 类型选择(8 种提供商) + - API Key 输入(支持显示/隐藏) + - 环境变量支持 + - Base URL 自定义 + - 启用/禁用开关 + +- **Endpoint Form**: + - 端点 ID(CLI 使用) + - 显示名称 + - 提供商选择(动态加载) + - 模型选择(根据提供商动态加载) + - 缓存策略配置 + - TTL(分钟) + - 最大大小(KB) + - 自动缓存文件模式 + +## 使用流程 + +### 1. 添加提供商 +1. 点击 "Add Provider" +2. 选择提供商类型(如 OpenAI) +3. 输入显示名称 +4. 输入 API Key(或使用环境变量) +5. 可选:输入自定义 API Base URL +6. 保存 + +### 2. 创建自定义端点 +1. 点击 "Add Endpoint" +2. 输入端点 ID(用于 CLI) +3. 输入显示名称 +4. 选择提供商 +5. 选择模型(自动加载该提供商支持的模型) +6. 可选:配置缓存策略 +7. 保存 + +### 3. 使用端点 +```bash +ccw cli -p "Analyze this code..." --model my-gpt4o +``` + +## 代码质量 + +- ✅ 遵循现有代码风格 +- ✅ 使用 i18n 函数支持国际化 +- ✅ 响应式设计(移动端友好) +- ✅ 完整的表单验证 +- ✅ 用户友好的错误提示 +- ✅ 使用 Lucide 图标 +- ✅ 模态框复用现有样式 +- ✅ 与后端 API 完全集成 + +## 测试建议 + +1. **基础功能测试**: + - 添加/编辑/删除提供商 + - 添加/编辑/删除端点 + - 清除缓存 + +2. **表单验证测试**: + - 必填字段验证 + - API Key 显示/隐藏 + - 环境变量切换 + +3. **数据加载测试**: + - 模型列表动态加载 + - 缓存统计显示 + - 空状态显示 + +4. **国际化测试**: + - 切换语言(英文/中文) + - 验证所有文本正确显示 + +## 下一步 + +页面已完成并集成到项目中。启动 CCW Dashboard 后: +1. 导航栏会显示 "API Settings" 菜单项(Settings 图标) +2. 点击进入即可使用所有功能 +3. 所有操作会实时同步到配置文件 + +## 注意事项 + +- 页面使用现有的 LiteLLM API 路由(`litellm-api-routes.ts`) +- 配置保存在项目的 LiteLLM 配置文件中 +- 支持环境变量引用格式:`${VARIABLE_NAME}` +- API Key 在显示时会自动脱敏(显示前 4 位和后 4 位) diff --git a/ccw/LITELLM_INTEGRATION.md b/ccw/LITELLM_INTEGRATION.md new file mode 100644 index 00000000..4312a12e --- /dev/null +++ b/ccw/LITELLM_INTEGRATION.md @@ -0,0 +1,308 @@ +# LiteLLM Integration Guide + +## Overview + +CCW now supports custom LiteLLM endpoints with integrated context caching. You can configure multiple providers (OpenAI, Anthropic, Ollama, etc.) and create custom endpoints with file-based caching strategies. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CLI Executor │ +│ │ +│ ┌─────────────┐ ┌──────────────────────────────┐ │ +│ │ --model │────────>│ Route Decision: │ │ +│ │ flag │ │ - gemini/qwen/codex → CLI │ │ +│ └─────────────┘ │ - custom ID → LiteLLM │ │ +│ └──────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ LiteLLM Executor │ +│ │ +│ 1. Load endpoint config (litellm-api-config.json) │ +│ 2. Extract @patterns from prompt │ +│ 3. Pack files via context-cache │ +│ 4. Call LiteLLM client with cached content + prompt │ +│ 5. Return result │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Configuration + +### File Location + +Configuration is stored per-project: +``` +/.ccw/storage/config/litellm-api-config.json +``` + +### Configuration Structure + +```json +{ + "version": 1, + "providers": [ + { + "id": "openai-1234567890", + "name": "My OpenAI", + "type": "openai", + "apiKey": "${OPENAI_API_KEY}", + "enabled": true, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z" + } + ], + "endpoints": [ + { + "id": "my-gpt4o", + "name": "GPT-4o with Context Cache", + "providerId": "openai-1234567890", + "model": "gpt-4o", + "description": "GPT-4o with automatic file caching", + "cacheStrategy": { + "enabled": true, + "ttlMinutes": 60, + "maxSizeKB": 512, + "filePatterns": ["*.md", "*.ts", "*.js"] + }, + "enabled": true, + "createdAt": "2025-01-01T00:00:00.000Z", + "updatedAt": "2025-01-01T00:00:00.000Z" + } + ], + "defaultEndpoint": "my-gpt4o", + "globalCacheSettings": { + "enabled": true, + "cacheDir": "~/.ccw/cache/context", + "maxTotalSizeMB": 100 + } +} +``` + +## Usage + +### Via CLI + +```bash +# Use custom endpoint with --model flag +ccw cli -p "Analyze authentication flow" --tool litellm --model my-gpt4o + +# With context patterns (automatically cached) +ccw cli -p "@src/auth/**/*.ts Review security" --tool litellm --model my-gpt4o + +# Disable caching for specific call +ccw cli -p "Quick question" --tool litellm --model my-gpt4o --no-cache +``` + +### Via Dashboard API + +#### Create Provider +```bash +curl -X POST http://localhost:3000/api/litellm-api/providers \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My OpenAI", + "type": "openai", + "apiKey": "${OPENAI_API_KEY}", + "enabled": true + }' +``` + +#### Create Endpoint +```bash +curl -X POST http://localhost:3000/api/litellm-api/endpoints \ + -H "Content-Type: application/json" \ + -d '{ + "id": "my-gpt4o", + "name": "GPT-4o with Cache", + "providerId": "openai-1234567890", + "model": "gpt-4o", + "cacheStrategy": { + "enabled": true, + "ttlMinutes": 60, + "maxSizeKB": 512, + "filePatterns": ["*.md", "*.ts"] + }, + "enabled": true + }' +``` + +#### Test Provider Connection +```bash +curl -X POST http://localhost:3000/api/litellm-api/providers/openai-1234567890/test +``` + +## Context Caching + +### How It Works + +1. **Pattern Detection**: LiteLLM executor scans prompt for `@patterns` + ``` + @src/**/*.ts + @CLAUDE.md + @../shared/**/* + ``` + +2. **File Packing**: Files matching patterns are packed via `context-cache` tool + - Respects `max_file_size` limit (default: 1MB per file) + - Applies TTL from endpoint config + - Generates session ID for retrieval + +3. **Cache Integration**: Cached content is prepended to prompt + ``` + + --- + + ``` + +4. **LLM Call**: Combined prompt sent to LiteLLM with provider credentials + +### Cache Strategy Configuration + +```typescript +interface CacheStrategy { + enabled: boolean; // Enable/disable caching for this endpoint + ttlMinutes: number; // Cache lifetime (default: 60) + maxSizeKB: number; // Max cache size (default: 512KB) + filePatterns: string[]; // Glob patterns to cache +} +``` + +### Example: Security Audit with Cache + +```bash +ccw cli -p " +PURPOSE: OWASP Top 10 security audit of authentication module +TASK: • Check SQL injection • Verify session management • Test XSS vectors +CONTEXT: @src/auth/**/*.ts @src/middleware/auth.ts +EXPECTED: Security report with severity levels and remediation steps +" --tool litellm --model my-security-scanner --mode analysis +``` + +**What happens:** +1. Executor detects `@src/auth/**/*.ts` and `@src/middleware/auth.ts` +2. Packs matching files into context cache +3. Cache entry valid for 60 minutes (per endpoint config) +4. Subsequent calls reuse cached files (no re-packing) +5. LiteLLM receives full context without manual file specification + +## Environment Variables + +### Provider API Keys + +LiteLLM uses standard environment variable names: + +| Provider | Env Var Name | +|------------|-----------------------| +| OpenAI | `OPENAI_API_KEY` | +| Anthropic | `ANTHROPIC_API_KEY` | +| Google | `GOOGLE_API_KEY` | +| Azure | `AZURE_API_KEY` | +| Mistral | `MISTRAL_API_KEY` | +| DeepSeek | `DEEPSEEK_API_KEY` | + +### Configuration Syntax + +Use `${ENV_VAR}` syntax in config: +```json +{ + "apiKey": "${OPENAI_API_KEY}" +} +``` + +The executor resolves these at runtime via `resolveEnvVar()`. + +## API Reference + +### Config Manager (`litellm-api-config-manager.ts`) + +#### Provider Management +```typescript +getAllProviders(baseDir: string): ProviderCredential[] +getProvider(baseDir: string, providerId: string): ProviderCredential | null +getProviderWithResolvedEnvVars(baseDir: string, providerId: string): ProviderCredential & { resolvedApiKey: string } | null +addProvider(baseDir: string, providerData): ProviderCredential +updateProvider(baseDir: string, providerId: string, updates): ProviderCredential +deleteProvider(baseDir: string, providerId: string): boolean +``` + +#### Endpoint Management +```typescript +getAllEndpoints(baseDir: string): CustomEndpoint[] +getEndpoint(baseDir: string, endpointId: string): CustomEndpoint | null +findEndpointById(baseDir: string, endpointId: string): CustomEndpoint | null +addEndpoint(baseDir: string, endpointData): CustomEndpoint +updateEndpoint(baseDir: string, endpointId: string, updates): CustomEndpoint +deleteEndpoint(baseDir: string, endpointId: string): boolean +``` + +### Executor (`litellm-executor.ts`) + +```typescript +interface LiteLLMExecutionOptions { + prompt: string; + endpointId: string; + baseDir: string; + cwd?: string; + includeDirs?: string[]; + enableCache?: boolean; + onOutput?: (data: { type: string; data: string }) => void; +} + +interface LiteLLMExecutionResult { + success: boolean; + output: string; + model: string; + provider: string; + cacheUsed: boolean; + cachedFiles?: string[]; + error?: string; +} + +executeLiteLLMEndpoint(options: LiteLLMExecutionOptions): Promise +extractPatterns(prompt: string): string[] +``` + +## Dashboard Integration + +The dashboard provides UI for managing LiteLLM configuration: + +- **Providers**: Add/edit/delete provider credentials +- **Endpoints**: Configure custom endpoints with cache strategies +- **Cache Stats**: View cache usage and clear entries +- **Test Connections**: Verify provider API access + +Routes are handled by `litellm-api-routes.ts`. + +## Limitations + +1. **Python Dependency**: Requires `ccw-litellm` Python package installed +2. **Model Support**: Limited to models supported by LiteLLM library +3. **Cache Scope**: Context cache is in-memory (not persisted across restarts) +4. **Pattern Syntax**: Only supports glob-style `@patterns`, not regex + +## Troubleshooting + +### Error: "Endpoint not found" +- Verify endpoint ID matches config file +- Check `litellm-api-config.json` exists in `.ccw/storage/config/` + +### Error: "API key not configured" +- Ensure environment variable is set +- Verify `${ENV_VAR}` syntax in config +- Test with `echo $OPENAI_API_KEY` + +### Error: "Failed to spawn Python process" +- Install ccw-litellm: `pip install ccw-litellm` +- Verify Python accessible: `python --version` + +### Cache Not Applied +- Check endpoint has `cacheStrategy.enabled: true` +- Verify prompt contains `@patterns` +- Check cache TTL hasn't expired + +## Examples + +See `examples/litellm-config.json` for complete configuration template. diff --git a/ccw/examples/litellm-usage.ts b/ccw/examples/litellm-usage.ts new file mode 100644 index 00000000..86f28259 --- /dev/null +++ b/ccw/examples/litellm-usage.ts @@ -0,0 +1,77 @@ +/** + * LiteLLM Usage Examples + * Demonstrates how to use the LiteLLM TypeScript client + */ + +import { getLiteLLMClient, getLiteLLMStatus } from '../src/tools/litellm-client'; + +async function main() { + console.log('=== LiteLLM TypeScript Bridge Examples ===\n'); + + // Example 1: Check availability + console.log('1. Checking LiteLLM availability...'); + const status = await getLiteLLMStatus(); + console.log(' Status:', status); + console.log(''); + + if (!status.available) { + console.log('❌ LiteLLM is not available. Please install ccw-litellm:'); + console.log(' pip install ccw-litellm'); + return; + } + + const client = getLiteLLMClient(); + + // Example 2: Get configuration + console.log('2. Getting configuration...'); + try { + const config = await client.getConfig(); + console.log(' Config:', config); + } catch (error) { + console.log(' Error:', error.message); + } + console.log(''); + + // Example 3: Generate embeddings + console.log('3. Generating embeddings...'); + try { + const texts = ['Hello world', 'Machine learning is amazing']; + const embedResult = await client.embed(texts, 'default'); + console.log(' Dimensions:', embedResult.dimensions); + console.log(' Vectors count:', embedResult.vectors.length); + console.log(' First vector (first 5 dims):', embedResult.vectors[0]?.slice(0, 5)); + } catch (error) { + console.log(' Error:', error.message); + } + console.log(''); + + // Example 4: Single message chat + console.log('4. Single message chat...'); + try { + const response = await client.chat('What is 2+2?', 'default'); + console.log(' Response:', response); + } catch (error) { + console.log(' Error:', error.message); + } + console.log(''); + + // Example 5: Multi-turn chat + console.log('5. Multi-turn chat...'); + try { + const chatResponse = await client.chatMessages([ + { role: 'system', content: 'You are a helpful math tutor.' }, + { role: 'user', content: 'What is the Pythagorean theorem?' } + ], 'default'); + console.log(' Content:', chatResponse.content); + console.log(' Model:', chatResponse.model); + console.log(' Usage:', chatResponse.usage); + } catch (error) { + console.log(' Error:', error.message); + } + console.log(''); + + console.log('=== Examples completed ==='); +} + +// Run examples +main().catch(console.error); diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index 6bf1e138..fd452212 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -855,7 +855,7 @@ export async function cliCommand( console.log(chalk.gray(' --model Model override')); console.log(chalk.gray(' --cd Working directory')); console.log(chalk.gray(' --includeDirs Additional directories')); - console.log(chalk.gray(' --timeout Timeout (default: 300000)')); + console.log(chalk.gray(' --timeout Timeout (default: 0=disabled)')); console.log(chalk.gray(' --resume [id] Resume previous session')); console.log(chalk.gray(' --cache Cache: comma-separated @patterns and text')); console.log(chalk.gray(' --inject-mode Inject mode: none, full, progressive')); diff --git a/ccw/src/commands/hook.ts b/ccw/src/commands/hook.ts index 1b710cf6..74533d56 100644 --- a/ccw/src/commands/hook.ts +++ b/ccw/src/commands/hook.ts @@ -6,7 +6,7 @@ import chalk from 'chalk'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; -import { tmpdir } from 'os'; +import { homedir } from 'os'; interface HookOptions { stdin?: boolean; @@ -53,9 +53,10 @@ async function readStdin(): Promise { /** * Get session state file path + * Uses ~/.claude/.ccw-sessions/ for reliable persistence across sessions */ function getSessionStateFile(sessionId: string): string { - const stateDir = join(tmpdir(), '.ccw-sessions'); + const stateDir = join(homedir(), '.claude', '.ccw-sessions'); if (!existsSync(stateDir)) { mkdirSync(stateDir, { recursive: true }); } diff --git a/ccw/src/config/.litellm-api-config-manager.ts.2025-12-23T11-57-43-727Z.bak b/ccw/src/config/.litellm-api-config-manager.ts.2025-12-23T11-57-43-727Z.bak new file mode 100644 index 00000000..c3022ff0 --- /dev/null +++ b/ccw/src/config/.litellm-api-config-manager.ts.2025-12-23T11-57-43-727Z.bak @@ -0,0 +1,441 @@ +/** + * LiteLLM API Config Manager + * Manages provider credentials, endpoint configurations, and model discovery + */ + +import { join } from 'path'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; + +// =========================== +// Type Definitions +// =========================== + +export type ProviderType = + | 'openai' + | 'anthropic' + | 'google' + | 'cohere' + | 'azure' + | 'bedrock' + | 'vertexai' + | 'huggingface' + | 'ollama' + | 'custom'; + +export interface ProviderCredential { + id: string; + name: string; + type: ProviderType; + apiKey?: string; + baseUrl?: string; + apiVersion?: string; + region?: string; + projectId?: string; + organizationId?: string; + enabled: boolean; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + +export interface EndpointConfig { + id: string; + name: string; + providerId: string; + model: string; + alias?: string; + temperature?: number; + maxTokens?: number; + topP?: number; + enabled: boolean; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + +export interface ModelInfo { + id: string; + name: string; + provider: ProviderType; + contextWindow: number; + supportsFunctions: boolean; + supportsStreaming: boolean; + inputCostPer1k?: number; + outputCostPer1k?: number; +} + +export interface LiteLLMApiConfig { + version: string; + providers: ProviderCredential[]; + endpoints: EndpointConfig[]; +} + +// =========================== +// Model Definitions +// =========================== + +export const PROVIDER_MODELS: Record = { + openai: [ + { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo', + provider: 'openai', + contextWindow: 128000, + supportsFunctions: true, + supportsStreaming: true, + inputCostPer1k: 0.01, + outputCostPer1k: 0.03, + }, + { + id: 'gpt-4', + name: 'GPT-4', + provider: 'openai', + contextWindow: 8192, + supportsFunctions: true, + supportsStreaming: true, + inputCostPer1k: 0.03, + outputCostPer1k: 0.06, + }, + { + id: 'gpt-3.5-turbo', + name: 'GPT-3.5 Turbo', + provider: 'openai', + contextWindow: 16385, + supportsFunctions: true, + supportsStreaming: true, + inputCostPer1k: 0.0005, + outputCostPer1k: 0.0015, + }, + ], + anthropic: [ + { + id: 'claude-3-opus-20240229', + name: 'Claude 3 Opus', + provider: 'anthropic', + contextWindow: 200000, + supportsFunctions: true, + supportsStreaming: true, + inputCostPer1k: 0.015, + outputCostPer1k: 0.075, + }, + { + id: 'claude-3-sonnet-20240229', + name: 'Claude 3 Sonnet', + provider: 'anthropic', + contextWindow: 200000, + supportsFunctions: true, + supportsStreaming: true, + inputCostPer1k: 0.003, + outputCostPer1k: 0.015, + }, + { + id: 'claude-3-haiku-20240307', + name: 'Claude 3 Haiku', + provider: 'anthropic', + contextWindow: 200000, + supportsFunctions: true, + supportsStreaming: true, + inputCostPer1k: 0.00025, + outputCostPer1k: 0.00125, + }, + ], + google: [ + { + id: 'gemini-pro', + name: 'Gemini Pro', + provider: 'google', + contextWindow: 32768, + supportsFunctions: true, + supportsStreaming: true, + }, + { + id: 'gemini-pro-vision', + name: 'Gemini Pro Vision', + provider: 'google', + contextWindow: 16384, + supportsFunctions: false, + supportsStreaming: true, + }, + ], + cohere: [ + { + id: 'command', + name: 'Command', + provider: 'cohere', + contextWindow: 4096, + supportsFunctions: false, + supportsStreaming: true, + }, + { + id: 'command-light', + name: 'Command Light', + provider: 'cohere', + contextWindow: 4096, + supportsFunctions: false, + supportsStreaming: true, + }, + ], + azure: [], + bedrock: [], + vertexai: [], + huggingface: [], + ollama: [], + custom: [], +}; + +// =========================== +// Config File Management +// =========================== + +const CONFIG_DIR = join(homedir(), '.claude', 'litellm'); +const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); + +function ensureConfigDir(): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } +} + +function loadConfig(): LiteLLMApiConfig { + ensureConfigDir(); + + if (!existsSync(CONFIG_FILE)) { + const defaultConfig: LiteLLMApiConfig = { + version: '1.0.0', + providers: [], + endpoints: [], + }; + saveConfig(defaultConfig); + return defaultConfig; + } + + try { + const content = readFileSync(CONFIG_FILE, 'utf-8'); + return JSON.parse(content); + } catch (err) { + throw new Error(`Failed to load config: ${(err as Error).message}`); + } +} + +function saveConfig(config: LiteLLMApiConfig): void { + ensureConfigDir(); + + try { + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); + } catch (err) { + throw new Error(`Failed to save config: ${(err as Error).message}`); + } +} + +// =========================== +// Provider Management +// =========================== + +export function getAllProviders(): ProviderCredential[] { + const config = loadConfig(); + return config.providers; +} + +export function getProvider(id: string): ProviderCredential | null { + const config = loadConfig(); + return config.providers.find((p) => p.id === id) || null; +} + +export function createProvider( + data: Omit +): ProviderCredential { + const config = loadConfig(); + + const now = new Date().toISOString(); + const provider: ProviderCredential = { + ...data, + id: `provider-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + createdAt: now, + updatedAt: now, + }; + + config.providers.push(provider); + saveConfig(config); + + return provider; +} + +export function updateProvider( + id: string, + updates: Partial +): ProviderCredential | null { + const config = loadConfig(); + + const index = config.providers.findIndex((p) => p.id === id); + if (index === -1) { + return null; + } + + const updated: ProviderCredential = { + ...config.providers[index], + ...updates, + id, + updatedAt: new Date().toISOString(), + }; + + config.providers[index] = updated; + saveConfig(config); + + return updated; +} + +export function deleteProvider(id: string): { success: boolean } { + const config = loadConfig(); + + const index = config.providers.findIndex((p) => p.id === id); + if (index === -1) { + return { success: false }; + } + + config.providers.splice(index, 1); + + // Also delete endpoints using this provider + config.endpoints = config.endpoints.filter((e) => e.providerId !== id); + + saveConfig(config); + + return { success: true }; +} + +export async function testProviderConnection( + providerId: string +): Promise<{ success: boolean; error?: string }> { + const provider = getProvider(providerId); + + if (!provider) { + return { success: false, error: 'Provider not found' }; + } + + if (!provider.enabled) { + return { success: false, error: 'Provider is disabled' }; + } + + // Basic validation + if (!provider.apiKey && provider.type !== 'ollama' && provider.type !== 'custom') { + return { success: false, error: 'API key is required for this provider type' }; + } + + // TODO: Implement actual provider connection testing using litellm-client + // For now, just validate the configuration + return { success: true }; +} + +// =========================== +// Endpoint Management +// =========================== + +export function getAllEndpoints(): EndpointConfig[] { + const config = loadConfig(); + return config.endpoints; +} + +export function getEndpoint(id: string): EndpointConfig | null { + const config = loadConfig(); + return config.endpoints.find((e) => e.id === id) || null; +} + +export function createEndpoint( + data: Omit +): EndpointConfig { + const config = loadConfig(); + + // Validate provider exists + const provider = config.providers.find((p) => p.id === data.providerId); + if (!provider) { + throw new Error('Provider not found'); + } + + const now = new Date().toISOString(); + const endpoint: EndpointConfig = { + ...data, + id: `endpoint-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + createdAt: now, + updatedAt: now, + }; + + config.endpoints.push(endpoint); + saveConfig(config); + + return endpoint; +} + +export function updateEndpoint( + id: string, + updates: Partial +): EndpointConfig | null { + const config = loadConfig(); + + const index = config.endpoints.findIndex((e) => e.id === id); + if (index === -1) { + return null; + } + + // Validate provider if being updated + if (updates.providerId) { + const provider = config.providers.find((p) => p.id === updates.providerId); + if (!provider) { + throw new Error('Provider not found'); + } + } + + const updated: EndpointConfig = { + ...config.endpoints[index], + ...updates, + id, + updatedAt: new Date().toISOString(), + }; + + config.endpoints[index] = updated; + saveConfig(config); + + return updated; +} + +export function deleteEndpoint(id: string): { success: boolean } { + const config = loadConfig(); + + const index = config.endpoints.findIndex((e) => e.id === id); + if (index === -1) { + return { success: false }; + } + + config.endpoints.splice(index, 1); + saveConfig(config); + + return { success: true }; +} + +// =========================== +// Model Discovery +// =========================== + +export function getModelsForProviderType(providerType: ProviderType): ModelInfo[] | null { + return PROVIDER_MODELS[providerType] || null; +} + +export function getAllModels(): Record { + return PROVIDER_MODELS; +} + +// =========================== +// Config Access +// =========================== + +export function getFullConfig(): LiteLLMApiConfig { + return loadConfig(); +} + +export function resetConfig(): void { + const defaultConfig: LiteLLMApiConfig = { + version: '1.0.0', + providers: [], + endpoints: [], + }; + saveConfig(defaultConfig); +} diff --git a/ccw/src/config/provider-models.ts b/ccw/src/config/provider-models.ts index 86c1e4c2..4bcdf240 100644 --- a/ccw/src/config/provider-models.ts +++ b/ccw/src/config/provider-models.ts @@ -25,10 +25,33 @@ export interface ModelInfo { } /** - * Predefined models for each provider + * Embedding model information metadata + */ +export interface EmbeddingModelInfo { + /** Model identifier (used in API calls) */ + id: string; + + /** Human-readable display name */ + name: string; + + /** Embedding dimensions */ + dimensions: number; + + /** Maximum input tokens */ + maxTokens: number; + + /** Provider identifier */ + provider: string; +} + + +/** + * Predefined models for each API format * Used for UI selection and validation + * Note: Most providers use OpenAI-compatible format */ export const PROVIDER_MODELS: Record = { + // OpenAI-compatible format (used by OpenAI, DeepSeek, Ollama, etc.) openai: [ { id: 'gpt-4o', @@ -49,19 +72,32 @@ export const PROVIDER_MODELS: Record = { supportsCaching: true }, { - id: 'o1-mini', - name: 'O1 Mini', - contextWindow: 128000, - supportsCaching: true + id: 'deepseek-chat', + name: 'DeepSeek Chat', + contextWindow: 64000, + supportsCaching: false }, { - id: 'gpt-4-turbo', - name: 'GPT-4 Turbo', + id: 'deepseek-coder', + name: 'DeepSeek Coder', + contextWindow: 64000, + supportsCaching: false + }, + { + id: 'llama3.2', + name: 'Llama 3.2', contextWindow: 128000, supportsCaching: false + }, + { + id: 'qwen2.5-coder', + name: 'Qwen 2.5 Coder', + contextWindow: 32000, + supportsCaching: false } ], + // Anthropic format anthropic: [ { id: 'claude-sonnet-4-20250514', @@ -89,135 +125,7 @@ export const PROVIDER_MODELS: Record = { } ], - ollama: [ - { - id: 'llama3.2', - name: 'Llama 3.2', - contextWindow: 128000, - supportsCaching: false - }, - { - id: 'llama3.1', - name: 'Llama 3.1', - contextWindow: 128000, - supportsCaching: false - }, - { - id: 'qwen2.5-coder', - name: 'Qwen 2.5 Coder', - contextWindow: 32000, - supportsCaching: false - }, - { - id: 'codellama', - name: 'Code Llama', - contextWindow: 16000, - supportsCaching: false - }, - { - id: 'mistral', - name: 'Mistral', - contextWindow: 32000, - supportsCaching: false - } - ], - - azure: [ - { - id: 'gpt-4o', - name: 'GPT-4o (Azure)', - contextWindow: 128000, - supportsCaching: true - }, - { - id: 'gpt-4o-mini', - name: 'GPT-4o Mini (Azure)', - contextWindow: 128000, - supportsCaching: true - }, - { - id: 'gpt-4-turbo', - name: 'GPT-4 Turbo (Azure)', - contextWindow: 128000, - supportsCaching: false - }, - { - id: 'gpt-35-turbo', - name: 'GPT-3.5 Turbo (Azure)', - contextWindow: 16000, - supportsCaching: false - } - ], - - google: [ - { - id: 'gemini-2.0-flash-exp', - name: 'Gemini 2.0 Flash Experimental', - contextWindow: 1048576, - supportsCaching: true - }, - { - id: 'gemini-1.5-pro', - name: 'Gemini 1.5 Pro', - contextWindow: 2097152, - supportsCaching: true - }, - { - id: 'gemini-1.5-flash', - name: 'Gemini 1.5 Flash', - contextWindow: 1048576, - supportsCaching: true - }, - { - id: 'gemini-1.0-pro', - name: 'Gemini 1.0 Pro', - contextWindow: 32000, - supportsCaching: false - } - ], - - mistral: [ - { - id: 'mistral-large-latest', - name: 'Mistral Large', - contextWindow: 128000, - supportsCaching: false - }, - { - id: 'mistral-medium-latest', - name: 'Mistral Medium', - contextWindow: 32000, - supportsCaching: false - }, - { - id: 'mistral-small-latest', - name: 'Mistral Small', - contextWindow: 32000, - supportsCaching: false - }, - { - id: 'codestral-latest', - name: 'Codestral', - contextWindow: 32000, - supportsCaching: false - } - ], - - deepseek: [ - { - id: 'deepseek-chat', - name: 'DeepSeek Chat', - contextWindow: 64000, - supportsCaching: false - }, - { - id: 'deepseek-coder', - name: 'DeepSeek Coder', - contextWindow: 64000, - supportsCaching: false - } - ], - + // Custom format custom: [ { id: 'custom-model', @@ -237,6 +145,61 @@ export function getModelsForProvider(providerType: ProviderType): ModelInfo[] { return PROVIDER_MODELS[providerType] || []; } +/** + * Predefined embedding models for each API format + * Used for UI selection and validation + */ +export const EMBEDDING_MODELS: Record = { + // OpenAI embedding models + openai: [ + { + id: 'text-embedding-3-small', + name: 'Text Embedding 3 Small', + dimensions: 1536, + maxTokens: 8191, + provider: 'openai' + }, + { + id: 'text-embedding-3-large', + name: 'Text Embedding 3 Large', + dimensions: 3072, + maxTokens: 8191, + provider: 'openai' + }, + { + id: 'text-embedding-ada-002', + name: 'Ada 002', + dimensions: 1536, + maxTokens: 8191, + provider: 'openai' + } + ], + + // Anthropic doesn't have embedding models + anthropic: [], + + // Custom embedding models + custom: [ + { + id: 'custom-embedding', + name: 'Custom Embedding', + dimensions: 1536, + maxTokens: 8192, + provider: 'custom' + } + ] +}; + +/** + * Get embedding models for a specific provider + * @param providerType - Provider type to get embedding models for + * @returns Array of embedding model information + */ +export function getEmbeddingModelsForProvider(providerType: ProviderType): EmbeddingModelInfo[] { + return EMBEDDING_MODELS[providerType] || []; +} + + /** * Get model information by ID within a provider * @param providerType - Provider type diff --git a/ccw/src/core/routes/hooks-routes.ts b/ccw/src/core/routes/hooks-routes.ts index c316c5f9..7d85647e 100644 --- a/ccw/src/core/routes/hooks-routes.ts +++ b/ccw/src/core/routes/hooks-routes.ts @@ -181,29 +181,13 @@ function deleteHookFromSettings(projectPath, scope, event, hookIndex) { } // ======================================== -// Session State Tracking (for progressive disclosure) +// Session State Tracking // ======================================== - -// Track sessions that have received startup context -// Key: sessionId, Value: timestamp of first context load -const sessionContextState = new Map(); - -// Cleanup old sessions (older than 24 hours) -function cleanupOldSessions() { - const cutoff = Date.now() - 24 * 60 * 60 * 1000; - for (const [sessionId, state] of sessionContextState.entries()) { - if (new Date(state.firstLoad).getTime() < cutoff) { - sessionContextState.delete(sessionId); - } - } -} - -// Run cleanup every hour -setInterval(cleanupOldSessions, 60 * 60 * 1000); +// NOTE: Session state is managed by the CLI command (src/commands/hook.ts) +// using file-based persistence (~/.claude/.ccw-sessions/). +// This ensures consistent state tracking across all invocation methods. +// The /api/hook endpoint delegates to SessionClusteringService without +// managing its own state, as the authoritative state lives in the CLI layer. // ======================================== // Route Handler @@ -286,7 +270,8 @@ export async function handleHooksRoutes(ctx: RouteContext): Promise { } // API: Unified Session Context endpoint (Progressive Disclosure) - // Automatically detects first prompt vs subsequent prompts + // DEPRECATED: Use CLI command `ccw hook session-context --stdin` instead. + // This endpoint now uses file-based state (shared with CLI) for consistency. // - First prompt: returns cluster-based session overview // - Subsequent prompts: returns intent-matched sessions based on prompt if (pathname === '/api/hook/session-context' && req.method === 'POST') { @@ -306,21 +291,30 @@ export async function handleHooksRoutes(ctx: RouteContext): Promise { const { SessionClusteringService } = await import('../session-clustering-service.js'); const clusteringService = new SessionClusteringService(projectPath); - // Check if this is the first prompt for this session - const existingState = sessionContextState.get(sessionId); + // Use file-based session state (shared with CLI hook.ts) + const sessionStateDir = join(homedir(), '.claude', '.ccw-sessions'); + const sessionStateFile = join(sessionStateDir, `session-${sessionId}.json`); + + let existingState: { firstLoad: string; loadCount: number; lastPrompt?: string } | null = null; + if (existsSync(sessionStateFile)) { + try { + existingState = JSON.parse(readFileSync(sessionStateFile, 'utf-8')); + } catch { + existingState = null; + } + } + const isFirstPrompt = !existingState; - // Update session state - if (isFirstPrompt) { - sessionContextState.set(sessionId, { - firstLoad: new Date().toISOString(), - loadCount: 1, - lastPrompt: prompt - }); - } else { - existingState.loadCount++; - existingState.lastPrompt = prompt; + // Update session state (file-based) + const newState = isFirstPrompt + ? { firstLoad: new Date().toISOString(), loadCount: 1, lastPrompt: prompt } + : { ...existingState!, loadCount: existingState!.loadCount + 1, lastPrompt: prompt }; + + if (!existsSync(sessionStateDir)) { + mkdirSync(sessionStateDir, { recursive: true }); } + writeFileSync(sessionStateFile, JSON.stringify(newState, null, 2)); // Determine which type of context to return let contextType: 'session-start' | 'context'; @@ -351,7 +345,7 @@ export async function handleHooksRoutes(ctx: RouteContext): Promise { success: true, type: contextType, isFirstPrompt, - loadCount: sessionContextState.get(sessionId)?.loadCount || 1, + loadCount: newState.loadCount, content, sessionId }; diff --git a/ccw/src/templates/dashboard-css/31-api-settings.css b/ccw/src/templates/dashboard-css/31-api-settings.css index f90c2e3e..4b32bd47 100644 --- a/ccw/src/templates/dashboard-css/31-api-settings.css +++ b/ccw/src/templates/dashboard-css/31-api-settings.css @@ -1,91 +1,186 @@ /* ======================================== - * API Settings Styles + * API Settings Styles - Modern UI * ======================================== */ -/* Main Container */ +/* Main Layout */ .api-settings-container { display: flex; flex-direction: column; - gap: 1.5rem; - padding: 1rem; + gap: 2rem; + padding: 1.5rem; + max-width: 1400px; + margin: 0 auto; } -/* Section Styles */ +/* Section Containers */ .api-settings-section { - background: hsl(var(--card)); - border: 1px solid hsl(var(--border)); - border-radius: 0.75rem; - padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 1.25rem; + animation: fadeIn 0.3s ease-in-out; } +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Section Headers */ .section-header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 1rem; - padding-bottom: 0.75rem; + padding-bottom: 0.5rem; border-bottom: 1px solid hsl(var(--border)); } -.section-header h3 { - font-size: 1rem; - font-weight: 600; - color: hsl(var(--foreground)); - margin: 0; -} - -/* Settings List */ -.api-settings-list { +.section-title-group { display: flex; - flex-direction: column; + align-items: center; gap: 0.75rem; } -/* Settings Card */ -.api-settings-card { - background: hsl(var(--background)); +.section-header h3 { + font-size: 1.125rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; + letter-spacing: -0.01em; +} + +.section-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.125rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: 9999px; + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); +} + +/* Grid Layout */ +.api-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.25rem; +} + +/* Cards */ +.api-card { + position: relative; + background: hsl(var(--card)); border: 1px solid hsl(var(--border)); - border-radius: 0.5rem; - padding: 1rem; + border-radius: 0.75rem; + padding: 1.25rem; transition: all 0.2s ease; + display: flex; + flex-direction: column; + gap: 1rem; } -.api-settings-card:hover { - border-color: hsl(var(--primary) / 0.3); - box-shadow: 0 2px 8px hsl(var(--primary) / 0.1); +.api-card:hover { + border-color: hsl(var(--primary) / 0.5); + box-shadow: 0 4px 12px hsl(var(--primary) / 0.05); + transform: translateY(-2px); } -.api-settings-card.disabled { - opacity: 0.6; - background: hsl(var(--muted) / 0.3); +.api-card.disabled { + opacity: 0.75; + background: hsl(var(--muted) / 0.1); + border-style: dashed; } /* Card Header */ .card-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - margin-bottom: 0.75rem; + gap: 1rem; } -.card-info { +.card-title-group { display: flex; align-items: center; gap: 0.75rem; - flex: 1; + min-width: 0; } -.card-info h4 { +.card-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 0.5rem; + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); + flex-shrink: 0; +} + +.provider-icon-openai { background: hsl(142 76% 36% / 0.1); color: hsl(142 76% 36%); } +.provider-icon-anthropic { background: hsl(22 90% 50% / 0.1); color: hsl(22 90% 50%); } +.provider-icon-google { background: hsl(217 91% 60% / 0.1); color: hsl(217 91% 60%); } +.provider-icon-azure { background: hsl(199 89% 48% / 0.1); color: hsl(199 89% 48%); } +.provider-icon-ollama { background: hsl(270 60% 60% / 0.1); color: hsl(270 60% 60%); } +.provider-icon-custom { background: hsl(var(--muted)); color: hsl(var(--foreground)); } + +.card-info { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.card-title { font-size: 0.9375rem; font-weight: 600; color: hsl(var(--foreground)); margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } +.card-subtitle { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + display: flex; + align-items: center; + gap: 0.375rem; +} + +/* Card Actions */ .card-actions { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.25rem; + margin-left: auto; +} + +.btn-icon-sm { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.375rem; + color: hsl(var(--muted-foreground)); + transition: all 0.2s; + background: transparent; + border: none; + cursor: pointer; +} + +.btn-icon-sm:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); +} + +.btn-icon-sm.text-destructive:hover { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); } /* Card Body */ @@ -95,22 +190,75 @@ gap: 0.75rem; } -.card-meta { +.card-meta-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; +} + +.meta-item { display: flex; - flex-wrap: wrap; - gap: 1rem; + flex-direction: column; + gap: 0.125rem; +} + +.meta-label { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: hsl(var(--muted-foreground)); + font-weight: 500; +} + +.meta-value { font-size: 0.8125rem; + color: hsl(var(--foreground)); + font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Card Footer */ +.card-footer { + margin-top: auto; + padding-top: 1rem; + border-top: 1px solid hsl(var(--border) / 0.5); + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: 0.25rem; + line-height: 1.25; +} + +.badge-outline { + border: 1px solid hsl(var(--border)); color: hsl(var(--muted-foreground)); } -.card-meta span { - display: flex; - align-items: center; - gap: 0.375rem; +.badge-primary { + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); } -.card-meta i { - font-size: 0.875rem; +.badge-success { + background: hsl(142 76% 36% / 0.1); + color: hsl(142 76% 36%); +} + +.badge-warning { + background: hsl(45 93% 47% / 0.1); + color: hsl(45 93% 47%); } /* Provider Type Badge */ @@ -127,6 +275,213 @@ letter-spacing: 0.03em; } +/* Status Badge */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.625rem; + font-size: 0.6875rem; + font-weight: 600; + border-radius: 9999px; +} + +.status-badge.status-enabled { + background: hsl(142 76% 36% / 0.1); + color: hsl(142 76% 36%); +} + +.status-badge.status-disabled { + background: hsl(var(--muted) / 0.5); + color: hsl(var(--muted-foreground)); +} + +/* Toggle Switch */ +.toggle-switch { + position: relative; + display: inline-flex; + align-items: center; + cursor: pointer; +} + +.toggle-switch input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.toggle-track { + width: 2.25rem; + height: 1.25rem; + background-color: hsl(var(--muted)); + border-radius: 9999px; + transition: background-color 0.2s; + position: relative; +} + +.toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 1rem; + height: 1rem; + background-color: white; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.toggle-switch input:checked + .toggle-track { + background-color: hsl(var(--primary)); +} + +.toggle-switch input:checked + .toggle-track .toggle-thumb { + transform: translateX(1rem); +} + +.toggle-label { + margin-left: 0.5rem; + font-size: 0.875rem; + color: hsl(var(--foreground)); + white-space: nowrap; + flex-shrink: 0; +} + +/* Cache Settings Panel */ +.cache-panel { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + overflow: hidden; +} + +.cache-header { + padding: 1.25rem; + background: hsl(var(--muted) / 0.3); + border-bottom: 1px solid hsl(var(--border)); + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; +} + +.cache-header .section-title-group { + flex-shrink: 0; +} + +.cache-header .toggle-switch { + flex-shrink: 0; +} + +.cache-content { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.cache-visual { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.cache-bars { + height: 1.5rem; + background: hsl(var(--muted) / 0.5); + border-radius: 0.5rem; + overflow: hidden; + position: relative; + display: flex; +} + +.cache-bar-fill { + height: 100%; + background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--primary) / 0.8)); + min-width: 2px; + transition: width 0.5s ease; +} + +.cache-legend { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; +} + +.stat-card { + background: hsl(var(--background)); + padding: 1rem; + border-radius: 0.5rem; + border: 1px solid hsl(var(--border)); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.stat-value { + font-size: 1.25rem; + font-weight: 700; + color: hsl(var(--foreground)); +} + +.stat-desc { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + background: hsl(var(--card)); + border: 1px dashed hsl(var(--border)); + border-radius: 0.75rem; + text-align: center; +} + +.empty-icon-wrapper { + width: 4rem; + height: 4rem; + background: hsl(var(--muted) / 0.5); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; + color: hsl(var(--muted-foreground)); +} + +.empty-state h4 { + font-size: 1rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0 0 0.5rem 0; +} + +.empty-state p { + font-size: 0.875rem; + color: hsl(var(--muted-foreground)); + max-width: 400px; + margin: 0; +} + /* Endpoint ID */ .endpoint-id { font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace; @@ -154,112 +509,16 @@ font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace; font-size: 0.6875rem; color: hsl(var(--foreground)); -} - -/* Status Badge */ -.status-badge { - display: inline-flex; - align-items: center; - padding: 0.25rem 0.625rem; - font-size: 0.6875rem; - font-weight: 600; - border-radius: 9999px; -} - -.status-badge.status-enabled { - background: hsl(142 76% 36% / 0.1); - color: hsl(142 76% 36%); -} - -.status-badge.status-disabled { background: hsl(var(--muted) / 0.5); - color: hsl(var(--muted-foreground)); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; } -/* Empty State */ -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 2.5rem 1rem; - text-align: center; - color: hsl(var(--muted-foreground)); -} - -.empty-icon { - font-size: 3rem; - opacity: 0.3; - margin-bottom: 0.75rem; -} - -.empty-state p { - font-size: 0.875rem; - margin: 0; -} - -/* Cache Settings Panel */ -.cache-settings-panel { - padding: 1rem; -} - -.cache-settings-content { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.cache-stats { - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 1rem; - background: hsl(var(--muted) / 0.3); - border-radius: 0.5rem; -} - -.stat-item { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 0.8125rem; -} - -.stat-label { - color: hsl(var(--muted-foreground)); - font-weight: 500; -} - -.stat-value { - color: hsl(var(--foreground)); - font-weight: 600; - font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace; -} - -/* Progress Bar */ -.progress-bar { - width: 100%; - height: 8px; - background: hsl(var(--muted) / 0.5); - border-radius: 9999px; - overflow: hidden; -} - -.progress-fill { - height: 100%; - background: hsl(var(--primary)); - border-radius: 9999px; - transition: width 0.3s ease; -} - -/* ======================================== - * Form Styles - * ======================================== */ - +/* Form Enhancements */ .api-settings-form { display: flex; flex-direction: column; - gap: 1rem; + gap: 1.25rem; } .form-group { @@ -269,15 +528,124 @@ } .form-group label { - font-size: 0.8125rem; + font-size: 0.875rem; font-weight: 500; + margin-bottom: 0.25rem; + display: block; color: hsl(var(--foreground)); } +/* Input Styles */ +.cli-input { + width: 100%; + padding: 0.625rem 0.875rem; + font-size: 0.875rem; + line-height: 1.5; + color: hsl(var(--foreground)); + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.cli-input:focus { + outline: none; + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1); +} + +.cli-input:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: hsl(var(--muted) / 0.3); +} + +.cli-input::placeholder { + color: hsl(var(--muted-foreground)); +} + +/* Select Styles */ +select.cli-input { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; +} + +/* Checkbox Label */ +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: hsl(var(--foreground)); + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: 1rem; + height: 1rem; + border-radius: 0.25rem; + border: 1px solid hsl(var(--border)); + cursor: pointer; + accent-color: hsl(var(--primary)); +} + +/* Form Hint */ .form-hint { font-size: 0.75rem; color: hsl(var(--muted-foreground)); - font-style: italic; + margin-top: 0.25rem; +} + +/* Form Fieldset */ +.form-fieldset { + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + padding: 1rem; + margin: 0; +} + +.form-fieldset legend { + font-size: 0.875rem; + font-weight: 500; + padding: 0 0.5rem; + color: hsl(var(--foreground)); +} + +/* Modal Actions */ +.modal-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid hsl(var(--border)); +} + +/* Button Icon */ +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: all 0.2s; +} + +.btn-icon:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); } .text-muted { @@ -285,6 +653,44 @@ font-weight: 400; } +.input-with-icon { + position: relative; + display: flex; +} + +.input-with-icon input { + padding-right: 2.5rem; + flex: 1; +} + +.input-icon-btn { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + padding: 0.25rem; + background: transparent; + border: none; + color: hsl(var(--muted-foreground)); + cursor: pointer; + border-radius: 0.25rem; +} + +.input-icon-btn:hover { + color: hsl(var(--foreground)); + background: hsl(var(--muted)); +} + +/* Modal Footer */ +.modal-footer { + padding: 1rem 1.5rem; + background: hsl(var(--muted) / 0.3); + border-top: 1px solid hsl(var(--border)); + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + /* API Key Input Group */ .api-key-input-group { display: flex; @@ -295,96 +701,6 @@ flex: 1; } -.api-key-input-group .btn-icon { - flex-shrink: 0; -} - -/* Checkbox Label */ -.checkbox-label { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.8125rem; - color: hsl(var(--foreground)); - cursor: pointer; -} - -.checkbox-label input[type="checkbox"] { - width: 1rem; - height: 1rem; - cursor: pointer; -} - -/* Fieldset */ -.form-fieldset { - border: 1px solid hsl(var(--border)); - border-radius: 0.5rem; - padding: 1rem; - margin: 0; -} - -.form-fieldset legend { - font-size: 0.875rem; - font-weight: 600; - color: hsl(var(--foreground)); - padding: 0 0.5rem; -} - -/* Modal Actions */ -.modal-actions { - display: flex; - gap: 0.75rem; - justify-content: flex-end; - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid hsl(var(--border)); -} - -/* ======================================== - * Responsive Design - * ======================================== */ - -@media (min-width: 768px) { - .api-settings-container { - padding: 1.5rem; - } - - .card-meta { - gap: 1.5rem; - } -} - -@media (max-width: 640px) { - .section-header { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - - .card-header { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - - .card-actions { - align-self: flex-end; - } - - .card-meta { - flex-direction: column; - gap: 0.5rem; - } - - .modal-actions { - flex-direction: column; - } - - .modal-actions .btn { - width: 100%; - } -} - /* Error Message */ .error-message { display: flex; @@ -395,3 +711,1209 @@ color: hsl(var(--destructive)); text-align: center; } + +/* Responsive adjustments */ +@media (max-width: 640px) { + .api-grid { + grid-template-columns: 1fr; + } + + .card-meta-grid { + grid-template-columns: 1fr; + } + + .section-header { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .section-header .btn { + width: 100%; + } + + .cache-header { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } +} + +@media (min-width: 768px) { + .api-settings-container { + padding: 2rem; + } +} + + +/* ======================================== + * Advanced Settings - Collapsible Panel + * ======================================== */ + +.advanced-settings-fieldset { + margin-top: 0.5rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + padding: 0; + overflow: hidden; +} + +.advanced-settings-legend { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.75rem 1rem; + margin: 0; + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + background: hsl(var(--muted) / 0.3); + cursor: pointer; + transition: all 0.2s; + border: none; +} + +.advanced-settings-legend:hover { + background: hsl(var(--muted) / 0.5); + color: hsl(var(--foreground)); +} + +.advanced-toggle-icon { + width: 1rem; + height: 1rem; + color: hsl(var(--muted-foreground)); + transition: transform 0.2s; +} + +.advanced-settings-legend.expanded .advanced-toggle-icon { + transform: rotate(90deg); +} + +.advanced-settings-content { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + border-top: 1px solid hsl(var(--border)); +} + +.advanced-settings-content.collapsed { + display: none; +} + +/* Form Row for Side-by-Side Fields */ +.form-row { + display: flex; + gap: 1rem; +} + +.form-group-half { + flex: 1; + min-width: 0; +} + +/* Textarea Styling */ +.cli-textarea { + resize: vertical; + min-height: 4rem; + max-height: 12rem; + font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace; + font-size: 0.8125rem; +} + +/* Provider-Specific Fields */ +.provider-specific { + transition: opacity 0.2s ease, max-height 0.2s ease; +} + +/* Responsive Adjustments for Advanced Settings */ +@media (max-width: 480px) { + .form-row { + flex-direction: column; + } + + .form-group-half { + width: 100%; + } +} + + +/* ======================================== + * Split Layout - Provider List + Detail + * ======================================== */ + +/* Main Split Container */ +.api-settings-container.api-settings-split { + display: flex; + flex-direction: row; + height: calc(100vh - 180px); + min-height: 500px; + gap: 0; + padding: 0; + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + overflow: hidden; + background: hsl(var(--card)); +} + +/* Left Sidebar */ +.api-settings-sidebar { + width: 280px; + min-width: 240px; + border-right: 1px solid hsl(var(--border)); + display: flex; + flex-direction: column; + background: hsl(var(--background)); +} + +/* =========================== + Sidebar Tabs + =========================== */ + +.sidebar-tabs { + display: flex; + gap: 0; + padding: 0.5rem; + background: hsl(var(--muted) / 0.3); + border-bottom: 1px solid hsl(var(--border)); +} + +.sidebar-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.5rem 0.25rem; + font-size: 0.75rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + background: transparent; + border: none; + border-radius: 0.375rem; + cursor: pointer; + transition: all 0.2s; +} + +.sidebar-tab:hover { + color: hsl(var(--foreground)); + background: hsl(var(--muted) / 0.5); +} + +.sidebar-tab.active { + color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.1); +} + +.sidebar-tab i { + width: 14px; + height: 14px; +} + +/* Sidebar content areas */ +.sidebar-content { + flex: 1; + overflow-y: auto; +} + +/* Cache panel in sidebar */ +.cache-sidebar-panel { + padding: 1rem; +} + +.cache-sidebar-panel h4 { + margin: 0 0 1rem 0; + font-size: 0.9rem; + font-weight: 600; +} + +.cache-field { + margin-bottom: 1rem; +} + +.cache-field label { + display: block; + font-size: 0.8rem; + color: hsl(var(--muted-foreground)); + margin-bottom: 0.25rem; +} + +.cache-field input { + width: 100%; +} + +/* Endpoints list in sidebar */ +.endpoints-sidebar-list { + padding: 0.5rem; +} + +.endpoint-sidebar-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + margin-bottom: 0.5rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s; +} + +.endpoint-sidebar-item:hover { + border-color: hsl(var(--primary) / 0.5); +} + +.endpoint-sidebar-item.selected { + border-color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.05); +} + +.endpoint-sidebar-info { + flex: 1; + min-width: 0; +} + +.endpoint-sidebar-name { + font-weight: 500; + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.endpoint-sidebar-model { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Empty state for endpoints */ +.endpoints-empty-state { + text-align: center; + padding: 2rem 1rem; + color: hsl(var(--muted-foreground)); +} + +.endpoints-empty-state i { + width: 48px; + height: 48px; + margin-bottom: 1rem; + opacity: 0.5; +} + +/* Responsive adjustments for tabs */ +@media (max-width: 768px) { + .sidebar-tab { + padding: 0.5rem 0.5rem; + font-size: 0.7rem; + } + + .sidebar-tab i { + width: 12px; + height: 12px; + } +} + + +/* Provider Search */ +.provider-search { + position: relative; + padding: 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.provider-search input { + width: 100%; + padding-left: 2.25rem; +} + +.provider-search .search-icon { + position: absolute; + left: 1.5rem; + top: 50%; + transform: translateY(-50%); + width: 1rem; + height: 1rem; + color: hsl(var(--muted-foreground)); + pointer-events: none; +} + +/* Provider List */ +.provider-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.provider-list-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + margin-bottom: 0.25rem; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.15s ease; + border: 1px solid transparent; +} + +.provider-list-item:hover { + background: hsl(var(--muted) / 0.5); +} + +.provider-list-item.selected { + background: hsl(var(--primary) / 0.1); + border-color: hsl(var(--primary) / 0.3); +} + +.provider-item-icon { + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 0.875rem; + font-weight: 600; +} + +.provider-item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.provider-item-name { + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.provider-item-type { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.provider-list-footer { + padding: 1rem; + border-top: 1px solid hsl(var(--border)); +} + +.btn-full { + width: 100%; +} + +/* Right Main Panel */ +.api-settings-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Provider Detail Header */ +.provider-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.2); +} + +.provider-detail-title { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.provider-detail-title h2 { + font-size: 1.125rem; + font-weight: 600; + margin: 0; +} + +.provider-detail-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +/* Provider Detail Content */ +.provider-detail-content { + flex: 1; + overflow-y: auto; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* Field Groups */ +.field-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.field-label { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--foreground)); +} + +.field-label-actions { + display: flex; + gap: 0.5rem; +} + +.field-input-group { + display: flex; + gap: 0.5rem; +} + +.field-input-group input { + flex: 1; +} + +.field-hint { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +/* Model Section */ +.model-section { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 200px; +} + +.model-section-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.model-tabs { + display: flex; + gap: 0.25rem; + padding: 0.25rem; + background: hsl(var(--muted) / 0.5); + border-radius: 0.5rem; +} + +.model-tab { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + background: transparent; + border: none; + border-radius: 0.375rem; + cursor: pointer; + transition: all 0.15s; +} + +.model-tab:hover { + color: hsl(var(--foreground)); +} + +.model-tab.active { + background: hsl(var(--background)); + color: hsl(var(--foreground)); + box-shadow: 0 1px 2px hsl(var(--foreground) / 0.05); +} + +.model-section-actions { + display: flex; + gap: 0.5rem; +} + +/* Model Tree */ +.model-tree { + flex: 1; + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + overflow: hidden; +} + +.model-tree-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + color: hsl(var(--muted-foreground)); + text-align: center; +} + +.model-tree-empty-icon { + width: 3rem; + height: 3rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.model-group { + border-bottom: 1px solid hsl(var(--border)); +} + +.model-group:last-child { + border-bottom: none; +} + +.model-group-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: hsl(var(--muted) / 0.3); + cursor: pointer; + transition: background 0.15s; +} + +.model-group-header:hover { + background: hsl(var(--muted) / 0.5); +} + +.model-group-toggle { + width: 1rem; + height: 1rem; + color: hsl(var(--muted-foreground)); + transition: transform 0.2s; +} + +.model-group.expanded .model-group-toggle { + transform: rotate(90deg); +} + +.model-group-name { + flex: 1; + font-size: 0.875rem; + font-weight: 500; +} + +.model-group-count { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + padding: 0.125rem 0.5rem; + background: hsl(var(--muted) / 0.5); + border-radius: 9999px; +} + +.model-group-children { + display: none; +} + +.model-group.expanded .model-group-children { + display: block; +} + +.model-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 1rem 0.625rem 2.5rem; + border-top: 1px solid hsl(var(--border) / 0.5); + transition: background 0.15s; +} + +.model-item:hover { + background: hsl(var(--muted) / 0.2); +} + +.model-item-icon { + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + color: hsl(var(--primary)); +} + +.model-item-name { + flex: 1; + font-size: 0.875rem; + font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace; +} + +.model-item-badge { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + background: hsl(var(--muted) / 0.5); + border-radius: 0.25rem; + color: hsl(var(--muted-foreground)); +} + +.model-item-actions { + display: flex; + gap: 0.25rem; + opacity: 0; + transition: opacity 0.15s; +} + +.model-item:hover .model-item-actions { + opacity: 1; +} + +/* Empty State for Main Panel */ +.provider-empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + color: hsl(var(--muted-foreground)); + text-align: center; +} + +.provider-empty-state-icon { + width: 4rem; + height: 4rem; + margin-bottom: 1rem; + opacity: 0.3; +} + +.provider-empty-state h3 { + font-size: 1rem; + font-weight: 500; + margin: 0 0 0.5rem; + color: hsl(var(--foreground)); +} + +.provider-empty-state p { + font-size: 0.875rem; + margin: 0; +} + +/* Cache FAB Button */ +.cache-fab { + position: fixed; + bottom: 2rem; + right: 2rem; + width: 3.5rem; + height: 3.5rem; + background: hsl(var(--primary)); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 4px 12px hsl(var(--primary) / 0.4); + transition: all 0.2s; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; +} + +.cache-fab:hover { + transform: scale(1.05); + box-shadow: 0 6px 16px hsl(var(--primary) / 0.5); +} + +.cache-fab svg { + width: 1.5rem; + height: 1.5rem; +} + +/* Cache Panel Overlay */ +.cache-panel-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: hsl(var(--foreground) / 0.5); + z-index: 100; + display: none; + align-items: flex-end; + justify-content: flex-end; + padding: 2rem; +} + +.cache-panel-overlay.active { + display: flex; +} + +.cache-panel-content { + width: 400px; + max-width: 90vw; + max-height: 80vh; + background: hsl(var(--card)); + border-radius: 0.75rem; + box-shadow: 0 8px 32px hsl(var(--foreground) / 0.2); + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Responsive */ +@media (max-width: 768px) { + .api-settings-container.api-settings-split { + flex-direction: column; + height: auto; + min-height: auto; + } + + .api-settings-sidebar { + width: 100%; + max-height: 300px; + border-right: none; + border-bottom: 1px solid hsl(var(--border)); + } + + .api-settings-main { + min-height: 400px; + } + + .cache-fab { + bottom: 1rem; + right: 1rem; + } + + .cache-panel-overlay { + padding: 1rem; + } + + .cache-panel-content { + width: 100%; + } +} + +/* =========================== + Multi-Key Trigger Button + =========================== */ + +.multi-key-trigger { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid hsl(var(--border)); +} + +.multi-key-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + font-size: 0.9rem; +} + +.multi-key-btn i { + width: 18px; + height: 18px; +} + +/* =========================== + Multi-Key Management (Modal) + =========================== */ + +.api-keys-section { + margin-top: 1.5rem; + padding: 1rem; + background: hsl(var(--muted) / 0.3); + border-radius: 0.5rem; +} + +.api-keys-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.api-keys-header h4 { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} + +.api-key-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.api-key-item { + display: grid; + grid-template-columns: 1fr 2fr auto auto auto; + gap: 0.5rem; + align-items: center; + padding: 0.75rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; +} + +.api-key-item input { + font-size: 0.85rem; +} + +.api-key-item .key-label { + grid-column: 1; +} + +.api-key-item .key-value { + grid-column: 2; +} + +.api-key-item .key-weight { + width: 60px; +} + +.api-key-item .key-status { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.key-status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.key-status-indicator.healthy { + background: hsl(var(--success)); +} + +.key-status-indicator.unhealthy { + background: hsl(var(--destructive)); +} + +.key-status-indicator.unknown { + background: hsl(var(--muted-foreground)); +} + +.api-key-actions { + display: flex; + gap: 0.25rem; +} + +.api-key-actions button { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +.add-key-btn { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 1rem; + font-size: 0.85rem; +} + +.no-keys-message { + text-align: center; + color: hsl(var(--muted-foreground)); + padding: 1rem; + font-size: 0.85rem; +} + +/* =========================== + Routing Strategy + =========================== */ + +.routing-section { + margin-top: 1rem; + padding: 1rem; + background: hsl(var(--muted) / 0.2); + border-radius: 0.5rem; +} + +.routing-section label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.85rem; + font-weight: 500; +} + +.routing-hint { + margin-top: 0.25rem; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +/* =========================== + Health Check Configuration + =========================== */ + +.health-check-section { + margin-top: 1rem; + padding: 1rem; + background: hsl(var(--muted) / 0.2); + border-radius: 0.5rem; +} + +.health-check-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.health-check-header h5 { + margin: 0; + font-size: 0.85rem; + font-weight: 600; +} + +.health-check-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} + +.health-check-field label { + display: block; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + margin-bottom: 0.25rem; +} + +.health-check-field input { + width: 100%; +} + +/* =========================== + Key Test Button + =========================== */ + +.test-key-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + background: hsl(var(--primary) / 0.1); + border: 1px solid hsl(var(--primary) / 0.3); + border-radius: 0.25rem; + color: hsl(var(--primary)); + cursor: pointer; + transition: all 0.2s; +} + +.test-key-btn:hover { + background: hsl(var(--primary) / 0.2); +} + +.test-key-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.test-key-btn.testing { + animation: pulse 1s infinite; +} + +.test-result { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + border-radius: 0.25rem; +} + +.test-result.success { + background: hsl(var(--success) / 0.1); + color: hsl(var(--success)); +} + +.test-result.error { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* =========================== + Endpoints List in Sidebar + =========================== */ + +.endpoints-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.endpoints-list .api-card { + margin-bottom: 0.75rem; +} + +/* =========================== + Main Panel Sections + =========================== */ + +.endpoints-main-panel, +.cache-main-panel { + padding: 2rem; + overflow-y: auto; +} + +.panel-header { + margin-bottom: 2rem; +} + +.panel-header h2 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.panel-subtitle { + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; +} + +.endpoints-stats { + display: flex; + gap: 1rem; + margin-bottom: 2rem; +} + +.cache-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + padding: 1.5rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.stat-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: hsl(var(--primary) / 0.1); + border-radius: 0.5rem; + color: hsl(var(--primary)); +} + +.stat-icon i { + width: 20px; + height: 20px; +} + +.stat-info { + flex: 1; +} + +.stat-value { + font-size: 1.75rem; + font-weight: 700; + color: hsl(var(--foreground)); + margin-bottom: 0.25rem; +} + +.stat-label { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.settings-section { + margin-bottom: 2rem; + padding: 1.5rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; +} + +.settings-section h3 { + margin: 0 0 1rem 0; + font-size: 1.125rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.storage-bar-container { + margin-top: 1rem; +} + +.storage-bar { + width: 100%; + height: 8px; + background: hsl(var(--muted)); + border-radius: 4px; + overflow: hidden; + margin-bottom: 0.5rem; +} + +.storage-bar-fill { + height: 100%; + background: hsl(var(--primary)); + transition: width 0.3s ease; +} + +.storage-label { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + text-align: center; +} + +.cache-sidebar-info { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.cache-sidebar-info p { + margin: 0; + text-align: center; + line-height: 1.5; +} + +/* =========================== + Responsive Adjustments + =========================== */ + +@media (max-width: 768px) { + .api-key-item { + grid-template-columns: 1fr; + gap: 0.5rem; + } + + .api-key-item .key-label, + .api-key-item .key-value { + grid-column: 1; + } + + .health-check-grid { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 32d83ba3..92dd84de 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -23,6 +23,8 @@ const i18n = { 'common.loading': 'Loading...', 'common.error': 'Error', 'common.success': 'Success', + 'common.deleteSuccess': 'Deleted successfully', + 'common.deleteFailed': 'Delete failed', 'common.retry': 'Retry', 'common.refresh': 'Refresh', 'common.minutes': 'minutes', @@ -1345,17 +1347,64 @@ const i18n = { 'apiSettings.editEndpoint': 'Edit Endpoint', 'apiSettings.deleteEndpoint': 'Delete Endpoint', 'apiSettings.providerType': 'Provider Type', + 'apiSettings.apiFormat': 'API Format', + 'apiSettings.compatible': 'Compatible', + 'apiSettings.customFormat': 'Custom Format', + 'apiSettings.apiFormatHint': 'Most providers (DeepSeek, Ollama, etc.) use OpenAI-compatible format', 'apiSettings.displayName': 'Display Name', 'apiSettings.apiKey': 'API Key', 'apiSettings.apiBaseUrl': 'API Base URL', 'apiSettings.useEnvVar': 'Use environment variable', 'apiSettings.enableProvider': 'Enable provider', + 'apiSettings.advancedSettings': 'Advanced Settings', + 'apiSettings.basicInfo': 'Basic Info', + 'apiSettings.endpointSettings': 'Endpoint Settings', + 'apiSettings.timeout': 'Timeout (seconds)', + 'apiSettings.seconds': 'seconds', + 'apiSettings.timeoutHint': 'Request timeout in seconds (default: 300)', + 'apiSettings.maxRetries': 'Max Retries', + 'apiSettings.maxRetriesHint': 'Maximum retry attempts on failure', + 'apiSettings.organization': 'Organization ID', + 'apiSettings.organizationHint': 'OpenAI organization ID (org-...)', + 'apiSettings.apiVersion': 'API Version', + 'apiSettings.apiVersionHint': 'Azure API version (e.g., 2024-02-01)', + 'apiSettings.rpm': 'RPM Limit', + 'apiSettings.tpm': 'TPM Limit', + 'apiSettings.unlimited': 'Unlimited', + 'apiSettings.proxy': 'Proxy Server', + 'apiSettings.proxyHint': 'HTTP proxy server URL', + 'apiSettings.customHeaders': 'Custom Headers', + 'apiSettings.customHeadersHint': 'JSON object with custom HTTP headers', + 'apiSettings.invalidJsonHeaders': 'Invalid JSON in custom headers', + 'apiSettings.searchProviders': 'Search providers...', + 'apiSettings.selectProvider': 'Select a Provider', + 'apiSettings.selectProviderHint': 'Select a provider from the list to view and manage its settings', + 'apiSettings.noProvidersFound': 'No providers found', + 'apiSettings.llmModels': 'LLM Models', + 'apiSettings.embeddingModels': 'Embedding Models', + 'apiSettings.manageModels': 'Manage', + 'apiSettings.addModel': 'Add Model', + 'apiSettings.multiKeySettings': 'Multi-Key Settings', + 'apiSettings.noModels': 'No models configured', + 'apiSettings.previewModel': 'Preview', + 'apiSettings.modelSettings': 'Model Settings', + 'apiSettings.deleteModel': 'Delete Model', + 'apiSettings.providerUpdated': 'Provider updated', + 'apiSettings.preview': 'Preview', + 'apiSettings.used': 'used', + 'apiSettings.total': 'total', 'apiSettings.testConnection': 'Test Connection', 'apiSettings.endpointId': 'Endpoint ID', 'apiSettings.endpointIdHint': 'Usage: ccw cli -p "..." --model ', + 'apiSettings.endpoints': 'Endpoints', + 'apiSettings.addEndpointHint': 'Create custom endpoint aliases for CLI usage', + 'apiSettings.endpointModel': 'Model', + 'apiSettings.selectEndpoint': 'Select an endpoint', + 'apiSettings.selectEndpointHint': 'Choose an endpoint from the list to view or edit its settings', 'apiSettings.provider': 'Provider', 'apiSettings.model': 'Model', 'apiSettings.selectModel': 'Select model', + 'apiSettings.noModelsConfigured': 'No models configured for this provider', 'apiSettings.cacheStrategy': 'Cache Strategy', 'apiSettings.enableContextCaching': 'Enable Context Caching', 'apiSettings.cacheTTL': 'TTL (minutes)', @@ -1386,6 +1435,82 @@ const i18n = { 'apiSettings.addProviderFirst': 'Please add a provider first', 'apiSettings.failedToLoad': 'Failed to load API settings', 'apiSettings.toggleVisibility': 'Toggle visibility', + 'apiSettings.noProvidersHint': 'Add an API provider to get started', + 'apiSettings.noEndpointsHint': 'Create custom endpoints for quick access to models', + 'apiSettings.cache': 'Cache', + 'apiSettings.off': 'Off', + 'apiSettings.used': 'used', + 'apiSettings.total': 'total', + 'apiSettings.cacheUsage': 'Usage', + 'apiSettings.cacheSize': 'Size', + 'apiSettings.endpointsDescription': 'Manage custom API endpoints for quick model access', + 'apiSettings.totalEndpoints': 'Total Endpoints', + 'apiSettings.cachedEndpoints': 'Cached Endpoints', + 'apiSettings.cacheTabHint': 'Configure global cache settings and view statistics in the main panel', + 'apiSettings.cacheDescription': 'Manage response caching to improve performance and reduce costs', + 'apiSettings.cachedEntries': 'Cached Entries', + 'apiSettings.storageUsed': 'Storage Used', + 'apiSettings.cacheActions': 'Cache Actions', + 'apiSettings.cacheStatistics': 'Cache Statistics', + 'apiSettings.globalCache': 'Global Cache', + + // Multi-key management + 'apiSettings.apiKeys': 'API Keys', + 'apiSettings.addKey': 'Add Key', + 'apiSettings.keyLabel': 'Label', + 'apiSettings.keyValue': 'API Key', + 'apiSettings.keyWeight': 'Weight', + 'apiSettings.removeKey': 'Remove', + 'apiSettings.noKeys': 'No API keys configured', + 'apiSettings.primaryKey': 'Primary Key', + + // Routing strategy + 'apiSettings.routingStrategy': 'Routing Strategy', + 'apiSettings.simpleShuffleRouting': 'Simple Shuffle (Random)', + 'apiSettings.weightedRouting': 'Weighted Distribution', + 'apiSettings.latencyRouting': 'Latency-Based', + 'apiSettings.costRouting': 'Cost-Based', + 'apiSettings.leastBusyRouting': 'Least Busy', + 'apiSettings.routingHint': 'How to distribute requests across multiple API keys', + + // Health check + 'apiSettings.healthCheck': 'Health Check', + 'apiSettings.enableHealthCheck': 'Enable Health Check', + 'apiSettings.healthInterval': 'Check Interval (seconds)', + 'apiSettings.healthCooldown': 'Cooldown (seconds)', + 'apiSettings.failureThreshold': 'Failure Threshold', + 'apiSettings.healthStatus': 'Status', + 'apiSettings.healthy': 'Healthy', + 'apiSettings.unhealthy': 'Unhealthy', + 'apiSettings.unknown': 'Unknown', + 'apiSettings.lastCheck': 'Last Check', + 'apiSettings.testKey': 'Test Key', + 'apiSettings.testingKey': 'Testing...', + 'apiSettings.keyValid': 'Key is valid', + 'apiSettings.keyInvalid': 'Key is invalid', + + // Embedding models + 'apiSettings.embeddingDimensions': 'Dimensions', + 'apiSettings.embeddingMaxTokens': 'Max Tokens', + 'apiSettings.selectEmbeddingModel': 'Select Embedding Model', + + // Model modal + 'apiSettings.addLlmModel': 'Add LLM Model', + 'apiSettings.addEmbeddingModel': 'Add Embedding Model', + 'apiSettings.modelId': 'Model ID', + 'apiSettings.modelName': 'Display Name', + 'apiSettings.modelSeries': 'Series', + 'apiSettings.selectFromPresets': 'Select from Presets', + 'apiSettings.customModel': 'Custom Model', + 'apiSettings.capabilities': 'Capabilities', + 'apiSettings.streaming': 'Streaming', + 'apiSettings.functionCalling': 'Function Calling', + 'apiSettings.vision': 'Vision', + 'apiSettings.contextWindow': 'Context Window', + 'apiSettings.description': 'Description', + 'apiSettings.optional': 'Optional', + 'apiSettings.modelIdExists': 'Model ID already exists', + 'apiSettings.useModelTreeToManage': 'Use the model tree to manage individual models', // Common 'common.cancel': 'Cancel', @@ -1410,6 +1535,7 @@ const i18n = { 'common.saveFailed': 'Failed to save', 'common.unknownError': 'Unknown error', 'common.exception': 'Exception', + 'common.status': 'Status', // Core Memory 'title.coreMemory': 'Core Memory', @@ -1537,6 +1663,8 @@ const i18n = { 'common.loading': '加载中...', 'common.error': '错误', 'common.success': '成功', + 'common.deleteSuccess': '删除成功', + 'common.deleteFailed': '删除失败', 'common.retry': '重试', 'common.refresh': '刷新', 'common.minutes': '分钟', @@ -2869,17 +2997,64 @@ const i18n = { 'apiSettings.editEndpoint': '编辑端点', 'apiSettings.deleteEndpoint': '删除端点', 'apiSettings.providerType': '提供商类型', + 'apiSettings.apiFormat': 'API 格式', + 'apiSettings.compatible': '兼容', + 'apiSettings.customFormat': '自定义格式', + 'apiSettings.apiFormatHint': '大多数供应商(DeepSeek、Ollama 等)使用 OpenAI 兼容格式', 'apiSettings.displayName': '显示名称', 'apiSettings.apiKey': 'API 密钥', 'apiSettings.apiBaseUrl': 'API 基础 URL', 'apiSettings.useEnvVar': '使用环境变量', 'apiSettings.enableProvider': '启用提供商', + 'apiSettings.advancedSettings': '高级设置', + 'apiSettings.basicInfo': '基本信息', + 'apiSettings.endpointSettings': '端点设置', + 'apiSettings.timeout': '超时时间(秒)', + 'apiSettings.seconds': '秒', + 'apiSettings.timeoutHint': '请求超时时间,单位秒(默认:300)', + 'apiSettings.maxRetries': '最大重试次数', + 'apiSettings.maxRetriesHint': '失败后最大重试次数', + 'apiSettings.organization': '组织 ID', + 'apiSettings.organizationHint': 'OpenAI 组织 ID(org-...)', + 'apiSettings.apiVersion': 'API 版本', + 'apiSettings.apiVersionHint': 'Azure API 版本(如 2024-02-01)', + 'apiSettings.rpm': 'RPM 限制', + 'apiSettings.tpm': 'TPM 限制', + 'apiSettings.unlimited': '无限制', + 'apiSettings.proxy': '代理服务器', + 'apiSettings.proxyHint': 'HTTP 代理服务器 URL', + 'apiSettings.customHeaders': '自定义请求头', + 'apiSettings.customHeadersHint': '自定义 HTTP 请求头的 JSON 对象', + 'apiSettings.invalidJsonHeaders': '自定义请求头 JSON 格式无效', + 'apiSettings.searchProviders': '搜索供应商...', + 'apiSettings.selectProvider': '选择供应商', + 'apiSettings.selectProviderHint': '从列表中选择一个供应商来查看和管理其设置', + 'apiSettings.noProvidersFound': '未找到供应商', + 'apiSettings.llmModels': '大语言模型', + 'apiSettings.embeddingModels': '向量模型', + 'apiSettings.manageModels': '管理', + 'apiSettings.addModel': '添加模型', + 'apiSettings.multiKeySettings': '多密钥设置', + 'apiSettings.noModels': '暂无模型配置', + 'apiSettings.previewModel': '预览', + 'apiSettings.modelSettings': '模型设置', + 'apiSettings.deleteModel': '删除模型', + 'apiSettings.providerUpdated': '供应商已更新', + 'apiSettings.preview': '预览', + 'apiSettings.used': '已使用', + 'apiSettings.total': '总计', 'apiSettings.testConnection': '测试连接', 'apiSettings.endpointId': '端点 ID', 'apiSettings.endpointIdHint': '用法: ccw cli -p "..." --model <端点ID>', + 'apiSettings.endpoints': '端点', + 'apiSettings.addEndpointHint': '创建用于 CLI 的自定义端点别名', + 'apiSettings.endpointModel': '模型', + 'apiSettings.selectEndpoint': '选择端点', + 'apiSettings.selectEndpointHint': '从列表中选择一个端点以查看或编辑其设置', 'apiSettings.provider': '提供商', 'apiSettings.model': '模型', 'apiSettings.selectModel': '选择模型', + 'apiSettings.noModelsConfigured': '该供应商未配置模型', 'apiSettings.cacheStrategy': '缓存策略', 'apiSettings.enableContextCaching': '启用上下文缓存', 'apiSettings.cacheTTL': 'TTL (分钟)', @@ -2910,6 +3085,82 @@ const i18n = { 'apiSettings.addProviderFirst': '请先添加提供商', 'apiSettings.failedToLoad': '加载 API 设置失败', 'apiSettings.toggleVisibility': '切换可见性', + 'apiSettings.noProvidersHint': '添加 API 提供商以开始使用', + 'apiSettings.noEndpointsHint': '创建自定义端点以快速访问模型', + 'apiSettings.cache': '缓存', + 'apiSettings.off': '关闭', + 'apiSettings.used': '已用', + 'apiSettings.total': '总计', + 'apiSettings.cacheUsage': '使用率', + 'apiSettings.cacheSize': '大小', + 'apiSettings.endpointsDescription': '管理自定义 API 端点以快速访问模型', + 'apiSettings.totalEndpoints': '总端点数', + 'apiSettings.cachedEndpoints': '缓存端点数', + 'apiSettings.cacheTabHint': '在主面板中配置全局缓存设置并查看统计信息', + 'apiSettings.cacheDescription': '管理响应缓存以提高性能并降低成本', + 'apiSettings.cachedEntries': '缓存条目', + 'apiSettings.storageUsed': '已用存储', + 'apiSettings.cacheActions': '缓存操作', + 'apiSettings.cacheStatistics': '缓存统计', + 'apiSettings.globalCache': '全局缓存', + + // Multi-key management + 'apiSettings.apiKeys': 'API 密钥', + 'apiSettings.addKey': '添加密钥', + 'apiSettings.keyLabel': '标签', + 'apiSettings.keyValue': 'API 密钥', + 'apiSettings.keyWeight': '权重', + 'apiSettings.removeKey': '移除', + 'apiSettings.noKeys': '未配置 API 密钥', + 'apiSettings.primaryKey': '主密钥', + + // Routing strategy + 'apiSettings.routingStrategy': '路由策略', + 'apiSettings.simpleShuffleRouting': '简单随机', + 'apiSettings.weightedRouting': '权重分配', + 'apiSettings.latencyRouting': '延迟优先', + 'apiSettings.costRouting': '成本优先', + 'apiSettings.leastBusyRouting': '最少并发', + 'apiSettings.routingHint': '如何在多个 API 密钥间分配请求', + + // Health check + 'apiSettings.healthCheck': '健康检查', + 'apiSettings.enableHealthCheck': '启用健康检查', + 'apiSettings.healthInterval': '检查间隔(秒)', + 'apiSettings.healthCooldown': '冷却时间(秒)', + 'apiSettings.failureThreshold': '失败阈值', + 'apiSettings.healthStatus': '状态', + 'apiSettings.healthy': '健康', + 'apiSettings.unhealthy': '异常', + 'apiSettings.unknown': '未知', + 'apiSettings.lastCheck': '最后检查', + 'apiSettings.testKey': '测试密钥', + 'apiSettings.testingKey': '测试中...', + 'apiSettings.keyValid': '密钥有效', + 'apiSettings.keyInvalid': '密钥无效', + + // Embedding models + 'apiSettings.embeddingDimensions': '向量维度', + 'apiSettings.embeddingMaxTokens': '最大 Token', + 'apiSettings.selectEmbeddingModel': '选择嵌入模型', + + // Model modal + 'apiSettings.addLlmModel': '添加 LLM 模型', + 'apiSettings.addEmbeddingModel': '添加嵌入模型', + 'apiSettings.modelId': '模型 ID', + 'apiSettings.modelName': '显示名称', + 'apiSettings.modelSeries': '模型系列', + 'apiSettings.selectFromPresets': '从预设选择', + 'apiSettings.customModel': '自定义模型', + 'apiSettings.capabilities': '能力', + 'apiSettings.streaming': '流式输出', + 'apiSettings.functionCalling': '函数调用', + 'apiSettings.vision': '视觉能力', + 'apiSettings.contextWindow': '上下文窗口', + 'apiSettings.description': '描述', + 'apiSettings.optional': '可选', + 'apiSettings.modelIdExists': '模型 ID 已存在', + 'apiSettings.useModelTreeToManage': '使用模型树管理各个模型', // Common 'common.cancel': '取消', @@ -2934,6 +3185,7 @@ const i18n = { 'common.saveFailed': '保存失败', 'common.unknownError': '未知错误', 'common.exception': '异常', + 'common.status': '状态', // Core Memory 'title.coreMemory': '核心记忆', diff --git a/ccw/src/templates/dashboard-js/views/api-settings.js b/ccw/src/templates/dashboard-js/views/api-settings.js index 8ca07389..6e0fdacb 100644 --- a/ccw/src/templates/dashboard-js/views/api-settings.js +++ b/ccw/src/templates/dashboard-js/views/api-settings.js @@ -2,9 +2,16 @@ // Manages LiteLLM API providers, custom endpoints, and cache settings // ========== State Management ========== -var apiSettingsData = null; -var providerModels = {}; -var currentModal = null; +let apiSettingsData = null; +const providerModels = {}; +let currentModal = null; + +// New state for split layout +let selectedProviderId = null; +let providerSearchQuery = ''; +let activeModelTab = 'llm'; +let expandedModelGroups = new Set(); +let activeSidebarTab = 'providers'; // 'providers' | 'endpoints' | 'cache' // ========== Data Loading ========== @@ -13,7 +20,7 @@ var currentModal = null; */ async function loadApiSettings() { try { - var response = await fetch('/api/litellm-api/config'); + const response = await fetch('/api/litellm-api/config'); if (!response.ok) throw new Error('Failed to load API settings'); apiSettingsData = await response.json(); return apiSettingsData; @@ -29,9 +36,9 @@ async function loadApiSettings() { */ async function loadProviderModels(providerType) { try { - var response = await fetch('/api/litellm-api/models/' + providerType); + const response = await fetch('/api/litellm-api/models/' + providerType); if (!response.ok) throw new Error('Failed to load models'); - var data = await response.json(); + const data = await response.json(); providerModels[providerType] = data.models || []; return data.models; } catch (err) { @@ -45,7 +52,7 @@ async function loadProviderModels(providerType) { */ async function loadCacheStats() { try { - var response = await fetch('/api/litellm-api/cache/stats'); + const response = await fetch('/api/litellm-api/cache/stats'); if (!response.ok) throw new Error('Failed to load cache stats'); return await response.json(); } catch (err) { @@ -60,7 +67,7 @@ async function loadCacheStats() { * Show add provider modal */ async function showAddProviderModal() { - var modalHtml = '
' + + const modalHtml = '
' + '
' + '
' + '

' + t('apiSettings.addProvider') + '

' + @@ -69,17 +76,13 @@ async function showAddProviderModal() { '
' + '
' + '
' + - '' + - '' + + '' + '' + - '' + - '' + - '' + - '' + - '' + - '' + + '' + '' + + '' + t('apiSettings.apiFormatHint') + '' + '
' + '
' + '' + @@ -109,6 +112,60 @@ async function showAddProviderModal() { t('apiSettings.enableProvider') + '' + '
' + + // Advanced Settings Collapsible Panel + '
' + + '' + + ' ' + + t('apiSettings.advancedSettings') + + '' + + '' + + '
' + ''; + + // Build sidebar content based on active tab + var sidebarContentHtml = ''; + var addButtonHtml = ''; + + if (activeSidebarTab === 'providers') { + sidebarContentHtml = '' + + '
'; + addButtonHtml = ''; + } else if (activeSidebarTab === 'endpoints') { + sidebarContentHtml = '
'; + addButtonHtml = ''; + } else if (activeSidebarTab === 'cache') { + sidebarContentHtml = '
' + + '

' + t('apiSettings.cacheTabHint') + '

' + + '
'; + } + + // Build split layout + container.innerHTML = '
' + + // Left Sidebar + '' + + // Right Main Panel + '
' + + '
' + + // Cache Panel Overlay + '
'; + + // Render content based on active tab + if (activeSidebarTab === 'providers') { + renderProviderList(); + // Auto-select first provider if exists + if (!selectedProviderId && apiSettingsData.providers && apiSettingsData.providers.length > 0) { + selectProvider(apiSettingsData.providers[0].id); + } else if (selectedProviderId) { + renderProviderDetail(selectedProviderId); + } else { + renderProviderEmptyState(); + } + } else if (activeSidebarTab === 'endpoints') { + renderEndpointsList(); + renderEndpointsMainPanel(); + } else if (activeSidebarTab === 'cache') { + renderCacheMainPanel(); + } + + if (window.lucide) lucide.createIcons(); +} + +/** + * Render provider list in sidebar + */ +function renderProviderList() { + var container = document.getElementById('provider-list'); + if (!container) return; + + var providers = apiSettingsData.providers || []; + var query = providerSearchQuery.toLowerCase(); + + // Filter providers + if (query) { + providers = providers.filter(function(p) { + return p.name.toLowerCase().includes(query) || p.type.toLowerCase().includes(query); + }); + } + + if (providers.length === 0) { + container.innerHTML = '
' + + '

' + (query ? t('apiSettings.noProvidersFound') : t('apiSettings.noProviders')) + '

' + + '
'; + return; + } + + var html = ''; + providers.forEach(function(provider) { + var isSelected = provider.id === selectedProviderId; + var iconClass = getProviderIconClass(provider.type); + var iconLetter = provider.type.charAt(0).toUpperCase(); + + html += '
' + + '
' + iconLetter + '
' + + '
' + + '' + escapeHtml(provider.name) + '' + + '' + provider.type + '' + + '
' + + '' + + (provider.enabled ? 'ON' : 'OFF') + + '' + + '
'; + }); + + container.innerHTML = html; +} + +/** + * Filter providers by search query + */ +function filterProviders(query) { + providerSearchQuery = query; + renderProviderList(); +} + +/** + * Switch sidebar tab + */ +function switchSidebarTab(tab) { + activeSidebarTab = tab; + renderApiSettings(); +} + +/** + * Select a provider + */ +function selectProvider(providerId) { + selectedProviderId = providerId; + renderProviderList(); + renderProviderDetail(providerId); +} + +/** + * Render provider detail panel + */ +function renderProviderDetail(providerId) { + var container = document.getElementById('provider-detail-panel'); + if (!container) return; + + var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); + if (!provider) { + renderProviderEmptyState(); + return; + } + + var maskedKey = provider.apiKey ? '••••••••••••••••' + provider.apiKey.slice(-4) : '••••••••'; + var apiBasePreview = (provider.apiBase || getDefaultApiBase(provider.type)) + '/chat/completions'; + + var html = '
' + + '
' + + '
' + + provider.type.charAt(0).toUpperCase() + + '
' + + '

' + escapeHtml(provider.name) + '

' + + '' + + '' + '
' + - '
' + + '
' + + '' + '
' + - '
' + - '
' + - '

' + t('apiSettings.customEndpoints') + '

' + - '
' + + '
' + + // API Key field + '
' + + '
' + + '' + t('apiSettings.apiKey') + '' + + '
' + + '' + '
' + - '
' + '
' + - '
' + - '
' + - '

' + t('apiSettings.cacheSettings') + '

' + + '
' + + '' + + '' + + '' + '
' + - '
' + + '
' + + // API Base URL field + '
' + + '
' + + '' + t('apiSettings.apiBaseUrl') + '' + + '
' + + '' + + '' + t('apiSettings.preview') + ': ' + apiBasePreview + '' + + '
' + + // Model Section + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + // Multi-key settings button + '
' + + '' + '
' + '
'; - renderProvidersList(); - renderEndpointsList(); - renderCacheSettings(cacheStats); + container.innerHTML = html; + renderModelTree(provider); if (window.lucide) lucide.createIcons(); } +/** + * Render provider empty state + */ +function renderProviderEmptyState() { + var container = document.getElementById('provider-detail-panel'); + if (!container) return; + + container.innerHTML = '
' + + '' + + '

' + t('apiSettings.selectProvider') + '

' + + '

' + t('apiSettings.selectProviderHint') + '

' + + '
'; + + if (window.lucide) lucide.createIcons(); +} + +/** + * Render model tree + */ +function renderModelTree(provider) { + var container = document.getElementById('model-tree'); + if (!container) return; + + var models = activeModelTab === 'llm' + ? (provider.llmModels || []) + : (provider.embeddingModels || []); + + if (models.length === 0) { + container.innerHTML = '
' + + '' + + '

' + t('apiSettings.noModels') + '

' + + '
'; + if (window.lucide) lucide.createIcons(); + return; + } + + // Group models by series + var groups = groupModelsBySeries(models); + + var html = ''; + groups.forEach(function(group) { + var isExpanded = expandedModelGroups.has(group.series); + + html += '
' + + '
' + + '' + + '' + escapeHtml(group.series) + '' + + '' + group.models.length + '' + + '
' + + '
'; + + group.models.forEach(function(model) { + var badge = model.capabilities && model.capabilities.contextWindow + ? formatContextWindow(model.capabilities.contextWindow) + : ''; + + html += '
' + + '' + + '' + escapeHtml(model.name) + '' + + (badge ? '' + badge + '' : '') + + '
' + + '' + + '' + + '' + + '
' + + '
'; + }); + + html += '
'; + }); + + container.innerHTML = html; + if (window.lucide) lucide.createIcons(); +} + +/** + * Group models by series + */ +function groupModelsBySeries(models) { + var seriesMap = {}; + + models.forEach(function(model) { + var series = model.series || 'Other'; + if (!seriesMap[series]) { + seriesMap[series] = []; + } + seriesMap[series].push(model); + }); + + return Object.keys(seriesMap).map(function(series) { + return { series: series, models: seriesMap[series] }; + }).sort(function(a, b) { + return a.series.localeCompare(b.series); + }); +} + +/** + * Toggle model group expand/collapse + */ +function toggleModelGroup(series) { + if (expandedModelGroups.has(series)) { + expandedModelGroups.delete(series); + } else { + expandedModelGroups.add(series); + } + + var provider = apiSettingsData.providers.find(function(p) { return p.id === selectedProviderId; }); + if (provider) { + renderModelTree(provider); + } +} + +/** + * Switch model tab (LLM / Embedding) + */ +function switchModelTab(tab) { + activeModelTab = tab; + expandedModelGroups.clear(); + + var provider = apiSettingsData.providers.find(function(p) { return p.id === selectedProviderId; }); + if (provider) { + renderProviderDetail(selectedProviderId); + } +} + +/** + * Format context window for display + */ +function formatContextWindow(tokens) { + if (tokens >= 1000000) return Math.round(tokens / 1000000) + 'M'; + if (tokens >= 1000) return Math.round(tokens / 1000) + 'K'; + return tokens.toString(); +} + +/** + * Get default API base URL for provider type + */ +function getDefaultApiBase(type) { + var defaults = { + 'openai': 'https://api.openai.com/v1', + 'anthropic': 'https://api.anthropic.com/v1' + }; + return defaults[type] || 'https://api.example.com/v1'; +} + +/** + * Toggle provider enabled status + */ +async function toggleProviderEnabled(providerId, enabled) { + try { + var response = await fetch('/api/litellm-api/providers/' + providerId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: enabled }) + }); + if (!response.ok) throw new Error('Failed to update provider'); + + // Update local data + var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); + if (provider) provider.enabled = enabled; + + renderProviderList(); + showRefreshToast(t('apiSettings.providerUpdated'), 'success'); + } catch (err) { + console.error('Failed to toggle provider:', err); + showRefreshToast(t('common.error') + ': ' + err.message, 'error'); + } +} + +/** + * Show cache panel + */ +async function showCachePanel() { + var overlay = document.getElementById('cache-panel-overlay'); + if (!overlay) return; + + var cacheStats = await loadCacheStats(); + var usedMB = (cacheStats.totalSize / 1048576).toFixed(1); + var maxMB = (cacheStats.maxSize / 1048576).toFixed(0); + var usagePercent = cacheStats.maxSize > 0 ? Math.round((cacheStats.totalSize / cacheStats.maxSize) * 100) : 0; + + overlay.innerHTML = '
' + + '
' + + '
' + + '

' + t('apiSettings.cacheSettings') + '

' + + '
' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + usedMB + ' MB ' + t('apiSettings.used') + '' + + '' + maxMB + ' MB ' + t('apiSettings.total') + '' + + '
' + + '
' + + '
' + + '
' + + '' + usagePercent + '%' + + '' + t('apiSettings.cacheUsage') + '' + + '
' + + '
' + + '' + cacheStats.entries + '' + + '' + t('apiSettings.cacheEntries') + '' + + '
' + + '
' + + '' + + '
' + + '
'; + + overlay.classList.add('active'); + if (window.lucide) lucide.createIcons(); +} + +/** + * Close cache panel + */ +function closeCachePanel() { + var overlay = document.getElementById('cache-panel-overlay'); + if (overlay) { + overlay.classList.remove('active'); + } +} + +/** + * Close cache panel when clicking overlay + */ +function closeCachePanelOverlay(event) { + if (event.target.id === 'cache-panel-overlay') { + closeCachePanel(); + } +} + +/** + * Escape HTML special characters + */ +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// ========== Model Management ========== + +/** + * Show add model modal + */ +function showAddModelModal(providerId, modelType) { + // Default to active tab if no modelType provided + if (!modelType) { + modelType = activeModelTab; + } + + // Get provider to know which presets to show + const provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); + if (!provider) return; + + const isLlm = modelType === 'llm'; + const title = isLlm ? t('apiSettings.addLlmModel') : t('apiSettings.addEmbeddingModel'); + + // Get model presets based on provider type + const presets = isLlm ? getLlmPresetsForType(provider.type) : getEmbeddingPresetsForType(provider.type); + + // Group presets by series + const groupedPresets = groupPresetsBySeries(presets); + + const modalHtml = '
' + + '
' + + '
' + + '

' + title + '

' + + '' + + '
' + + '
' + + '' + + + // Preset Selection + '
' + + '' + + '' + + '
' + + + // Model ID + '
' + + '' + + '' + + '
' + + + // Display Name + '
' + + '' + + '' + + '
' + + + // Series + '
' + + '' + + '' + + '
' + + + // Capabilities based on model type + (isLlm ? + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + : + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + ) + + + // Description + '
' + + '' + + '' + + '
' + + + '' + + '' + + '
' + + '
' + + '
'; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + if (window.lucide) lucide.createIcons(); +} + +/** + * Close add model modal + */ +function closeAddModelModal() { + const modal = document.getElementById('add-model-modal'); + if (modal) modal.remove(); +} + +/** + * Get LLM presets for provider type + */ +function getLlmPresetsForType(providerType) { + const presets = { + openai: [ + { id: 'gpt-4o', name: 'GPT-4o', series: 'GPT-4', contextWindow: 128000 }, + { id: 'gpt-4o-mini', name: 'GPT-4o Mini', series: 'GPT-4', contextWindow: 128000 }, + { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', series: 'GPT-4', contextWindow: 128000 }, + { id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', series: 'GPT-3.5', contextWindow: 16385 }, + { id: 'o1', name: 'O1', series: 'O1', contextWindow: 200000 }, + { id: 'o1-mini', name: 'O1 Mini', series: 'O1', contextWindow: 128000 }, + { id: 'deepseek-chat', name: 'DeepSeek Chat', series: 'DeepSeek', contextWindow: 64000 }, + { id: 'deepseek-coder', name: 'DeepSeek Coder', series: 'DeepSeek', contextWindow: 64000 } + ], + anthropic: [ + { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', series: 'Claude 4', contextWindow: 200000 }, + { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', series: 'Claude 3.5', contextWindow: 200000 }, + { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku', series: 'Claude 3.5', contextWindow: 200000 }, + { id: 'claude-3-opus-20240229', name: 'Claude 3 Opus', series: 'Claude 3', contextWindow: 200000 } + ], + custom: [ + { id: 'custom-model', name: 'Custom Model', series: 'Custom', contextWindow: 128000 } + ] + }; + return presets[providerType] || presets.custom; +} + +/** + * Get Embedding presets for provider type + */ +function getEmbeddingPresetsForType(providerType) { + const presets = { + openai: [ + { id: 'text-embedding-3-small', name: 'Text Embedding 3 Small', series: 'Embedding V3', dimensions: 1536, maxTokens: 8191 }, + { id: 'text-embedding-3-large', name: 'Text Embedding 3 Large', series: 'Embedding V3', dimensions: 3072, maxTokens: 8191 }, + { id: 'text-embedding-ada-002', name: 'Ada 002', series: 'Embedding V2', dimensions: 1536, maxTokens: 8191 } + ], + anthropic: [], // Anthropic doesn't have embedding models + custom: [ + { id: 'custom-embedding', name: 'Custom Embedding', series: 'Custom', dimensions: 1536, maxTokens: 8192 } + ] + }; + return presets[providerType] || presets.custom; +} + +/** + * Group presets by series + */ +function groupPresetsBySeries(presets) { + const grouped = {}; + presets.forEach(function(preset) { + if (!grouped[preset.series]) { + grouped[preset.series] = []; + } + grouped[preset.series].push(preset); + }); + return grouped; +} + +/** + * Fill model form from preset + */ +function fillModelFromPreset(presetId, modelType) { + if (!presetId) { + // Clear fields for custom model + document.getElementById('model-id').value = ''; + document.getElementById('model-name').value = ''; + document.getElementById('model-series').value = ''; + return; + } + + const provider = apiSettingsData.providers.find(function(p) { return p.id === selectedProviderId; }); + if (!provider) return; + + const isLlm = modelType === 'llm'; + const presets = isLlm ? getLlmPresetsForType(provider.type) : getEmbeddingPresetsForType(provider.type); + const preset = presets.find(function(p) { return p.id === presetId; }); + + if (preset) { + document.getElementById('model-id').value = preset.id; + document.getElementById('model-name').value = preset.name; + document.getElementById('model-series').value = preset.series; + + if (isLlm && preset.contextWindow) { + document.getElementById('model-context-window').value = preset.contextWindow; + } + if (!isLlm && preset.dimensions) { + document.getElementById('model-dimensions').value = preset.dimensions; + if (preset.maxTokens) { + document.getElementById('model-max-tokens').value = preset.maxTokens; + } + } + } +} + +/** + * Save new model + */ +function saveNewModel(event, providerId, modelType) { + event.preventDefault(); + + const isLlm = modelType === 'llm'; + const now = new Date().toISOString(); + + const newModel = { + id: document.getElementById('model-id').value.trim(), + name: document.getElementById('model-name').value.trim(), + type: modelType, + series: document.getElementById('model-series').value.trim(), + enabled: true, + description: document.getElementById('model-description').value.trim() || undefined, + createdAt: now, + updatedAt: now + }; + + // Add capabilities based on model type + if (isLlm) { + newModel.capabilities = { + contextWindow: parseInt(document.getElementById('model-context-window').value) || 128000, + streaming: document.getElementById('cap-streaming').checked, + functionCalling: document.getElementById('cap-function-calling').checked, + vision: document.getElementById('cap-vision').checked + }; + } else { + newModel.capabilities = { + embeddingDimension: parseInt(document.getElementById('model-dimensions').value) || 1536, + contextWindow: parseInt(document.getElementById('model-max-tokens').value) || 8192 + }; + } + + // Save to provider + fetch('/api/litellm-api/providers/' + providerId) + .then(function(res) { return res.json(); }) + .then(function(provider) { + const modelsKey = isLlm ? 'llmModels' : 'embeddingModels'; + const models = provider[modelsKey] || []; + + // Check for duplicate ID + if (models.some(function(m) { return m.id === newModel.id; })) { + showRefreshToast(t('apiSettings.modelIdExists'), 'error'); + return Promise.reject('Duplicate ID'); + } + + models.push(newModel); + return fetch('/api/litellm-api/providers/' + providerId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [modelsKey]: models }) + }); + }) + .then(function() { + closeAddModelModal(); + return loadApiSettings(); + }) + .then(function() { + if (selectedProviderId === providerId) { + selectProvider(providerId); + } + showRefreshToast(t('common.saveSuccess'), 'success'); + }) + .catch(function(err) { + if (err !== 'Duplicate ID') { + console.error('Failed to save model:', err); + showRefreshToast(t('common.saveFailed'), 'error'); + } + }); +} + +function showManageModelsModal(providerId) { + // For now, show a helpful message + showRefreshToast(t('apiSettings.useModelTreeToManage'), 'info'); +} + +function showModelSettingsModal(providerId, modelId, modelType) { + var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); + if (!provider) return; + + var isLlm = modelType === 'llm'; + var models = isLlm ? (provider.llmModels || []) : (provider.embeddingModels || []); + var model = models.find(function(m) { return m.id === modelId; }); + if (!model) return; + + var capabilities = model.capabilities || {}; + var endpointSettings = model.endpointSettings || {}; + + var modalHtml = ''; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + if (window.lucide) lucide.createIcons(); +} + +function closeModelSettingsModal() { + var modal = document.getElementById('model-settings-modal'); + if (modal) modal.remove(); +} + +function saveModelSettings(event, providerId, modelId, modelType) { + event.preventDefault(); + + var isLlm = modelType === 'llm'; + var modelsKey = isLlm ? 'llmModels' : 'embeddingModels'; + + fetch('/api/litellm-api/providers/' + providerId) + .then(function(res) { return res.json(); }) + .then(function(provider) { + var models = provider[modelsKey] || []; + var modelIndex = models.findIndex(function(m) { return m.id === modelId; }); + + if (modelIndex === -1) { + throw new Error('Model not found'); + } + + // Update model fields + models[modelIndex].name = document.getElementById('model-settings-name').value.trim(); + models[modelIndex].series = document.getElementById('model-settings-series').value.trim(); + models[modelIndex].description = document.getElementById('model-settings-description').value.trim() || undefined; + models[modelIndex].updatedAt = new Date().toISOString(); + + // Update capabilities + if (isLlm) { + models[modelIndex].capabilities = { + contextWindow: parseInt(document.getElementById('model-settings-context').value) || 128000, + streaming: document.getElementById('model-settings-streaming').checked, + functionCalling: document.getElementById('model-settings-function-calling').checked, + vision: document.getElementById('model-settings-vision').checked + }; + } else { + models[modelIndex].capabilities = { + embeddingDimension: parseInt(document.getElementById('model-settings-dimensions').value) || 1536, + contextWindow: parseInt(document.getElementById('model-settings-max-tokens').value) || 8192 + }; + } + + // Update endpoint settings + models[modelIndex].endpointSettings = { + timeout: parseInt(document.getElementById('model-settings-timeout').value) || 300, + maxRetries: parseInt(document.getElementById('model-settings-retries').value) || 3 + }; + + var updateData = {}; + updateData[modelsKey] = models; + + return fetch('/api/litellm-api/providers/' + providerId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData) + }); + }) + .then(function() { + closeModelSettingsModal(); + return loadApiSettings(); + }) + .then(function() { + if (selectedProviderId === providerId) { + selectProvider(providerId); + } + showRefreshToast(t('common.saveSuccess'), 'success'); + }) + .catch(function(err) { + console.error('Failed to save model settings:', err); + showRefreshToast(t('common.saveFailed'), 'error'); + }); +} + +function previewModel(providerId, modelId, modelType) { + // Just open the settings modal in read mode for now + showModelSettingsModal(providerId, modelId, modelType); +} + +function deleteModel(providerId, modelId, modelType) { + if (!confirm(t('common.confirmDelete'))) return; + + var isLlm = modelType === 'llm'; + var modelsKey = isLlm ? 'llmModels' : 'embeddingModels'; + + fetch('/api/litellm-api/providers/' + providerId) + .then(function(res) { return res.json(); }) + .then(function(provider) { + var models = provider[modelsKey] || []; + var updatedModels = models.filter(function(m) { return m.id !== modelId; }); + + var updateData = {}; + updateData[modelsKey] = updatedModels; + + return fetch('/api/litellm-api/providers/' + providerId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData) + }); + }) + .then(function() { + return loadApiSettings(); + }) + .then(function() { + if (selectedProviderId === providerId) { + selectProvider(providerId); + } + showRefreshToast(t('common.deleteSuccess'), 'success'); + }) + .catch(function(err) { + console.error('Failed to delete model:', err); + showRefreshToast(t('common.deleteFailed'), 'error'); + }); +} + +function copyProviderApiKey(providerId) { + var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; }); + if (provider && provider.apiKey) { + navigator.clipboard.writeText(provider.apiKey); + showRefreshToast(t('common.copied'), 'success'); + } +} + +/** + * Delete provider with confirmation + */ +async function deleteProviderWithConfirm(providerId) { + if (!confirm(t('apiSettings.confirmDeleteProvider'))) return; + + try { + var response = await fetch('/api/litellm-api/providers/' + providerId, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Failed to delete provider'); + + // Remove from local data + apiSettingsData.providers = apiSettingsData.providers.filter(function(p) { + return p.id !== providerId; + }); + + // Clear selection if deleted provider was selected + if (selectedProviderId === providerId) { + selectedProviderId = null; + if (apiSettingsData.providers.length > 0) { + selectProvider(apiSettingsData.providers[0].id); + } else { + renderProviderEmptyState(); + } + } + + renderProviderList(); + showRefreshToast(t('apiSettings.providerDeleted'), 'success'); + } catch (err) { + console.error('Failed to delete provider:', err); + showRefreshToast(t('common.error') + ': ' + err.message, 'error'); + } +} + +/** + * Get provider icon class based on type + */ +function getProviderIconClass(type) { + var iconMap = { + 'openai': 'provider-icon-openai', + 'anthropic': 'provider-icon-anthropic' + }; + return iconMap[type] || 'provider-icon-custom'; +} + +/** + * Get provider icon name based on type + */ +function getProviderIcon(type) { + const iconMap = { + 'openai': 'sparkles', + 'anthropic': 'brain', + 'google': 'cloud', + 'azure': 'cloud-cog', + 'ollama': 'server', + 'mistral': 'wind', + 'deepseek': 'search' + }; + return iconMap[type] || 'settings'; +} + /** * Render providers list */ function renderProvidersList() { - var container = document.getElementById('providers-list'); + const container = document.getElementById('providers-list'); if (!container) return; - var providers = apiSettingsData.providers || []; + const providers = apiSettingsData.providers || []; if (providers.length === 0) { container.innerHTML = '
' + - '' + - '

' + t('apiSettings.noProviders') + '

' + + '
' + + '' + + '
' + + '

' + t('apiSettings.noProviders') + '

' + + '

' + t('apiSettings.noProvidersHint') + '

' + '
'; if (window.lucide) lucide.createIcons(); return; } container.innerHTML = providers.map(function(provider) { - var statusClass = provider.enabled === false ? 'disabled' : 'enabled'; - var statusText = provider.enabled === false ? t('apiSettings.disabled') : t('apiSettings.enabled'); + const statusClass = provider.enabled === false ? 'disabled' : 'enabled'; + const statusText = provider.enabled === false ? t('apiSettings.disabled') : t('apiSettings.enabled'); + const iconClass = getProviderIconClass(provider.type); + const iconName = getProviderIcon(provider.type); - return '
' + + return '
' + '
' + + '
' + + '
' + + '' + + '
' + '
' + - '

' + provider.name + '

' + - '' + provider.type + '' + + '

' + provider.name + '

' + + '' + provider.type + '' + + '
' + '
' + '
' + - '' + - '' + '
' + '
' + '
' + - '
' + - ' ' + maskApiKey(provider.apiKey) + '' + - (provider.apiBase ? ' ' + provider.apiBase + '' : '') + + '
' + + '
' + + '' + t('apiSettings.apiKey') + '' + + '' + maskApiKey(provider.apiKey) + '' + + '
' + + '
' + + '' + t('common.status') + '' + '' + statusText + '' + '
' + + (provider.apiBase ? + '
' + + '' + t('apiSettings.apiBaseUrl') + '' + + '' + provider.apiBase + '' + + '
' : '') + + '
' + '
' + '
'; }).join(''); @@ -710,51 +1960,74 @@ function renderProvidersList() { * Render endpoints list */ function renderEndpointsList() { - var container = document.getElementById('endpoints-list'); + const container = document.getElementById('endpoints-list'); if (!container) return; - var endpoints = apiSettingsData.endpoints || []; + const endpoints = apiSettingsData.endpoints || []; if (endpoints.length === 0) { container.innerHTML = '
' + - '' + - '

' + t('apiSettings.noEndpoints') + '

' + + '
' + + '' + + '
' + + '

' + t('apiSettings.noEndpoints') + '

' + + '

' + t('apiSettings.noEndpointsHint') + '

' + '
'; if (window.lucide) lucide.createIcons(); return; } container.innerHTML = endpoints.map(function(endpoint) { - var provider = apiSettingsData.providers.find(function(p) { return p.id === endpoint.providerId; }); - var providerName = provider ? provider.name : endpoint.providerId; + const provider = apiSettingsData.providers.find(function(p) { return p.id === endpoint.providerId; }); + const providerName = provider ? provider.name : endpoint.providerId; + const providerType = provider ? provider.type : 'custom'; + const iconClass = getProviderIconClass(providerType); + const iconName = getProviderIcon(providerType); - var cacheStatus = endpoint.cacheStrategy?.enabled - ? t('apiSettings.cacheEnabled') + ' (' + endpoint.cacheStrategy.ttlMinutes + ' min)' - : t('apiSettings.cacheDisabled'); + const cacheEnabled = endpoint.cacheStrategy?.enabled; + const cacheStatus = cacheEnabled + ? endpoint.cacheStrategy.ttlMinutes + ' min' + : t('apiSettings.off'); - return '
' + + return '
' + '
' + + '
' + + '
' + + '' + + '
' + '
' + - '

' + endpoint.name + '

' + + '

' + endpoint.name + '

' + '' + endpoint.id + '' + '
' + + '
' + '
' + - '' + - '' + '
' + '
' + '
' + - '
' + - ' ' + providerName + '' + - ' ' + endpoint.model + '' + - ' ' + cacheStatus + '' + + '
' + + '
' + + '' + t('apiSettings.provider') + '' + + '' + providerName + '' + + '
' + + '
' + + '' + t('apiSettings.model') + '' + + '' + endpoint.model + '' + + '
' + + '
' + + '' + t('apiSettings.cache') + '' + + '' + + (cacheEnabled ? '' : '') + + cacheStatus + '' + + '
' + '
' + '
' + - ' ' + + '' + 'ccw cli -p "..." --model ' + endpoint.id + '' + '
' + '
' + @@ -764,44 +2037,547 @@ function renderEndpointsList() { if (window.lucide) lucide.createIcons(); } +/** + * Render endpoints main panel + */ +function renderEndpointsMainPanel() { + var container = document.getElementById('provider-detail-panel'); + if (!container) return; + + var endpoints = apiSettingsData.endpoints || []; + + var html = '
' + + '
' + + '

' + t('apiSettings.endpoints') + '

' + + '

' + t('apiSettings.endpointsDescription') + '

' + + '
' + + '
' + + '
' + + '
' + endpoints.length + '
' + + '
' + t('apiSettings.totalEndpoints') + '
' + + '
' + + '
' + + '
' + endpoints.filter(function(e) { return e.cacheStrategy?.enabled; }).length + '
' + + '
' + t('apiSettings.cachedEndpoints') + '
' + + '
' + + '
' + + '
'; + + container.innerHTML = html; + if (window.lucide) lucide.createIcons(); +} + +/** + * Render cache main panel + */ +async function renderCacheMainPanel() { + var container = document.getElementById('provider-detail-panel'); + if (!container) return; + + // Load cache stats + var stats = await loadCacheStats(); + if (!stats) { + stats = { totalSize: 0, maxSize: 104857600, entries: 0 }; + } + + var globalSettings = apiSettingsData.globalCache || { enabled: false }; + var totalSize = stats.totalSize || 0; + var maxSize = stats.maxSize || 104857600; // Default 100MB + var usedMB = (totalSize / 1024 / 1024).toFixed(2); + var maxMB = (maxSize / 1024 / 1024).toFixed(0); + var usagePercent = maxSize > 0 ? ((totalSize / maxSize) * 100).toFixed(1) : 0; + + var html = '
' + + '
' + + '

' + t('apiSettings.cacheSettings') + '

' + + '

' + t('apiSettings.cacheDescription') + '

' + + '
' + + // Global Cache Settings + '
' + + '
' + + '

' + t('apiSettings.globalCache') + '

' + + '' + + '
' + + '
' + + // Cache Statistics + '
' + + '

' + t('apiSettings.cacheStatistics') + '

' + + '
' + + '
' + + '
' + + '
' + + '
' + (stats.entries || 0) + '
' + + '
' + t('apiSettings.cachedEntries') + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + usedMB + ' MB
' + + '
' + t('apiSettings.storageUsed') + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + usedMB + ' MB / ' + maxMB + ' MB (' + usagePercent + '%)
' + + '
' + + '
' + + // Cache Actions + '
' + + '

' + t('apiSettings.cacheActions') + '

' + + '' + + '
' + + '
'; + + container.innerHTML = html; + if (window.lucide) lucide.createIcons(); +} + /** * Render cache settings panel */ function renderCacheSettings(stats) { - var container = document.getElementById('cache-settings-panel'); + const container = document.getElementById('cache-settings-panel'); if (!container) return; - var globalSettings = apiSettingsData.globalCache || { enabled: false }; - var usedMB = (stats.totalSize / 1024 / 1024).toFixed(2); - var maxMB = (stats.maxSize / 1024 / 1024).toFixed(0); - var usagePercent = stats.maxSize > 0 ? ((stats.totalSize / stats.maxSize) * 100).toFixed(1) : 0; + const globalSettings = apiSettingsData.globalCache || { enabled: false }; + const totalSize = stats.totalSize || 0; + const maxSize = stats.maxSize || 104857600; // Default 100MB + const usedMB = (totalSize / 1024 / 1024).toFixed(2); + const maxMB = (maxSize / 1024 / 1024).toFixed(0); + const usagePercent = maxSize > 0 ? ((totalSize / maxSize) * 100).toFixed(1) : 0; - container.innerHTML = '
' + - '