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.
This commit is contained in:
catlog22
2025-12-24 16:32:27 +08:00
parent b00113d212
commit e671b45948
25 changed files with 2889 additions and 153 deletions

View File

@@ -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
- **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

46
.claude/cli-tools.json Normal file
View File

@@ -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": ""
}
}
}

378
.codex/prompts/compact.md Normal file
View File

@@ -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]
<details>
<summary>Full Execution Plan (Click to expand)</summary>
[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
</details>
## 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}
<details>
<summary>Full Execution Plan (Click to expand)</summary>
${plan.content}
</details>`;
};
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 <ID>" ║
║ (MCP: core_memory export | CLI: ccw core-memory export --id <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

View File

@@ -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(),

View File

@@ -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)

View File

@@ -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<string, unknown> {
const config = loadLiteLLMApiConfig(baseDir);
// Build providers object
const providers: Record<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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<string, string> = {
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<string, unknown>);
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 };

View File

@@ -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<boolean> {
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<any>;
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;
}

View File

@@ -405,7 +405,7 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
// 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<boolean>
} 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

View File

@@ -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<boolean
return true;
}
// ===========================
// Config Sync Routes
// ===========================
// POST /api/litellm-api/config/sync - Sync UI config to ccw_litellm YAML config
if (pathname === '/api/litellm-api/config/sync' && req.method === 'POST') {
try {
const yamlPath = saveLiteLLMYamlConfig(initialPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: 'Config synced to ccw_litellm',
yamlPath,
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// GET /api/litellm-api/config/yaml-preview - Preview YAML config without saving
if (pathname === '/api/litellm-api/config/yaml-preview' && req.method === 'GET') {
try {
const yamlConfig = generateLiteLLMYamlConfig(initialPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
config: yamlConfig,
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// ===========================
// CCW-LiteLLM Package Management
// ===========================
// GET /api/litellm-api/ccw-litellm/status - Check ccw-litellm installation status
if (pathname === '/api/litellm-api/ccw-litellm/status' && req.method === 'GET') {
try {
const { spawn } = await import('child_process');
const result = await new Promise<{ installed: boolean; version?: string }>((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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 `
<div class="cli-tool-card tool-${tool} ${isAvailable ? 'available' : 'unavailable'}">
<div class="cli-tool-card tool-${tool} ${isAvailable ? 'available' : 'unavailable'} ${!isEnabled ? 'disabled' : ''}">
<div class="cli-tool-header">
<span class="cli-tool-status ${isAvailable ? 'status-available' : 'status-unavailable'}"></span>
<span class="cli-tool-status ${isAvailable && isEnabled ? 'status-available' : 'status-unavailable'}"></span>
<span class="cli-tool-name">${tool.charAt(0).toUpperCase() + tool.slice(1)}</span>
${isDefault ? '<span class="cli-tool-badge">Default</span>' : ''}
${!isEnabled && isAvailable ? '<span class="cli-tool-badge-disabled">Disabled</span>' : ''}
</div>
<div class="cli-tool-desc text-xs text-muted-foreground mt-1">
${toolDescriptions[tool]}
</div>
<div class="cli-tool-info mt-2">
${isAvailable
? `<span class="text-success flex items-center gap-1"><i data-lucide="check-circle" class="w-3 h-3"></i> Ready</span>`
: `<span class="text-muted-foreground flex items-center gap-1"><i data-lucide="circle-dashed" class="w-3 h-3"></i> Not Installed</span>`
}
<div class="cli-tool-info mt-2 flex items-center justify-between">
<div>
${isAvailable
? (isEnabled
? `<span class="text-success flex items-center gap-1"><i data-lucide="check-circle" class="w-3 h-3"></i> Ready</span>`
: `<span class="text-warning flex items-center gap-1"><i data-lucide="pause-circle" class="w-3 h-3"></i> Disabled</span>`)
: `<span class="text-muted-foreground flex items-center gap-1"><i data-lucide="circle-dashed" class="w-3 h-3"></i> Not Installed</span>`
}
</div>
</div>
<div class="cli-tool-actions mt-3">
${isAvailable && !isDefault
<div class="cli-tool-actions mt-3 flex gap-2">
${isAvailable ? (isEnabled
? `<button class="btn-sm btn-outline-warning flex items-center gap-1" onclick="toggleCliTool('${tool}', false)">
<i data-lucide="pause" class="w-3 h-3"></i> Disable
</button>`
: `<button class="btn-sm btn-outline-success flex items-center gap-1" onclick="toggleCliTool('${tool}', true)">
<i data-lucide="play" class="w-3 h-3"></i> Enable
</button>`
) : ''}
${canSetDefault
? `<button class="btn-sm btn-outline flex items-center gap-1" onclick="setDefaultCliTool('${tool}')">
<i data-lucide="star" class="w-3 h-3"></i> Set Default
</button>`
@@ -365,11 +450,42 @@ function renderCliStatus() {
</div>
` : '';
// API Endpoints section
const apiEndpointsHtml = apiEndpoints.length > 0 ? `
<div class="cli-api-endpoints-section" style="margin-top: 1.5rem;">
<div class="cli-section-header" style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
<h4 style="display: flex; align-items: center; gap: 0.5rem; font-weight: 600; margin: 0;">
<i data-lucide="link" class="w-4 h-4"></i> API Endpoints
</h4>
<span class="badge" style="padding: 0.125rem 0.5rem; font-size: 0.75rem; border-radius: 0.25rem; background: var(--muted); color: var(--muted-foreground);">${apiEndpoints.length}</span>
</div>
<div class="cli-endpoints-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 0.75rem;">
${apiEndpoints.map(ep => `
<div class="cli-endpoint-card ${ep.enabled ? 'available' : 'unavailable'}" style="padding: 0.75rem; border: 1px solid var(--border); border-radius: 0.5rem; background: var(--card);">
<div class="cli-endpoint-header" style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span class="cli-tool-status ${ep.enabled ? 'status-available' : 'status-unavailable'}" style="width: 8px; height: 8px; border-radius: 50%; background: ${ep.enabled ? 'var(--success)' : 'var(--muted-foreground)'}; flex-shrink: 0;"></span>
<span class="cli-endpoint-id" style="font-weight: 500; font-size: 0.875rem;">${ep.id}</span>
</div>
<div class="cli-endpoint-info" style="margin-top: 0.25rem;">
<span class="text-xs text-muted-foreground" style="font-size: 0.75rem; color: var(--muted-foreground);">${ep.model}</span>
</div>
</div>
`).join('')}
</div>
</div>
` : '';
// 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 = `
<div class="cli-settings-section">
<div class="cli-settings-header">
<h4><i data-lucide="settings" class="w-3.5 h-3.5"></i> Settings</h4>
<span class="badge text-xs ${configSourceClass}" title="${configInfo.activePath || ''}">${configSourceLabel}</span>
</div>
<div class="cli-settings-grid">
<div class="cli-setting-item">
@@ -436,6 +552,20 @@ function renderCliStatus() {
</div>
<p class="cli-setting-desc">Maximum files to include in smart context</p>
</div>
<div class="cli-setting-item">
<label class="cli-setting-label">
<i data-lucide="hard-drive" class="w-3 h-3"></i>
Cache Injection
</label>
<div class="cli-setting-control">
<select class="cli-setting-select" onchange="setCacheInjectionMode(this.value)">
<option value="auto" ${getCacheInjectionMode() === 'auto' ? 'selected' : ''}>Auto</option>
<option value="manual" ${getCacheInjectionMode() === 'manual' ? 'selected' : ''}>Manual</option>
<option value="disabled" ${getCacheInjectionMode() === 'disabled' ? 'selected' : ''}>Disabled</option>
</select>
</div>
<p class="cli-setting-desc">Cache prefix/suffix injection mode for prompts</p>
</div>
</div>
</div>
`;
@@ -453,6 +583,7 @@ function renderCliStatus() {
${codexLensHtml}
${semanticHtml}
</div>
${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();
}

View File

@@ -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': '缓存清除成功',

View File

@@ -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() {
'</div>' +
'</fieldset>' +
'<div class="modal-actions">' +
'<button type="button" class="btn btn-secondary" onclick="closeEndpointModal()">' + t('common.cancel') + '</button>' +
'<button type="button" class="btn btn-secondary" onclick="closeEndpointModal()"><i data-lucide="x"></i> ' + t('common.cancel') + '</button>' +
'<button type="submit" class="btn btn-primary">' +
'<i data-lucide="save"></i> ' + t('common.save') +
'<i data-lucide="check"></i> ' + t('common.save') +
'</button>' +
'</div>' +
'</form>' +
@@ -845,7 +855,10 @@ async function renderApiSettings() {
}
// Build split layout
container.innerHTML = '<div class="api-settings-container api-settings-split">' +
container.innerHTML =
// CCW-LiteLLM Status Container
'<div id="ccwLitellmStatusContainer" class="mb-4"></div>' +
'<div class="api-settings-container api-settings-split">' +
// Left Sidebar
'<aside class="api-settings-sidebar">' +
sidebarTabsHtml +
@@ -878,6 +891,9 @@ async function renderApiSettings() {
renderCacheMainPanel();
}
// Check and render ccw-litellm status
checkCcwLitellmStatus().then(renderCcwLitellmStatusCard);
if (window.lucide) lucide.createIcons();
}
@@ -966,7 +982,10 @@ function renderProviderDetail(providerId) {
}
var maskedKey = provider.apiKey ? '••••••••••••••••' + provider.apiKey.slice(-4) : '••••••••';
var apiBasePreview = (provider.apiBase || getDefaultApiBase(provider.type)) + '/chat/completions';
var currentApiBase = provider.apiBase || getDefaultApiBase(provider.type);
// Show full endpoint URL preview based on active model tab
var endpointPath = activeModelTab === 'embedding' ? '/embeddings' : '/chat/completions';
var apiBasePreview = currentApiBase + endpointPath;
var html = '<div class="provider-detail-header">' +
'<div class="provider-detail-title">' +
@@ -1007,13 +1026,18 @@ function renderProviderDetail(providerId) {
'<button class="btn btn-secondary" onclick="testProviderConnection()">' + t('apiSettings.testConnection') + '</button>' +
'</div>' +
'</div>' +
// API Base URL field
// API Base URL field - editable
'<div class="field-group">' +
'<div class="field-label">' +
'<span>' + t('apiSettings.apiBaseUrl') + '</span>' +
'</div>' +
'<input type="text" class="cli-input" value="' + escapeHtml(provider.apiBase || getDefaultApiBase(provider.type)) + '" readonly />' +
'<span class="field-hint">' + t('apiSettings.preview') + ': ' + apiBasePreview + '</span>' +
'<div class="field-input-group">' +
'<input type="text" class="cli-input" id="provider-detail-apibase" value="' + escapeHtml(currentApiBase) + '" placeholder="https://api.openai.com/v1" oninput="updateApiBasePreview(this.value)" />' +
'<button class="btn btn-secondary" onclick="saveProviderApiBase(\'' + providerId + '\')">' +
'<i data-lucide="save"></i> ' + t('common.save') +
'</button>' +
'</div>' +
'<span class="field-hint" id="api-base-preview">' + t('apiSettings.preview') + ': ' + escapeHtml(apiBasePreview) + '</span>' +
'</div>' +
// Model Section
'<div class="model-section">' +
@@ -1037,11 +1061,14 @@ function renderProviderDetail(providerId) {
'</div>' +
'<div class="model-tree" id="model-tree"></div>' +
'</div>' +
// Multi-key settings button
// Multi-key and sync buttons
'<div class="multi-key-trigger">' +
'<button class="btn btn-secondary multi-key-btn" onclick="showMultiKeyModal(\'' + providerId + '\')">' +
'<i data-lucide="key-round"></i> ' + t('apiSettings.multiKeySettings') +
'</button>' +
'<button class="btn btn-secondary" onclick="syncConfigToCodexLens()">' +
'<i data-lucide="refresh-cw"></i> ' + t('apiSettings.syncToCodexLens') +
'</button>' +
'</div>' +
'</div>';
@@ -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 += '<div class="model-item" data-model-id="' + model.id + '">' +
'<i data-lucide="' + (activeModelTab === 'llm' ? 'sparkles' : 'box') + '" class="model-item-icon"></i>' +
'<span class="model-item-name">' + escapeHtml(model.name) + '</span>' +
(badge ? '<span class="model-item-badge">' + badge + '</span>' : '') +
(displayBadge ? '<span class="model-item-badge">' + displayBadge + '</span>' : '') +
'<div class="model-item-actions">' +
'<button class="btn-icon-sm" onclick="previewModel(\'' + model.id + '\')" title="' + t('apiSettings.previewModel') + '">' +
'<i data-lucide="eye"></i>' +
'</button>' +
'<button class="btn-icon-sm" onclick="showModelSettingsModal(\'' + model.id + '\')" title="' + t('apiSettings.modelSettings') + '">' +
'<button class="btn-icon-sm" onclick="showModelSettingsModal(\'' + selectedProviderId + '\', \'' + model.id + '\', \'' + activeModelTab + '\')" title="' + t('apiSettings.modelSettings') + '">' +
'<i data-lucide="settings"></i>' +
'</button>' +
'<button class="btn-icon-sm text-destructive" onclick="deleteModel(\'' + model.id + '\')" title="' + t('apiSettings.deleteModel') + '">' +
'<button class="btn-icon-sm text-destructive" onclick="deleteModel(\'' + selectedProviderId + '\', \'' + model.id + '\', \'' + activeModelTab + '\')" title="' + t('apiSettings.deleteModel') + '">' +
'<i data-lucide="trash-2"></i>' +
'</button>' +
'</div>' +
@@ -1418,8 +1448,8 @@ function showAddModelModal(providerId, modelType) {
'</div>' +
'<div class="modal-actions">' +
'<button type="button" class="btn btn-secondary" onclick="closeAddModelModal()">' + t('common.cancel') + '</button>' +
'<button type="submit" class="btn btn-primary">' + t('common.save') + '</button>' +
'<button type="button" class="btn btn-secondary" onclick="closeAddModelModal()"><i data-lucide="x"></i> ' + t('common.cancel') + '</button>' +
'<button type="submit" class="btn btn-primary"><i data-lucide="check"></i> ' + t('common.save') + '</button>' +
'</div>' +
'</form>' +
'</div>' +
@@ -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 = '<div class="modal-overlay" id="model-settings-modal">' +
'<div class="modal-content" style="max-width: 550px;">' +
'<div class="modal-content" style="max-width: 600px;">' +
'<div class="modal-header">' +
'<h3>' + t('apiSettings.modelSettings') + ': ' + model.name + '</h3>' +
'<h3>' + t('apiSettings.modelSettings') + ': ' + escapeHtml(model.name) + '</h3>' +
'<button class="modal-close" onclick="closeModelSettingsModal()">&times;</button>' +
'</div>' +
'<div class="modal-body">' +
'<form id="model-settings-form" onsubmit="saveModelSettings(event, \'' + providerId + '\', \'' + modelId + '\', \'' + modelType + '\')">' +
// Endpoint Preview Section (combined view + settings)
'<div class="form-section endpoint-preview-section">' +
'<h4><i data-lucide="' + (isLlm ? 'message-square' : 'box') + '"></i> ' + t('apiSettings.endpointPreview') + '</h4>' +
'<div class="endpoint-preview-box">' +
'<code id="model-endpoint-preview">' + escapeHtml(endpointPreview) + '</code>' +
'<button type="button" class="btn-icon-sm" onclick="copyModelEndpoint()" title="' + t('common.copy') + '">' +
'<i data-lucide="copy"></i>' +
'</button>' +
'</div>' +
'<div class="form-group">' +
'<label>' + t('apiSettings.modelBaseUrlOverride') + ' <span class="text-muted">(' + t('common.optional') + ')</span></label>' +
'<input type="text" id="model-settings-baseurl" class="cli-input" value="' + escapeHtml(endpointSettings.baseUrl || '') + '" placeholder="' + escapeHtml(providerBase) + '" oninput="updateModelEndpointPreview(\'' + (isLlm ? 'chat/completions' : 'embeddings') + '\', \'' + escapeHtml(providerBase) + '\')">' +
'<small class="form-hint">' + t('apiSettings.modelBaseUrlHint') + '</small>' +
'</div>' +
'</div>' +
// Basic Info
'<div class="form-section">' +
'<h4>' + t('apiSettings.basicInfo') + '</h4>' +
'<div class="form-group">' +
'<label>' + t('apiSettings.modelName') + '</label>' +
'<input type="text" id="model-settings-name" class="cli-input" value="' + (model.name || '') + '" required>' +
'<input type="text" id="model-settings-name" class="cli-input" value="' + escapeHtml(model.name || '') + '" required>' +
'</div>' +
'<div class="form-group">' +
'<label>' + t('apiSettings.modelSeries') + '</label>' +
'<input type="text" id="model-settings-series" class="cli-input" value="' + (model.series || '') + '" required>' +
'<input type="text" id="model-settings-series" class="cli-input" value="' + escapeHtml(model.series || '') + '" required>' +
'</div>' +
'<div class="form-group">' +
'<label>' + t('apiSettings.description') + '</label>' +
'<textarea id="model-settings-description" class="cli-input" rows="2">' + (model.description || '') + '</textarea>' +
'<textarea id="model-settings-description" class="cli-input" rows="2">' + escapeHtml(model.description || '') + '</textarea>' +
'</div>' +
'</div>' +
@@ -1678,19 +1730,21 @@ function showModelSettingsModal(providerId, modelId, modelType) {
// Endpoint Settings
'<div class="form-section">' +
'<h4>' + t('apiSettings.endpointSettings') + '</h4>' +
'<div class="form-group">' +
'<div class="form-row">' +
'<div class="form-group form-group-half">' +
'<label>' + t('apiSettings.timeout') + ' (' + t('apiSettings.seconds') + ')</label>' +
'<input type="number" id="model-settings-timeout" class="cli-input" value="' + (endpointSettings.timeout || 300) + '" min="10" max="3600">' +
'</div>' +
'<div class="form-group">' +
'<div class="form-group form-group-half">' +
'<label>' + t('apiSettings.maxRetries') + '</label>' +
'<input type="number" id="model-settings-retries" class="cli-input" value="' + (endpointSettings.maxRetries || 3) + '" min="0" max="10">' +
'</div>' +
'</div>' +
'</div>' +
'<div class="modal-actions">' +
'<button type="button" class="btn-secondary" onclick="closeModelSettingsModal()">' + t('common.cancel') + '</button>' +
'<button type="submit" class="btn-primary">' + t('common.save') + '</button>' +
'<button type="button" class="btn-secondary" onclick="closeModelSettingsModal()"><i data-lucide="x"></i> ' + t('common.cancel') + '</button>' +
'<button type="submit" class="btn-primary"><i data-lucide="check"></i> ' + t('common.save') + '</button>' +
'</div>' +
'</form>' +
'</div>' +
@@ -1701,6 +1755,33 @@ function showModelSettingsModal(providerId, modelId, modelType) {
if (window.lucide) lucide.createIcons();
}
/**
* Update model endpoint preview when base URL changes
*/
function updateModelEndpointPreview(endpointPath, defaultBase) {
var baseUrlInput = document.getElementById('model-settings-baseurl');
var previewElement = document.getElementById('model-endpoint-preview');
if (!baseUrlInput || !previewElement) return;
var baseUrl = baseUrlInput.value.trim() || defaultBase;
// Remove trailing slash if present
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
previewElement.textContent = baseUrl + '/' + endpointPath;
}
/**
* Copy model endpoint URL to clipboard
*/
function copyModelEndpoint() {
var previewElement = document.getElementById('model-endpoint-preview');
if (previewElement) {
navigator.clipboard.writeText(previewElement.textContent);
showRefreshToast(t('common.copied'), 'success');
}
}
function closeModelSettingsModal() {
var modal = document.getElementById('model-settings-modal');
if (modal) modal.remove();
@@ -1744,7 +1825,13 @@ function saveModelSettings(event, providerId, modelId, modelType) {
}
// Update endpoint settings
var baseUrlOverride = document.getElementById('model-settings-baseurl').value.trim();
// Remove trailing slash if present
if (baseUrlOverride && baseUrlOverride.endsWith('/')) {
baseUrlOverride = baseUrlOverride.slice(0, -1);
}
models[modelIndex].endpointSettings = {
baseUrl: baseUrlOverride || undefined,
timeout: parseInt(document.getElementById('model-settings-timeout').value) || 300,
maxRetries: parseInt(document.getElementById('model-settings-retries').value) || 3
};
@@ -1774,11 +1861,6 @@ function saveModelSettings(event, providerId, modelId, modelType) {
});
}
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;
@@ -1823,6 +1905,59 @@ function copyProviderApiKey(providerId) {
}
}
/**
* Save provider API base URL
*/
async function saveProviderApiBase(providerId) {
var input = document.getElementById('provider-detail-apibase');
if (!input) return;
var newApiBase = input.value.trim();
// Remove trailing slash if present
if (newApiBase.endsWith('/')) {
newApiBase = newApiBase.slice(0, -1);
}
try {
var response = await fetch('/api/litellm-api/providers/' + providerId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiBase: newApiBase || undefined })
});
if (!response.ok) throw new Error('Failed to update API base');
// Update local data
var provider = apiSettingsData.providers.find(function(p) { return p.id === providerId; });
if (provider) {
provider.apiBase = newApiBase || undefined;
}
// Update preview
updateApiBasePreview(newApiBase);
showRefreshToast(t('apiSettings.apiBaseUpdated'), 'success');
} catch (err) {
console.error('Failed to save API base:', err);
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
}
}
/**
* Update API base preview text showing full endpoint URL
*/
function updateApiBasePreview(apiBase) {
var preview = document.getElementById('api-base-preview');
if (!preview) return;
var base = apiBase || getDefaultApiBase('openai');
// Remove trailing slash if present
if (base.endsWith('/')) {
base = base.slice(0, -1);
}
var endpointPath = activeModelTab === 'embedding' ? '/embeddings' : '/chat/completions';
preview.textContent = t('apiSettings.preview') + ': ' + base + endpointPath;
}
/**
* Delete provider with confirmation
*/
@@ -1859,6 +1994,25 @@ async function deleteProviderWithConfirm(providerId) {
}
}
/**
* Sync config to CodexLens (generate YAML config for ccw_litellm)
*/
async function syncConfigToCodexLens() {
try {
var response = await fetch('/api/litellm-api/config/sync', {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to sync config');
var result = await response.json();
showRefreshToast(t('apiSettings.configSynced') + ' (' + result.yamlPath + ')', 'success');
} catch (err) {
console.error('Failed to sync config:', err);
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
}
}
/**
* Get provider icon class based on type
*/
@@ -2343,7 +2497,7 @@ function showMultiKeyModal(providerId) {
renderHealthCheckSection(provider) +
'</div>' +
'<div class="modal-actions">' +
'<button type="button" class="btn-primary" onclick="closeMultiKeyModal()">' + t('common.close') + '</button>' +
'<button type="button" class="btn-primary" onclick="closeMultiKeyModal()"><i data-lucide="check"></i> ' + t('common.close') + '</button>' +
'</div>' +
'</div>' +
'</div>';
@@ -2578,6 +2732,99 @@ function toggleKeyVisibility(btn) {
}
// ========== CCW-LiteLLM Management ==========
/**
* Check ccw-litellm installation status
*/
async function checkCcwLitellmStatus() {
try {
var response = await fetch('/api/litellm-api/ccw-litellm/status');
var status = await response.json();
window.ccwLitellmStatus = status;
return status;
} catch (e) {
console.warn('[API Settings] Could not check ccw-litellm status:', e);
return { installed: false };
}
}
/**
* Render ccw-litellm status card
*/
function renderCcwLitellmStatusCard() {
var container = document.getElementById('ccwLitellmStatusContainer');
if (!container) return;
var status = window.ccwLitellmStatus || { installed: false };
if (status.installed) {
container.innerHTML =
'<div class="flex items-center gap-2 text-sm">' +
'<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-success/10 text-success border border-success/20">' +
'<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>' +
'ccw-litellm ' + (status.version || '') +
'</span>' +
'</div>';
} else {
container.innerHTML =
'<div class="flex items-center gap-2">' +
'<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-muted text-muted-foreground border border-border text-sm">' +
'<i data-lucide="circle" class="w-3.5 h-3.5"></i>' +
'ccw-litellm not installed' +
'</span>' +
'<button class="btn-sm btn-primary" onclick="installCcwLitellm()">' +
'<i data-lucide="download" class="w-3.5 h-3.5"></i> Install' +
'</button>' +
'</div>';
}
if (window.lucide) lucide.createIcons();
}
/**
* Install ccw-litellm package
*/
async function installCcwLitellm() {
var container = document.getElementById('ccwLitellmStatusContainer');
if (container) {
container.innerHTML =
'<div class="flex items-center gap-2 text-sm text-muted-foreground">' +
'<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>' +
'Installing ccw-litellm...' +
'</div>';
}
try {
var response = await fetch('/api/litellm-api/ccw-litellm/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
var result = await response.json();
if (result.success) {
showRefreshToast('ccw-litellm installed successfully!', 'success');
// Refresh status
await checkCcwLitellmStatus();
renderCcwLitellmStatusCard();
} else {
showRefreshToast('Failed to install ccw-litellm: ' + result.error, 'error');
renderCcwLitellmStatusCard();
}
} catch (e) {
showRefreshToast('Installation error: ' + e.message, 'error');
renderCcwLitellmStatusCard();
}
}
// Make functions globally accessible
window.checkCcwLitellmStatus = checkCcwLitellmStatus;
window.renderCcwLitellmStatusCard = renderCcwLitellmStatusCard;
window.installCcwLitellm = installCcwLitellm;
// ========== Utility Functions ==========
/**

View File

@@ -1166,10 +1166,12 @@ async function deleteModel(profile) {
* Initialize CodexLens index with bottom floating progress bar
* @param {string} indexType - 'vector' (with embeddings), 'normal' (FTS only), or 'full' (FTS + Vector)
* @param {string} embeddingModel - Model profile: 'code', 'fast'
* @param {string} embeddingBackend - Backend: 'fastembed' (local) or 'litellm' (API)
*/
async function initCodexLensIndex(indexType, embeddingModel) {
async function initCodexLensIndex(indexType, embeddingModel, embeddingBackend) {
indexType = indexType || 'vector';
embeddingModel = embeddingModel || 'code';
embeddingBackend = embeddingBackend || 'fastembed';
// For vector or full index, check if semantic dependencies are available
if (indexType === 'vector' || indexType === 'full') {
@@ -1235,7 +1237,8 @@ async function initCodexLensIndex(indexType, embeddingModel) {
var modelLabel = '';
if (indexType !== 'normal') {
var modelNames = { code: 'Code', fast: 'Fast' };
modelLabel = ' [' + (modelNames[embeddingModel] || embeddingModel) + ']';
var backendLabel = embeddingBackend === 'litellm' ? 'API: ' : '';
modelLabel = ' [' + backendLabel + (modelNames[embeddingModel] || embeddingModel) + ']';
}
progressBar.innerHTML =
@@ -1272,17 +1275,19 @@ async function initCodexLensIndex(indexType, embeddingModel) {
var apiIndexType = (indexType === 'full') ? 'vector' : indexType;
// Start indexing with specified type and model
startCodexLensIndexing(apiIndexType, embeddingModel);
startCodexLensIndexing(apiIndexType, embeddingModel, embeddingBackend);
}
/**
* Start the indexing process
* @param {string} indexType - 'vector' or 'normal'
* @param {string} embeddingModel - Model profile: 'code', 'fast'
* @param {string} embeddingBackend - Backend: 'fastembed' (local) or 'litellm' (API)
*/
async function startCodexLensIndexing(indexType, embeddingModel) {
async function startCodexLensIndexing(indexType, embeddingModel, embeddingBackend) {
indexType = indexType || 'vector';
embeddingModel = embeddingModel || 'code';
embeddingBackend = embeddingBackend || 'fastembed';
var statusText = document.getElementById('codexlensIndexStatus');
var progressBar = document.getElementById('codexlensIndexProgressBar');
var percentText = document.getElementById('codexlensIndexPercent');
@@ -1314,11 +1319,11 @@ async function startCodexLensIndexing(indexType, embeddingModel) {
}
try {
console.log('[CodexLens] Starting index for:', projectPath, 'type:', indexType, 'model:', embeddingModel);
console.log('[CodexLens] Starting index for:', projectPath, 'type:', indexType, 'model:', embeddingModel, 'backend:', embeddingBackend);
var response = await fetch('/api/codexlens/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: projectPath, indexType: indexType, embeddingModel: embeddingModel })
body: JSON.stringify({ path: projectPath, indexType: indexType, embeddingModel: embeddingModel, embeddingBackend: embeddingBackend })
});
var result = await response.json();
@@ -1883,6 +1888,16 @@ async function renderCodexLensManager() {
await loadCodexLensStatus();
}
// Load LiteLLM API config for embedding backend options
try {
var litellmResponse = await fetch('/api/litellm-api/config');
if (litellmResponse.ok) {
window.litellmApiConfig = await litellmResponse.json();
}
} catch (e) {
console.warn('[CodexLens] Could not load LiteLLM config:', e);
}
var response = await fetch('/api/codexlens/config');
var config = await response.json();
@@ -1946,6 +1961,15 @@ function buildCodexLensManagerPage(config) {
'<div class="bg-card border border-border rounded-lg p-5">' +
'<h4 class="text-lg font-semibold mb-4 flex items-center gap-2"><i data-lucide="layers" class="w-5 h-5 text-primary"></i> ' + t('codexlens.createIndex') + '</h4>' +
'<div class="space-y-4">' +
// Backend selector (fastembed local or litellm API)
'<div class="mb-4">' +
'<label class="block text-sm font-medium mb-1.5">' + (t('codexlens.embeddingBackend') || 'Embedding Backend') + '</label>' +
'<select id="pageBackendSelect" class="w-full px-3 py-2 border border-border rounded-lg bg-background text-sm" onchange="onEmbeddingBackendChange()">' +
'<option value="fastembed">' + (t('codexlens.localFastembed') || 'Local (FastEmbed)') + '</option>' +
'<option value="litellm">' + (t('codexlens.apiLitellm') || 'API (LiteLLM)') + '</option>' +
'</select>' +
'<p class="text-xs text-muted-foreground mt-1">' + (t('codexlens.backendHint') || 'Select local model or remote API endpoint') + '</p>' +
'</div>' +
// Model selector
'<div>' +
'<label class="block text-sm font-medium mb-1.5">' + t('codexlens.embeddingModel') + '</label>' +
@@ -2150,18 +2174,68 @@ function buildModelSelectOptionsForPage() {
return options;
}
/**
* Handle embedding backend change
*/
function onEmbeddingBackendChange() {
var backendSelect = document.getElementById('pageBackendSelect');
var modelSelect = document.getElementById('pageModelSelect');
if (!backendSelect || !modelSelect) return;
var backend = backendSelect.value;
if (backend === 'litellm') {
// Load LiteLLM embedding models
modelSelect.innerHTML = buildLiteLLMModelOptions();
} else {
// Load local fastembed models
modelSelect.innerHTML = buildModelSelectOptionsForPage();
}
}
/**
* Build LiteLLM model options from config
*/
function buildLiteLLMModelOptions() {
var litellmConfig = window.litellmApiConfig || {};
var providers = litellmConfig.providers || [];
var options = '';
providers.forEach(function(provider) {
if (!provider.enabled) return;
var models = provider.models || [];
models.forEach(function(model) {
if (model.type !== 'embedding' || !model.enabled) return;
var label = model.name || model.id;
var selected = options === '' ? ' selected' : '';
options += '<option value="' + model.id + '"' + selected + '>' + label + '</option>';
});
});
if (options === '') {
options = '<option value="" disabled selected>' + (t('codexlens.noApiModels') || 'No API embedding models configured') + '</option>';
}
return options;
}
// Make functions globally accessible
window.onEmbeddingBackendChange = onEmbeddingBackendChange;
/**
* Initialize index from page with selected model
*/
function initCodexLensIndexFromPage(indexType) {
var backendSelect = document.getElementById('pageBackendSelect');
var modelSelect = document.getElementById('pageModelSelect');
var selectedBackend = backendSelect ? backendSelect.value : 'fastembed';
var selectedModel = modelSelect ? modelSelect.value : 'code';
// For FTS-only index, model is not needed
if (indexType === 'normal') {
initCodexLensIndex(indexType);
} else {
initCodexLensIndex(indexType, selectedModel);
initCodexLensIndex(indexType, selectedModel, selectedBackend);
}
}

View File

@@ -0,0 +1,300 @@
/**
* Claude CLI Tools Configuration Manager
* Manages .claude/cli-tools.json with fallback:
* 1. Project workspace: {projectDir}/.claude/cli-tools.json (priority)
* 2. Global: ~/.claude/cli-tools.json (fallback)
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// ========== Types ==========
export interface ClaudeCliTool {
enabled: boolean;
isBuiltin: boolean;
command: string;
description: string;
}
export interface ClaudeCacheSettings {
injectionMode: 'auto' | 'manual' | 'disabled';
defaultPrefix: string;
defaultSuffix: string;
}
export interface ClaudeCliToolsConfig {
$schema?: string;
version: string;
tools: Record<string, ClaudeCliTool>;
customEndpoints: Array<{
id: string;
name: string;
enabled: boolean;
}>;
defaultTool: string;
settings: {
promptFormat: 'plain' | 'yaml' | 'json';
smartContext: {
enabled: boolean;
maxFiles: number;
};
nativeResume: boolean;
recursiveQuery: boolean;
cache: ClaudeCacheSettings;
};
}
// ========== Default Config ==========
const DEFAULT_CONFIG: ClaudeCliToolsConfig = {
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: ''
}
}
};
// ========== Helper Functions ==========
function getProjectConfigPath(projectDir: string): string {
return path.join(projectDir, '.claude', 'cli-tools.json');
}
function getGlobalConfigPath(): string {
return path.join(os.homedir(), '.claude', 'cli-tools.json');
}
/**
* Resolve config path with fallback:
* 1. Project: {projectDir}/.claude/cli-tools.json
* 2. Global: ~/.claude/cli-tools.json
* Returns { path, source } where source is 'project' | 'global' | 'default'
*/
function resolveConfigPath(projectDir: string): { path: string; source: 'project' | 'global' | 'default' } {
const projectPath = getProjectConfigPath(projectDir);
if (fs.existsSync(projectPath)) {
return { path: projectPath, source: 'project' };
}
const globalPath = getGlobalConfigPath();
if (fs.existsSync(globalPath)) {
return { path: globalPath, source: 'global' };
}
return { path: projectPath, source: 'default' };
}
function ensureClaudeDir(projectDir: string): void {
const claudeDir = path.join(projectDir, '.claude');
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true });
}
}
// ========== Main Functions ==========
/**
* Load CLI tools configuration with fallback:
* 1. Project: {projectDir}/.claude/cli-tools.json
* 2. Global: ~/.claude/cli-tools.json
* 3. Default config
*/
export function loadClaudeCliTools(projectDir: string): ClaudeCliToolsConfig & { _source?: string } {
const resolved = resolveConfigPath(projectDir);
try {
if (resolved.source === 'default') {
// No config file found, return defaults
return { ...DEFAULT_CONFIG, _source: 'default' };
}
const content = fs.readFileSync(resolved.path, 'utf-8');
const parsed = JSON.parse(content) as Partial<ClaudeCliToolsConfig>;
// Merge with defaults
const config = {
...DEFAULT_CONFIG,
...parsed,
tools: { ...DEFAULT_CONFIG.tools, ...(parsed.tools || {}) },
settings: {
...DEFAULT_CONFIG.settings,
...(parsed.settings || {}),
smartContext: {
...DEFAULT_CONFIG.settings.smartContext,
...(parsed.settings?.smartContext || {})
},
cache: {
...DEFAULT_CONFIG.settings.cache,
...(parsed.settings?.cache || {})
}
},
_source: resolved.source
};
console.log(`[claude-cli-tools] Loaded config from ${resolved.source}: ${resolved.path}`);
return config;
} catch (err) {
console.error('[claude-cli-tools] Error loading config:', err);
return { ...DEFAULT_CONFIG, _source: 'default' };
}
}
/**
* Save CLI tools configuration to project .claude/cli-tools.json
* Always saves to project directory (not global)
*/
export function saveClaudeCliTools(projectDir: string, config: ClaudeCliToolsConfig & { _source?: string }): void {
ensureClaudeDir(projectDir);
const configPath = getProjectConfigPath(projectDir);
// Remove internal _source field before saving
const { _source, ...configToSave } = config;
try {
fs.writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8');
console.log(`[claude-cli-tools] Saved config to project: ${configPath}`);
} catch (err) {
console.error('[claude-cli-tools] Error saving config:', err);
throw new Error(`Failed to save CLI tools config: ${err}`);
}
}
/**
* Update enabled status for a specific tool
*/
export function updateClaudeToolEnabled(
projectDir: string,
toolName: string,
enabled: boolean
): ClaudeCliToolsConfig {
const config = loadClaudeCliTools(projectDir);
if (config.tools[toolName]) {
config.tools[toolName].enabled = enabled;
saveClaudeCliTools(projectDir, config);
}
return config;
}
/**
* Update cache settings
*/
export function updateClaudeCacheSettings(
projectDir: string,
cacheSettings: Partial<ClaudeCacheSettings>
): ClaudeCliToolsConfig {
const config = loadClaudeCliTools(projectDir);
config.settings.cache = {
...config.settings.cache,
...cacheSettings
};
saveClaudeCliTools(projectDir, config);
return config;
}
/**
* Update default tool
*/
export function updateClaudeDefaultTool(
projectDir: string,
defaultTool: string
): ClaudeCliToolsConfig {
const config = loadClaudeCliTools(projectDir);
config.defaultTool = defaultTool;
saveClaudeCliTools(projectDir, config);
return config;
}
/**
* Add custom endpoint
*/
export function addClaudeCustomEndpoint(
projectDir: string,
endpoint: { id: string; name: string; enabled: boolean }
): ClaudeCliToolsConfig {
const config = loadClaudeCliTools(projectDir);
// Check if endpoint already exists
const existingIndex = config.customEndpoints.findIndex(e => e.id === endpoint.id);
if (existingIndex >= 0) {
config.customEndpoints[existingIndex] = endpoint;
} else {
config.customEndpoints.push(endpoint);
}
saveClaudeCliTools(projectDir, config);
return config;
}
/**
* Remove custom endpoint
*/
export function removeClaudeCustomEndpoint(
projectDir: string,
endpointId: string
): ClaudeCliToolsConfig {
const config = loadClaudeCliTools(projectDir);
config.customEndpoints = config.customEndpoints.filter(e => e.id !== endpointId);
saveClaudeCliTools(projectDir, config);
return config;
}
/**
* Get config source info
*/
export function getClaudeCliToolsInfo(projectDir: string): {
projectPath: string;
globalPath: string;
activePath: string;
source: 'project' | 'global' | 'default';
} {
const resolved = resolveConfigPath(projectDir);
return {
projectPath: getProjectConfigPath(projectDir),
globalPath: getGlobalConfigPath(),
activePath: resolved.path,
source: resolved.source
};
}

View File

@@ -16,6 +16,8 @@ const OperationEnum = z.enum(['list', 'import', 'export', 'summary', 'embed', 's
const ParamsSchema = z.object({
operation: OperationEnum,
// Path parameter - highest priority for project resolution
path: z.string().optional(),
text: z.string().optional(),
id: z.string().optional(),
tool: z.enum(['gemini', 'qwen']).optional().default('gemini'),
@@ -106,17 +108,21 @@ interface EmbedStatusResult {
type OperationResult = ListResult | ImportResult | ExportResult | SummaryResult | EmbedResult | SearchResult | EmbedStatusResult;
/**
* Get project path from current working directory
* Get project path - uses explicit path if provided, otherwise falls back to current working directory
* Priority: path parameter > getProjectRoot()
*/
function getProjectPath(): string {
function getProjectPath(explicitPath?: string): string {
if (explicitPath) {
return explicitPath;
}
return getProjectRoot();
}
/**
* Get database path for current project
* Get database path for project
*/
function getDatabasePath(): string {
const projectPath = getProjectPath();
function getDatabasePath(explicitPath?: string): string {
const projectPath = getProjectPath(explicitPath);
const paths = StoragePaths.project(projectPath);
return join(paths.root, 'core-memory', 'core_memory.db');
}
@@ -129,8 +135,8 @@ const PREVIEW_MAX_LENGTH = 100;
* List all memories with compact output
*/
function executeList(params: Params): ListResult {
const { limit } = params;
const store = getCoreMemoryStore(getProjectPath());
const { limit, path } = params;
const store = getCoreMemoryStore(getProjectPath(path));
const memories = store.getMemories({ limit }) as CoreMemory[];
// Convert to compact format with truncated preview
@@ -160,13 +166,13 @@ function executeList(params: Params): ListResult {
* Import text as a new memory
*/
function executeImport(params: Params): ImportResult {
const { text } = params;
const { text, path } = params;
if (!text || text.trim() === '') {
throw new Error('Parameter "text" is required for import operation');
}
const store = getCoreMemoryStore(getProjectPath());
const store = getCoreMemoryStore(getProjectPath(path));
const memory = store.upsertMemory({
content: text.trim(),
});
@@ -184,14 +190,14 @@ function executeImport(params: Params): ImportResult {
* Searches current project first, then all projects if not found
*/
function executeExport(params: Params): ExportResult {
const { id } = params;
const { id, path } = params;
if (!id) {
throw new Error('Parameter "id" is required for export operation');
}
// Try current project first
const store = getCoreMemoryStore(getProjectPath());
// Try current project first (or explicit path if provided)
const store = getCoreMemoryStore(getProjectPath(path));
let memory = store.getMemory(id);
// If not found, search across all projects
@@ -218,13 +224,13 @@ function executeExport(params: Params): ExportResult {
* Generate AI summary for a memory
*/
async function executeSummary(params: Params): Promise<SummaryResult> {
const { id, tool = 'gemini' } = params;
const { id, tool = 'gemini', path } = params;
if (!id) {
throw new Error('Parameter "id" is required for summary operation');
}
const store = getCoreMemoryStore(getProjectPath());
const store = getCoreMemoryStore(getProjectPath(path));
const memory = store.getMemory(id);
if (!memory) {
@@ -245,8 +251,8 @@ async function executeSummary(params: Params): Promise<SummaryResult> {
* Generate embeddings for memory chunks
*/
async function executeEmbed(params: Params): Promise<EmbedResult> {
const { source_id, batch_size = 8, force = false } = params;
const dbPath = getDatabasePath();
const { source_id, batch_size = 8, force = false, path } = params;
const dbPath = getDatabasePath(path);
const result = await MemoryEmbedder.generateEmbeddings(dbPath, {
sourceId: source_id,
@@ -272,13 +278,13 @@ async function executeEmbed(params: Params): Promise<EmbedResult> {
* Search memory chunks using semantic search
*/
async function executeSearch(params: Params): Promise<SearchResult> {
const { query, top_k = 10, min_score = 0.3, source_type } = params;
const { query, top_k = 10, min_score = 0.3, source_type, path } = params;
if (!query) {
throw new Error('Parameter "query" is required for search operation');
}
const dbPath = getDatabasePath();
const dbPath = getDatabasePath(path);
const result = await MemoryEmbedder.searchMemories(dbPath, query, {
topK: top_k,
@@ -309,7 +315,8 @@ async function executeSearch(params: Params): Promise<SearchResult> {
* Get embedding status statistics
*/
async function executeEmbedStatus(params: Params): Promise<EmbedStatusResult> {
const dbPath = getDatabasePath();
const { path } = params;
const dbPath = getDatabasePath(path);
const result = await MemoryEmbedder.getEmbeddingStatus(dbPath);
@@ -368,6 +375,9 @@ Usage:
core_memory(operation="search", query="authentication") # Search memories semantically
core_memory(operation="embed_status") # Check embedding status
Path parameter (highest priority):
core_memory(operation="list", path="/path/to/project") # Use specific project path
Memory IDs use format: CMEM-YYYYMMDD-HHMMSS`,
inputSchema: {
type: 'object',
@@ -377,6 +387,10 @@ Memory IDs use format: CMEM-YYYYMMDD-HHMMSS`,
enum: ['list', 'import', 'export', 'summary', 'embed', 'search', 'embed_status'],
description: 'Operation to perform',
},
path: {
type: 'string',
description: 'Project path (highest priority - overrides auto-detected project root)',
},
text: {
type: 'string',
description: 'Text content to import (required for import operation)',

View File

@@ -4,8 +4,10 @@ import gc
import logging
import sqlite3
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from itertools import islice
from pathlib import Path
from threading import Lock
from typing import Dict, Generator, List, Optional, Tuple
try:
@@ -79,6 +81,44 @@ def _generate_chunks_from_cursor(
failed_files.append((file_path, str(e)))
def _create_token_aware_batches(
chunk_generator: Generator,
max_tokens_per_batch: int = 8000,
) -> Generator[List[Tuple], None, None]:
"""Group chunks by total token count instead of fixed count.
Uses fast token estimation (len(content) // 4) for efficiency.
Yields batches when approaching the token limit.
Args:
chunk_generator: Generator yielding (chunk, file_path) tuples
max_tokens_per_batch: Maximum tokens per batch (default: 8000)
Yields:
List of (chunk, file_path) tuples representing a batch
"""
current_batch = []
current_tokens = 0
for chunk, file_path in chunk_generator:
# Fast token estimation: len(content) // 4
chunk_tokens = len(chunk.content) // 4
# If adding this chunk would exceed limit and we have items, yield current batch
if current_tokens + chunk_tokens > max_tokens_per_batch and current_batch:
yield current_batch
current_batch = []
current_tokens = 0
# Add chunk to current batch
current_batch.append((chunk, file_path))
current_tokens += chunk_tokens
# Yield final batch if not empty
if current_batch:
yield current_batch
def _get_path_column(conn: sqlite3.Connection) -> str:
"""Detect whether files table uses 'path' or 'full_path' column.
@@ -189,31 +229,69 @@ def check_index_embeddings(index_path: Path) -> Dict[str, any]:
}
def _get_embedding_defaults() -> tuple[str, str, bool]:
"""Get default embedding settings from config.
Returns:
Tuple of (backend, model, use_gpu)
"""
try:
from codexlens.config import Config
config = Config.load()
return config.embedding_backend, config.embedding_model, config.embedding_use_gpu
except Exception:
return "fastembed", "code", True
def generate_embeddings(
index_path: Path,
embedding_backend: str = "fastembed",
model_profile: str = "code",
embedding_backend: Optional[str] = None,
model_profile: Optional[str] = None,
force: bool = False,
chunk_size: int = 2000,
overlap: int = 200,
progress_callback: Optional[callable] = None,
use_gpu: Optional[bool] = None,
max_tokens_per_batch: Optional[int] = None,
max_workers: int = 1,
) -> Dict[str, any]:
"""Generate embeddings for an index using memory-efficient batch processing.
This function processes files in small batches to keep memory usage under 2GB,
regardless of the total project size.
regardless of the total project size. Supports concurrent API calls for
LiteLLM backend to improve throughput.
Args:
index_path: Path to _index.db file
embedding_backend: Embedding backend to use (fastembed or litellm)
embedding_backend: Embedding backend to use (fastembed or litellm).
Defaults to config setting.
model_profile: Model profile for fastembed (fast, code, multilingual, balanced)
or model name for litellm (e.g., text-embedding-3-small)
or model name for litellm (e.g., qwen3-embedding).
Defaults to config setting.
force: If True, regenerate even if embeddings exist
chunk_size: Maximum chunk size in characters
overlap: Overlap size in characters for sliding window chunking (default: 200)
progress_callback: Optional callback for progress updates
use_gpu: Whether to use GPU acceleration (fastembed only).
Defaults to config setting.
max_tokens_per_batch: Maximum tokens per batch for token-aware batching.
If None, attempts to get from embedder.max_tokens,
then falls back to 8000. If set, overrides automatic detection.
max_workers: Maximum number of concurrent API calls (default: 1 for sequential).
Recommended: 2-4 for LiteLLM API backends.
Returns:
Result dictionary with generation statistics
"""
# Get defaults from config if not specified
default_backend, default_model, default_gpu = _get_embedding_defaults()
if embedding_backend is None:
embedding_backend = default_backend
if model_profile is None:
model_profile = default_model
if use_gpu is None:
use_gpu = default_gpu
if not SEMANTIC_AVAILABLE:
return {
"success": False,
@@ -261,9 +339,9 @@ def generate_embeddings(
# Initialize embedder using factory (supports both fastembed and litellm)
# For fastembed: model_profile is a profile name (fast/code/multilingual/balanced)
# For litellm: model_profile is a model name (e.g., text-embedding-3-small)
# For litellm: model_profile is a model name (e.g., qwen3-embedding)
if embedding_backend == "fastembed":
embedder = get_embedder_factory(backend="fastembed", profile=model_profile, use_gpu=True)
embedder = get_embedder_factory(backend="fastembed", profile=model_profile, use_gpu=use_gpu)
elif embedding_backend == "litellm":
embedder = get_embedder_factory(backend="litellm", model=model_profile)
else:
@@ -274,7 +352,11 @@ def generate_embeddings(
# skip_token_count=True: Use fast estimation (len/4) instead of expensive tiktoken
# This significantly reduces CPU usage with minimal impact on metadata accuracy
chunker = Chunker(config=ChunkConfig(max_chunk_size=chunk_size, skip_token_count=True))
chunker = Chunker(config=ChunkConfig(
max_chunk_size=chunk_size,
overlap=overlap,
skip_token_count=True
))
if progress_callback:
progress_callback(f"Using model: {embedder.model_name} ({embedder.embedding_dim} dimensions)")
@@ -336,43 +418,105 @@ def generate_embeddings(
cursor, chunker, path_column, FILE_BATCH_SIZE, failed_files
)
# Determine max tokens per batch
# Priority: explicit parameter > embedder.max_tokens > default 8000
if max_tokens_per_batch is None:
max_tokens_per_batch = getattr(embedder, 'max_tokens', 8000)
# Create token-aware batches or fall back to fixed-size batching
if max_tokens_per_batch:
batch_generator = _create_token_aware_batches(
chunk_generator, max_tokens_per_batch
)
else:
# Fallback to fixed-size batching for backward compatibility
def fixed_size_batches():
while True:
batch = list(islice(chunk_generator, EMBEDDING_BATCH_SIZE))
if not batch:
break
yield batch
batch_generator = fixed_size_batches()
batch_number = 0
files_seen = set()
while True:
# Get a small batch of chunks from the generator (EMBEDDING_BATCH_SIZE at a time)
chunk_batch = list(islice(chunk_generator, EMBEDDING_BATCH_SIZE))
if not chunk_batch:
break
# Thread-safe counters for concurrent processing
counter_lock = Lock()
batch_number += 1
def process_batch(batch_data: Tuple[int, List[Tuple]]) -> Tuple[int, set, Optional[str]]:
"""Process a single batch: generate embeddings and store.
# Track unique files for progress
for _, file_path in chunk_batch:
files_seen.add(file_path)
Args:
batch_data: Tuple of (batch_number, chunk_batch)
Returns:
Tuple of (chunks_created, files_in_batch, error_message)
"""
batch_num, chunk_batch = batch_data
batch_files = set()
# Generate embeddings directly to numpy (no tolist() conversion)
try:
# Track files in this batch
for _, file_path in chunk_batch:
batch_files.add(file_path)
# Generate embeddings
batch_contents = [chunk.content for chunk, _ in chunk_batch]
# Pass batch_size to fastembed for optimal GPU utilization
embeddings_numpy = embedder.embed_to_numpy(batch_contents, batch_size=EMBEDDING_BATCH_SIZE)
# Use add_chunks_batch_numpy to avoid numpy->list->numpy roundtrip
# Store embeddings (thread-safe via SQLite's serialized mode)
vector_store.add_chunks_batch_numpy(chunk_batch, embeddings_numpy)
total_chunks_created += len(chunk_batch)
total_files_processed = len(files_seen)
if progress_callback and batch_number % 10 == 0:
progress_callback(f" Batch {batch_number}: {total_chunks_created} chunks, {total_files_processed} files")
# Cleanup intermediate data
del batch_contents, embeddings_numpy, chunk_batch
return len(chunk_batch), batch_files, None
except Exception as e:
logger.error(f"Failed to process embedding batch {batch_number}: {str(e)}")
# Continue to next batch instead of failing entirely
continue
error_msg = f"Batch {batch_num}: {str(e)}"
logger.error(f"Failed to process embedding batch {batch_num}: {str(e)}")
return 0, batch_files, error_msg
# Collect batches for concurrent processing
all_batches = []
for chunk_batch in batch_generator:
batch_number += 1
all_batches.append((batch_number, chunk_batch))
# Process batches (sequential or concurrent based on max_workers)
if max_workers <= 1:
# Sequential processing (original behavior)
for batch_num, chunk_batch in all_batches:
chunks_created, batch_files, error = process_batch((batch_num, chunk_batch))
files_seen.update(batch_files)
total_chunks_created += chunks_created
total_files_processed = len(files_seen)
if progress_callback and batch_num % 10 == 0:
progress_callback(f" Batch {batch_num}: {total_chunks_created} chunks, {total_files_processed} files")
else:
# Concurrent processing for API backends
if progress_callback:
progress_callback(f"Processing {len(all_batches)} batches with {max_workers} concurrent workers...")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(process_batch, batch): batch[0] for batch in all_batches}
completed = 0
for future in as_completed(futures):
batch_num = futures[future]
try:
chunks_created, batch_files, error = future.result()
with counter_lock:
files_seen.update(batch_files)
total_chunks_created += chunks_created
total_files_processed = len(files_seen)
completed += 1
if progress_callback and completed % 10 == 0:
progress_callback(f" Completed {completed}/{len(all_batches)} batches: {total_chunks_created} chunks")
except Exception as e:
logger.error(f"Batch {batch_num} raised exception: {str(e)}")
# Notify before ANN index finalization (happens when bulk_insert context exits)
if progress_callback:
@@ -445,26 +589,49 @@ def find_all_indexes(scan_dir: Path) -> List[Path]:
def generate_embeddings_recursive(
index_root: Path,
embedding_backend: str = "fastembed",
model_profile: str = "code",
embedding_backend: Optional[str] = None,
model_profile: Optional[str] = None,
force: bool = False,
chunk_size: int = 2000,
overlap: int = 200,
progress_callback: Optional[callable] = None,
use_gpu: Optional[bool] = None,
max_tokens_per_batch: Optional[int] = None,
max_workers: int = 1,
) -> Dict[str, any]:
"""Generate embeddings for all index databases in a project recursively.
Args:
index_root: Root index directory containing _index.db files
embedding_backend: Embedding backend to use (fastembed or litellm)
embedding_backend: Embedding backend to use (fastembed or litellm).
Defaults to config setting.
model_profile: Model profile for fastembed (fast, code, multilingual, balanced)
or model name for litellm (e.g., text-embedding-3-small)
or model name for litellm (e.g., qwen3-embedding).
Defaults to config setting.
force: If True, regenerate even if embeddings exist
chunk_size: Maximum chunk size in characters
overlap: Overlap size in characters for sliding window chunking (default: 200)
progress_callback: Optional callback for progress updates
use_gpu: Whether to use GPU acceleration (fastembed only).
Defaults to config setting.
max_tokens_per_batch: Maximum tokens per batch for token-aware batching.
If None, attempts to get from embedder.max_tokens,
then falls back to 8000. If set, overrides automatic detection.
max_workers: Maximum number of concurrent API calls (default: 1 for sequential).
Recommended: 2-4 for LiteLLM API backends.
Returns:
Aggregated result dictionary with generation statistics
"""
# Get defaults from config if not specified
default_backend, default_model, default_gpu = _get_embedding_defaults()
if embedding_backend is None:
embedding_backend = default_backend
if model_profile is None:
model_profile = default_model
if use_gpu is None:
use_gpu = default_gpu
# Discover all _index.db files
index_files = discover_all_index_dbs(index_root)
@@ -498,7 +665,11 @@ def generate_embeddings_recursive(
model_profile=model_profile,
force=force,
chunk_size=chunk_size,
overlap=overlap,
progress_callback=None, # Don't cascade callbacks
use_gpu=use_gpu,
max_tokens_per_batch=max_tokens_per_batch,
max_workers=max_workers,
)
all_results.append({

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from functools import cached_property
@@ -14,6 +15,9 @@ from .errors import ConfigError
# Workspace-local directory name
WORKSPACE_DIR_NAME = ".codexlens"
# Settings file name
SETTINGS_FILE_NAME = "settings.json"
def _default_global_dir() -> Path:
"""Get global CodexLens data directory."""
@@ -89,6 +93,13 @@ class Config:
# Hybrid chunker configuration
hybrid_max_chunk_size: int = 2000 # Max characters per chunk before LLM refinement
hybrid_llm_refinement: bool = False # Enable LLM-based semantic boundary refinement
# Embedding configuration
embedding_backend: str = "fastembed" # "fastembed" (local) or "litellm" (API)
embedding_model: str = "code" # For fastembed: profile (fast/code/multilingual/balanced)
# For litellm: model name from config (e.g., "qwen3-embedding")
embedding_use_gpu: bool = True # For fastembed: whether to use GPU acceleration
def __post_init__(self) -> None:
try:
self.data_dir = self.data_dir.expanduser().resolve()
@@ -133,6 +144,67 @@ class Config:
"""Get parsing rules for a specific language, falling back to defaults."""
return {**self.parsing_rules.get("default", {}), **self.parsing_rules.get(language_id, {})}
@cached_property
def settings_path(self) -> Path:
"""Path to the settings file."""
return self.data_dir / SETTINGS_FILE_NAME
def save_settings(self) -> None:
"""Save embedding and other settings to file."""
settings = {
"embedding": {
"backend": self.embedding_backend,
"model": self.embedding_model,
"use_gpu": self.embedding_use_gpu,
},
"llm": {
"enabled": self.llm_enabled,
"tool": self.llm_tool,
"timeout_ms": self.llm_timeout_ms,
"batch_size": self.llm_batch_size,
},
}
with open(self.settings_path, "w", encoding="utf-8") as f:
json.dump(settings, f, indent=2)
def load_settings(self) -> None:
"""Load settings from file if exists."""
if not self.settings_path.exists():
return
try:
with open(self.settings_path, "r", encoding="utf-8") as f:
settings = json.load(f)
# Load embedding settings
embedding = settings.get("embedding", {})
if "backend" in embedding:
self.embedding_backend = embedding["backend"]
if "model" in embedding:
self.embedding_model = embedding["model"]
if "use_gpu" in embedding:
self.embedding_use_gpu = embedding["use_gpu"]
# Load LLM settings
llm = settings.get("llm", {})
if "enabled" in llm:
self.llm_enabled = llm["enabled"]
if "tool" in llm:
self.llm_tool = llm["tool"]
if "timeout_ms" in llm:
self.llm_timeout_ms = llm["timeout_ms"]
if "batch_size" in llm:
self.llm_batch_size = llm["batch_size"]
except Exception:
pass # Silently ignore errors
@classmethod
def load(cls) -> "Config":
"""Load config with settings from file."""
config = cls()
config.load_settings()
return config
@dataclass
class WorkspaceConfig:

View File

@@ -38,6 +38,16 @@ class BaseEmbedder(ABC):
"""
...
@property
def max_tokens(self) -> int:
"""Return maximum token limit for embeddings.
Returns:
int: Maximum number of tokens that can be embedded at once.
Default is 8192 if not overridden by implementation.
"""
return 8192
@abstractmethod
def embed_to_numpy(self, texts: str | Iterable[str]) -> np.ndarray:
"""Embed texts to numpy array.

View File

@@ -39,7 +39,7 @@ from codexlens.parsers.tokenizer import get_default_tokenizer
class ChunkConfig:
"""Configuration for chunking strategies."""
max_chunk_size: int = 1000 # Max characters per chunk
overlap: int = 100 # Overlap for sliding window
overlap: int = 200 # Overlap for sliding window (increased from 100 for better context)
strategy: str = "auto" # Chunking strategy: auto, symbol, sliding_window, hybrid
min_chunk_size: int = 50 # Minimum chunk size
skip_token_count: bool = False # Skip expensive token counting (use char/4 estimate)
@@ -80,6 +80,7 @@ class Chunker:
"""Chunk code by extracted symbols (functions, classes).
Each symbol becomes one chunk with its full content.
Large symbols exceeding max_chunk_size are recursively split using sliding window.
Args:
content: Source code content
@@ -101,27 +102,49 @@ class Chunker:
if len(chunk_content.strip()) < self.config.min_chunk_size:
continue
# Calculate token count if not provided
token_count = None
if symbol_token_counts and symbol.name in symbol_token_counts:
token_count = symbol_token_counts[symbol.name]
else:
token_count = self._estimate_token_count(chunk_content)
# Check if symbol content exceeds max_chunk_size
if len(chunk_content) > self.config.max_chunk_size:
# Create line mapping for correct line number tracking
line_mapping = list(range(start_line, end_line + 1))
chunks.append(SemanticChunk(
content=chunk_content,
embedding=None,
metadata={
"file": str(file_path),
"language": language,
"symbol_name": symbol.name,
"symbol_kind": symbol.kind,
"start_line": start_line,
"end_line": end_line,
"strategy": "symbol",
"token_count": token_count,
}
))
# Use sliding window to split large symbol
sub_chunks = self.chunk_sliding_window(
chunk_content,
file_path=file_path,
language=language,
line_mapping=line_mapping
)
# Update sub_chunks with parent symbol metadata
for sub_chunk in sub_chunks:
sub_chunk.metadata["symbol_name"] = symbol.name
sub_chunk.metadata["symbol_kind"] = symbol.kind
sub_chunk.metadata["strategy"] = "symbol_split"
sub_chunk.metadata["parent_symbol_range"] = (start_line, end_line)
chunks.extend(sub_chunks)
else:
# Calculate token count if not provided
token_count = None
if symbol_token_counts and symbol.name in symbol_token_counts:
token_count = symbol_token_counts[symbol.name]
else:
token_count = self._estimate_token_count(chunk_content)
chunks.append(SemanticChunk(
content=chunk_content,
embedding=None,
metadata={
"file": str(file_path),
"language": language,
"symbol_name": symbol.name,
"symbol_kind": symbol.kind,
"start_line": start_line,
"end_line": end_line,
"strategy": "symbol",
"token_count": token_count,
}
))
return chunks

View File

@@ -165,6 +165,33 @@ class Embedder(BaseEmbedder):
"""Get embedding dimension for current model."""
return self.MODEL_DIMS.get(self._model_name, 768) # Default to 768 if unknown
@property
def max_tokens(self) -> int:
"""Get maximum token limit for current model.
Returns:
int: Maximum number of tokens based on model profile.
- fast: 512 (lightweight, optimized for speed)
- code: 8192 (code-optimized, larger context)
- multilingual: 512 (standard multilingual model)
- balanced: 512 (general purpose)
"""
# Determine profile from model name
profile = None
for prof, model in self.MODELS.items():
if model == self._model_name:
profile = prof
break
# Return token limit based on profile
if profile == "code":
return 8192
elif profile in ("fast", "multilingual", "balanced"):
return 512
else:
# Default for unknown models
return 512
@property
def providers(self) -> List[str]:
"""Get configured ONNX execution providers."""

View File

@@ -63,11 +63,39 @@ class LiteLLMEmbedderWrapper(BaseEmbedder):
"""
return self._embedder.model_name
def embed_to_numpy(self, texts: str | Iterable[str]) -> np.ndarray:
@property
def max_tokens(self) -> int:
"""Return maximum token limit for the embedding model.
Returns:
int: Maximum number of tokens that can be embedded at once.
Inferred from model config or model name patterns.
"""
# Try to get from LiteLLM config first
if hasattr(self._embedder, 'max_input_tokens') and self._embedder.max_input_tokens:
return self._embedder.max_input_tokens
# Infer from model name
model_name_lower = self.model_name.lower()
# Large models (8B or "large" in name)
if '8b' in model_name_lower or 'large' in model_name_lower:
return 32768
# OpenAI text-embedding-3-* models
if 'text-embedding-3' in model_name_lower:
return 8191
# Default fallback
return 8192
def embed_to_numpy(self, texts: str | Iterable[str], **kwargs) -> np.ndarray:
"""Embed texts to numpy array using LiteLLMEmbedder.
Args:
texts: Single text or iterable of texts to embed.
**kwargs: Additional arguments (ignored for LiteLLM backend).
Accepts batch_size for API compatibility with fastembed.
Returns:
numpy.ndarray: Array of shape (n_texts, embedding_dim) containing embeddings.
@@ -76,4 +104,5 @@ class LiteLLMEmbedderWrapper(BaseEmbedder):
texts = [texts]
else:
texts = list(texts)
# LiteLLM handles batching internally, ignore batch_size parameter
return self._embedder.embed(texts)

View File

@@ -0,0 +1,291 @@
"""Tests for recursive splitting of large symbols in chunker."""
import pytest
from codexlens.entities import Symbol
from codexlens.semantic.chunker import Chunker, ChunkConfig
class TestRecursiveSplitting:
"""Test cases for recursive splitting of large symbols."""
def test_small_symbol_no_split(self):
"""Test that small symbols are not split."""
config = ChunkConfig(max_chunk_size=1000, overlap=100)
chunker = Chunker(config)
content = '''def small_function():
# This is a small function
x = 1
y = 2
return x + y
'''
symbols = [Symbol(name='small_function', kind='function', range=(1, 5))]
chunks = chunker.chunk_by_symbol(content, symbols, 'test.py', 'python')
assert len(chunks) == 1
assert chunks[0].metadata['strategy'] == 'symbol'
assert chunks[0].metadata['symbol_name'] == 'small_function'
assert chunks[0].metadata['symbol_kind'] == 'function'
assert 'parent_symbol_range' not in chunks[0].metadata
def test_large_symbol_splits(self):
"""Test that large symbols are recursively split."""
config = ChunkConfig(max_chunk_size=100, overlap=20)
chunker = Chunker(config)
content = '''def large_function():
# Line 1
# Line 2
# Line 3
# Line 4
# Line 5
# Line 6
# Line 7
# Line 8
# Line 9
# Line 10
# Line 11
# Line 12
# Line 13
# Line 14
# Line 15
pass
'''
symbols = [Symbol(name='large_function', kind='function', range=(1, 18))]
chunks = chunker.chunk_by_symbol(content, symbols, 'test.py', 'python')
# Should be split into multiple chunks
assert len(chunks) > 1
# All chunks should have symbol metadata
for chunk in chunks:
assert chunk.metadata['strategy'] == 'symbol_split'
assert chunk.metadata['symbol_name'] == 'large_function'
assert chunk.metadata['symbol_kind'] == 'function'
assert chunk.metadata['parent_symbol_range'] == (1, 18)
def test_boundary_condition(self):
"""Test symbol exactly at max_chunk_size boundary."""
config = ChunkConfig(max_chunk_size=90, overlap=20)
chunker = Chunker(config)
content = '''def boundary_function():
# This function is exactly at boundary
x = 1
y = 2
return x + y
'''
symbols = [Symbol(name='boundary_function', kind='function', range=(1, 5))]
chunks = chunker.chunk_by_symbol(content, symbols, 'test.py', 'python')
# Content is slightly over 90 chars, should be split
assert len(chunks) >= 1
assert chunks[0].metadata['strategy'] == 'symbol_split'
def test_multiple_symbols_mixed_sizes(self):
"""Test chunking with multiple symbols of different sizes."""
config = ChunkConfig(max_chunk_size=150, overlap=30)
chunker = Chunker(config)
content = '''def small():
return 1
def medium():
# Medium function
x = 1
y = 2
z = 3
return x + y + z
def very_large():
# Line 1
# Line 2
# Line 3
# Line 4
# Line 5
# Line 6
# Line 7
# Line 8
# Line 9
# Line 10
# Line 11
# Line 12
# Line 13
# Line 14
# Line 15
pass
'''
symbols = [
Symbol(name='small', kind='function', range=(1, 2)),
Symbol(name='medium', kind='function', range=(4, 9)),
Symbol(name='very_large', kind='function', range=(11, 28)),
]
chunks = chunker.chunk_by_symbol(content, symbols, 'test.py', 'python')
# Find chunks for each symbol
small_chunks = [c for c in chunks if c.metadata['symbol_name'] == 'small']
medium_chunks = [c for c in chunks if c.metadata['symbol_name'] == 'medium']
large_chunks = [c for c in chunks if c.metadata['symbol_name'] == 'very_large']
# Small should be filtered (< min_chunk_size)
assert len(small_chunks) == 0
# Medium should not be split
assert len(medium_chunks) == 1
assert medium_chunks[0].metadata['strategy'] == 'symbol'
# Large should be split
assert len(large_chunks) > 1
for chunk in large_chunks:
assert chunk.metadata['strategy'] == 'symbol_split'
def test_line_numbers_preserved(self):
"""Test that line numbers are correctly preserved in sub-chunks."""
config = ChunkConfig(max_chunk_size=100, overlap=20)
chunker = Chunker(config)
content = '''def large_function():
# Line 1 with some extra content to make it longer
# Line 2 with some extra content to make it longer
# Line 3 with some extra content to make it longer
# Line 4 with some extra content to make it longer
# Line 5 with some extra content to make it longer
# Line 6 with some extra content to make it longer
# Line 7 with some extra content to make it longer
# Line 8 with some extra content to make it longer
# Line 9 with some extra content to make it longer
# Line 10 with some extra content to make it longer
pass
'''
symbols = [Symbol(name='large_function', kind='function', range=(1, 13))]
chunks = chunker.chunk_by_symbol(content, symbols, 'test.py', 'python')
# Verify line numbers are correct and sequential
assert len(chunks) > 1
assert chunks[0].metadata['start_line'] == 1
# Each chunk should have valid line numbers
for chunk in chunks:
assert chunk.metadata['start_line'] >= 1
assert chunk.metadata['end_line'] <= 13
assert chunk.metadata['start_line'] <= chunk.metadata['end_line']
def test_overlap_in_split_chunks(self):
"""Test that overlap is applied when splitting large symbols."""
config = ChunkConfig(max_chunk_size=100, overlap=30)
chunker = Chunker(config)
content = '''def large_function():
# Line 1
# Line 2
# Line 3
# Line 4
# Line 5
# Line 6
# Line 7
# Line 8
# Line 9
# Line 10
# Line 11
# Line 12
pass
'''
symbols = [Symbol(name='large_function', kind='function', range=(1, 14))]
chunks = chunker.chunk_by_symbol(content, symbols, 'test.py', 'python')
# With overlap, consecutive chunks should overlap
if len(chunks) > 1:
for i in range(len(chunks) - 1):
# Next chunk should start before current chunk ends (overlap)
current_end = chunks[i].metadata['end_line']
next_start = chunks[i + 1].metadata['start_line']
# Overlap should exist
assert next_start <= current_end
def test_empty_symbol_filtered(self):
"""Test that symbols smaller than min_chunk_size are filtered."""
config = ChunkConfig(max_chunk_size=1000, min_chunk_size=50)
chunker = Chunker(config)
content = '''def tiny():
pass
'''
symbols = [Symbol(name='tiny', kind='function', range=(1, 2))]
chunks = chunker.chunk_by_symbol(content, symbols, 'test.py', 'python')
# Should be filtered due to min_chunk_size
assert len(chunks) == 0
def test_class_symbol_splits(self):
"""Test that large class symbols are also split correctly."""
config = ChunkConfig(max_chunk_size=120, overlap=25)
chunker = Chunker(config)
content = '''class LargeClass:
"""A large class with many methods."""
def method1(self):
return 1
def method2(self):
return 2
def method3(self):
return 3
def method4(self):
return 4
'''
symbols = [Symbol(name='LargeClass', kind='class', range=(1, 14))]
chunks = chunker.chunk_by_symbol(content, symbols, 'test.py', 'python')
# Should be split
assert len(chunks) > 1
# All chunks should preserve class metadata
for chunk in chunks:
assert chunk.metadata['symbol_name'] == 'LargeClass'
assert chunk.metadata['symbol_kind'] == 'class'
assert chunk.metadata['strategy'] == 'symbol_split'
class TestLightweightMode:
"""Test recursive splitting with lightweight token counting."""
def test_large_symbol_splits_lightweight_mode(self):
"""Test that large symbols split correctly in lightweight mode."""
config = ChunkConfig(max_chunk_size=100, overlap=20, skip_token_count=True)
chunker = Chunker(config)
content = '''def large_function():
# Line 1 with some extra content to make it longer
# Line 2 with some extra content to make it longer
# Line 3 with some extra content to make it longer
# Line 4 with some extra content to make it longer
# Line 5 with some extra content to make it longer
# Line 6 with some extra content to make it longer
# Line 7 with some extra content to make it longer
# Line 8 with some extra content to make it longer
# Line 9 with some extra content to make it longer
# Line 10 with some extra content to make it longer
pass
'''
symbols = [Symbol(name='large_function', kind='function', range=(1, 13))]
chunks = chunker.chunk_by_symbol(content, symbols, 'test.py', 'python')
# Should split even in lightweight mode
assert len(chunks) > 1
# All chunks should have token_count (estimated)
for chunk in chunks:
assert 'token_count' in chunk.metadata
assert chunk.metadata['token_count'] > 0

View File

@@ -60,6 +60,8 @@
".qwen/",
"codex-lens/src/codexlens/",
"codex-lens/pyproject.toml",
"ccw-litellm/src/ccw_litellm/",
"ccw-litellm/pyproject.toml",
"CLAUDE.md",
"README.md"
],