From e671b45948127ffbec795bf535b0aa7e447a5c2e Mon Sep 17 00:00:00 2001 From: catlog22 Date: Wed, 24 Dec 2025 16:32:27 +0800 Subject: [PATCH] feat: Enhance configuration management and embedding capabilities - Added JSON-based settings management in Config class for embedding and LLM configurations. - Introduced methods to save and load settings from a JSON file. - Updated BaseEmbedder and its subclasses to include max_tokens property for better token management. - Enhanced chunking strategy to support recursive splitting of large symbols with improved overlap handling. - Implemented comprehensive tests for recursive splitting and chunking behavior. - Added CLI tools configuration management for better integration with external tools. - Introduced a new command for compacting session memory into structured text for recovery. --- .claude/CLAUDE.md | 18 +- .claude/cli-tools.json | 46 +++ .codex/prompts/compact.md | 378 ++++++++++++++++++ .../ccw_litellm/clients/litellm_embedder.py | 11 +- ccw-litellm/src/ccw_litellm/config/loader.py | 198 ++++++++- ccw/src/config/litellm-api-config-manager.ts | 173 +++++++- ccw/src/core/routes/cli-routes.ts | 103 +++++ ccw/src/core/routes/codexlens-routes.ts | 6 +- ccw/src/core/routes/litellm-api-routes.ts | 147 +++++++ .../templates/dashboard-css/12-cli-legacy.css | 44 ++ .../dashboard-css/31-api-settings.css | 183 ++++++++- .../dashboard-js/components/cli-status.js | 228 ++++++++++- ccw/src/templates/dashboard-js/i18n.js | 13 + .../dashboard-js/views/api-settings.js | 315 +++++++++++++-- .../dashboard-js/views/codexlens-manager.js | 88 +++- ccw/src/tools/claude-cli-tools.ts | 300 ++++++++++++++ ccw/src/tools/core-memory.ts | 52 ++- .../src/codexlens/cli/embedding_manager.py | 241 +++++++++-- codex-lens/src/codexlens/config.py | 72 ++++ codex-lens/src/codexlens/semantic/base.py | 10 + codex-lens/src/codexlens/semantic/chunker.py | 65 ++- codex-lens/src/codexlens/semantic/embedder.py | 27 ++ .../codexlens/semantic/litellm_embedder.py | 31 +- codex-lens/tests/test_recursive_splitting.py | 291 ++++++++++++++ package.json | 2 + 25 files changed, 2889 insertions(+), 153 deletions(-) create mode 100644 .claude/cli-tools.json create mode 100644 .codex/prompts/compact.md create mode 100644 ccw/src/tools/claude-cli-tools.ts create mode 100644 codex-lens/tests/test_recursive_splitting.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e94d6fb7..0a0d6e14 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,7 +4,23 @@ - **Coding Philosophy**: @~/.claude/workflows/coding-philosophy.md - **Context Requirements**: @~/.claude/workflows/context-tools.md - **File Modification**: @~/.claude/workflows/file-modification.md +- **CLI Endpoints Config**: @.claude/cli-tools.json + +## CLI Endpoints + +**Strictly follow the @.claude/cli-tools.json configuration** + +Available CLI endpoints are dynamically defined by the config file: +- Built-in tools and their enable/disable status +- Custom API endpoints registered via the Dashboard +- Managed through the CCW Dashboard Status page ## Agent Execution -- **Always use `run_in_background = false`** for Task tool agent calls to ensure synchronous execution and immediate result visibility \ No newline at end of file +- **Always use `run_in_background = false`** for Task tool agent calls to ensure synchronous execution and immediate result visibility + +## Code Diagnostics + +- **Prefer `mcp__ide__getDiagnostics`** for code error checking over shell-based TypeScript compilation +- Usage: `mcp__ide__getDiagnostics({ uri: "file:///path/to/file.ts" })` for specific file or omit uri for all files +- Benefits: Works across platforms, no shell environment issues, real-time IDE integration \ No newline at end of file diff --git a/.claude/cli-tools.json b/.claude/cli-tools.json new file mode 100644 index 00000000..ccd49e1e --- /dev/null +++ b/.claude/cli-tools.json @@ -0,0 +1,46 @@ +{ + "$schema": "./cli-tools.schema.json", + "version": "1.0.0", + "tools": { + "gemini": { + "enabled": true, + "isBuiltin": true, + "command": "gemini", + "description": "Google AI for code analysis" + }, + "qwen": { + "enabled": true, + "isBuiltin": true, + "command": "qwen", + "description": "Alibaba AI assistant" + }, + "codex": { + "enabled": true, + "isBuiltin": true, + "command": "codex", + "description": "OpenAI code generation" + }, + "claude": { + "enabled": true, + "isBuiltin": true, + "command": "claude", + "description": "Anthropic AI assistant" + } + }, + "customEndpoints": [], + "defaultTool": "gemini", + "settings": { + "promptFormat": "plain", + "smartContext": { + "enabled": false, + "maxFiles": 10 + }, + "nativeResume": true, + "recursiveQuery": true, + "cache": { + "injectionMode": "auto", + "defaultPrefix": "", + "defaultSuffix": "" + } + } +} diff --git a/.codex/prompts/compact.md b/.codex/prompts/compact.md new file mode 100644 index 00000000..ccb0592a --- /dev/null +++ b/.codex/prompts/compact.md @@ -0,0 +1,378 @@ +--- +description: Compact current session memory into structured text for session recovery +argument-hint: "[optional: session description]" +--- + +# Memory Compact Command (/memory:compact) + +## 1. Overview + +The `memory:compact` command **compresses current session working memory** into structured text optimized for **session recovery**, extracts critical information, and saves it to persistent storage via MCP `core_memory` tool. + +**Core Philosophy**: +- **Session Recovery First**: Capture everything needed to resume work seamlessly +- **Minimize Re-exploration**: Include file paths, decisions, and state to avoid redundant analysis +- **Preserve Train of Thought**: Keep notes and hypotheses for complex debugging +- **Actionable State**: Record last action result and known issues + +## 2. Parameters + +- `"session description"` (Optional): Session description to supplement objective + - Example: "completed core-memory module" + - Example: "debugging JWT refresh - suspected memory leak" + +## 3. Structured Output Format + +```markdown +## Session ID +[WFS-ID if workflow session active, otherwise (none)] + +## Project Root +[Absolute path to project root, e.g., D:\Claude_dms3] + +## Objective +[High-level goal - the "North Star" of this session] + +## Execution Plan +[CRITICAL: Embed the LATEST plan in its COMPLETE and DETAILED form] + +### Source: [workflow | todo | user-stated | inferred] + +
+Full Execution Plan (Click to expand) + +[PRESERVE COMPLETE PLAN VERBATIM - DO NOT SUMMARIZE] +- ALL phases, tasks, subtasks +- ALL file paths (absolute) +- ALL dependencies and prerequisites +- ALL acceptance criteria +- ALL status markers ([x] done, [ ] pending) +- ALL notes and context + +Example: +## Phase 1: Setup +- [x] Initialize project structure + - Created D:\Claude_dms3\src\core\index.ts + - Added dependencies: lodash, zod +- [ ] Configure TypeScript + - Update tsconfig.json for strict mode + +## Phase 2: Implementation +- [ ] Implement core API + - Target: D:\Claude_dms3\src\api\handler.ts + - Dependencies: Phase 1 complete + - Acceptance: All tests pass + +
+ +## Working Files (Modified) +[Absolute paths to actively modified files] +- D:\Claude_dms3\src\file1.ts (role: main implementation) +- D:\Claude_dms3\tests\file1.test.ts (role: unit tests) + +## Reference Files (Read-Only) +[Absolute paths to context files - NOT modified but essential for understanding] +- D:\Claude_dms3\.claude\CLAUDE.md (role: project instructions) +- D:\Claude_dms3\src\types\index.ts (role: type definitions) +- D:\Claude_dms3\package.json (role: dependencies) + +## Last Action +[Last significant action and its result/status] + +## Decisions +- [Decision]: [Reasoning] +- [Decision]: [Reasoning] + +## Constraints +- [User-specified limitation or preference] + +## Dependencies +- [Added/changed packages or environment requirements] + +## Known Issues +- [Deferred bug or edge case] + +## Changes Made +- [Completed modification] + +## Pending +- [Next step] or (none) + +## Notes +[Unstructured thoughts, hypotheses, debugging trails] +``` + +## 4. Field Definitions + +| Field | Purpose | Recovery Value | +|-------|---------|----------------| +| **Session ID** | Workflow session identifier (WFS-*) | Links memory to specific stateful task execution | +| **Project Root** | Absolute path to project directory | Enables correct path resolution in new sessions | +| **Objective** | Ultimate goal of the session | Prevents losing track of broader feature | +| **Execution Plan** | Complete plan from any source (verbatim) | Preserves full planning context, avoids re-planning | +| **Working Files** | Actively modified files (absolute paths) | Immediately identifies where work was happening | +| **Reference Files** | Read-only context files (absolute paths) | Eliminates re-exploration for critical context | +| **Last Action** | Final tool output/status | Immediate state awareness (success/failure) | +| **Decisions** | Architectural choices + reasoning | Prevents re-litigating settled decisions | +| **Constraints** | User-imposed limitations | Maintains personalized coding style | +| **Dependencies** | Package/environment changes | Prevents missing dependency errors | +| **Known Issues** | Deferred bugs/edge cases | Ensures issues aren't forgotten | +| **Changes Made** | Completed modifications | Clear record of what was done | +| **Pending** | Next steps | Immediate action items | +| **Notes** | Hypotheses, debugging trails | Preserves "train of thought" | + +## 5. Execution Flow + +### Step 1: Analyze Current Session + +Extract the following from conversation history: + +```javascript +const sessionAnalysis = { + sessionId: "", // WFS-* if workflow session active, null otherwise + projectRoot: "", // Absolute path: D:\Claude_dms3 + objective: "", // High-level goal (1-2 sentences) + executionPlan: { + source: "workflow" | "todo" | "user-stated" | "inferred", + content: "" // Full plan content - ALWAYS preserve COMPLETE and DETAILED form + }, + workingFiles: [], // {absolutePath, role} - modified files + referenceFiles: [], // {absolutePath, role} - read-only context files + lastAction: "", // Last significant action + result + decisions: [], // {decision, reasoning} + constraints: [], // User-specified limitations + dependencies: [], // Added/changed packages + knownIssues: [], // Deferred bugs + changesMade: [], // Completed modifications + pending: [], // Next steps + notes: "" // Unstructured thoughts +} +``` + +### Step 2: Generate Structured Text + +```javascript +// Helper: Generate execution plan section +const generateExecutionPlan = (plan) => { + const sourceLabels = { + 'workflow': 'workflow (IMPL_PLAN.md)', + 'todo': 'todo (TodoWrite)', + 'user-stated': 'user-stated', + 'inferred': 'inferred' + }; + + // CRITICAL: Preserve complete plan content verbatim - DO NOT summarize + return `### Source: ${sourceLabels[plan.source] || plan.source} + +
+Full Execution Plan (Click to expand) + +${plan.content} + +
`; +}; + +const structuredText = `## Session ID +${sessionAnalysis.sessionId || '(none)'} + +## Project Root +${sessionAnalysis.projectRoot} + +## Objective +${sessionAnalysis.objective} + +## Execution Plan +${generateExecutionPlan(sessionAnalysis.executionPlan)} + +## Working Files (Modified) +${sessionAnalysis.workingFiles.map(f => `- ${f.absolutePath} (role: ${f.role})`).join('\n') || '(none)'} + +## Reference Files (Read-Only) +${sessionAnalysis.referenceFiles.map(f => `- ${f.absolutePath} (role: ${f.role})`).join('\n') || '(none)'} + +## Last Action +${sessionAnalysis.lastAction} + +## Decisions +${sessionAnalysis.decisions.map(d => `- ${d.decision}: ${d.reasoning}`).join('\n') || '(none)'} + +## Constraints +${sessionAnalysis.constraints.map(c => `- ${c}`).join('\n') || '(none)'} + +## Dependencies +${sessionAnalysis.dependencies.map(d => `- ${d}`).join('\n') || '(none)'} + +## Known Issues +${sessionAnalysis.knownIssues.map(i => `- ${i}`).join('\n') || '(none)'} + +## Changes Made +${sessionAnalysis.changesMade.map(c => `- ${c}`).join('\n') || '(none)'} + +## Pending +${sessionAnalysis.pending.length > 0 + ? sessionAnalysis.pending.map(p => `- ${p}`).join('\n') + : '(none)'} + +## Notes +${sessionAnalysis.notes || '(none)'}` +``` + +### Step 3: Import to Core Memory via MCP + +Use the MCP `core_memory` tool to save the structured text: + +```javascript +mcp__ccw-tools__core_memory({ + operation: "import", + text: structuredText +}) +``` + +Or via CLI (pipe structured text to import): + +```bash +# Write structured text to temp file, then import +echo "$structuredText" | ccw core-memory import + +# Or from a file +ccw core-memory import --file /path/to/session-memory.md +``` + +**Response Format**: +```json +{ + "operation": "import", + "id": "CMEM-YYYYMMDD-HHMMSS", + "message": "Created memory: CMEM-YYYYMMDD-HHMMSS" +} +``` + +### Step 4: Report Recovery ID + +After successful import, **clearly display the Recovery ID** to the user: + +``` +╔════════════════════════════════════════════════════════════════════════════╗ +║ ✓ Session Memory Saved ║ +║ ║ +║ Recovery ID: CMEM-YYYYMMDD-HHMMSS ║ +║ ║ +║ To restore: "Please import memory " ║ +║ (MCP: core_memory export | CLI: ccw core-memory export --id ) ║ +╚════════════════════════════════════════════════════════════════════════════╝ +``` + +## 6. Quality Checklist + +Before generating: +- [ ] Session ID captured if workflow session active (WFS-*) +- [ ] Project Root is absolute path (e.g., D:\Claude_dms3) +- [ ] Objective clearly states the "North Star" goal +- [ ] Execution Plan: COMPLETE plan preserved VERBATIM (no summarization) +- [ ] Plan Source: Clearly identified (workflow | todo | user-stated | inferred) +- [ ] Plan Details: ALL phases, tasks, file paths, dependencies, status markers included +- [ ] All file paths are ABSOLUTE (not relative) +- [ ] Working Files: 3-8 modified files with roles +- [ ] Reference Files: Key context files (CLAUDE.md, types, configs) +- [ ] Last Action captures final state (success/failure) +- [ ] Decisions include reasoning, not just choices +- [ ] Known Issues separates deferred from forgotten bugs +- [ ] Notes preserve debugging hypotheses if any + +## 7. Path Resolution Rules + +### Project Root Detection +1. Check current working directory from environment +2. Look for project markers: `.git/`, `package.json`, `.claude/` +3. Use the topmost directory containing these markers + +### Absolute Path Conversion +```javascript +// Convert relative to absolute +const toAbsolutePath = (relativePath, projectRoot) => { + if (path.isAbsolute(relativePath)) return relativePath; + return path.join(projectRoot, relativePath); +}; + +// Example: "src/api/auth.ts" → "D:\Claude_dms3\src\api\auth.ts" +``` + +### Reference File Categories +| Category | Examples | Priority | +|----------|----------|----------| +| Project Config | `.claude/CLAUDE.md`, `package.json`, `tsconfig.json` | High | +| Type Definitions | `src/types/*.ts`, `*.d.ts` | High | +| Related Modules | Parent/sibling modules with shared interfaces | Medium | +| Test Files | Corresponding test files for modified code | Medium | +| Documentation | `README.md`, `ARCHITECTURE.md` | Low | + +## 8. Plan Detection (Priority Order) + +### Priority 1: Workflow Session (IMPL_PLAN.md) +```javascript +// Check for active workflow session +const manifest = await mcp__ccw-tools__session_manager({ + operation: "list", + location: "active" +}); + +if (manifest.sessions?.length > 0) { + const session = manifest.sessions[0]; + const plan = await mcp__ccw-tools__session_manager({ + operation: "read", + session_id: session.id, + content_type: "plan" + }); + sessionAnalysis.sessionId = session.id; + sessionAnalysis.executionPlan.source = "workflow"; + sessionAnalysis.executionPlan.content = plan.content; +} +``` + +### Priority 2: TodoWrite (Current Session Todos) +```javascript +// Extract from conversation - look for TodoWrite tool calls +// Preserve COMPLETE todo list with all details +const todos = extractTodosFromConversation(); +if (todos.length > 0) { + sessionAnalysis.executionPlan.source = "todo"; + // Format todos with full context - preserve status markers + sessionAnalysis.executionPlan.content = todos.map(t => + `- [${t.status === 'completed' ? 'x' : t.status === 'in_progress' ? '>' : ' '}] ${t.content}` + ).join('\n'); +} +``` + +### Priority 3: User-Stated Plan +```javascript +// Look for explicit plan statements in user messages: +// - "Here's my plan: 1. ... 2. ... 3. ..." +// - "I want to: first..., then..., finally..." +// - Numbered or bulleted lists describing steps +const userPlan = extractUserStatedPlan(); +if (userPlan) { + sessionAnalysis.executionPlan.source = "user-stated"; + sessionAnalysis.executionPlan.content = userPlan; +} +``` + +### Priority 4: Inferred Plan +```javascript +// If no explicit plan, infer from: +// - Task description and breakdown discussion +// - Sequence of actions taken +// - Outstanding work mentioned +const inferredPlan = inferPlanFromDiscussion(); +if (inferredPlan) { + sessionAnalysis.executionPlan.source = "inferred"; + sessionAnalysis.executionPlan.content = inferredPlan; +} +``` + +## 9. Notes + +- **Timing**: Execute at task completion or before context switch +- **Frequency**: Once per independent task or milestone +- **Recovery**: New session can immediately continue with full context +- **Knowledge Graph**: Entity relationships auto-extracted for visualization +- **Absolute Paths**: Critical for cross-session recovery on different machines diff --git a/ccw-litellm/src/ccw_litellm/clients/litellm_embedder.py b/ccw-litellm/src/ccw_litellm/clients/litellm_embedder.py index b71cc7d5..66d9ebd8 100644 --- a/ccw-litellm/src/ccw_litellm/clients/litellm_embedder.py +++ b/ccw-litellm/src/ccw_litellm/clients/litellm_embedder.py @@ -81,7 +81,7 @@ class LiteLLMEmbedder(AbstractEmbedder): """Format model name for LiteLLM. Returns: - Formatted model name (e.g., "text-embedding-3-small") + Formatted model name (e.g., "openai/text-embedding-3-small") """ provider = self._model_config.provider model = self._model_config.model @@ -90,6 +90,11 @@ class LiteLLMEmbedder(AbstractEmbedder): if provider in ["azure", "vertex_ai", "bedrock"]: return f"{provider}/{model}" + # For providers with custom api_base (OpenAI-compatible endpoints), + # use openai/ prefix to tell LiteLLM to use OpenAI API format + if self._provider_config.api_base and provider not in ["openai", "anthropic"]: + return f"openai/{model}" + return model @property @@ -133,6 +138,10 @@ class LiteLLMEmbedder(AbstractEmbedder): embedding_kwargs = {**self._litellm_kwargs, **kwargs} try: + # For OpenAI-compatible endpoints, ensure encoding_format is set + if self._provider_config.api_base and "encoding_format" not in embedding_kwargs: + embedding_kwargs["encoding_format"] = "float" + # Call LiteLLM embedding response = litellm.embedding( model=self._format_model_name(), diff --git a/ccw-litellm/src/ccw_litellm/config/loader.py b/ccw-litellm/src/ccw_litellm/config/loader.py index d738d09f..f5c7ec21 100644 --- a/ccw-litellm/src/ccw_litellm/config/loader.py +++ b/ccw-litellm/src/ccw_litellm/config/loader.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import re from pathlib import Path @@ -11,8 +12,12 @@ import yaml from .models import LiteLLMConfig -# Default configuration path -DEFAULT_CONFIG_PATH = Path.home() / ".ccw" / "config" / "litellm-config.yaml" +# Default configuration paths +# JSON format (UI config) takes priority over YAML format +DEFAULT_JSON_CONFIG_PATH = Path.home() / ".ccw" / "config" / "litellm-api-config.json" +DEFAULT_YAML_CONFIG_PATH = Path.home() / ".ccw" / "config" / "litellm-config.yaml" +# Keep backward compatibility +DEFAULT_CONFIG_PATH = DEFAULT_YAML_CONFIG_PATH # Global configuration singleton _config_instance: LiteLLMConfig | None = None @@ -84,11 +89,147 @@ def _get_default_config() -> dict[str, Any]: } -def load_config(config_path: Path | str | None = None) -> LiteLLMConfig: - """Load LiteLLM configuration from YAML file. +def _convert_json_to_internal_format(json_config: dict[str, Any]) -> dict[str, Any]: + """Convert UI JSON config format to internal format. + + The UI stores config in a different structure: + - providers: array of {id, name, type, apiKey, apiBase, llmModels[], embeddingModels[]} + + Internal format uses: + - providers: dict of {provider_id: {api_key, api_base}} + - llm_models: dict of {model_id: {provider, model}} + - embedding_models: dict of {model_id: {provider, model, dimensions}} Args: - config_path: Path to configuration file (default: ~/.ccw/config/litellm-config.yaml) + json_config: Configuration in UI JSON format + + Returns: + Configuration in internal format + """ + providers: dict[str, Any] = {} + llm_models: dict[str, Any] = {} + embedding_models: dict[str, Any] = {} + default_provider: str | None = None + + for provider in json_config.get("providers", []): + if not provider.get("enabled", True): + continue + + provider_id = provider.get("id", "") + if not provider_id: + continue + + # Set first enabled provider as default + if default_provider is None: + default_provider = provider_id + + # Convert provider with advanced settings + provider_config: dict[str, Any] = { + "api_key": provider.get("apiKey", ""), + "api_base": provider.get("apiBase"), + } + + # Map advanced settings + adv = provider.get("advancedSettings", {}) + if adv.get("timeout"): + provider_config["timeout"] = adv["timeout"] + if adv.get("maxRetries"): + provider_config["max_retries"] = adv["maxRetries"] + if adv.get("organization"): + provider_config["organization"] = adv["organization"] + if adv.get("apiVersion"): + provider_config["api_version"] = adv["apiVersion"] + if adv.get("customHeaders"): + provider_config["custom_headers"] = adv["customHeaders"] + + providers[provider_id] = provider_config + + # Convert LLM models + for model in provider.get("llmModels", []): + if not model.get("enabled", True): + continue + model_id = model.get("id", "") + if not model_id: + continue + + llm_model_config: dict[str, Any] = { + "provider": provider_id, + "model": model.get("name", ""), + } + # Add model-specific endpoint settings + endpoint = model.get("endpointSettings", {}) + if endpoint.get("baseUrl"): + llm_model_config["api_base"] = endpoint["baseUrl"] + if endpoint.get("timeout"): + llm_model_config["timeout"] = endpoint["timeout"] + if endpoint.get("maxRetries"): + llm_model_config["max_retries"] = endpoint["maxRetries"] + + # Add capabilities + caps = model.get("capabilities", {}) + if caps.get("contextWindow"): + llm_model_config["context_window"] = caps["contextWindow"] + if caps.get("maxOutputTokens"): + llm_model_config["max_output_tokens"] = caps["maxOutputTokens"] + + llm_models[model_id] = llm_model_config + + # Convert embedding models + for model in provider.get("embeddingModels", []): + if not model.get("enabled", True): + continue + model_id = model.get("id", "") + if not model_id: + continue + + embedding_model_config: dict[str, Any] = { + "provider": provider_id, + "model": model.get("name", ""), + "dimensions": model.get("capabilities", {}).get("embeddingDimension", 1536), + } + # Add model-specific endpoint settings + endpoint = model.get("endpointSettings", {}) + if endpoint.get("baseUrl"): + embedding_model_config["api_base"] = endpoint["baseUrl"] + if endpoint.get("timeout"): + embedding_model_config["timeout"] = endpoint["timeout"] + + embedding_models[model_id] = embedding_model_config + + # Ensure we have defaults if no models found + if not llm_models: + llm_models["default"] = { + "provider": default_provider or "openai", + "model": "gpt-4", + } + + if not embedding_models: + embedding_models["default"] = { + "provider": default_provider or "openai", + "model": "text-embedding-3-small", + "dimensions": 1536, + } + + return { + "version": json_config.get("version", 1), + "default_provider": default_provider or "openai", + "providers": providers, + "llm_models": llm_models, + "embedding_models": embedding_models, + } + + +def load_config(config_path: Path | str | None = None) -> LiteLLMConfig: + """Load LiteLLM configuration from JSON or YAML file. + + Priority order: + 1. Explicit config_path if provided + 2. JSON config (UI format): ~/.ccw/config/litellm-api-config.json + 3. YAML config: ~/.ccw/config/litellm-config.yaml + 4. Default configuration + + Args: + config_path: Path to configuration file (optional) Returns: Parsed and validated configuration @@ -97,22 +238,47 @@ def load_config(config_path: Path | str | None = None) -> LiteLLMConfig: FileNotFoundError: If config file not found and no default available ValueError: If configuration is invalid """ - if config_path is None: - config_path = DEFAULT_CONFIG_PATH - else: - config_path = Path(config_path) + raw_config: dict[str, Any] | None = None + is_json_format = False - # Load configuration - if config_path.exists(): + if config_path is not None: + config_path = Path(config_path) + if config_path.exists(): + try: + with open(config_path, "r", encoding="utf-8") as f: + if config_path.suffix == ".json": + raw_config = json.load(f) + is_json_format = True + else: + raw_config = yaml.safe_load(f) + except Exception as e: + raise ValueError(f"Failed to load configuration from {config_path}: {e}") from e + + # Check JSON config first (UI format) + if raw_config is None and DEFAULT_JSON_CONFIG_PATH.exists(): try: - with open(config_path, "r", encoding="utf-8") as f: + with open(DEFAULT_JSON_CONFIG_PATH, "r", encoding="utf-8") as f: + raw_config = json.load(f) + is_json_format = True + except Exception: + pass # Fall through to YAML + + # Check YAML config + if raw_config is None and DEFAULT_YAML_CONFIG_PATH.exists(): + try: + with open(DEFAULT_YAML_CONFIG_PATH, "r", encoding="utf-8") as f: raw_config = yaml.safe_load(f) - except Exception as e: - raise ValueError(f"Failed to load configuration from {config_path}: {e}") from e - else: - # Use default configuration + except Exception: + pass # Fall through to default + + # Use default configuration + if raw_config is None: raw_config = _get_default_config() + # Convert JSON format to internal format if needed + if is_json_format: + raw_config = _convert_json_to_internal_format(raw_config) + # Substitute environment variables config_data = _substitute_env_vars(raw_config) diff --git a/ccw/src/config/litellm-api-config-manager.ts b/ccw/src/config/litellm-api-config-manager.ts index 81bd0e27..23a02bb6 100644 --- a/ccw/src/config/litellm-api-config-manager.ts +++ b/ccw/src/config/litellm-api-config-manager.ts @@ -5,7 +5,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { StoragePaths, ensureStorageDir } from './storage-paths.js'; +import { StoragePaths, GlobalPaths, ensureStorageDir } from './storage-paths.js'; import type { LiteLLMApiConfig, ProviderCredential, @@ -32,12 +32,12 @@ function getDefaultConfig(): LiteLLMApiConfig { } /** - * Get config file path for a project + * Get config file path (global, shared across all projects) */ -function getConfigPath(baseDir: string): string { - const paths = StoragePaths.project(baseDir); - ensureStorageDir(paths.config); - return join(paths.config, 'litellm-api-config.json'); +function getConfigPath(_baseDir?: string): string { + const configDir = GlobalPaths.config(); + ensureStorageDir(configDir); + return join(configDir, 'litellm-api-config.json'); } /** @@ -356,5 +356,166 @@ export function updateGlobalCacheSettings( saveConfig(baseDir, config); } +// =========================== +// YAML Config Generation for ccw_litellm +// =========================== + +/** + * Convert UI config (JSON) to ccw_litellm config (YAML format object) + * This allows CodexLens to use UI-configured providers + */ +export function generateLiteLLMYamlConfig(baseDir: string): Record { + const config = loadLiteLLMApiConfig(baseDir); + + // Build providers object + const providers: Record = {}; + for (const provider of config.providers) { + if (!provider.enabled) continue; + + providers[provider.id] = { + api_key: provider.apiKey, + api_base: provider.apiBase || getDefaultApiBaseForType(provider.type), + }; + } + + // Build embedding_models object from providers' embeddingModels + const embeddingModels: Record = {}; + for (const provider of config.providers) { + if (!provider.enabled || !provider.embeddingModels) continue; + + for (const model of provider.embeddingModels) { + if (!model.enabled) continue; + + embeddingModels[model.id] = { + provider: provider.id, + model: model.name, + dimensions: model.capabilities?.embeddingDimension || 1536, + // Use model-specific base URL if set, otherwise use provider's + ...(model.endpointSettings?.baseUrl && { + api_base: model.endpointSettings.baseUrl, + }), + }; + } + } + + // Build llm_models object from providers' llmModels + const llmModels: Record = {}; + for (const provider of config.providers) { + if (!provider.enabled || !provider.llmModels) continue; + + for (const model of provider.llmModels) { + if (!model.enabled) continue; + + llmModels[model.id] = { + provider: provider.id, + model: model.name, + ...(model.endpointSettings?.baseUrl && { + api_base: model.endpointSettings.baseUrl, + }), + }; + } + } + + // Find default provider + const defaultProvider = config.providers.find((p) => p.enabled)?.id || 'openai'; + + return { + version: 1, + default_provider: defaultProvider, + providers, + embedding_models: Object.keys(embeddingModels).length > 0 ? embeddingModels : { + default: { + provider: defaultProvider, + model: 'text-embedding-3-small', + dimensions: 1536, + }, + }, + llm_models: Object.keys(llmModels).length > 0 ? llmModels : { + default: { + provider: defaultProvider, + model: 'gpt-4', + }, + }, + }; +} + +/** + * Get default API base URL for provider type + */ +function getDefaultApiBaseForType(type: ProviderType): string { + const defaults: Record = { + openai: 'https://api.openai.com/v1', + anthropic: 'https://api.anthropic.com/v1', + custom: 'https://api.example.com/v1', + }; + return defaults[type] || 'https://api.openai.com/v1'; +} + +/** + * Save ccw_litellm YAML config file + * Writes to ~/.ccw/config/litellm-config.yaml + */ +export function saveLiteLLMYamlConfig(baseDir: string): string { + const yamlConfig = generateLiteLLMYamlConfig(baseDir); + + // Convert to YAML manually (simple format) + const yamlContent = objectToYaml(yamlConfig); + + // Write to ~/.ccw/config/litellm-config.yaml + const homePath = process.env.HOME || process.env.USERPROFILE || ''; + const yamlPath = join(homePath, '.ccw', 'config', 'litellm-config.yaml'); + + // Ensure directory exists + const configDir = join(homePath, '.ccw', 'config'); + ensureStorageDir(configDir); + + writeFileSync(yamlPath, yamlContent, 'utf-8'); + return yamlPath; +} + +/** + * Simple object to YAML converter + */ +function objectToYaml(obj: unknown, indent: number = 0): string { + const spaces = ' '.repeat(indent); + + if (obj === null || obj === undefined) { + return 'null'; + } + + if (typeof obj === 'string') { + // Quote strings that contain special characters + if (obj.includes(':') || obj.includes('#') || obj.includes('\n') || obj.startsWith('$')) { + return `"${obj.replace(/"/g, '\\"')}"`; + } + return obj; + } + + if (typeof obj === 'number' || typeof obj === 'boolean') { + return String(obj); + } + + if (Array.isArray(obj)) { + if (obj.length === 0) return '[]'; + return obj.map((item) => `${spaces}- ${objectToYaml(item, indent + 1).trimStart()}`).join('\n'); + } + + if (typeof obj === 'object') { + const entries = Object.entries(obj as Record); + if (entries.length === 0) return '{}'; + + return entries + .map(([key, value]) => { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return `${spaces}${key}:\n${objectToYaml(value, indent + 1)}`; + } + return `${spaces}${key}: ${objectToYaml(value, indent)}`; + }) + .join('\n'); + } + + return String(obj); +} + // Re-export types export type { ProviderCredential, CustomEndpoint, ProviderType, CacheStrategy }; diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index ac3a2f38..019566f0 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -33,6 +33,13 @@ import { getFullConfigResponse, PREDEFINED_MODELS } from '../../tools/cli-config-manager.js'; +import { + loadClaudeCliTools, + saveClaudeCliTools, + updateClaudeToolEnabled, + updateClaudeCacheSettings, + getClaudeCliToolsInfo +} from '../../tools/claude-cli-tools.js'; export interface RouteContext { pathname: string; @@ -558,5 +565,101 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { return true; } + // API: Get CLI Tools Config from .claude/cli-tools.json (with fallback to global) + if (pathname === '/api/cli/tools-config' && req.method === 'GET') { + try { + const config = loadClaudeCliTools(initialPath); + const info = getClaudeCliToolsInfo(initialPath); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + ...config, + _configInfo: info + })); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + } + return true; + } + + // API: Update CLI Tools Config + if (pathname === '/api/cli/tools-config' && req.method === 'PUT') { + handlePostRequest(req, res, async (body: unknown) => { + try { + const updates = body as Partial; + const config = loadClaudeCliTools(initialPath); + + // Merge updates + const updatedConfig = { + ...config, + ...updates, + tools: { ...config.tools, ...(updates.tools || {}) }, + settings: { + ...config.settings, + ...(updates.settings || {}), + cache: { + ...config.settings.cache, + ...(updates.settings?.cache || {}) + } + } + }; + + saveClaudeCliTools(initialPath, updatedConfig); + + broadcastToClients({ + type: 'CLI_TOOLS_CONFIG_UPDATED', + payload: { config: updatedConfig, timestamp: new Date().toISOString() } + }); + + return { success: true, config: updatedConfig }; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // API: Update specific tool enabled status + const toolsConfigMatch = pathname.match(/^\/api\/cli\/tools-config\/([a-zA-Z0-9_-]+)$/); + if (toolsConfigMatch && req.method === 'PUT') { + const toolName = toolsConfigMatch[1]; + handlePostRequest(req, res, async (body: unknown) => { + try { + const { enabled } = body as { enabled: boolean }; + const config = updateClaudeToolEnabled(initialPath, toolName, enabled); + + broadcastToClients({ + type: 'CLI_TOOL_TOGGLED', + payload: { tool: toolName, enabled, timestamp: new Date().toISOString() } + }); + + return { success: true, config }; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + + // API: Update cache settings + if (pathname === '/api/cli/tools-config/cache' && req.method === 'PUT') { + handlePostRequest(req, res, async (body: unknown) => { + try { + const cacheSettings = body as { injectionMode?: string; defaultPrefix?: string; defaultSuffix?: string }; + const config = updateClaudeCacheSettings(initialPath, cacheSettings as any); + + broadcastToClients({ + type: 'CLI_CACHE_SETTINGS_UPDATED', + payload: { cache: config.settings.cache, timestamp: new Date().toISOString() } + }); + + return { success: true, config }; + } catch (err) { + return { error: (err as Error).message, status: 500 }; + } + }); + return true; + } + return false; } diff --git a/ccw/src/core/routes/codexlens-routes.ts b/ccw/src/core/routes/codexlens-routes.ts index 8ff78d1b..3bac068e 100644 --- a/ccw/src/core/routes/codexlens-routes.ts +++ b/ccw/src/core/routes/codexlens-routes.ts @@ -405,7 +405,7 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise // API: CodexLens Init (Initialize workspace index) if (pathname === '/api/codexlens/init' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { - const { path: projectPath, indexType = 'vector', embeddingModel = 'code' } = body; + const { path: projectPath, indexType = 'vector', embeddingModel = 'code', embeddingBackend = 'fastembed' } = body; const targetPath = projectPath || initialPath; // Build CLI arguments based on index type @@ -415,6 +415,10 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise } else { // Add embedding model selection for vector index args.push('--embedding-model', embeddingModel); + // Add embedding backend if not using default fastembed + if (embeddingBackend && embeddingBackend !== 'fastembed') { + args.push('--embedding-backend', embeddingBackend); + } } // Broadcast start event diff --git a/ccw/src/core/routes/litellm-api-routes.ts b/ccw/src/core/routes/litellm-api-routes.ts index ae5127b2..7470c6eb 100644 --- a/ccw/src/core/routes/litellm-api-routes.ts +++ b/ccw/src/core/routes/litellm-api-routes.ts @@ -20,6 +20,8 @@ import { getGlobalCacheSettings, updateGlobalCacheSettings, loadLiteLLMApiConfig, + saveLiteLLMYamlConfig, + generateLiteLLMYamlConfig, type ProviderCredential, type CustomEndpoint, type ProviderType, @@ -481,5 +483,150 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise((resolve) => { + const proc = spawn('python', ['-c', 'import ccw_litellm; print(ccw_litellm.__version__ if hasattr(ccw_litellm, "__version__") else "installed")'], { + shell: true, + timeout: 10000 + }); + + let output = ''; + proc.stdout?.on('data', (data) => { output += data.toString(); }); + proc.on('close', (code) => { + if (code === 0) { + resolve({ installed: true, version: output.trim() || 'unknown' }); + } else { + resolve({ installed: false }); + } + }); + proc.on('error', () => resolve({ installed: false })); + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } catch (err) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ installed: false, error: (err as Error).message })); + } + return true; + } + + // POST /api/litellm-api/ccw-litellm/install - Install ccw-litellm package + if (pathname === '/api/litellm-api/ccw-litellm/install' && req.method === 'POST') { + handlePostRequest(req, res, async () => { + try { + const { spawn } = await import('child_process'); + const path = await import('path'); + const fs = await import('fs'); + + // Try to find ccw-litellm package in distribution + const possiblePaths = [ + path.join(initialPath, 'ccw-litellm'), + path.join(initialPath, '..', 'ccw-litellm'), + path.join(process.cwd(), 'ccw-litellm'), + ]; + + let packagePath = ''; + for (const p of possiblePaths) { + const pyproject = path.join(p, 'pyproject.toml'); + if (fs.existsSync(pyproject)) { + packagePath = p; + break; + } + } + + if (!packagePath) { + // Try pip install from PyPI as fallback + return new Promise((resolve) => { + const proc = spawn('pip', ['install', 'ccw-litellm'], { shell: true, timeout: 300000 }); + let output = ''; + let error = ''; + proc.stdout?.on('data', (data) => { output += data.toString(); }); + proc.stderr?.on('data', (data) => { error += data.toString(); }); + proc.on('close', (code) => { + if (code === 0) { + resolve({ success: true, message: 'ccw-litellm installed from PyPI' }); + } else { + resolve({ success: false, error: error || 'Installation failed' }); + } + }); + proc.on('error', (err) => resolve({ success: false, error: err.message })); + }); + } + + // Install from local package + return new Promise((resolve) => { + const proc = spawn('pip', ['install', '-e', packagePath], { shell: true, timeout: 300000 }); + let output = ''; + let error = ''; + proc.stdout?.on('data', (data) => { output += data.toString(); }); + proc.stderr?.on('data', (data) => { error += data.toString(); }); + proc.on('close', (code) => { + if (code === 0) { + // Broadcast installation event + broadcastToClients({ + type: 'CCW_LITELLM_INSTALLED', + payload: { timestamp: new Date().toISOString() } + }); + resolve({ success: true, message: 'ccw-litellm installed successfully', path: packagePath }); + } else { + resolve({ success: false, error: error || output || 'Installation failed' }); + } + }); + proc.on('error', (err) => resolve({ success: false, error: err.message })); + }); + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }); + return true; + } + return false; } diff --git a/ccw/src/templates/dashboard-css/12-cli-legacy.css b/ccw/src/templates/dashboard-css/12-cli-legacy.css index 72af2b7d..73be67ed 100644 --- a/ccw/src/templates/dashboard-css/12-cli-legacy.css +++ b/ccw/src/templates/dashboard-css/12-cli-legacy.css @@ -170,6 +170,27 @@ letter-spacing: 0.03em; } +.cli-tool-badge-disabled { + font-size: 0.5625rem; + font-weight: 600; + padding: 0.125rem 0.375rem; + background: hsl(38 92% 50% / 0.2); + color: hsl(38 92% 50%); + border-radius: 9999px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* Disabled tool card state */ +.cli-tool-card.disabled { + opacity: 0.7; + border-style: dashed; +} + +.cli-tool-card.disabled .cli-tool-name { + color: hsl(var(--muted-foreground)); +} + .cli-tool-info { font-size: 0.6875rem; margin-bottom: 0.3125rem; @@ -773,6 +794,29 @@ border-color: hsl(var(--destructive) / 0.5); } +/* Enable/Disable button variants */ +.btn-sm.btn-outline-success { + background: transparent; + border: 1px solid hsl(142 76% 36% / 0.4); + color: hsl(142 76% 36%); +} + +.btn-sm.btn-outline-success:hover { + background: hsl(142 76% 36% / 0.1); + border-color: hsl(142 76% 36% / 0.6); +} + +.btn-sm.btn-outline-warning { + background: transparent; + border: 1px solid hsl(38 92% 50% / 0.4); + color: hsl(38 92% 50%); +} + +.btn-sm.btn-outline-warning:hover { + background: hsl(38 92% 50% / 0.1); + border-color: hsl(38 92% 50% / 0.6); +} + /* Empty State */ .empty-state { display: flex; diff --git a/ccw/src/templates/dashboard-css/31-api-settings.css b/ccw/src/templates/dashboard-css/31-api-settings.css index 4b32bd47..81103e2c 100644 --- a/ccw/src/templates/dashboard-css/31-api-settings.css +++ b/ccw/src/templates/dashboard-css/31-api-settings.css @@ -622,11 +622,110 @@ select.cli-input { align-items: center; justify-content: flex-end; gap: 0.75rem; - margin-top: 1rem; - padding-top: 1rem; + margin-top: 1.25rem; + padding-top: 1.25rem; border-top: 1px solid hsl(var(--border)); } +.modal-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + min-width: 5rem; +} + +.modal-actions .btn-secondary { + background: transparent; + border: 1px solid hsl(var(--border)); + color: hsl(var(--muted-foreground)); +} + +.modal-actions .btn-secondary:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); + border-color: hsl(var(--muted-foreground) / 0.3); +} + +.modal-actions .btn-primary { + background: hsl(var(--primary)); + border: 1px solid hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.modal-actions .btn-primary:hover { + background: hsl(var(--primary) / 0.9); + box-shadow: 0 2px 8px hsl(var(--primary) / 0.3); +} + +.modal-actions .btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; +} + +.modal-actions .btn-danger { + background: hsl(var(--destructive)); + border: 1px solid hsl(var(--destructive)); + color: hsl(var(--destructive-foreground)); +} + +.modal-actions .btn-danger:hover { + background: hsl(var(--destructive) / 0.9); + box-shadow: 0 2px 8px hsl(var(--destructive) / 0.3); +} + +.modal-actions button i, +.modal-actions button svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +/* Handle .btn class prefix */ +.modal-actions .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + min-width: 5rem; +} + +.modal-actions .btn.btn-secondary { + background: transparent; + border: 1px solid hsl(var(--border)); + color: hsl(var(--muted-foreground)); +} + +.modal-actions .btn.btn-secondary:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); + border-color: hsl(var(--muted-foreground) / 0.3); +} + +.modal-actions .btn.btn-primary { + background: hsl(var(--primary)); + border: 1px solid hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.modal-actions .btn.btn-primary:hover { + background: hsl(var(--primary) / 0.9); + box-shadow: 0 2px 8px hsl(var(--primary) / 0.3); +} + /* Button Icon */ .btn-icon { display: inline-flex; @@ -1916,4 +2015,84 @@ select.cli-input { .health-check-grid { grid-template-columns: 1fr; } +} + +/* =========================== + Model Settings Modal - Endpoint Preview + =========================== */ + +.endpoint-preview-section { + background: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 0.5rem; +} + +.endpoint-preview-section h4 { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.endpoint-preview-section h4 i { + width: 16px; + height: 16px; + color: hsl(var(--primary)); +} + +.endpoint-preview-box { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + margin-bottom: 1rem; +} + +.endpoint-preview-box code { + flex: 1; + font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace; + font-size: 0.8125rem; + color: hsl(var(--primary)); + word-break: break-all; +} + +.endpoint-preview-box .btn-icon-sm { + flex-shrink: 0; +} + +/* Form Section within Modal */ +.form-section { + margin-bottom: 1.25rem; +} + +.form-section h4 { + margin: 0 0 0.75rem 0; + font-size: 0.8125rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.form-section:last-of-type { + margin-bottom: 0; +} + +/* Capabilities Checkboxes */ +.capabilities-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.5rem; +} + +.capabilities-checkboxes .checkbox-label { + font-size: 0.875rem; } \ No newline at end of file diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js index 04e24cb8..3f99e7f2 100644 --- a/ccw/src/templates/dashboard-js/components/cli-status.js +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -8,6 +8,8 @@ let semanticStatus = { available: false }; let ccwInstallStatus = { installed: true, workflowsInstalled: true, missingFiles: [], installPath: '' }; let defaultCliTool = 'gemini'; let promptConcatFormat = localStorage.getItem('ccw-prompt-format') || 'plain'; // plain, yaml, json +let cliToolsConfig = {}; // CLI tools enable/disable config +let apiEndpoints = []; // API endpoints from LiteLLM config // Smart Context settings let smartContextEnabled = localStorage.getItem('ccw-smart-context') === 'true'; @@ -41,6 +43,12 @@ async function loadAllStatuses() { semanticStatus = data.semantic || { available: false }; ccwInstallStatus = data.ccwInstall || { installed: true, workflowsInstalled: true, missingFiles: [], installPath: '' }; + // Load CLI tools config and API endpoints + await Promise.all([ + loadCliToolsConfig(), + loadApiEndpoints() + ]); + // Update badges updateCliBadge(); updateCodexLensBadge(); @@ -168,6 +176,67 @@ async function loadInstalledModels() { } } +/** + * Load CLI tools config from .claude/cli-tools.json (project or global fallback) + */ +async function loadCliToolsConfig() { + try { + const response = await fetch('/api/cli/tools-config'); + if (!response.ok) return null; + const data = await response.json(); + // Store full config and extract tools for backward compatibility + cliToolsConfig = data.tools || {}; + window.claudeCliToolsConfig = data; // Full config available globally + + // Load default tool from config + if (data.defaultTool) { + defaultCliTool = data.defaultTool; + } + + console.log('[CLI Config] Loaded from:', data._configInfo?.source || 'unknown', '| Default:', data.defaultTool); + return data; + } catch (err) { + console.error('Failed to load CLI tools config:', err); + return null; + } +} + +/** + * Update CLI tool enabled status + */ +async function updateCliToolEnabled(tool, enabled) { + try { + const response = await fetch('/api/cli/tools-config/' + tool, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: enabled }) + }); + if (!response.ok) throw new Error('Failed to update'); + showRefreshToast(tool + (enabled ? ' enabled' : ' disabled'), 'success'); + return await response.json(); + } catch (err) { + console.error('Failed to update CLI tool:', err); + showRefreshToast('Failed to update ' + tool, 'error'); + return null; + } +} + +/** + * Load API endpoints from LiteLLM config + */ +async function loadApiEndpoints() { + try { + const response = await fetch('/api/litellm-api/endpoints'); + if (!response.ok) return []; + const data = await response.json(); + apiEndpoints = data.endpoints || []; + return apiEndpoints; + } catch (err) { + console.error('Failed to load API endpoints:', err); + return []; + } +} + // ========== Badge Update ========== function updateCliBadge() { const badge = document.getElementById('badgeCliTools'); @@ -234,25 +303,41 @@ function renderCliStatus() { const status = cliToolStatus[tool] || {}; const isAvailable = status.available; const isDefault = defaultCliTool === tool; + const config = cliToolsConfig[tool] || { enabled: true }; + const isEnabled = config.enabled !== false; + const canSetDefault = isAvailable && isEnabled && !isDefault; return ` -
+
- + ${tool.charAt(0).toUpperCase() + tool.slice(1)} ${isDefault ? 'Default' : ''} + ${!isEnabled && isAvailable ? 'Disabled' : ''}
${toolDescriptions[tool]}
-
- ${isAvailable - ? ` Ready` - : ` Not Installed` - } +
+
+ ${isAvailable + ? (isEnabled + ? ` Ready` + : ` Disabled`) + : ` Not Installed` + } +
-
- ${isAvailable && !isDefault +
+ ${isAvailable ? (isEnabled + ? `` + : `` + ) : ''} + ${canSetDefault ? `` @@ -365,11 +450,42 @@ function renderCliStatus() {
` : ''; + // API Endpoints section + const apiEndpointsHtml = apiEndpoints.length > 0 ? ` +
+
+

+ API Endpoints +

+ ${apiEndpoints.length} +
+
+ ${apiEndpoints.map(ep => ` +
+
+ + ${ep.id} +
+
+ ${ep.model} +
+
+ `).join('')} +
+
+ ` : ''; + + // Config source info + const configInfo = window.claudeCliToolsConfig?._configInfo || {}; + const configSourceLabel = configInfo.source === 'project' ? 'Project' : configInfo.source === 'global' ? 'Global' : 'Default'; + const configSourceClass = configInfo.source === 'project' ? 'text-success' : configInfo.source === 'global' ? 'text-primary' : 'text-muted-foreground'; + // CLI Settings section const settingsHtml = `

Settings

+ ${configSourceLabel}
@@ -436,6 +552,20 @@ function renderCliStatus() {

Maximum files to include in smart context

+
+ +
+ +
+

Cache prefix/suffix injection mode for prompts

+
`; @@ -453,6 +583,7 @@ function renderCliStatus() { ${codexLensHtml} ${semanticHtml}
+ ${apiEndpointsHtml} ${settingsHtml} `; @@ -464,7 +595,30 @@ function renderCliStatus() { // ========== Actions ========== function setDefaultCliTool(tool) { + // Validate: tool must be available and enabled + const status = cliToolStatus[tool] || {}; + const config = cliToolsConfig[tool] || { enabled: true }; + + if (!status.available) { + showRefreshToast(`Cannot set ${tool} as default: not installed`, 'error'); + return; + } + + if (config.enabled === false) { + showRefreshToast(`Cannot set ${tool} as default: tool is disabled`, 'error'); + return; + } + defaultCliTool = tool; + // Save to config + if (window.claudeCliToolsConfig) { + window.claudeCliToolsConfig.defaultTool = tool; + fetch('/api/cli/tools-config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ defaultTool: tool }) + }).catch(err => console.error('Failed to save default tool:', err)); + } renderCliStatus(); showRefreshToast(`Default CLI tool set to ${tool}`, 'success'); } @@ -505,11 +659,67 @@ function setRecursiveQueryEnabled(enabled) { showRefreshToast(`Recursive Query ${enabled ? 'enabled' : 'disabled'}`, 'success'); } +function getCacheInjectionMode() { + if (window.claudeCliToolsConfig && window.claudeCliToolsConfig.settings) { + return window.claudeCliToolsConfig.settings.cache?.injectionMode || 'auto'; + } + return localStorage.getItem('ccw-cache-injection-mode') || 'auto'; +} + +async function setCacheInjectionMode(mode) { + try { + const response = await fetch('/api/cli/tools-config/cache', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ injectionMode: mode }) + }); + if (response.ok) { + localStorage.setItem('ccw-cache-injection-mode', mode); + if (window.claudeCliToolsConfig) { + window.claudeCliToolsConfig.settings.cache.injectionMode = mode; + } + showRefreshToast(`Cache injection mode set to ${mode}`, 'success'); + } else { + showRefreshToast('Failed to update cache settings', 'error'); + } + } catch (err) { + console.error('Failed to update cache settings:', err); + showRefreshToast('Failed to update cache settings', 'error'); + } +} + async function refreshAllCliStatus() { await loadAllStatuses(); renderCliStatus(); } +async function toggleCliTool(tool, enabled) { + // If disabling the current default tool, switch to another available+enabled tool + if (!enabled && defaultCliTool === tool) { + const tools = ['gemini', 'qwen', 'codex', 'claude']; + const newDefault = tools.find(t => { + if (t === tool) return false; + const status = cliToolStatus[t] || {}; + const config = cliToolsConfig[t] || { enabled: true }; + return status.available && config.enabled !== false; + }); + + if (newDefault) { + defaultCliTool = newDefault; + if (window.claudeCliToolsConfig) { + window.claudeCliToolsConfig.defaultTool = newDefault; + } + showRefreshToast(`Default tool switched to ${newDefault}`, 'info'); + } else { + showRefreshToast(`Warning: No other enabled tool available for default`, 'warning'); + } + } + + await updateCliToolEnabled(tool, enabled); + await loadAllStatuses(); + renderCliStatus(); +} + function installCodexLens() { openCodexLensInstallWizard(); } diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 92dd84de..8cb6928c 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -1389,7 +1389,13 @@ const i18n = { 'apiSettings.previewModel': 'Preview', 'apiSettings.modelSettings': 'Model Settings', 'apiSettings.deleteModel': 'Delete Model', + 'apiSettings.endpointPreview': 'Endpoint Preview', + 'apiSettings.modelBaseUrlOverride': 'Base URL Override', + 'apiSettings.modelBaseUrlHint': 'Override the provider base URL for this specific model (leave empty to use provider default)', 'apiSettings.providerUpdated': 'Provider updated', + 'apiSettings.syncToCodexLens': 'Sync to CodexLens', + 'apiSettings.configSynced': 'Config synced to CodexLens', + 'apiSettings.sdkAutoAppends': 'SDK auto-appends', 'apiSettings.preview': 'Preview', 'apiSettings.used': 'used', 'apiSettings.total': 'total', @@ -1422,6 +1428,7 @@ const i18n = { 'apiSettings.cacheDisabled': 'Cache Disabled', 'apiSettings.providerSaved': 'Provider saved successfully', 'apiSettings.providerDeleted': 'Provider deleted successfully', + 'apiSettings.apiBaseUpdated': 'API Base URL updated successfully', 'apiSettings.endpointSaved': 'Endpoint saved successfully', 'apiSettings.endpointDeleted': 'Endpoint deleted successfully', 'apiSettings.cacheCleared': 'Cache cleared successfully', @@ -3039,7 +3046,12 @@ const i18n = { 'apiSettings.previewModel': '预览', 'apiSettings.modelSettings': '模型设置', 'apiSettings.deleteModel': '删除模型', + 'apiSettings.endpointPreview': '端点预览', + 'apiSettings.modelBaseUrlOverride': '基础 URL 覆盖', + 'apiSettings.modelBaseUrlHint': '为此模型覆盖供应商的基础 URL(留空则使用供应商默认值)', 'apiSettings.providerUpdated': '供应商已更新', + 'apiSettings.syncToCodexLens': '同步到 CodexLens', + 'apiSettings.configSynced': '配置已同步到 CodexLens', 'apiSettings.preview': '预览', 'apiSettings.used': '已使用', 'apiSettings.total': '总计', @@ -3072,6 +3084,7 @@ const i18n = { 'apiSettings.cacheDisabled': '缓存已禁用', 'apiSettings.providerSaved': '提供商保存成功', 'apiSettings.providerDeleted': '提供商删除成功', + 'apiSettings.apiBaseUpdated': 'API 基础 URL 更新成功', 'apiSettings.endpointSaved': '端点保存成功', 'apiSettings.endpointDeleted': '端点删除成功', 'apiSettings.cacheCleared': '缓存清除成功', diff --git a/ccw/src/templates/dashboard-js/views/api-settings.js b/ccw/src/templates/dashboard-js/views/api-settings.js index 6e0fdacb..0e293ca1 100644 --- a/ccw/src/templates/dashboard-js/views/api-settings.js +++ b/ccw/src/templates/dashboard-js/views/api-settings.js @@ -359,10 +359,20 @@ async function deleteProvider(providerId) { /** * Test provider connection + * @param {string} [providerIdParam] - Optional provider ID. If not provided, uses form context or selectedProviderId */ -async function testProviderConnection() { - const form = document.getElementById('providerForm'); - const providerId = form.dataset.providerId; +async function testProviderConnection(providerIdParam) { + var providerId = providerIdParam; + + // Try to get providerId from different sources + if (!providerId) { + var form = document.getElementById('providerForm'); + if (form && form.dataset.providerId) { + providerId = form.dataset.providerId; + } else if (selectedProviderId) { + providerId = selectedProviderId; + } + } if (!providerId) { showRefreshToast(t('apiSettings.saveProviderFirst'), 'warning'); @@ -553,9 +563,9 @@ async function showAddEndpointModal() { '
' + '' + '' + '' + @@ -845,7 +855,10 @@ async function renderApiSettings() { } // Build split layout - container.innerHTML = '
' + + container.innerHTML = + // CCW-LiteLLM Status Container + '
' + + '
' + // Left Sidebar '
' + - // Multi-key settings button + // Multi-key and sync buttons '
' + '' + + '' + '
' + '
'; @@ -1107,18 +1134,21 @@ function renderModelTree(provider) { ? formatContextWindow(model.capabilities.contextWindow) : ''; + // Badge for embedding models shows dimension instead of context window + var embeddingBadge = model.capabilities && model.capabilities.embeddingDimension + ? model.capabilities.embeddingDimension + 'd' + : ''; + var displayBadge = activeModelTab === 'llm' ? badge : embeddingBadge; + html += '
' + '' + '' + escapeHtml(model.name) + '' + - (badge ? '' + badge + '' : '') + + (displayBadge ? '' + displayBadge + '' : '') + '
' + - '' + - '' + - '' + '
' + @@ -1418,8 +1448,8 @@ function showAddModelModal(providerId, modelType) { '
' + '' + '' + '
' + @@ -1624,29 +1654,51 @@ function showModelSettingsModal(providerId, modelId, modelType) { var capabilities = model.capabilities || {}; var endpointSettings = model.endpointSettings || {}; + // Calculate endpoint preview URL + var providerBase = provider.apiBase || getDefaultApiBase(provider.type); + var modelBaseUrl = endpointSettings.baseUrl || providerBase; + var endpointPath = isLlm ? '/chat/completions' : '/embeddings'; + var endpointPreview = modelBaseUrl + endpointPath; + var modalHtml = '