feat: add MCP server for semantic code search with FastMCP integration

This commit is contained in:
catlog22
2026-03-17 23:03:20 +08:00
parent ef2c5a58e1
commit ad9d3f94e0
80 changed files with 3427 additions and 21329 deletions

View File

@@ -0,0 +1,153 @@
---
name: wf-composer
description: Semantic workflow composer — parse natural language workflow description into a DAG of skill/CLI/agent nodes, auto-inject checkpoint save nodes, confirm with user, persist as reusable JSON template. Triggers on "wf-composer " or "/wf-composer".
argument-hint: "[workflow description]"
allowed-tools: Agent(*), AskUserQuestion(*), Read(*), Write(*), Edit(*), Bash(*), Glob(*), Grep(*)
---
# Workflow Design
Parse user's semantic workflow description → decompose into nodes → map to executors → auto-inject checkpoints → confirm pipeline → save as reusable `workflow-template.json`.
## Architecture
```
User describes workflow in natural language
-> Phase 1: Parse — extract intent steps + variables
-> Phase 2: Resolve — map each step to executor (skill/cli/agent/command)
-> Phase 3: Enrich — inject checkpoint nodes, set DAG edges
-> Phase 4: Confirm — visualize pipeline, user approval/edit
-> Phase 5: Persist — save .workflow/templates/<name>.json
```
## Shared Constants
| Constant | Value |
|----------|-------|
| Session prefix | `WFD` |
| Template dir | `.workflow/templates/` |
| Template ID format | `wft-<slug>-<date>` |
| Node ID format | `N-<seq>` (e.g. N-001), `CP-<seq>` for checkpoints |
| Max nodes | 20 |
## Entry Router
Parse `$ARGUMENTS`.
| Detection | Condition | Handler |
|-----------|-----------|---------|
| Resume design | `--resume` flag or existing WFD session | -> Phase 0: Resume |
| Edit template | `--edit <template-id>` flag | -> Phase 0: Load + Edit |
| New design | Default | -> Phase 1: Parse |
## Phase 0: Resume / Edit (optional)
**Resume design session**:
1. Scan `.workflow/templates/design-drafts/WFD-*.json` for in-progress designs
2. Multiple found → AskUserQuestion for selection
3. Load draft → skip to last incomplete phase
**Edit existing template**:
1. Load template from `--edit` path
2. Show current pipeline visualization
3. AskUserQuestion: which nodes to modify/add/remove
4. Re-enter at Phase 3 (Enrich) with edits applied
---
## Phase 1: Parse
Read `phases/01-parse.md` and execute.
**Objective**: Extract structured semantic steps + context variables from natural language.
**Success**: `design-session/intent.json` written with: steps[], variables[], task_type, complexity.
---
## Phase 2: Resolve
Read `phases/02-resolve.md` and execute.
**Objective**: Map each intent step to a concrete executor node.
**Executor types**:
- `skill` — invoke via `Skill(skill=..., args=...)`
- `cli` — invoke via `ccw cli -p "..." --tool ... --mode ...`
- `command` — invoke via `Skill(skill="<namespace:command>", args=...)`
- `agent` — invoke via `Agent(subagent_type=..., prompt=...)`
- `checkpoint` — state save + optional user pause
**Success**: `design-session/nodes.json` written with resolved executor for each step.
---
## Phase 3: Enrich
Read `phases/03-enrich.md` and execute.
**Objective**: Build DAG edges, auto-inject checkpoints at phase boundaries, validate port compatibility.
**Checkpoint injection rules**:
- After every `skill``skill` transition that crosses a semantic phase boundary
- Before any long-running `agent` spawn
- After any node that produces a persistent artifact (plan, spec, analysis)
- At user-defined breakpoints (if any)
**Success**: `design-session/dag.json` with nodes[], edges[], checkpoints[], context_schema{}.
---
## Phase 4: Confirm
Read `phases/04-confirm.md` and execute.
**Objective**: Visualize the pipeline, present to user, incorporate edits.
**Display format**:
```
Pipeline: <template-name>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
N-001 [skill] workflow-lite-plan "{goal}"
|
CP-01 [checkpoint] After Plan auto-continue
|
N-002 [skill] workflow-test-fix "--session N-001"
|
CP-02 [checkpoint] After Tests pause-for-user
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Variables: goal (required)
Checkpoints: 2 (1 auto, 1 pause)
```
AskUserQuestion:
- Confirm & Save
- Edit node (select node ID)
- Add node after (select position)
- Remove node (select node ID)
- Rename template
**Success**: User confirmed pipeline. Final dag.json ready.
---
## Phase 5: Persist
Read `phases/05-persist.md` and execute.
**Objective**: Assemble final template JSON, write to template library, output summary.
**Output**:
- `.workflow/templates/<slug>.json` — the reusable template
- Console summary with template path + usage command
**Success**: Template saved. User shown: `Skill(skill="wf-player", args="<template-path>")`
---
## Specs Reference
| Spec | Purpose |
|------|---------|
| [specs/node-catalog.md](specs/node-catalog.md) | Available executors, port definitions, arg templates |
| [specs/template-schema.md](specs/template-schema.md) | Full JSON template schema |

View File

@@ -0,0 +1,93 @@
# Phase 1: Parse — Semantic Intent Extraction
## Objective
Extract structured semantic steps and context variables from the user's natural language workflow description.
## Workflow
### Step 1.1 — Read Input
Parse `$ARGUMENTS` as the workflow description. If empty or ambiguous, AskUserQuestion:
- "Describe the workflow you want to automate. Include: what steps to run, in what order, and what varies each time (inputs)."
### Step 1.2 — Extract Steps
Scan the description for sequential actions. Each action becomes a candidate node.
**Signal patterns** (not exhaustive — apply NL understanding):
| Signal | Candidate Node Type |
|--------|---------------------|
| "analyze", "review", "explore" | analysis step (cli --mode analysis) |
| "plan", "design", "spec" | planning step (skill: workflow-lite-plan / workflow-plan) |
| "implement", "build", "code", "fix", "refactor" | execution step (skill: workflow-execute) |
| "test", "validate", "verify" | testing step (skill: workflow-test-fix) |
| "brainstorm", "ideate" | brainstorm step (skill: brainstorm / brainstorm-with-file) |
| "review code", "code review" | review step (skill: review-cycle) |
| "save", "checkpoint", "pause" | explicit checkpoint node |
| "spawn agent", "delegate", "subagent" | agent node |
| "then", "next", "after", "finally" | sequential edge signal |
| "parallel", "simultaneously", "at the same time" | parallel edge signal |
### Step 1.3 — Extract Variables
Identify inputs that vary per run. These become `context_schema` entries.
**Variable detection**:
- Direct mentions: "the goal", "the target", "my task", "user-provided X"
- Parameterized slots: `{goal}`, `[feature]`, `<scope>` patterns in the description
- Implicit from task type: any "feature/bugfix/topic" is `goal`
For each variable: assign name, type (string|path|boolean), required flag, description.
### Step 1.4 — Detect Task Type
Use ccw-coordinator task detection logic to classify the overall workflow:
```
bugfix | feature | tdd | review | brainstorm | spec-driven | roadmap |
refactor | integration-test | greenfield | quick-task | custom
```
`custom` = user describes a non-standard combination.
### Step 1.5 — Complexity Assessment
Count nodes, detect parallel tracks, identify dependencies:
- `simple` = 1-3 nodes, linear
- `medium` = 4-7 nodes, at most 1 parallel track
- `complex` = 8+ nodes or multiple parallel tracks
### Step 1.6 — Write Output
Create session dir: `.workflow/templates/design-drafts/WFD-<slug>-<date>/`
Write `intent.json`:
```json
{
"session_id": "WFD-<slug>-<date>",
"raw_description": "<original user input>",
"task_type": "<detected type>",
"complexity": "simple|medium|complex",
"steps": [
{
"seq": 1,
"description": "<extracted step description>",
"type_hint": "analysis|planning|execution|testing|review|checkpoint|agent|cli",
"parallel_with": null,
"variables": ["goal"]
}
],
"variables": {
"goal": { "type": "string", "required": true, "description": "<inferred description>" }
},
"created_at": "<ISO timestamp>"
}
```
## Success Criteria
- `intent.json` exists with at least 1 step
- All referenced variables extracted to `variables` map
- task_type and complexity assigned

View File

@@ -0,0 +1,89 @@
# Phase 2: Resolve — Map Steps to Executor Nodes
## Objective
Map each intent step from `intent.json` into a concrete executor node with assigned type, executor, and arg template.
## Workflow
### Step 2.1 — Load Intent
Read `design-session/intent.json`. Load steps[], variables{}.
### Step 2.2 — Map Each Step to Executor
For each step, determine the executor node using the Node Catalog (`specs/node-catalog.md`).
**Resolution algorithm**:
1. Match `type_hint` to executor candidates in catalog
2. If multiple candidates, select by semantic fit to step description
3. If no catalog match, emit `cli` node with inferred `--rule` and `--mode`
**Node type assignment**:
| Step type_hint | Default executor type | Default executor |
|----------------|----------------------|------------------|
| `planning` | skill | `workflow-lite-plan` (simple/medium) or `workflow-plan` (complex) |
| `execution` | skill | `workflow-execute` |
| `testing` | skill | `workflow-test-fix` |
| `review` | skill | `review-cycle` |
| `brainstorm` | skill | `brainstorm` |
| `analysis` | cli | `ccw cli --tool gemini --mode analysis` |
| `spec` | skill | `spec-generator` |
| `tdd` | skill | `workflow-tdd-plan` |
| `refactor` | command | `workflow:refactor-cycle` |
| `integration-test` | command | `workflow:integration-test-cycle` |
| `agent` | agent | (infer subagent_type from description) |
| `checkpoint` | checkpoint | — |
### Step 2.3 — Build Arg Templates
For each node, build `args_template` by substituting variable references:
```
skill node: args_template = `{goal}` (or `--session {prev_session}`)
cli node: args_template = `PURPOSE: {goal}\nTASK: ...\nMODE: analysis\nCONTEXT: @**/*`
agent node: args_template = `{goal}\nContext: {prev_output}`
```
**Context injection rules**:
- Planning nodes that follow analysis: inject `--context {prev_output_path}`
- Execution nodes that follow planning: inject `--resume-session {prev_session_id}`
- Testing nodes that follow execution: inject `--session {prev_session_id}`
Use `{prev_session_id}` and `{prev_output_path}` as runtime-resolved references — the executor will substitute these from node state at run time.
### Step 2.4 — Assign Parallel Groups
For steps with `parallel_with` set:
- Assign same `parallel_group` string to both nodes
- Parallel nodes share no data dependency (each gets same input)
### Step 2.5 — Write Output
Write `design-session/nodes.json`:
```json
{
"session_id": "<WFD-id>",
"nodes": [
{
"id": "N-001",
"seq": 1,
"name": "<step description shortened>",
"type": "skill|cli|command|agent|checkpoint",
"executor": "<skill name | cli command | agent subagent_type>",
"args_template": "<template string with {variable} placeholders>",
"input_ports": ["<port>"],
"output_ports": ["<port>"],
"parallel_group": null,
"on_fail": "abort"
}
]
}
```
## Success Criteria
- Every intent step has a corresponding node in nodes.json
- Every node has a non-empty executor and args_template
- Parallel groups correctly assigned where step.parallel_with is set

View File

@@ -0,0 +1,104 @@
# Phase 3: Enrich — Inject Checkpoints + Build DAG
## Objective
Build the directed acyclic graph (DAG) with proper edges, auto-inject checkpoint nodes at phase boundaries, and finalize the context_schema.
## Workflow
### Step 3.1 — Load Nodes
Read `design-session/nodes.json`. Get nodes[] list.
### Step 3.2 — Build Sequential Edges
Start with a linear chain: N-001 → N-002 → N-003 → ...
For nodes with the same `parallel_group`:
- Remove edges between them
- Add fan-out from the last non-parallel node to all group members
- Add fan-in from all group members to the next non-parallel node
### Step 3.3 — Auto-Inject Checkpoint Nodes
Scan the edge list and inject a `checkpoint` node between edges that cross a phase boundary.
**Phase boundary detection rules** (inject checkpoint if ANY rule triggers):
| Rule | Condition |
|------|-----------|
| **Artifact boundary** | Source node has output_ports containing `plan`, `spec`, `analysis`, `review-findings` |
| **Execution gate** | Target node type is `skill` with executor containing `execute` |
| **Agent spawn** | Target node type is `agent` |
| **Long-running** | Target node executor is `workflow-plan`, `spec-generator`, `collaborative-plan-with-file` |
| **User-defined** | Intent step had `type_hint: checkpoint` |
| **Post-testing** | Source node executor contains `test-fix` or `integration-test` |
**Checkpoint node template**:
```json
{
"id": "CP-<seq>",
"name": "Checkpoint: <description>",
"type": "checkpoint",
"description": "<what was just completed>",
"auto_continue": true,
"save_fields": ["session_id", "artifacts", "output_path"]
}
```
Set `auto_continue: false` for checkpoints that:
- Precede a user-facing deliverable (spec, plan, review report)
- Are explicitly requested by the user ("pause and show me")
### Step 3.4 — Insert Checkpoint Edges
For each injected checkpoint CP-X between edge (A → B):
- Remove edge A → B
- Add edges: A → CP-X, CP-X → B
### Step 3.5 — Finalize context_schema
Aggregate all `{variable}` references found in nodes' args_template strings.
For each unique variable name found:
- Look up from `intent.json#variables` if already defined
- Otherwise infer: type=string, required=true, description="<variable name>"
Produce final `context_schema{}` map.
### Step 3.6 — Validate DAG
Check:
- No cycles (topological sort must succeed)
- No orphan nodes (every node reachable from start)
- Every non-start node has at least one incoming edge
- Every non-terminal node has at least one outgoing edge
On cycle detection: report error, ask user to resolve.
### Step 3.7 — Write Output
Write `design-session/dag.json`:
```json
{
"session_id": "<WFD-id>",
"nodes": [ /* all nodes including injected checkpoints */ ],
"edges": [
{ "from": "N-001", "to": "CP-01" },
{ "from": "CP-01", "to": "N-002" }
],
"checkpoints": ["CP-01", "CP-02"],
"parallel_groups": { "<group-name>": ["N-003", "N-004"] },
"context_schema": {
"goal": { "type": "string", "required": true, "description": "..." }
},
"topological_order": ["N-001", "CP-01", "N-002"]
}
```
## Success Criteria
- dag.json exists and is valid (no cycles)
- At least one checkpoint exists (or user explicitly opted out)
- context_schema contains all variables referenced in args_templates
- topological_order covers all nodes

View File

@@ -0,0 +1,97 @@
# Phase 4: Confirm — Visualize + User Approval
## Objective
Render the pipeline as an ASCII diagram, present to user for confirmation and optional edits.
## Workflow
### Step 4.1 — Render Pipeline
Load `design-session/dag.json`. Render in topological order:
```
Pipeline: <template-name>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
N-001 [skill] workflow-lite-plan "{goal}"
|
CP-01 [checkpoint] After Plan auto-continue
|
N-002 [skill] workflow-execute --resume {N-001.session_id}
|
CP-02 [checkpoint] Before Review pause-for-user
|
N-003 [skill] review-cycle --session {N-002.session_id}
|
N-004 [skill] workflow-test-fix --session {N-002.session_id}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Variables (required): goal
Checkpoints: 2 (1 auto-continue, 1 pause-for-user)
Nodes: 4 work + 2 checkpoints
```
For parallel groups, show fan-out/fan-in:
```
N-003a [skill] review-cycle ─┐
├─ N-004 [skill] workflow-test-fix
N-003b [cli] gemini analysis ─┘
```
### Step 4.2 — Ask User
```
AskUserQuestion({
questions: [{
question: "Review the workflow pipeline above.",
header: "Confirm Pipeline",
options: [
{ label: "Confirm & Save", description: "Save as reusable template" },
{ label: "Edit a node", description: "Modify executor or args of a specific node" },
{ label: "Add a node", description: "Insert a new step at a position" },
{ label: "Remove a node", description: "Delete a step from the pipeline" },
{ label: "Rename template", description: "Change the template name" },
{ label: "Re-run checkpoint injection", description: "Reset and re-inject checkpoints" },
{ label: "Cancel", description: "Discard and exit" }
]
}]
})
```
### Step 4.3 — Handle Edit Actions
**Edit a node**:
- AskUserQuestion: "Which node ID to edit?" → show fields → apply change
- Re-render pipeline and re-ask
**Add a node**:
- AskUserQuestion: "Insert after which node ID?" + "Describe the new step"
- Re-run Phase 2 (resolve) for the new step description
- Insert new node + update edges
- Re-run Phase 3 (enrich) for checkpoint injection
- Re-render and re-ask
**Remove a node**:
- AskUserQuestion: "Which node ID to remove?"
- If node is a checkpoint: also remove it, re-wire edges
- If node is a work node: re-wire edges, re-run checkpoint injection
- Re-render and re-ask
**Rename template**:
- AskUserQuestion: "New template name?"
- Update slug for template_id
### Step 4.4 — Finalize
On "Confirm & Save":
- Freeze dag.json (mark as confirmed)
- Proceed to Phase 5
On "Cancel":
- Save draft to `design-session/dag-draft.json`
- Output: "Draft saved. Resume with: Skill(skill='wf-composer', args='--resume <session-id>')"
- Exit
## Success Criteria
- User selected "Confirm & Save"
- dag.json frozen with all user edits applied

View File

@@ -0,0 +1,107 @@
# Phase 5: Persist — Assemble + Save Template
## Objective
Assemble the final workflow template JSON from design session data, write to template library, output usage instructions.
## Workflow
### Step 5.1 — Load Design Session
Read:
- `design-session/intent.json` → template metadata
- `design-session/dag.json` → nodes, edges, checkpoints, context_schema
### Step 5.2 — Determine Template Name + Path
**Name**: Use user's confirmed name from Phase 4. If not set, derive from intent.task_type + first 3 meaningful words of raw_description.
**Slug**: kebab-case from name (e.g. "Feature TDD with Review" → "feature-tdd-with-review")
**Path**: `.workflow/templates/<slug>.json`
**template_id**: `wft-<slug>-<YYYYMMDD>`
Check for existing file:
- If exists and different content: append `-v2`, `-v3`, etc.
- If exists and identical: skip write, output "Template already exists"
### Step 5.3 — Assemble Template JSON
See `specs/template-schema.md` for full schema. Assemble:
```json
{
"template_id": "wft-<slug>-<date>",
"name": "<human name>",
"description": "<raw_description truncated to 120 chars>",
"version": "1.0",
"created_at": "<ISO timestamp>",
"source_session": "<WFD-id>",
"tags": ["<task_type>", "<complexity>"],
"context_schema": { /* from dag.json */ },
"nodes": [ /* from dag.json, full node objects */ ],
"edges": [ /* from dag.json */ ],
"checkpoints": [ /* checkpoint node IDs */ ],
"atomic_groups": [ /* from intent.json parallel groups */ ],
"execution_mode": "serial",
"metadata": {
"node_count": <n>,
"checkpoint_count": <n>,
"estimated_duration": "<rough estimate based on node types>"
}
}
```
### Step 5.4 — Write Template
Write assembled JSON to `.workflow/templates/<slug>.json`.
Ensure `.workflow/templates/` directory exists (create if not).
### Step 5.5 — Update Template Index
Read/create `.workflow/templates/index.json`:
```json
{
"templates": [
{
"template_id": "wft-<slug>-<date>",
"name": "<name>",
"path": ".workflow/templates/<slug>.json",
"tags": ["<task_type>"],
"created_at": "<ISO>",
"node_count": <n>
}
]
}
```
Append or update entry for this template. Write back.
### Step 5.6 — Output Summary
```
Template saved: .workflow/templates/<slug>.json
ID: wft-<slug>-<date>
Nodes: <n> work nodes + <n> checkpoints
Variables: <comma-separated required vars>
To execute:
Skill(skill="wf-player", args="<slug> --context goal='<your goal>'")
To edit later:
Skill(skill="wf-composer", args="--edit .workflow/templates/<slug>.json")
To list all templates:
Skill(skill="wf-player", args="--list")
```
### Step 5.7 — Clean Up Draft
Delete `design-session/` directory (or move to `.workflow/templates/design-drafts/archive/`).
## Success Criteria
- `.workflow/templates/<slug>.json` exists and is valid JSON
- `index.json` updated with new entry
- Console shows template path + usage command

View File

@@ -0,0 +1,96 @@
# Node Catalog — Available Executors
All executors available for node resolution in Phase 2.
## Skill Nodes
| Executor | Type | Input Ports | Output Ports | Typical Args Template |
|----------|------|-------------|--------------|----------------------|
| `workflow-lite-plan` | skill | requirement | plan | `"{goal}"` |
| `workflow-plan` | skill | requirement, specification | detailed-plan | `"{goal}"` |
| `workflow-execute` | skill | detailed-plan, verified-plan | code | `--resume-session {prev_session_id}` |
| `workflow-test-fix` | skill | failing-tests, code | test-passed | `--session {prev_session_id}` |
| `workflow-tdd-plan` | skill | requirement | tdd-tasks | `"{goal}"` |
| `workflow-multi-cli-plan` | skill | requirement | multi-cli-plan | `"{goal}"` |
| `review-cycle` | skill | code, session | review-findings | `--session {prev_session_id}` |
| `brainstorm` | skill | exploration-topic | brainstorm-analysis | `"{goal}"` |
| `spec-generator` | skill | requirement | specification | `"{goal}"` |
## Command Nodes (namespace skills)
| Executor | Type | Input Ports | Output Ports | Typical Args Template |
|----------|------|-------------|--------------|----------------------|
| `workflow:refactor-cycle` | command | codebase | refactored-code | `"{goal}"` |
| `workflow:integration-test-cycle` | command | requirement | test-passed | `"{goal}"` |
| `workflow:brainstorm-with-file` | command | exploration-topic | brainstorm-document | `"{goal}"` |
| `workflow:analyze-with-file` | command | analysis-topic | discussion-document | `"{goal}"` |
| `workflow:debug-with-file` | command | bug-report | understanding-document | `"{goal}"` |
| `workflow:collaborative-plan-with-file` | command | requirement | plan-note | `"{goal}"` |
| `workflow:roadmap-with-file` | command | requirement | execution-plan | `"{goal}"` |
| `workflow:unified-execute-with-file` | command | plan-note, discussion-document | code | (no args — reads from session) |
| `issue:discover` | command | codebase | pending-issues | (no args) |
| `issue:plan` | command | pending-issues | issue-plans | `--all-pending` |
| `issue:queue` | command | issue-plans | execution-queue | (no args) |
| `issue:execute` | command | execution-queue | completed-issues | `--queue auto` |
| `issue:convert-to-plan` | command | plan | converted-plan | `--latest-lite-plan` |
| `team-planex` | skill | requirement, execution-plan | code | `"{goal}"` |
## CLI Nodes
CLI nodes use `ccw cli` with a tool + mode + rule.
| Use Case | cli_tool | cli_mode | cli_rule |
|----------|----------|----------|----------|
| Architecture analysis | gemini | analysis | analysis-review-architecture |
| Code quality review | gemini | analysis | analysis-review-code-quality |
| Bug root cause | gemini | analysis | analysis-diagnose-bug-root-cause |
| Security assessment | gemini | analysis | analysis-assess-security-risks |
| Performance analysis | gemini | analysis | analysis-analyze-performance |
| Code patterns | gemini | analysis | analysis-analyze-code-patterns |
| Task breakdown | gemini | analysis | planning-breakdown-task-steps |
| Architecture design | gemini | analysis | planning-plan-architecture-design |
| Feature implementation | gemini | write | development-implement-feature |
| Refactoring | gemini | write | development-refactor-codebase |
| Test generation | gemini | write | development-generate-tests |
**CLI node args_template format**:
```
PURPOSE: {goal}
TASK: • [derived from step description]
MODE: analysis
CONTEXT: @**/* | Memory: {memory_context}
EXPECTED: [derived from step output_ports]
CONSTRAINTS: {scope}
```
## Agent Nodes
| subagent_type | Use Case | run_in_background |
|---------------|----------|-------------------|
| `general-purpose` | Freeform analysis or implementation | false |
| `team-worker` | Worker in team-coordinate pipeline | true |
| `code-reviewer` | Focused code review | false |
**Agent node args_template format**:
```
Task: {goal}
Context from previous step:
{prev_output}
Deliver: [specify expected output format]
```
## Checkpoint Nodes
Checkpoints are auto-generated — not selected from catalog.
| auto_continue | When to Use |
|---------------|-------------|
| `true` | Background save, execution continues automatically |
| `false` | Pause for user review before proceeding |
Set `auto_continue: false` when:
- The next node is user-facing (plan display, spec review)
- The user requested an explicit pause in their workflow description
- The next node spawns a background agent (give user chance to cancel)

View File

@@ -0,0 +1,202 @@
# Workflow Template Schema
## File Location
`.workflow/templates/<slug>.json`
## Full Schema
```json
{
"template_id": "wft-<slug>-<YYYYMMDD>",
"name": "Human readable template name",
"description": "Brief description of what this workflow achieves",
"version": "1.0",
"created_at": "2026-03-17T10:00:00Z",
"source_session": "WFD-<slug>-<date>",
"tags": ["feature", "medium"],
"context_schema": {
"goal": {
"type": "string",
"required": true,
"description": "Main task goal or feature to implement"
},
"scope": {
"type": "string",
"required": false,
"description": "Target file or module scope",
"default": "src/**/*"
}
},
"nodes": [
{
"id": "N-001",
"name": "Plan Feature",
"type": "skill",
"executor": "workflow-lite-plan",
"args_template": "{goal}",
"input_ports": ["requirement"],
"output_ports": ["plan"],
"parallel_group": null,
"on_fail": "abort"
},
{
"id": "CP-01",
"name": "Checkpoint: After Plan",
"type": "checkpoint",
"description": "Plan artifact saved before execution proceeds",
"auto_continue": true,
"save_fields": ["session_id", "artifacts", "output_path"]
},
{
"id": "N-002",
"name": "Execute Implementation",
"type": "skill",
"executor": "workflow-execute",
"args_template": "--resume-session {N-001.session_id}",
"input_ports": ["plan"],
"output_ports": ["code"],
"parallel_group": null,
"on_fail": "abort"
},
{
"id": "CP-02",
"name": "Checkpoint: Before Testing",
"type": "checkpoint",
"description": "Implementation complete, ready for test validation",
"auto_continue": true,
"save_fields": ["session_id", "artifacts"]
},
{
"id": "N-003",
"name": "Run Tests",
"type": "skill",
"executor": "workflow-test-fix",
"args_template": "--session {N-002.session_id}",
"input_ports": ["code"],
"output_ports": ["test-passed"],
"parallel_group": null,
"on_fail": "abort"
}
],
"edges": [
{ "from": "N-001", "to": "CP-01" },
{ "from": "CP-01", "to": "N-002" },
{ "from": "N-002", "to": "CP-02" },
{ "from": "CP-02", "to": "N-003" }
],
"checkpoints": ["CP-01", "CP-02"],
"atomic_groups": [
{
"name": "planning-execution",
"nodes": ["N-001", "CP-01", "N-002"],
"description": "Plan must be followed by execution"
}
],
"execution_mode": "serial",
"metadata": {
"node_count": 3,
"checkpoint_count": 2,
"estimated_duration": "20-40 min"
}
}
```
## Node Type Definitions
### `skill` node
```json
{
"id": "N-<seq>",
"name": "<descriptive name>",
"type": "skill",
"executor": "<skill-name>",
"args_template": "<string with {variable} and {prev-node-id.field} refs>",
"input_ports": ["<port-name>"],
"output_ports": ["<port-name>"],
"parallel_group": "<group-name> | null",
"on_fail": "abort | skip | retry"
}
```
### `cli` node
```json
{
"id": "N-<seq>",
"name": "<descriptive name>",
"type": "cli",
"executor": "ccw cli",
"cli_tool": "gemini | qwen | codex",
"cli_mode": "analysis | write",
"cli_rule": "<rule-template-name>",
"args_template": "PURPOSE: {goal}\nTASK: ...\nMODE: analysis\nCONTEXT: @**/*\nEXPECTED: ...\nCONSTRAINTS: ...",
"input_ports": ["analysis-topic"],
"output_ports": ["analysis"],
"parallel_group": null,
"on_fail": "abort"
}
```
### `command` node
```json
{
"id": "N-<seq>",
"name": "<descriptive name>",
"type": "command",
"executor": "workflow:refactor-cycle",
"args_template": "{goal}",
"input_ports": ["codebase"],
"output_ports": ["refactored-code"],
"parallel_group": null,
"on_fail": "abort"
}
```
### `agent` node
```json
{
"id": "N-<seq>",
"name": "<descriptive name>",
"type": "agent",
"executor": "general-purpose",
"args_template": "Task: {goal}\n\nContext from previous step:\n{prev_output}",
"input_ports": ["requirement"],
"output_ports": ["analysis"],
"parallel_group": "<group-name> | null",
"run_in_background": false,
"on_fail": "abort"
}
```
### `checkpoint` node
```json
{
"id": "CP-<seq>",
"name": "Checkpoint: <description>",
"type": "checkpoint",
"description": "<what was just completed, what comes next>",
"auto_continue": true,
"save_fields": ["session_id", "artifacts", "output_path"]
}
```
## Runtime Reference Syntax
In `args_template` strings, these references are resolved at execution time by `wf-player`:
| Reference | Resolves To |
|-----------|-------------|
| `{variable}` | Value from context (bound at run start) |
| `{N-001.session_id}` | `node_states["N-001"].session_id` |
| `{N-001.output_path}` | `node_states["N-001"].output_path` |
| `{N-001.artifacts[0]}` | First artifact from N-001 |
| `{prev_session_id}` | session_id of the immediately preceding work node |
| `{prev_output}` | Full output text of the immediately preceding node |
| `{prev_output_path}` | Output file path of the immediately preceding node |

View File

@@ -0,0 +1,151 @@
---
name: wf-player
description: Workflow template player — load a JSON template produced by wf-composer, bind context variables, execute nodes in DAG order (serial/parallel), persist state at checkpoints, support resume from any checkpoint. Uses ccw-coordinator serial-blocking for CLI nodes and team-coordinate worker pattern for parallel agent nodes. Triggers on "wf-player " or "/wf-player".
argument-hint: "<template-slug|path> [--context key=value...] [--resume <session-id>] [--list] [--dry-run]"
allowed-tools: Agent(*), AskUserQuestion(*), Read(*), Write(*), Edit(*), Bash(*), Glob(*), Grep(*), Skill(*)
---
# Workflow Run
Load a workflow template → bind variables → execute DAG → persist checkpoints → resume capable.
## Architecture
```
Skill(skill="wf-player", args="<template> --context goal='...'")
|
+-- Phase 0: Entry Router
|-- --list -> list available templates, exit
|-- --resume -> load session, skip to Phase 3 (Execute)
|-- --dry-run -> load + show execution plan, no execution
|-- default -> Phase 1 (Load)
|
+-- Phase 1: Load & Bind
| Load template JSON, bind {variables} from --context, validate required vars
|
+-- Phase 2: Instantiate
| Init session state, topological sort, write WFR session file
|
+-- Phase 3: Execute Loop
| For each node in order:
| skill node -> Skill(skill=...) [synchronous]
| cli node -> ccw cli [background + stop, hook callback]
| command node -> Skill(skill="namespace:cmd") [synchronous]
| agent node -> Agent(...) [run_in_background per node config]
| checkpoint -> save state, optionally pause
|
+-- Phase 4: Complete
Archive session, output summary
```
## Shared Constants
| Constant | Value |
|----------|-------|
| Session prefix | `WFR` |
| Session dir | `.workflow/sessions/WFR-<slug>-<date>/` |
| State file | `session-state.json` |
| Template dir | `.workflow/templates/` |
| Template index | `.workflow/templates/index.json` |
## Entry Router
Parse `$ARGUMENTS`:
| Detection | Condition | Handler |
|-----------|-----------|---------|
| List templates | `--list` in args | -> handleList |
| Resume session | `--resume <session-id>` in args | -> Phase 2 (resume) |
| Dry run | `--dry-run` in args | -> Phase 1 + 2, print plan, exit |
| Normal | Template slug/path provided | -> Phase 1 |
| No args | Empty args | -> handleList + AskUserQuestion |
### handleList
Scan `.workflow/templates/index.json`. Display:
```
Available workflow templates:
feature-tdd-review [feature, complex] 3 work nodes, 2 checkpoints
quick-bugfix [bugfix, simple] 2 work nodes, 1 checkpoint
...
Run: Skill(skill="wf-player", args="<slug> --context goal='...'")
```
---
## Phase 0 (Resume): Session Reconciliation
**Trigger**: `--resume <session-id>` or active WFR session found in `.workflow/sessions/WFR-*/`
1. Scan `.workflow/sessions/WFR-*/session-state.json` for status = "running" | "paused"
2. Multiple found → AskUserQuestion for selection
3. Load session-state.json
4. Identify `last_checkpoint` and `node_states`
5. Reset any `running` nodes back to `pending` (they were interrupted)
6. Determine next executable node from `topological_order` after last checkpoint
7. Resume at Phase 3 (Execute Loop) from that node
---
## Phase 1: Load & Bind
Read `phases/01-load.md` and execute.
**Objective**: Load template, collect missing variables, bind all {variable} references.
**Success**: Template loaded, all required variables bound, `bound_context{}` ready.
---
## Phase 2: Instantiate
Read `phases/02-instantiate.md` and execute.
**Objective**: Create WFR session directory, init state, compute execution plan.
**Success**: `session-state.json` written, topological_order ready.
---
## Phase 3: Execute Loop
Read `phases/03-execute.md` and execute.
**Objective**: Execute each node in topological_order using appropriate mechanism.
**CRITICAL — CLI node blocking**:
- CLI nodes launch `ccw cli` in background and immediately STOP
- Wait for hook callback — DO NOT poll with TaskOutput
- Hook callback resumes execution at next node
**Success**: All nodes completed, all checkpoints saved.
---
## Phase 4: Complete
Read `phases/04-complete.md` and execute.
**Objective**: Archive session, output execution summary and artifact paths.
---
## Error Handling
| Scenario | Resolution |
|----------|------------|
| Required variable missing | AskUserQuestion to collect it |
| Template not found | Show `--list` and suggest closest match |
| Node failed (on_fail=abort) | AskUserQuestion: Retry / Skip / Abort |
| Node failed (on_fail=skip) | Log warning, continue to next node |
| Node failed (on_fail=retry) | Retry once, then abort |
| Interrupted mid-execution | State saved at last checkpoint; resume with `--resume <session-id>` |
| Cycle in DAG | Error immediately, point to template for fix |
## Specs Reference
| Spec | Purpose |
|------|---------|
| [specs/node-executor.md](specs/node-executor.md) | Execution mechanism per node type |
| [specs/state-schema.md](specs/state-schema.md) | session-state.json schema |

View File

@@ -0,0 +1,92 @@
# Phase 1: Load & Bind
## Objective
Locate and load the workflow template, collect any missing context variables from the user, bind all `{variable}` references.
## Workflow
### Step 1.1 — Resolve Template Path
Parse `$ARGUMENTS` for template identifier:
**Path resolution order**:
1. Absolute path: use as-is
2. Relative path (starts with `.`): resolve from cwd
3. Slug only (e.g. `feature-tdd-review`): look up in `.workflow/templates/index.json` → get path
4. Partial slug match: scan index for closest match → confirm with user
If not found:
- Show available templates from index
- AskUserQuestion: "Which template to run?"
### Step 1.2 — Parse --context Arguments
Extract `--context key=value` pairs from `$ARGUMENTS`.
Examples:
```
--context goal="Implement user auth" --context scope="src/auth"
--context goal='Fix login bug' scope=src/auth
```
Build `bound_context = { goal: "...", scope: "..." }`.
### Step 1.3 — Load Template
Read template JSON from resolved path.
Validate:
- `template_id`, `nodes`, `edges`, `context_schema` all present
- `nodes` array non-empty
### Step 1.4 — Collect Missing Required Variables
For each variable in `context_schema` where `required: true`:
- If not in `bound_context`: collect via AskUserQuestion
- If has `default` value: use default if not provided
```
AskUserQuestion({
questions: [{
question: "Provide values for required workflow inputs:",
header: "Workflow: <template.name>",
// one question per missing required variable
}]
})
```
For optional variables not provided: use `default` value or leave as empty string.
### Step 1.5 — Bind Variables
Apply substitution throughout all `args_template` strings:
- Replace `{variable_name}` with `bound_context[variable_name]`
- Leave `{N-001.session_id}` and `{prev_*}` references unresolved — these are runtime-resolved in Phase 3
Write bound context to memory for Phase 3 use.
### Step 1.6 — Dry Run Output (if --dry-run)
Print execution plan and exit:
```
Workflow: <template.name>
Context:
goal = "<value>"
scope = "<value>"
Execution Plan:
[1] N-001 [skill] workflow-lite-plan "<goal>"
[2] CP-01 [checkpoint] After Plan auto-continue
[3] N-002 [skill] workflow-execute --resume-session {N-001.session_id}
[4] CP-02 [checkpoint] Before Tests pause-for-user
[5] N-003 [skill] workflow-test-fix --session {N-002.session_id}
To execute: Skill(skill="wf-player", args="<slug> --context goal='...'")
```
## Success Criteria
- Template loaded and validated
- All required context variables bound
- bound_context{} available for Phase 2

View File

@@ -0,0 +1,110 @@
# Phase 2: Instantiate — Init Session State
## Objective
Create the WFR session directory, initialize `session-state.json` with all nodes marked pending, compute topological execution order.
## Workflow
### Step 2.1 — Generate Session ID
```
session_id = "WFR-<template-slug>-<YYYYMMDD>-<HHmmss>"
session_dir = ".workflow/sessions/<session_id>/"
```
Create session directory.
### Step 2.2 — Topological Sort
Run topological sort on `template.nodes` + `template.edges`:
```
function topoSort(nodes, edges):
build adjacency list from edges
Kahn's algorithm (BFS from nodes with no incoming edges)
return ordered node IDs
```
**Parallel group handling**: Nodes in the same `parallel_group` can execute concurrently. In topological order, keep them adjacent and mark them as a parallel batch.
Store `execution_plan`:
```json
[
{ "batch": 1, "nodes": ["N-001"], "parallel": false },
{ "batch": 2, "nodes": ["CP-01"], "parallel": false },
{ "batch": 3, "nodes": ["N-002a", "N-002b"], "parallel": true },
{ "batch": 4, "nodes": ["N-003"], "parallel": false }
]
```
### Step 2.3 — Init Node States
For each node in template:
```json
{
"N-001": {
"status": "pending",
"started_at": null,
"completed_at": null,
"session_id": null,
"output_path": null,
"artifacts": [],
"error": null
}
}
```
Checkpoint nodes:
```json
{
"CP-01": {
"status": "pending",
"saved_at": null,
"snapshot_path": null
}
}
```
### Step 2.4 — Write session-state.json
See `specs/state-schema.md` for full schema. Write to `<session_dir>/session-state.json`:
```json
{
"session_id": "<WFR-id>",
"template_id": "<template.template_id>",
"template_path": "<path to template>",
"template_name": "<template.name>",
"status": "running",
"context": { /* bound_context from Phase 1 */ },
"execution_plan": [ /* batches */ ],
"current_batch": 1,
"current_node": "N-001",
"last_checkpoint": null,
"node_states": { /* all nodes as pending */ },
"created_at": "<ISO>",
"updated_at": "<ISO>"
}
```
### Step 2.5 — Show Execution Start Banner
```
[wf-player] ============================================
[wf-player] Starting: <template.name>
[wf-player] Session: <session_id>
[wf-player] Context: goal="<value>"
[wf-player]
[wf-player] Plan: <N> nodes, <C> checkpoints
[wf-player] N-001 [skill] workflow-lite-plan
[wf-player] CP-01 [checkpoint] After Plan
[wf-player] N-002 [skill] workflow-execute
[wf-player] ============================================
```
## Success Criteria
- `<session_dir>/session-state.json` written
- `execution_plan` has valid topological order
- Status = "running"

View File

@@ -0,0 +1,211 @@
# Phase 3: Execute Loop
## Objective
Execute each node batch in topological order. Use the correct mechanism per node type. Save state after every checkpoint. Support CLI serial-blocking with hook callback resume.
## Pre-execution: Runtime Reference Resolution
Before executing each node, resolve any `{N-xxx.field}` and `{prev_*}` references in `args_template`:
```
function resolveArgs(args_template, node_id, session_state):
for each {ref} in args_template:
if ref matches {variable}:
replace with session_state.context[variable]
if ref matches {N-001.session_id}:
replace with session_state.node_states["N-001"].session_id
if ref matches {N-001.output_path}:
replace with session_state.node_states["N-001"].output_path
if ref matches {prev_session_id}:
find previous non-checkpoint node -> replace with its session_id
if ref matches {prev_output}:
find previous non-checkpoint node -> replace with its output_text
if ref matches {prev_output_path}:
find previous non-checkpoint node -> replace with its output_path
return resolved_args
```
## Node Execution by Type
Read `specs/node-executor.md` for full details. Summary:
### skill node
```
resolved_args = resolveArgs(node.args_template, ...)
mark node status = "running", write session-state.json
result = Skill(skill=node.executor, args=resolved_args)
extract from result: session_id, output_path, artifacts[]
update node_states[node.id]:
status = "completed"
session_id = extracted_session_id
output_path = extracted_output_path
artifacts = extracted_artifacts
completed_at = now()
write session-state.json
advance to next node
```
### command node
Same as skill node but executor is a namespaced command:
```
Skill(skill=node.executor, args=resolved_args)
```
### cli node — CRITICAL: serial blocking
```
resolved_args = resolveArgs(node.args_template, ...)
mark node status = "running", write session-state.json
Bash({
command: `ccw cli -p "${resolved_args}" --tool ${node.cli_tool} --mode ${node.cli_mode} --rule ${node.cli_rule}`,
run_in_background: true
})
write session-state.json // persist "running" state
STOP — wait for hook callback
```
**Hook callback resumes here**:
```
// Called when ccw cli completes
load session-state.json
find node with status "running"
extract result: exec_id, output_path, cli_output
update node_states[node.id]:
status = "completed"
output_path = extracted_output_path
completed_at = now()
write session-state.json
advance to next node
```
### agent node
```
resolved_args = resolveArgs(node.args_template, ...)
mark node status = "running", write session-state.json
result = Agent({
subagent_type: node.executor,
prompt: resolved_args,
run_in_background: node.run_in_background ?? false,
description: node.name
})
update node_states[node.id]:
status = "completed"
output_path = result.output_path or session_dir + "/artifacts/" + node.id + ".md"
completed_at = now()
write session-state.json
advance to next node
```
**Parallel agent nodes** (same parallel_group):
```
// Launch all agents in parallel
for each node in parallel_batch:
mark node status = "running"
Agent({
subagent_type: node.executor,
prompt: resolveArgs(node.args_template, ...),
run_in_background: true, // parallel
description: node.name
})
// Wait for all to complete (Agent with run_in_background=false blocks — use team-coordinate pattern)
// team-coordinate: spawn as team-workers with callbacks if complex
// For simple parallel: use multiple Agent calls synchronously or use team-coordinate's spawn-and-stop
```
### checkpoint node
```
// Save snapshot
snapshot = {
session_id: session_state.session_id,
checkpoint_id: node.id,
checkpoint_name: node.name,
saved_at: now(),
node_states_snapshot: session_state.node_states,
last_completed_node: previous_node_id
}
write session-state.json (last_checkpoint = node.id)
write <session_dir>/checkpoints/<node.id>.json
if node.auto_continue == false:
// Pause for user
AskUserQuestion({
questions: [{
question: node.description + "\n\nReview checkpoint state and confirm to continue.",
header: "Checkpoint: " + node.name,
options: [
{ label: "Continue", description: "Proceed to next node" },
{ label: "Pause", description: "Save state and exit (resume later)" },
{ label: "Abort", description: "Stop execution" }
]
}]
})
on "Pause":
session_state.status = "paused"
write session-state.json
output "Session paused. Resume with: Skill(skill='wf-player', args='--resume <session_id>')"
EXIT
on "Abort":
session_state.status = "aborted"
write session-state.json
EXIT
// auto_continue or user chose Continue
mark checkpoint status = "completed"
write session-state.json
advance to next node
```
## Progress Display
After each node completes, print progress:
```
[wf-player] [2/5] CP-01 checkpoint saved ✓
[wf-player] [3/5] N-002 workflow-execute ... running
```
## Error Handling
On node failure (exception or skill returning error state):
```
on_fail = node.on_fail || "abort"
if on_fail == "skip":
mark node status = "skipped"
log warning
advance to next node
if on_fail == "retry":
retry once
if still fails: fall through to abort
if on_fail == "abort":
AskUserQuestion:
- Retry
- Skip this node
- Abort workflow
handle choice accordingly
```
## Loop Termination
After last node in execution_plan completes:
- All node_states should be "completed" or "skipped"
- Proceed to Phase 4 (Complete)

View File

@@ -0,0 +1,93 @@
# Phase 4: Complete — Archive + Summary
## Objective
Mark session complete, output execution summary with artifact paths, offer archive/keep options.
## Workflow
### Step 4.1 — Mark Session Complete
Load `session-state.json`.
Set:
```json
{
"status": "completed",
"completed_at": "<ISO timestamp>"
}
```
Write back to `session-state.json`.
### Step 4.2 — Collect Artifacts
Aggregate all artifacts from node_states:
```javascript
const artifacts = Object.values(node_states)
.filter(s => s.artifacts && s.artifacts.length > 0)
.flatMap(s => s.artifacts.map(a => ({ node: s.node_id, path: a })));
const outputPaths = Object.values(node_states)
.filter(s => s.output_path)
.map(s => ({ node: s.node_id, path: s.output_path }));
```
### Step 4.3 — Execution Summary
```
[wf-player] ============================================
[wf-player] COMPLETE: <template_name>
[wf-player]
[wf-player] Session: <session_id>
[wf-player] Context: goal="<value>"
[wf-player]
[wf-player] Nodes: <completed>/<total> completed
[wf-player] N-001 workflow-lite-plan ✓ (WFS-plan-xxx)
[wf-player] CP-01 After Plan ✓ (checkpoint saved)
[wf-player] N-002 workflow-execute ✓ (WFS-exec-xxx)
[wf-player] CP-02 Before Tests ✓ (checkpoint saved)
[wf-player] N-003 workflow-test-fix ✓ (WFS-test-xxx)
[wf-player]
[wf-player] Artifacts:
[wf-player] - IMPL_PLAN.md (N-001)
[wf-player] - src/auth/index.ts (N-002)
[wf-player] - test/auth.test.ts (N-003)
[wf-player]
[wf-player] Session dir: .workflow/sessions/<session_id>/
[wf-player] ============================================
```
### Step 4.4 — Completion Action
```
AskUserQuestion({
questions: [{
question: "Workflow complete. What would you like to do?",
header: "Completion Action",
options: [
{ label: "Archive session", description: "Move session to .workflow/sessions/archive/" },
{ label: "Keep session", description: "Leave session active for follow-up" },
{ label: "Run again", description: "Re-run template with same or new context" },
{ label: "Nothing", description: "Done" }
]
}]
})
```
**Archive**:
- Move `<session_dir>` to `.workflow/sessions/archive/<session_id>/`
- Update `session-state.json` status = "archived"
**Keep**: No action, session stays at `.workflow/sessions/<session_id>/`
**Run again**:
- AskUserQuestion: "Same context or new?" → new context → re-enter Phase 1
**Nothing**: Output final artifact paths list, done.
## Success Criteria
- session-state.json status = "completed" or "archived"
- All artifact paths listed in console output
- User presented completion action options

View File

@@ -0,0 +1,187 @@
# Node Executor — Execution Mechanisms per Node Type
## Overview
Each node type uses a specific execution mechanism. This spec defines the exact invocation pattern.
## 1. skill node
**Mechanism**: `Skill()` tool — synchronous, blocks until complete.
```
Skill({
skill: "<node.executor>",
args: "<resolved_args>"
})
```
**Output extraction**: Parse Skill() result for:
- Session ID pattern: `WFS-[a-z]+-\d{8}` or `TC-[a-z]+-\d{8}`
- Output path: last `.md` or `.json` file path mentioned
- Artifacts: all file paths in output
**Session ID sources**:
- Explicit: "Session: WFS-plan-20260317" in output
- Implicit: first session-like ID in output
**Examples**:
```
// Planning skill
Skill({ skill: "workflow-lite-plan", args: "Implement user authentication" })
// Execute skill (with prior session)
Skill({ skill: "workflow-execute", args: "--resume-session WFS-plan-20260317" })
// Test skill (with prior session)
Skill({ skill: "workflow-test-fix", args: "--session WFS-exec-20260317" })
```
---
## 2. command node
**Mechanism**: `Skill()` tool with namespace command name — synchronous.
```
Skill({
skill: "<node.executor>", // e.g. "workflow:refactor-cycle"
args: "<resolved_args>"
})
```
**Examples**:
```
Skill({ skill: "workflow:refactor-cycle", args: "Reduce coupling in auth module" })
Skill({ skill: "workflow:debug-with-file", args: "Login fails with 401 on valid tokens" })
Skill({ skill: "issue:discover", args: "" })
Skill({ skill: "issue:queue", args: "" })
```
---
## 3. cli node
**Mechanism**: `Bash()` with `run_in_background: true` — STOP after launch, resume via hook callback.
```
// Build command
const prompt = resolveArgs(node.args_template, ...)
const cmd = `ccw cli -p "${escapeForShell(prompt)}" --tool ${node.cli_tool} --mode ${node.cli_mode} --rule ${node.cli_rule}`
// Launch background
Bash({ command: cmd, run_in_background: true })
// Save CLI task ID to node state for hook matching
node_state.cli_task_id = <captured from stderr CCW_EXEC_ID>
// Write session-state.json
// STOP — do not proceed until hook callback fires
```
**Hook callback** (triggered when ccw cli completes):
```
// Identify which node was running (status = "running" with cli_task_id set)
// Extract from CLI output:
// - output_path: file written by CLI
// - cli_exec_id: from CCW_EXEC_ID
// Mark node completed
// Advance to next node
```
**CLI output escaping**:
```javascript
function escapeForShell(s) {
// Use single quotes with escaped single quotes inside
return "'" + s.replace(/'/g, "'\\''") + "'"
}
```
**Example**:
```
Bash({
command: `ccw cli -p 'PURPOSE: Analyze auth module architecture\nTASK: • Review class structure • Check dependencies\nMODE: analysis\nCONTEXT: @src/auth/**/*\nEXPECTED: Architecture report with issues list\nCONSTRAINTS: Read only' --tool gemini --mode analysis --rule analysis-review-architecture`,
run_in_background: true
})
```
---
## 4. agent node
**Mechanism**: `Agent()` tool.
Single agent (serial):
```
Agent({
subagent_type: node.executor, // "general-purpose" or "code-reviewer"
prompt: resolveArgs(node.args_template, ...),
run_in_background: false, // blocks until complete
description: node.name
})
```
Parallel agents (same parallel_group — use team-coordinate pattern):
```
// For 2-3 parallel agents: launch all with run_in_background: true
// Use SendMessage/callback or wait with sequential Skill() calls
// For complex parallel pipelines: delegate to team-coordinate
Skill({
skill: "team-coordinate",
args: "<description of parallel work>"
})
```
**Output extraction**:
- Agent output is usually the full response text
- Look for file paths in output for `output_path`
---
## 5. checkpoint node
**Mechanism**: Pure state management — no external calls unless `auto_continue: false`.
```
// 1. Write checkpoint snapshot
Write({
file_path: "<session_dir>/checkpoints/<node.id>.json",
content: JSON.stringify({
session_id, checkpoint_id: node.id, checkpoint_name: node.name,
saved_at: now(), context_snapshot: session_state.context,
node_states_snapshot: session_state.node_states,
last_completed_node: prev_node_id,
next_node: next_node_id
}, null, 2)
})
// 2. Update session state
session_state.last_checkpoint = node.id
node_states[node.id].status = "completed"
node_states[node.id].saved_at = now()
node_states[node.id].snapshot_path = checkpointPath
Write({ file_path: session_state_path, content: JSON.stringify(session_state, null, 2) })
// 3. If auto_continue = false: pause for user (see 03-execute.md)
// 4. If auto_continue = true: proceed immediately
```
---
## Context Passing Between Nodes
The runtime reference resolver in `03-execute.md` handles `{N-xxx.field}` substitution.
**Key resolved fields by node type**:
| Node type | Exposes | Referenced as |
|-----------|---------|---------------|
| skill | session_id | `{N-001.session_id}` |
| skill | output_path | `{N-001.output_path}` |
| skill | artifacts[0] | `{N-001.artifacts[0]}` |
| cli | output_path | `{N-001.output_path}` |
| agent | output_path | `{N-001.output_path}` |
| any | shorthand prev | `{prev_session_id}`, `{prev_output_path}` |
**Fallback**: If referenced field is null/empty, the args_template substitution results in empty string. The executor should handle gracefully (most skills default to latest session).

View File

@@ -0,0 +1,136 @@
# Session State Schema
## File Location
`.workflow/sessions/WFR-<slug>-<date>-<time>/session-state.json`
## Full Schema
```json
{
"session_id": "WFR-feature-tdd-review-20260317-143022",
"template_id": "wft-feature-tdd-review-20260310",
"template_path": ".workflow/templates/feature-tdd-review.json",
"template_name": "Feature TDD with Review",
"status": "running | paused | completed | failed | aborted | archived",
"context": {
"goal": "Implement user authentication",
"scope": "src/auth"
},
"execution_plan": [
{ "batch": 1, "nodes": ["N-001"], "parallel": false },
{ "batch": 2, "nodes": ["CP-01"], "parallel": false },
{ "batch": 3, "nodes": ["N-002"], "parallel": false },
{ "batch": 4, "nodes": ["CP-02"], "parallel": false },
{ "batch": 5, "nodes": ["N-003a", "N-003b"], "parallel": true },
{ "batch": 6, "nodes": ["N-004"], "parallel": false }
],
"current_batch": 3,
"current_node": "N-002",
"last_checkpoint": "CP-01",
"node_states": {
"N-001": {
"status": "completed",
"started_at": "2026-03-17T14:30:25Z",
"completed_at": "2026-03-17T14:35:10Z",
"session_id": "WFS-plan-20260317",
"output_path": ".workflow/sessions/WFS-plan-20260317/IMPL_PLAN.md",
"artifacts": [
".workflow/sessions/WFS-plan-20260317/IMPL_PLAN.md"
],
"error": null
},
"CP-01": {
"status": "completed",
"saved_at": "2026-03-17T14:35:12Z",
"snapshot_path": ".workflow/sessions/WFR-feature-tdd-review-20260317-143022/checkpoints/CP-01.json",
"auto_continue": true
},
"N-002": {
"status": "running",
"started_at": "2026-03-17T14:35:14Z",
"completed_at": null,
"session_id": null,
"output_path": null,
"artifacts": [],
"error": null,
"cli_task_id": "gem-143514-x7k2"
},
"CP-02": {
"status": "pending",
"saved_at": null,
"snapshot_path": null
},
"N-003a": {
"status": "pending",
"started_at": null,
"completed_at": null,
"session_id": null,
"output_path": null,
"artifacts": [],
"error": null
},
"N-003b": { "status": "pending", "..." : "..." },
"N-004": { "status": "pending", "..." : "..." }
},
"created_at": "2026-03-17T14:30:22Z",
"updated_at": "2026-03-17T14:35:14Z",
"completed_at": null
}
```
## Status Values
| Status | Description |
|--------|-------------|
| `running` | Active execution in progress |
| `paused` | User paused at checkpoint — resume with `--resume` |
| `completed` | All nodes executed successfully |
| `failed` | A node failed and abort was chosen |
| `aborted` | User aborted at checkpoint |
| `archived` | Completed and moved to archive/ |
## Node State Status Values
| Status | Description |
|--------|-------------|
| `pending` | Not yet started |
| `running` | Currently executing (may be waiting for CLI callback) |
| `completed` | Successfully finished |
| `skipped` | Skipped due to `on_fail: skip` |
| `failed` | Execution error |
## Checkpoint Snapshot Schema
`.workflow/sessions/<wfr-id>/checkpoints/<CP-id>.json`:
```json
{
"session_id": "WFR-<id>",
"checkpoint_id": "CP-01",
"checkpoint_name": "After Plan",
"saved_at": "2026-03-17T14:35:12Z",
"context_snapshot": { "goal": "...", "scope": "..." },
"node_states_snapshot": { /* full node_states at this point */ },
"last_completed_node": "N-001",
"next_node": "N-002"
}
```
## Session Directory Structure
```
.workflow/sessions/WFR-<slug>-<date>-<time>/
+-- session-state.json # Main state file, updated after every node
+-- checkpoints/ # Checkpoint snapshots
| +-- CP-01.json
| +-- CP-02.json
+-- artifacts/ # Optional: copies of key artifacts
+-- N-001-output.md
```

View File

@@ -1,154 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import { render, screen, waitFor } from '@/test/i18n';
import { AdvancedTab } from './AdvancedTab';
vi.mock('@/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks')>();
return {
...actual,
useCodexLensEnv: vi.fn(),
useUpdateCodexLensEnv: vi.fn(),
useCodexLensIgnorePatterns: vi.fn(),
useUpdateIgnorePatterns: vi.fn(),
useNotifications: vi.fn(),
};
});
import {
useCodexLensEnv,
useUpdateCodexLensEnv,
useCodexLensIgnorePatterns,
useUpdateIgnorePatterns,
useNotifications,
} from '@/hooks';
const mockRefetchEnv = vi.fn().mockResolvedValue(undefined);
const mockRefetchPatterns = vi.fn().mockResolvedValue(undefined);
const mockUpdateEnv = vi.fn().mockResolvedValue({ success: true, message: 'Saved' });
const mockUpdatePatterns = vi.fn().mockResolvedValue({
success: true,
patterns: ['dist', 'frontend/dist'],
extensionFilters: ['*.min.js', 'frontend/skip.ts'],
defaults: {
patterns: ['dist', 'build'],
extensionFilters: ['*.min.js'],
},
});
const mockToastSuccess = vi.fn();
const mockToastError = vi.fn();
function setupDefaultMocks() {
vi.mocked(useCodexLensEnv).mockReturnValue({
data: { success: true, env: {}, settings: {}, raw: '', path: '~/.codexlens/.env' },
raw: '',
env: {},
settings: {},
isLoading: false,
error: null,
refetch: mockRefetchEnv,
});
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: mockUpdateEnv,
isUpdating: false,
error: null,
});
vi.mocked(useCodexLensIgnorePatterns).mockReturnValue({
data: {
success: true,
patterns: ['dist', 'coverage'],
extensionFilters: ['*.min.js', '*.map'],
defaults: {
patterns: ['dist', 'build', 'coverage'],
extensionFilters: ['*.min.js', '*.map'],
},
},
patterns: ['dist', 'coverage'],
extensionFilters: ['*.min.js', '*.map'],
defaults: {
patterns: ['dist', 'build', 'coverage'],
extensionFilters: ['*.min.js', '*.map'],
},
isLoading: false,
error: null,
refetch: mockRefetchPatterns,
});
vi.mocked(useUpdateIgnorePatterns).mockReturnValue({
updatePatterns: mockUpdatePatterns,
isUpdating: false,
error: null,
});
vi.mocked(useNotifications).mockReturnValue({
success: mockToastSuccess,
error: mockToastError,
} as unknown as ReturnType<typeof useNotifications>);
}
describe('AdvancedTab', () => {
beforeEach(() => {
vi.clearAllMocks();
setupDefaultMocks();
});
it('renders existing filter configuration', () => {
render(<AdvancedTab enabled={true} />);
expect(screen.getByLabelText(/Ignored directories \/ paths/i)).toHaveValue('dist\ncoverage');
expect(screen.getByLabelText(/Skipped files \/ globs/i)).toHaveValue('*.min.js\n*.map');
expect(screen.getByText(/Directory filters: 2/i)).toBeInTheDocument();
expect(screen.getByText(/File filters: 2/i)).toBeInTheDocument();
});
it('saves parsed filter configuration', async () => {
render(<AdvancedTab enabled={true} />);
const ignorePatternsInput = screen.getByLabelText(/Ignored directories \/ paths/i);
const extensionFiltersInput = screen.getByLabelText(/Skipped files \/ globs/i);
fireEvent.change(ignorePatternsInput, { target: { value: 'dist,\nfrontend/dist' } });
fireEvent.change(extensionFiltersInput, { target: { value: '*.min.js,\nfrontend/skip.ts' } });
fireEvent.click(screen.getByRole('button', { name: /Save filters/i }));
await waitFor(() => {
expect(mockUpdatePatterns).toHaveBeenCalledWith({
patterns: ['dist', 'frontend/dist'],
extensionFilters: ['*.min.js', 'frontend/skip.ts'],
});
});
expect(mockRefetchPatterns).toHaveBeenCalled();
});
it('restores default filter values before saving', async () => {
render(<AdvancedTab enabled={true} />);
fireEvent.click(screen.getByRole('button', { name: /Restore defaults/i }));
expect(screen.getByLabelText(/Ignored directories \/ paths/i)).toHaveValue('dist\nbuild\ncoverage');
expect(screen.getByLabelText(/Skipped files \/ globs/i)).toHaveValue('*.min.js\n*.map');
fireEvent.click(screen.getByRole('button', { name: /Save filters/i }));
await waitFor(() => {
expect(mockUpdatePatterns).toHaveBeenCalledWith({
patterns: ['dist', 'build', 'coverage'],
extensionFilters: ['*.min.js', '*.map'],
});
});
});
it('blocks invalid filter entries before saving', async () => {
render(<AdvancedTab enabled={true} />);
fireEvent.change(screen.getByLabelText(/Ignored directories \/ paths/i), {
target: { value: 'bad pattern!' },
});
fireEvent.click(screen.getByRole('button', { name: /Save filters/i }));
expect(mockUpdatePatterns).not.toHaveBeenCalled();
expect(screen.getByText(/Invalid ignore patterns/i)).toBeInTheDocument();
});
});

View File

@@ -1,704 +0,0 @@
// ========================================
// CodexLens Advanced Tab
// ========================================
// Advanced settings including .env editor and ignore patterns
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Save, RefreshCw, AlertTriangle, FileCode, AlertCircle, Filter } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Textarea } from '@/components/ui/Textarea';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { Badge } from '@/components/ui/Badge';
import {
useCodexLensEnv,
useCodexLensIgnorePatterns,
useUpdateCodexLensEnv,
useUpdateIgnorePatterns,
} from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import { CcwToolsCard } from './CcwToolsCard';
interface AdvancedTabProps {
enabled?: boolean;
}
interface FormErrors {
env?: string;
ignorePatterns?: string;
extensionFilters?: string;
}
const FILTER_ENTRY_PATTERN = /^[-\w.*\\/]+$/;
function parseListEntries(text: string): string[] {
return text
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function normalizeListEntries(entries: string[]): string {
return entries
.map((entry) => entry.trim())
.filter(Boolean)
.join('\n');
}
function normalizeListText(text: string): string {
return normalizeListEntries(parseListEntries(text));
}
export function AdvancedTab({ enabled = true }: AdvancedTabProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const {
raw,
env,
settings,
isLoading: isLoadingEnv,
error: envError,
refetch,
} = useCodexLensEnv({ enabled });
const {
patterns,
extensionFilters,
defaults,
isLoading: isLoadingPatterns,
error: patternsError,
refetch: refetchPatterns,
} = useCodexLensIgnorePatterns({ enabled });
const { updateEnv, isUpdating } = useUpdateCodexLensEnv();
const { updatePatterns, isUpdating: isUpdatingPatterns } = useUpdateIgnorePatterns();
// Form state
const [envInput, setEnvInput] = useState('');
const [ignorePatternsInput, setIgnorePatternsInput] = useState('');
const [extensionFiltersInput, setExtensionFiltersInput] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
const [hasChanges, setHasChanges] = useState(false);
const [hasFilterChanges, setHasFilterChanges] = useState(false);
const [showWarning, setShowWarning] = useState(false);
const currentIgnorePatterns = patterns ?? [];
const currentExtensionFilters = extensionFilters ?? [];
const defaultIgnorePatterns = defaults?.patterns ?? [];
const defaultExtensionFilters = defaults?.extensionFilters ?? [];
// Initialize form from env - handles both undefined (loading) and empty string (empty file)
// The hook returns raw directly, so we check if it's been set (not undefined means data loaded)
useEffect(() => {
// Initialize when data is loaded (raw may be empty string but not undefined during loading)
// Note: During initial load, raw is undefined. After load completes, raw is set (even if empty string)
if (!isLoadingEnv) {
setEnvInput(raw ?? ''); // Use empty string if raw is undefined/null
setErrors({});
setHasChanges(false);
setShowWarning(false);
}
}, [raw, isLoadingEnv]);
useEffect(() => {
if (!isLoadingPatterns) {
const nextIgnorePatterns = patterns ?? [];
const nextExtensionFilters = extensionFilters ?? [];
setIgnorePatternsInput(nextIgnorePatterns.join('\n'));
setExtensionFiltersInput(nextExtensionFilters.join('\n'));
setErrors((prev) => ({
...prev,
ignorePatterns: undefined,
extensionFilters: undefined,
}));
setHasFilterChanges(false);
}
}, [extensionFilters, isLoadingPatterns, patterns]);
const updateFilterChangeState = (nextIgnorePatternsInput: string, nextExtensionFiltersInput: string) => {
const normalizedCurrentIgnorePatterns = normalizeListEntries(currentIgnorePatterns);
const normalizedCurrentExtensionFilters = normalizeListEntries(currentExtensionFilters);
setHasFilterChanges(
normalizeListText(nextIgnorePatternsInput) !== normalizedCurrentIgnorePatterns
|| normalizeListText(nextExtensionFiltersInput) !== normalizedCurrentExtensionFilters
);
};
const handleEnvChange = (value: string) => {
setEnvInput(value);
// Check if there are changes - compare with raw value (handle undefined as empty)
const currentRaw = raw ?? '';
setHasChanges(value !== currentRaw);
setShowWarning(value !== currentRaw);
if (errors.env) {
setErrors((prev) => ({ ...prev, env: undefined }));
}
};
const handleIgnorePatternsChange = (value: string) => {
setIgnorePatternsInput(value);
updateFilterChangeState(value, extensionFiltersInput);
if (errors.ignorePatterns) {
setErrors((prev) => ({ ...prev, ignorePatterns: undefined }));
}
};
const handleExtensionFiltersChange = (value: string) => {
setExtensionFiltersInput(value);
updateFilterChangeState(ignorePatternsInput, value);
if (errors.extensionFilters) {
setErrors((prev) => ({ ...prev, extensionFilters: undefined }));
}
};
const parseEnvVariables = (text: string): Record<string, string> => {
const envObj: Record<string, string> = {};
const lines = text.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
const [key, ...valParts] = trimmed.split('=');
const val = valParts.join('=');
if (key) {
envObj[key.trim()] = val.trim();
}
}
}
return envObj;
};
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
const parsed = parseEnvVariables(envInput);
// Check for invalid variable names
const invalidKeys = Object.keys(parsed).filter(
(key) => !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)
);
if (invalidKeys.length > 0) {
newErrors.env = formatMessage(
{ id: 'codexlens.advanced.validation.invalidKeys' },
{ keys: invalidKeys.join(', ') }
);
}
setErrors((prev) => ({ ...prev, env: newErrors.env }));
return Object.keys(newErrors).length === 0;
};
const validateFilterForm = (): boolean => {
const nextErrors: Pick<FormErrors, 'ignorePatterns' | 'extensionFilters'> = {};
const parsedIgnorePatterns = parseListEntries(ignorePatternsInput);
const parsedExtensionFilters = parseListEntries(extensionFiltersInput);
const invalidIgnorePatterns = parsedIgnorePatterns.filter(
(entry) => !FILTER_ENTRY_PATTERN.test(entry)
);
if (invalidIgnorePatterns.length > 0) {
nextErrors.ignorePatterns = formatMessage(
{
id: 'codexlens.advanced.validation.invalidIgnorePatterns',
defaultMessage: 'Invalid ignore patterns: {values}',
},
{ values: invalidIgnorePatterns.join(', ') }
);
}
const invalidExtensionFilters = parsedExtensionFilters.filter(
(entry) => !FILTER_ENTRY_PATTERN.test(entry)
);
if (invalidExtensionFilters.length > 0) {
nextErrors.extensionFilters = formatMessage(
{
id: 'codexlens.advanced.validation.invalidExtensionFilters',
defaultMessage: 'Invalid file filters: {values}',
},
{ values: invalidExtensionFilters.join(', ') }
);
}
setErrors((prev) => ({
...prev,
ignorePatterns: nextErrors.ignorePatterns,
extensionFilters: nextErrors.extensionFilters,
}));
return Object.values(nextErrors).every((value) => !value);
};
const handleSave = async () => {
if (!validateForm()) {
return;
}
try {
const parsed = parseEnvVariables(envInput);
const result = await updateEnv({ env: parsed });
if (result.success) {
success(
formatMessage({ id: 'codexlens.advanced.saveSuccess' }),
result.message || formatMessage({ id: 'codexlens.advanced.envUpdated' })
);
refetch();
setShowWarning(false);
} else {
showError(
formatMessage({ id: 'codexlens.advanced.saveFailed' }),
result.message || formatMessage({ id: 'codexlens.advanced.saveError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.advanced.saveFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.advanced.unknownError' })
);
}
};
const handleReset = () => {
// Reset to current raw value (handle undefined as empty)
setEnvInput(raw ?? '');
setErrors((prev) => ({ ...prev, env: undefined }));
setHasChanges(false);
setShowWarning(false);
};
const handleSaveFilters = async () => {
if (!validateFilterForm()) {
return;
}
const parsedIgnorePatterns = parseListEntries(ignorePatternsInput);
const parsedExtensionFilters = parseListEntries(extensionFiltersInput);
try {
const result = await updatePatterns({
patterns: parsedIgnorePatterns,
extensionFilters: parsedExtensionFilters,
});
if (result.success) {
setIgnorePatternsInput((result.patterns ?? parsedIgnorePatterns).join('\n'));
setExtensionFiltersInput((result.extensionFilters ?? parsedExtensionFilters).join('\n'));
setHasFilterChanges(false);
setErrors((prev) => ({
...prev,
ignorePatterns: undefined,
extensionFilters: undefined,
}));
await refetchPatterns();
}
} catch {
// Hook-level mutation already reports the error.
}
};
const handleResetFilters = () => {
setIgnorePatternsInput(currentIgnorePatterns.join('\n'));
setExtensionFiltersInput(currentExtensionFilters.join('\n'));
setErrors((prev) => ({
...prev,
ignorePatterns: undefined,
extensionFilters: undefined,
}));
setHasFilterChanges(false);
};
const handleRestoreDefaultFilters = () => {
const defaultIgnoreText = defaultIgnorePatterns.join('\n');
const defaultExtensionText = defaultExtensionFilters.join('\n');
setIgnorePatternsInput(defaultIgnoreText);
setExtensionFiltersInput(defaultExtensionText);
setErrors((prev) => ({
...prev,
ignorePatterns: undefined,
extensionFilters: undefined,
}));
updateFilterChangeState(defaultIgnoreText, defaultExtensionText);
};
const isLoading = isLoadingEnv;
const isLoadingFilters = isLoadingPatterns;
// Get current env variables as array for display
const currentEnvVars = env
? Object.entries(env).map(([key, value]) => ({ key, value }))
: [];
// Get settings variables
const settingsVars = settings
? Object.entries(settings).map(([key, value]) => ({ key, value }))
: [];
return (
<div className="space-y-6">
{/* Error Card */}
{envError && (
<Card className="p-4 bg-destructive/10 border-destructive/20">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-medium text-destructive-foreground">
{formatMessage({ id: 'codexlens.advanced.loadError' })}
</h4>
<p className="text-xs text-destructive-foreground/80 mt-1">
{envError.message || formatMessage({ id: 'codexlens.advanced.loadErrorDesc' })}
</p>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="mt-2"
>
<RefreshCw className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
</div>
</Card>
)}
{/* Sensitivity Warning Card */}
{showWarning && (
<Card className="p-4 bg-warning/10 border-warning/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-warning-foreground">
{formatMessage({ id: 'codexlens.advanced.warningTitle' })}
</h4>
<p className="text-xs text-warning-foreground/80 mt-1">
{formatMessage({ id: 'codexlens.advanced.warningMessage' })}
</p>
</div>
</div>
</Card>
)}
{/* Current Variables Summary */}
{(currentEnvVars.length > 0 || settingsVars.length > 0) && (
<Card className="p-4 bg-muted/30">
<h4 className="text-sm font-medium text-foreground mb-3">
{formatMessage({ id: 'codexlens.advanced.currentVars' })}
</h4>
<div className="space-y-3">
{settingsVars.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">
{formatMessage({ id: 'codexlens.advanced.settingsVars' })}
</p>
<div className="flex flex-wrap gap-2">
{settingsVars.map(({ key }) => (
<Badge key={key} variant="outline" className="font-mono text-xs">
{key}
</Badge>
))}
</div>
</div>
)}
{currentEnvVars.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">
{formatMessage({ id: 'codexlens.advanced.customVars' })}
</p>
<div className="flex flex-wrap gap-2">
{currentEnvVars.map(({ key }) => (
<Badge key={key} variant="secondary" className="font-mono text-xs">
{key}
</Badge>
))}
</div>
</div>
)}
</div>
</Card>
)}
{/* CCW Tools Card */}
<CcwToolsCard />
{/* Index Filters */}
<Card className="p-6">
<div className="flex flex-col gap-3 mb-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="text-lg font-semibold text-foreground">
{formatMessage({
id: 'codexlens.advanced.indexFilters',
defaultMessage: 'Index Filters',
})}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="text-xs">
{formatMessage(
{
id: 'codexlens.advanced.ignorePatternCount',
defaultMessage: 'Directory filters: {count}',
},
{ count: currentIgnorePatterns.length }
)}
</Badge>
<Badge variant="outline" className="text-xs">
{formatMessage(
{
id: 'codexlens.advanced.extensionFilterCount',
defaultMessage: 'File filters: {count}',
},
{ count: currentExtensionFilters.length }
)}
</Badge>
</div>
</div>
<div className="space-y-4">
{patternsError && (
<div className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 text-destructive" />
<div>
<p className="text-sm font-medium text-destructive">
{formatMessage({
id: 'codexlens.advanced.filtersLoadError',
defaultMessage: 'Unable to load current filter settings',
})}
</p>
<p className="text-xs text-destructive/80 mt-1">{patternsError.message}</p>
</div>
</div>
</div>
)}
<div className="grid gap-4 xl:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ignore-patterns-input">
{formatMessage({
id: 'codexlens.advanced.ignorePatterns',
defaultMessage: 'Ignored directories / paths',
})}
</Label>
<Textarea
id="ignore-patterns-input"
value={ignorePatternsInput}
onChange={(event) => handleIgnorePatternsChange(event.target.value)}
placeholder={formatMessage({
id: 'codexlens.advanced.ignorePatternsPlaceholder',
defaultMessage: 'dist\nfrontend/dist\ncoverage',
})}
className={cn(
'min-h-[220px] font-mono text-sm',
errors.ignorePatterns && 'border-destructive focus-visible:ring-destructive'
)}
disabled={isLoadingFilters || isUpdatingPatterns}
/>
{errors.ignorePatterns && (
<p className="text-sm text-destructive">{errors.ignorePatterns}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({
id: 'codexlens.advanced.ignorePatternsHint',
defaultMessage: 'One entry per line. Supports exact names, relative paths, and glob patterns.',
})}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="extension-filters-input">
{formatMessage({
id: 'codexlens.advanced.extensionFilters',
defaultMessage: 'Skipped files / globs',
})}
</Label>
<Textarea
id="extension-filters-input"
value={extensionFiltersInput}
onChange={(event) => handleExtensionFiltersChange(event.target.value)}
placeholder={formatMessage({
id: 'codexlens.advanced.extensionFiltersPlaceholder',
defaultMessage: '*.min.js\n*.map\npackage-lock.json',
})}
className={cn(
'min-h-[220px] font-mono text-sm',
errors.extensionFilters && 'border-destructive focus-visible:ring-destructive'
)}
disabled={isLoadingFilters || isUpdatingPatterns}
/>
{errors.extensionFilters && (
<p className="text-sm text-destructive">{errors.extensionFilters}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({
id: 'codexlens.advanced.extensionFiltersHint',
defaultMessage: 'Use this for generated or low-value files that should stay out of the index.',
})}
</p>
</div>
</div>
<div className="grid gap-3 rounded-md border border-border/60 bg-muted/30 p-3 md:grid-cols-2">
<div>
<p className="text-xs font-medium text-foreground">
{formatMessage({
id: 'codexlens.advanced.defaultIgnorePatterns',
defaultMessage: 'Default directory filters',
})}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{defaultIgnorePatterns.slice(0, 6).map((pattern) => (
<Badge key={pattern} variant="secondary" className="font-mono text-xs">
{pattern}
</Badge>
))}
{defaultIgnorePatterns.length > 6 && (
<Badge variant="outline" className="text-xs">
+{defaultIgnorePatterns.length - 6}
</Badge>
)}
</div>
</div>
<div>
<p className="text-xs font-medium text-foreground">
{formatMessage({
id: 'codexlens.advanced.defaultExtensionFilters',
defaultMessage: 'Default file filters',
})}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{defaultExtensionFilters.slice(0, 6).map((pattern) => (
<Badge key={pattern} variant="secondary" className="font-mono text-xs">
{pattern}
</Badge>
))}
{defaultExtensionFilters.length > 6 && (
<Badge variant="outline" className="text-xs">
+{defaultExtensionFilters.length - 6}
</Badge>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={handleSaveFilters}
disabled={isLoadingFilters || isUpdatingPatterns || !hasFilterChanges}
>
<Save className={cn('w-4 h-4 mr-2', isUpdatingPatterns && 'animate-spin')} />
{isUpdatingPatterns
? formatMessage({ id: 'codexlens.advanced.saving', defaultMessage: 'Saving...' })
: formatMessage({
id: 'codexlens.advanced.saveFilters',
defaultMessage: 'Save filters',
})
}
</Button>
<Button
variant="outline"
onClick={handleResetFilters}
disabled={isLoadingFilters || !hasFilterChanges}
>
<RefreshCw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.advanced.reset', defaultMessage: 'Reset' })}
</Button>
<Button
variant="outline"
onClick={handleRestoreDefaultFilters}
disabled={isLoadingFilters || isUpdatingPatterns}
>
{formatMessage({
id: 'codexlens.advanced.restoreDefaults',
defaultMessage: 'Restore defaults',
})}
</Button>
</div>
</div>
</Card>
{/* Environment Variables Editor */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FileCode className="w-5 h-5 text-muted-foreground" />
<h3 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'codexlens.advanced.envEditor' })}
</h3>
</div>
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'codexlens.advanced.envFile' })}: .env
</Badge>
</div>
<div className="space-y-4">
{/* Env Textarea */}
<div className="space-y-2">
<Label htmlFor="env-input">
{formatMessage({ id: 'codexlens.advanced.envContent' })}
</Label>
<Textarea
id="env-input"
value={envInput}
onChange={(e) => handleEnvChange(e.target.value)}
placeholder={formatMessage({ id: 'codexlens.advanced.envPlaceholder' })}
className={cn(
'min-h-[300px] font-mono text-sm',
errors.env && 'border-destructive focus-visible:ring-destructive'
)}
disabled={isLoading}
/>
{errors.env && (
<p className="text-sm text-destructive">{errors.env}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.advanced.envHint' })}
</p>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<Button
onClick={handleSave}
disabled={isLoading || isUpdating || !hasChanges}
>
<Save className={cn('w-4 h-4 mr-2', isUpdating && 'animate-spin')} />
{isUpdating
? formatMessage({ id: 'codexlens.advanced.saving' })
: formatMessage({ id: 'codexlens.advanced.save' })
}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={isLoading || !hasChanges}
>
<RefreshCw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.advanced.reset' })}
</Button>
</div>
</div>
</Card>
{/* Help Card */}
<Card className="p-4 bg-info/10 border-info/20">
<h4 className="text-sm font-medium text-info-foreground mb-2">
{formatMessage({ id: 'codexlens.advanced.helpTitle' })}
</h4>
<ul className="text-xs text-info-foreground/80 space-y-1">
<li> {formatMessage({ id: 'codexlens.advanced.helpComment' })}</li>
<li> {formatMessage({ id: 'codexlens.advanced.helpFormat' })}</li>
<li> {formatMessage({ id: 'codexlens.advanced.helpQuotes' })}</li>
<li> {formatMessage({ id: 'codexlens.advanced.helpRestart' })}</li>
</ul>
</Card>
</div>
);
}
export default AdvancedTab;

View File

@@ -1,153 +0,0 @@
// ========================================
// CCW Tools Card Component
// ========================================
// Displays all registered CCW tools, highlighting codex-lens related tools
import { useIntl } from 'react-intl';
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { useCcwToolsList } from '@/hooks';
import type { CcwToolInfo } from '@/lib/api';
const CODEX_LENS_PREFIX = 'codex_lens';
function isCodexLensTool(tool: CcwToolInfo): boolean {
return tool.name.startsWith(CODEX_LENS_PREFIX);
}
export function CcwToolsCard() {
const { formatMessage } = useIntl();
const { tools, isLoading, error } = useCcwToolsList();
const codexLensTools = tools.filter(isCodexLensTool);
const otherTools = tools.filter((t) => !isCodexLensTool(t));
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{formatMessage({ id: 'codexlens.mcp.loading' })}</span>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card className="border-destructive/20">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-2 text-sm">
<AlertCircle className="w-4 h-4 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">
{formatMessage({ id: 'codexlens.mcp.error' })}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'codexlens.mcp.errorDesc' })}
</p>
</div>
</div>
</CardContent>
</Card>
);
}
if (tools.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.mcp.emptyDesc' })}
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
<Wrench className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.mcp.title' })}</span>
</div>
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'codexlens.mcp.totalCount' }, { count: tools.length })}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* CodexLens Tools Section */}
{codexLensTools.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
{formatMessage({ id: 'codexlens.mcp.codexLensSection' })}
</p>
<div className="space-y-1.5">
{codexLensTools.map((tool) => (
<ToolRow key={tool.name} tool={tool} variant="default" />
))}
</div>
</div>
)}
{/* Other Tools Section */}
{otherTools.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
{formatMessage({ id: 'codexlens.mcp.otherSection' })}
</p>
<div className="space-y-1.5">
{otherTools.map((tool) => (
<ToolRow key={tool.name} tool={tool} variant="secondary" />
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface ToolRowProps {
tool: CcwToolInfo;
variant: 'default' | 'secondary';
}
function ToolRow({ tool, variant }: ToolRowProps) {
return (
<div className="flex items-center gap-2 py-1 px-2 rounded-md hover:bg-muted/50 transition-colors">
<Badge variant={variant} className="text-xs font-mono shrink-0">
{tool.name}
</Badge>
<span className="text-xs text-muted-foreground truncate" title={tool.description}>
{tool.description}
</span>
</div>
);
}
export default CcwToolsCard;

View File

@@ -1,135 +0,0 @@
// ========================================
// CodexLens File Watcher Card
// ========================================
// Displays file watcher status, stats, and toggle control
import { useIntl } from 'react-intl';
import {
Eye,
EyeOff,
Activity,
Clock,
FolderOpen,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import { useCodexLensWatcher, useCodexLensWatcherMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
interface FileWatcherCardProps {
disabled?: boolean;
}
export function FileWatcherCard({ disabled = false }: FileWatcherCardProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const { running, rootPath, eventsProcessed, uptimeSeconds, isLoading } = useCodexLensWatcher();
const { startWatcher, stopWatcher, isStarting, isStopping } = useCodexLensWatcherMutations();
const isMutating = isStarting || isStopping;
const handleToggle = async () => {
if (running) {
await stopWatcher();
} else {
await startWatcher(projectPath);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.watcher.title' })}</span>
</div>
<Badge variant={running ? 'success' : 'secondary'}>
{running
? formatMessage({ id: 'codexlens.watcher.status.running' })
: formatMessage({ id: 'codexlens.watcher.status.stopped' })
}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 text-sm">
<Activity className={cn('w-4 h-4', running ? 'text-success' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.watcher.eventsProcessed' })}
</p>
<p className="font-semibold text-foreground">{eventsProcessed}</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Clock className={cn('w-4 h-4', running ? 'text-info' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.watcher.uptime' })}
</p>
<p className="font-semibold text-foreground">
{running ? formatUptime(uptimeSeconds) : '--'}
</p>
</div>
</div>
</div>
{/* Watched Path */}
{running && rootPath && (
<div className="flex items-center gap-2 text-sm">
<FolderOpen className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground truncate" title={rootPath}>
{rootPath}
</span>
</div>
)}
{/* Toggle Button */}
<Button
variant={running ? 'outline' : 'default'}
size="sm"
className="w-full"
onClick={handleToggle}
disabled={disabled || isMutating || isLoading}
>
{running ? (
<>
<EyeOff className="w-4 h-4 mr-2" />
{isStopping
? formatMessage({ id: 'codexlens.watcher.stopping' })
: formatMessage({ id: 'codexlens.watcher.stop' })
}
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
{isStarting
? formatMessage({ id: 'codexlens.watcher.starting' })
: formatMessage({ id: 'codexlens.watcher.start' })
}
</>
)}
</Button>
</CardContent>
</Card>
);
}
export default FileWatcherCard;

View File

@@ -1,293 +0,0 @@
// ========================================
// CodexLens GPU Selector
// ========================================
// GPU detection, listing, and selection component
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Cpu, Search, Check, X, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useCodexLensGpu, useSelectGpu } from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import type { CodexLensGpuDevice } from '@/lib/api';
interface GpuSelectorProps {
enabled?: boolean;
compact?: boolean;
}
export function GpuSelector({ enabled = true, compact = false }: GpuSelectorProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const {
supported,
devices,
selectedDeviceId,
isLoadingDetect,
isLoadingList,
refetch,
} = useCodexLensGpu({ enabled });
const { selectGpu, resetGpu, isSelecting, isResetting } = useSelectGpu();
const [isDetecting, setIsDetecting] = useState(false);
const isLoading = isLoadingDetect || isLoadingList || isDetecting;
const handleDetect = async () => {
setIsDetecting(true);
try {
await refetch();
success(
formatMessage({ id: 'codexlens.gpu.detectSuccess' }),
formatMessage({ id: 'codexlens.gpu.detectComplete' }, { count: devices?.length ?? 0 })
);
} catch (err) {
showError(
formatMessage({ id: 'codexlens.gpu.detectFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.detectError' })
);
} finally {
setIsDetecting(false);
}
};
const handleSelect = async (deviceId: string | number) => {
try {
const result = await selectGpu(deviceId);
if (result.success) {
success(
formatMessage({ id: 'codexlens.gpu.selectSuccess' }),
result.message || formatMessage({ id: 'codexlens.gpu.gpuSelected' })
);
refetch();
} else {
showError(
formatMessage({ id: 'codexlens.gpu.selectFailed' }),
result.message || formatMessage({ id: 'codexlens.gpu.selectError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.gpu.selectFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.unknownError' })
);
}
};
const handleReset = async () => {
try {
const result = await resetGpu();
if (result.success) {
success(
formatMessage({ id: 'codexlens.gpu.resetSuccess' }),
result.message || formatMessage({ id: 'codexlens.gpu.gpuReset' })
);
refetch();
} else {
showError(
formatMessage({ id: 'codexlens.gpu.resetFailed' }),
result.message || formatMessage({ id: 'codexlens.gpu.resetError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.gpu.resetFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.gpu.unknownError' })
);
}
};
if (compact) {
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.gpu.status' })}:
</span>
{supported !== false ? (
selectedDeviceId !== undefined ? (
<Badge variant="success" className="text-xs">
{formatMessage({ id: 'codexlens.gpu.enabled' })}
</Badge>
) : (
<Badge variant="outline" className="text-xs">
{formatMessage({ id: 'codexlens.gpu.available' })}
</Badge>
)
) : (
<Badge variant="secondary" className="text-xs">
{formatMessage({ id: 'codexlens.gpu.unavailable' })}
</Badge>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDetect}
disabled={isLoading}
>
<Search className={cn('w-3 h-3 mr-1', isLoading && 'animate-spin')} />
{formatMessage({ id: 'codexlens.gpu.detect' })}
</Button>
{selectedDeviceId !== undefined && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isResetting}
>
<X className="w-3 h-3 mr-1" />
{formatMessage({ id: 'codexlens.gpu.reset' })}
</Button>
)}
</div>
</div>
</Card>
);
}
return (
<div className="space-y-4">
{/* Header Card */}
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
supported !== false ? 'bg-success/10' : 'bg-secondary'
)}>
<Cpu className={cn(
'w-5 h-5',
supported !== false ? 'text-success' : 'text-muted-foreground'
)} />
</div>
<div>
<h4 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'codexlens.gpu.title' })}
</h4>
<p className="text-xs text-muted-foreground">
{supported !== false
? formatMessage({ id: 'codexlens.gpu.supported' })
: formatMessage({ id: 'codexlens.gpu.notSupported' })
}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDetect}
disabled={isLoading}
>
<Search className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
{formatMessage({ id: 'codexlens.gpu.detect' })}
</Button>
{selectedDeviceId !== undefined && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isResetting}
>
<RefreshCw className={cn('w-4 h-4 mr-2', isResetting && 'animate-spin')} />
{formatMessage({ id: 'codexlens.gpu.reset' })}
</Button>
)}
</div>
</div>
</Card>
{/* Device List */}
{devices && devices.length > 0 ? (
<div className="space-y-2">
{devices.map((device) => {
const deviceId = device.device_id ?? device.index;
return (
<DeviceCard
key={deviceId}
device={device}
isSelected={deviceId === selectedDeviceId}
onSelect={() => handleSelect(deviceId)}
isSelecting={isSelecting}
/>
);
})}
</div>
) : (
<Card className="p-8 text-center">
<Cpu className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">
{supported !== false
? formatMessage({ id: 'codexlens.gpu.noDevices' })
: formatMessage({ id: 'codexlens.gpu.notAvailable' })
}
</p>
</Card>
)}
</div>
);
}
interface DeviceCardProps {
device: CodexLensGpuDevice;
isSelected: boolean;
onSelect: () => void;
isSelecting: boolean;
}
function DeviceCard({ device, isSelected, onSelect, isSelecting }: DeviceCardProps) {
const { formatMessage } = useIntl();
return (
<Card className={cn(
'p-4 transition-colors',
isSelected && 'border-primary bg-primary/5'
)}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h5 className="text-sm font-medium text-foreground">
{device.name || formatMessage({ id: 'codexlens.gpu.unknownDevice' })}
</h5>
{isSelected && (
<Badge variant="success" className="text-xs">
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'codexlens.gpu.selected' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.gpu.type' })}: {formatMessage({ id: device.type === 'discrete' ? 'codexlens.gpu.discrete' : 'codexlens.gpu.integrated' })}
</p>
{device.memory?.total && (
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.gpu.memory' })}: {(device.memory.total / 1024).toFixed(1)} GB
</p>
)}
</div>
<Button
variant={isSelected ? 'outline' : 'default'}
size="sm"
onClick={onSelect}
disabled={isSelected || isSelecting}
>
{isSelected
? formatMessage({ id: 'codexlens.gpu.active' })
: formatMessage({ id: 'codexlens.gpu.select' })
}
</Button>
</div>
</Card>
);
}
export default GpuSelector;

View File

@@ -1,286 +0,0 @@
// ========================================
// CodexLens Index Operations Component
// ========================================
// Index management operations with progress tracking
import { useIntl } from 'react-intl';
import { useEffect, useState } from 'react';
import {
RotateCw,
Zap,
AlertCircle,
CheckCircle2,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Progress } from '@/components/ui/Progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { cn } from '@/lib/utils';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import {
useCodexLensIndexingStatus,
useRebuildIndex,
useUpdateIndex,
useCancelIndexing,
} from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import { useWebSocket } from '@/hooks/useWebSocket';
interface IndexOperationsProps {
disabled?: boolean;
onRefresh?: () => void;
}
interface IndexProgress {
stage: string;
message: string;
percent: number;
path?: string;
}
type IndexOperation = {
id: string;
type: 'fts_full' | 'fts_incremental' | 'vector_full' | 'vector_incremental';
label: string;
description: string;
icon: React.ReactNode;
};
export function IndexOperations({ disabled = false, onRefresh }: IndexOperationsProps) {
const { formatMessage } = useIntl();
const { success, error: showError, wsLastMessage } = useNotifications();
const projectPath = useWorkflowStore(selectProjectPath);
const { inProgress } = useCodexLensIndexingStatus();
const { rebuildIndex, isRebuilding } = useRebuildIndex();
const { updateIndex, isUpdating } = useUpdateIndex();
const { cancelIndexing, isCancelling } = useCancelIndexing();
useWebSocket();
const [indexProgress, setIndexProgress] = useState<IndexProgress | null>(null);
const [activeOperation, setActiveOperation] = useState<string | null>(null);
// Listen for WebSocket progress updates
useEffect(() => {
if (wsLastMessage?.type === 'CODEXLENS_INDEX_PROGRESS') {
const progress = wsLastMessage.payload as IndexProgress;
setIndexProgress(progress);
// Clear active operation when complete or error
if (progress.stage === 'complete' || progress.stage === 'error' || progress.stage === 'cancelled') {
if (progress.stage === 'complete') {
success(
formatMessage({ id: 'codexlens.index.operationComplete' }),
progress.message
);
onRefresh?.();
} else if (progress.stage === 'error') {
showError(
formatMessage({ id: 'codexlens.index.operationFailed' }),
progress.message
);
}
setActiveOperation(null);
setIndexProgress(null);
}
}
}, [wsLastMessage, formatMessage, success, showError, onRefresh]);
const isOperating = isRebuilding || isUpdating || inProgress || !!activeOperation;
const handleOperation = async (operation: IndexOperation) => {
if (!projectPath) {
showError(
formatMessage({ id: 'codexlens.index.noProject' }),
formatMessage({ id: 'codexlens.index.noProjectDesc' })
);
return;
}
setActiveOperation(operation.id);
setIndexProgress({ stage: 'start', message: formatMessage({ id: 'codexlens.index.starting' }), percent: 0 });
try {
// Determine index type and operation
const isVector = operation.type.includes('vector');
const isIncremental = operation.type.includes('incremental');
if (isIncremental) {
const result = await updateIndex(projectPath, {
indexType: isVector ? 'vector' : 'normal',
});
if (!result.success) {
throw new Error(result.error || 'Update failed');
}
} else {
const result = await rebuildIndex(projectPath, {
indexType: isVector ? 'vector' : 'normal',
});
if (!result.success) {
throw new Error(result.error || 'Rebuild failed');
}
}
} catch (err) {
setActiveOperation(null);
setIndexProgress(null);
showError(
formatMessage({ id: 'codexlens.index.operationFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.index.unknownError' })
);
}
};
const handleCancel = async () => {
const result = await cancelIndexing();
if (result.success) {
setActiveOperation(null);
setIndexProgress(null);
} else {
showError(
formatMessage({ id: 'codexlens.index.cancelFailed' }),
result.error || formatMessage({ id: 'codexlens.index.unknownError' })
);
}
};
const operations: IndexOperation[] = [
{
id: 'fts_full',
type: 'fts_full',
label: formatMessage({ id: 'codexlens.overview.actions.ftsFull' }),
description: formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' }),
icon: <RotateCw className="w-4 h-4" />,
},
{
id: 'fts_incremental',
type: 'fts_incremental',
label: formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' }),
description: formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' }),
icon: <Zap className="w-4 h-4" />,
},
{
id: 'vector_full',
type: 'vector_full',
label: formatMessage({ id: 'codexlens.overview.actions.vectorFull' }),
description: formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' }),
icon: <RotateCw className="w-4 h-4" />,
},
{
id: 'vector_incremental',
type: 'vector_incremental',
label: formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' }),
description: formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' }),
icon: <Zap className="w-4 h-4" />,
},
];
if (indexProgress && activeOperation) {
const operation = operations.find((op) => op.id === activeOperation);
const isComplete = indexProgress.stage === 'complete';
const isError = indexProgress.stage === 'error';
const isCancelled = indexProgress.stage === 'cancelled';
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<span>{operation?.label}</span>
{!isComplete && !isError && !isCancelled && (
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
disabled={isCancelling}
>
<X className="w-4 h-4 mr-1" />
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Status Icon */}
<div className="flex items-center gap-3">
{isComplete ? (
<CheckCircle2 className="w-6 h-6 text-success" />
) : isError || isCancelled ? (
<AlertCircle className="w-6 h-6 text-destructive" />
) : (
<RotateCw className="w-6 h-6 text-primary animate-spin" />
)}
<div className="flex-1">
<p className="text-sm font-medium text-foreground">
{isComplete
? formatMessage({ id: 'codexlens.index.complete' })
: isError
? formatMessage({ id: 'codexlens.index.failed' })
: isCancelled
? formatMessage({ id: 'codexlens.index.cancelled' })
: formatMessage({ id: 'codexlens.index.inProgress' })}
</p>
<p className="text-xs text-muted-foreground mt-1">{indexProgress.message}</p>
</div>
</div>
{/* Progress Bar */}
{!isComplete && !isError && !isCancelled && (
<div className="space-y-2">
<Progress value={indexProgress.percent} className="h-2" />
<p className="text-xs text-muted-foreground text-right">
{indexProgress.percent}%
</p>
</div>
)}
{/* Close Button */}
{(isComplete || isError || isCancelled) && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setActiveOperation(null);
setIndexProgress(null);
}}
>
{formatMessage({ id: 'common.actions.close' })}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">
{formatMessage({ id: 'codexlens.overview.actions.title' })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{operations.map((operation) => (
<Button
key={operation.id}
variant="outline"
className="h-auto p-4 flex flex-col items-start gap-2 text-left"
onClick={() => handleOperation(operation)}
disabled={disabled || isOperating}
>
<div className="flex items-center gap-2 w-full">
<span className={cn('text-muted-foreground', (disabled || isOperating) && 'opacity-50')}>
{operation.icon}
</span>
<span className="font-medium">{operation.label}</span>
</div>
<p className="text-xs text-muted-foreground">{operation.description}</p>
</Button>
))}
</div>
</CardContent>
</Card>
);
}
export default IndexOperations;

View File

@@ -1,256 +0,0 @@
// ========================================
// CodexLens Install Progress Overlay
// ========================================
// Dialog overlay showing 5-stage simulated progress during CodexLens bootstrap installation
import { useState, useEffect, useRef, useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
Check,
Download,
Info,
Loader2,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Progress } from '@/components/ui/Progress';
import { Card, CardContent } from '@/components/ui/Card';
// ----------------------------------------
// Types
// ----------------------------------------
interface InstallStage {
progress: number;
messageId: string;
}
interface InstallProgressOverlayProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onInstall: () => Promise<{ success: boolean }>;
onSuccess?: () => void;
}
// ----------------------------------------
// Constants
// ----------------------------------------
const INSTALL_STAGES: InstallStage[] = [
{ progress: 10, messageId: 'codexlens.install.stage.creatingVenv' },
{ progress: 30, messageId: 'codexlens.install.stage.installingPip' },
{ progress: 50, messageId: 'codexlens.install.stage.installingPackage' },
{ progress: 70, messageId: 'codexlens.install.stage.settingUpDeps' },
{ progress: 90, messageId: 'codexlens.install.stage.finalizing' },
];
const STAGE_INTERVAL_MS = 1500;
// ----------------------------------------
// Checklist items
// ----------------------------------------
interface ChecklistItem {
labelId: string;
descId: string;
}
const CHECKLIST_ITEMS: ChecklistItem[] = [
{ labelId: 'codexlens.install.pythonVenv', descId: 'codexlens.install.pythonVenvDesc' },
{ labelId: 'codexlens.install.codexlensPackage', descId: 'codexlens.install.codexlensPackageDesc' },
{ labelId: 'codexlens.install.sqliteFts', descId: 'codexlens.install.sqliteFtsDesc' },
];
// ----------------------------------------
// Component
// ----------------------------------------
export function InstallProgressOverlay({
open,
onOpenChange,
onInstall,
onSuccess,
}: InstallProgressOverlayProps) {
const { formatMessage } = useIntl();
const [isInstalling, setIsInstalling] = useState(false);
const [progress, setProgress] = useState(0);
const [stageText, setStageText] = useState('');
const [isComplete, setIsComplete] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const clearStageInterval = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setIsInstalling(false);
setProgress(0);
setStageText('');
setIsComplete(false);
clearStageInterval();
}
}, [open, clearStageInterval]);
// Cleanup on unmount
useEffect(() => {
return () => clearStageInterval();
}, [clearStageInterval]);
const handleInstall = async () => {
setIsInstalling(true);
setProgress(0);
setIsComplete(false);
// Start stage simulation
let currentStage = 0;
setStageText(formatMessage({ id: INSTALL_STAGES[0].messageId }));
setProgress(INSTALL_STAGES[0].progress);
currentStage = 1;
intervalRef.current = setInterval(() => {
if (currentStage < INSTALL_STAGES.length) {
setStageText(formatMessage({ id: INSTALL_STAGES[currentStage].messageId }));
setProgress(INSTALL_STAGES[currentStage].progress);
currentStage++;
}
}, STAGE_INTERVAL_MS);
try {
const result = await onInstall();
clearStageInterval();
if (result.success) {
setProgress(100);
setStageText(formatMessage({ id: 'codexlens.install.stage.complete' }));
setIsComplete(true);
// Auto-close after showing completion
setTimeout(() => {
onOpenChange(false);
onSuccess?.();
}, 1200);
} else {
setIsInstalling(false);
setProgress(0);
setStageText('');
}
} catch {
clearStageInterval();
setIsInstalling(false);
setProgress(0);
setStageText('');
}
};
return (
<Dialog open={open} onOpenChange={isInstalling ? undefined : onOpenChange}>
<DialogContent className="max-w-lg" onPointerDownOutside={isInstalling ? (e) => e.preventDefault() : undefined}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5 text-primary" />
{formatMessage({ id: 'codexlens.install.title' })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'codexlens.install.description' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Install Checklist */}
<div>
<h4 className="text-sm font-medium mb-2">
{formatMessage({ id: 'codexlens.install.checklist' })}
</h4>
<ul className="space-y-2">
{CHECKLIST_ITEMS.map((item) => (
<li key={item.labelId} className="flex items-start gap-2 text-sm">
<Check className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span>
<strong>{formatMessage({ id: item.labelId })}</strong>
{' - '}
{formatMessage({ id: item.descId })}
</span>
</li>
))}
</ul>
</div>
{/* Install Location Info */}
<Card className="bg-primary/5 border-primary/20">
<CardContent className="p-3 flex items-start gap-2">
<Info className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">
{formatMessage({ id: 'codexlens.install.location' })}
</p>
<p className="mt-1">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
{formatMessage({ id: 'codexlens.install.locationPath' })}
</code>
</p>
<p className="mt-1">
{formatMessage({ id: 'codexlens.install.timeEstimate' })}
</p>
</div>
</CardContent>
</Card>
{/* Progress Section - shown during install */}
{isInstalling && (
<div className="space-y-2">
<div className="flex items-center gap-3">
{isComplete ? (
<Check className="w-5 h-5 text-green-500" />
) : (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
<span className="text-sm">{stageText}</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isInstalling}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleInstall}
disabled={isInstalling}
>
{isInstalling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'codexlens.install.installing' })}
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.install.installNow' })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default InstallProgressOverlay;

View File

@@ -1,157 +0,0 @@
// ========================================
// CodexLens LSP Server Card
// ========================================
// Displays LSP server status, stats, and start/stop/restart controls
import { useIntl } from 'react-intl';
import {
Server,
Power,
PowerOff,
RotateCw,
FolderOpen,
Layers,
Cpu,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import { useCodexLensLspStatus, useCodexLensLspMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
interface LspServerCardProps {
disabled?: boolean;
}
export function LspServerCard({ disabled = false }: LspServerCardProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const {
available,
semanticAvailable,
projectCount,
modes,
isLoading,
} = useCodexLensLspStatus();
const { startLsp, stopLsp, restartLsp, isStarting, isStopping, isRestarting } = useCodexLensLspMutations();
const isMutating = isStarting || isStopping || isRestarting;
const handleToggle = async () => {
if (available) {
await stopLsp(projectPath);
} else {
await startLsp(projectPath);
}
};
const handleRestart = async () => {
await restartLsp(projectPath);
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="w-4 h-4" />
<span>{formatMessage({ id: 'codexlens.lsp.title' })}</span>
</div>
<Badge variant={available ? 'success' : 'secondary'}>
{available
? formatMessage({ id: 'codexlens.lsp.status.running' })
: formatMessage({ id: 'codexlens.lsp.status.stopped' })
}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-3">
<div className="flex items-center gap-2 text-sm">
<FolderOpen className={cn('w-4 h-4', available ? 'text-success' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.lsp.projects' })}
</p>
<p className="font-semibold text-foreground">
{available ? projectCount : '--'}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Cpu className={cn('w-4 h-4', semanticAvailable ? 'text-info' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.lsp.semanticAvailable' })}
</p>
<p className="font-semibold text-foreground">
{semanticAvailable
? formatMessage({ id: 'codexlens.lsp.available' })
: formatMessage({ id: 'codexlens.lsp.unavailable' })
}
</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Layers className={cn('w-4 h-4', available && modes.length > 0 ? 'text-accent' : 'text-muted-foreground')} />
<div>
<p className="text-muted-foreground text-xs">
{formatMessage({ id: 'codexlens.lsp.modes' })}
</p>
<p className="font-semibold text-foreground">
{available ? modes.length : '--'}
</p>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<Button
variant={available ? 'outline' : 'default'}
size="sm"
className="flex-1"
onClick={handleToggle}
disabled={disabled || isMutating || isLoading}
>
{available ? (
<>
<PowerOff className="w-4 h-4 mr-2" />
{isStopping
? formatMessage({ id: 'codexlens.lsp.stopping' })
: formatMessage({ id: 'codexlens.lsp.stop' })
}
</>
) : (
<>
<Power className="w-4 h-4 mr-2" />
{isStarting
? formatMessage({ id: 'codexlens.lsp.starting' })
: formatMessage({ id: 'codexlens.lsp.start' })
}
</>
)}
</Button>
{available && (
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={disabled || isMutating || isLoading}
>
<RotateCw className={cn('w-4 h-4 mr-2', isRestarting && 'animate-spin')} />
{isRestarting
? formatMessage({ id: 'codexlens.lsp.restarting' })
: formatMessage({ id: 'codexlens.lsp.restart' })
}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
export default LspServerCard;

View File

@@ -1,234 +0,0 @@
// ========================================
// Model Card Component
// ========================================
// Individual model display card with actions
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Download,
Trash2,
Package,
HardDrive,
X,
Loader2,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Progress } from '@/components/ui/Progress';
import { Input } from '@/components/ui/Input';
import type { CodexLensModel } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface ModelCardProps {
model: CodexLensModel;
isDownloading?: boolean;
downloadProgress?: number;
isDeleting?: boolean;
onDownload: (profile: string) => void;
onDelete: (profile: string) => void;
onCancelDownload?: () => void;
}
// ========== Helper Functions ==========
function getModelTypeVariant(type: 'embedding' | 'reranker'): 'default' | 'secondary' {
return type === 'embedding' ? 'default' : 'secondary';
}
function formatSize(size?: string): string {
if (!size) return '-';
return size;
}
// ========== Component ==========
export function ModelCard({
model,
isDownloading = false,
downloadProgress = 0,
isDeleting = false,
onDownload,
onDelete,
onCancelDownload,
}: ModelCardProps) {
const { formatMessage } = useIntl();
const handleDownload = () => {
onDownload(model.profile);
};
const handleDelete = () => {
if (confirm(formatMessage({ id: 'codexlens.models.deleteConfirm' }, { modelName: model.name }))) {
onDelete(model.profile);
}
};
return (
<Card className={cn('overflow-hidden hover-glow', !model.installed && 'opacity-80')}>
{/* Header */}
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={cn(
'p-2 rounded-lg flex-shrink-0',
model.installed ? 'bg-success/10' : 'bg-muted'
)}>
{model.installed ? (
<HardDrive className="w-4 h-4 text-success" />
) : (
<Package className="w-4 h-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">
{model.name}
</span>
<Badge
variant={getModelTypeVariant(model.type)}
className="text-xs flex-shrink-0"
>
{model.type}
</Badge>
<Badge
variant={model.installed ? 'success' : 'outline'}
className="text-xs flex-shrink-0"
>
{model.installed
? formatMessage({ id: 'codexlens.models.status.downloaded' })
: formatMessage({ id: 'codexlens.models.status.available' })
}
</Badge>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{model.dimensions && <span>{model.dimensions}d</span>}
<span>{formatSize(model.size)}</span>
{model.recommended && (
<Badge variant="success" className="text-[10px] px-1 py-0">Rec</Badge>
)}
</div>
{model.description && (
<p className="text-xs text-muted-foreground mt-1">
{model.description}
</p>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-1 flex-shrink-0">
{isDownloading ? (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onCancelDownload}
title={formatMessage({ id: 'codexlens.models.actions.cancel' })}
>
<X className="w-4 h-4" />
</Button>
) : model.installed ? (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={handleDelete}
disabled={isDeleting}
title={formatMessage({ id: 'codexlens.models.actions.delete' })}
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={handleDownload}
title={formatMessage({ id: 'codexlens.models.actions.download' })}
>
<Download className="w-4 h-4 mr-1" />
<span className="text-xs">{formatMessage({ id: 'codexlens.models.actions.download' })}</span>
</Button>
)}
</div>
</div>
{/* Download Progress */}
{isDownloading && (
<div className="mt-3 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.models.downloading' })}
</span>
<span className="font-medium">{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
</div>
)}
</div>
</Card>
);
}
// ========== Custom Model Input ==========
export interface CustomModelInputProps {
isDownloading: boolean;
onDownload: (modelName: string, modelType: 'embedding' | 'reranker') => void;
}
export function CustomModelInput({ isDownloading, onDownload }: CustomModelInputProps) {
const { formatMessage } = useIntl();
const [modelName, setModelName] = useState('');
const [modelType, setModelType] = useState<'embedding' | 'reranker'>('embedding');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (modelName.trim()) {
onDownload(modelName.trim(), modelType);
setModelName('');
}
};
return (
<Card className="p-4 bg-primary/5 border-primary/20">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<Package className="w-4 h-4 text-primary" />
{formatMessage({ id: 'codexlens.models.custom.title' })}
</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="flex gap-2">
<Input
placeholder={formatMessage({ id: 'codexlens.models.custom.placeholder' })}
value={modelName}
onChange={(e) => setModelName(e.target.value)}
disabled={isDownloading}
className="flex-1"
/>
<select
value={modelType}
onChange={(e) => setModelType(e.target.value as 'embedding' | 'reranker')}
disabled={isDownloading}
className="px-3 py-2 text-sm rounded-md border border-input bg-background"
>
<option value="embedding">{formatMessage({ id: 'codexlens.models.types.embedding' })}</option>
<option value="reranker">{formatMessage({ id: 'codexlens.models.types.reranker' })}</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.models.custom.description' })}
</p>
</form>
</Card>
);
}
export default ModelCard;

View File

@@ -1,195 +0,0 @@
// ========================================
// ModelSelectField Component
// ========================================
// Combobox-style input for selecting models from local + API sources
import { useState, useRef, useEffect, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { EnvVarFieldSchema, ModelGroup } from '@/types/codexlens';
import type { CodexLensModel } from '@/lib/api';
interface ModelSelectFieldProps {
field: EnvVarFieldSchema;
value: string;
onChange: (value: string) => void;
/** Currently loaded local models (installed) */
localModels?: CodexLensModel[];
/** Backend type determines which model list to show */
backendType: 'local' | 'api';
disabled?: boolean;
}
interface ModelOption {
id: string;
label: string;
group: string;
}
export function ModelSelectField({
field,
value,
onChange,
localModels = [],
backendType,
disabled = false,
}: ModelSelectFieldProps) {
const { formatMessage } = useIntl();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Close on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [open]);
// Build model options based on backend type
const options = useMemo<ModelOption[]>(() => {
const result: ModelOption[] = [];
if (backendType === 'api') {
// API mode: show preset API models from schema
const apiGroups: ModelGroup[] = field.apiModels || [];
for (const group of apiGroups) {
for (const item of group.items) {
result.push({ id: item, label: item, group: group.group });
}
}
} else {
// Local mode: show installed local models, then preset profiles as fallback
if (localModels.length > 0) {
for (const model of localModels) {
const modelId = model.profile || model.name;
const displayText =
model.profile && model.name && model.profile !== model.name
? `${model.profile} (${model.name})`
: model.name || model.profile;
result.push({
id: modelId,
label: displayText,
group: formatMessage({ id: 'codexlens.downloadedModels', defaultMessage: 'Downloaded Models' }),
});
}
} else {
// Fallback to preset local models from schema
const localGroups: ModelGroup[] = field.localModels || [];
for (const group of localGroups) {
for (const item of group.items) {
result.push({ id: item, label: item, group: group.group });
}
}
}
}
return result;
}, [backendType, field.apiModels, field.localModels, localModels, formatMessage]);
// Filter by search
const filtered = useMemo(() => {
if (!search) return options;
const q = search.toLowerCase();
return options.filter(
(opt) => opt.id.toLowerCase().includes(q) || opt.label.toLowerCase().includes(q)
);
}, [options, search]);
// Group filtered options
const grouped = useMemo(() => {
const groups: Record<string, ModelOption[]> = {};
for (const opt of filtered) {
if (!groups[opt.group]) groups[opt.group] = [];
groups[opt.group].push(opt);
}
return groups;
}, [filtered]);
const handleSelect = (modelId: string) => {
onChange(modelId);
setOpen(false);
setSearch('');
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setSearch(val);
onChange(val);
if (!open) setOpen(true);
};
return (
<div ref={containerRef} className="relative flex-1">
<div className="relative">
<input
ref={inputRef}
type="text"
value={open ? search || value : value}
onChange={handleInputChange}
onFocus={() => {
setOpen(true);
setSearch('');
}}
placeholder={field.placeholder || 'Select model...'}
disabled={disabled}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 pr-8 text-sm',
'ring-offset-background placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
/>
<ChevronDown
className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
/>
</div>
{open && !disabled && (
<div className="absolute z-50 mt-1 w-full rounded-md border border-border bg-card shadow-md">
<div className="max-h-56 overflow-y-auto p-1">
{Object.keys(grouped).length === 0 ? (
<div className="py-3 text-center text-xs text-muted-foreground">
{backendType === 'api'
? formatMessage({ id: 'codexlens.noConfiguredModels', defaultMessage: 'No models configured' })
: formatMessage({ id: 'codexlens.noLocalModels', defaultMessage: 'No models downloaded' })}
</div>
) : (
Object.entries(grouped).map(([group, items]) => (
<div key={group}>
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
{group}
</div>
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handleSelect(item.id)}
className={cn(
'flex w-full items-center rounded-sm px-2 py-1.5 text-xs cursor-pointer',
'hover:bg-accent hover:text-accent-foreground',
value === item.id && 'bg-accent/50'
)}
>
{item.label}
</button>
))}
</div>
))
)}
</div>
</div>
)}
</div>
);
}
export default ModelSelectField;

View File

@@ -1,405 +0,0 @@
// ========================================
// Models Tab Component Tests
// ========================================
// Tests for CodexLens Models Tab component
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { ModelsTab } from './ModelsTab';
import type { CodexLensModel } from '@/lib/api';
// Mock hooks - use importOriginal to preserve all exports
vi.mock('@/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks')>();
return {
...actual,
useCodexLensModels: vi.fn(),
useCodexLensMutations: vi.fn(),
};
});
import { useCodexLensModels, useCodexLensMutations } from '@/hooks';
const mockModels: CodexLensModel[] = [
{
profile: 'embedding1',
name: 'BAAI/bge-small-en-v1.5',
type: 'embedding',
backend: 'onnx',
installed: true,
cache_path: '/cache/embedding1',
},
{
profile: 'reranker1',
name: 'BAAI/bge-reranker-v2-m3',
type: 'reranker',
backend: 'onnx',
installed: false,
cache_path: '/cache/reranker1',
},
{
profile: 'embedding2',
name: 'sentence-transformers/all-MiniLM-L6-v2',
type: 'embedding',
backend: 'torch',
installed: false,
cache_path: '/cache/embedding2',
},
];
const mockMutations = {
updateConfig: vi.fn().mockResolvedValue({ success: true }) as any,
isUpdatingConfig: false,
bootstrap: vi.fn().mockResolvedValue({ success: true }) as any,
isBootstrapping: false,
installSemantic: vi.fn().mockResolvedValue({ success: true }) as any,
isInstallingSemantic: false,
uninstall: vi.fn().mockResolvedValue({ success: true }) as any,
isUninstalling: false,
downloadModel: vi.fn().mockResolvedValue({ success: true }) as any,
downloadCustomModel: vi.fn().mockResolvedValue({ success: true }) as any,
isDownloading: false,
deleteModel: vi.fn().mockResolvedValue({ success: true }) as any,
deleteModelByPath: vi.fn().mockResolvedValue({ success: true }) as any,
isDeleting: false,
updateEnv: vi.fn().mockResolvedValue({ success: true, env: {}, settings: {}, raw: '' }) as any,
isUpdatingEnv: false,
selectGpu: vi.fn().mockResolvedValue({ success: true }) as any,
resetGpu: vi.fn().mockResolvedValue({ success: true }) as any,
isSelectingGpu: false,
updatePatterns: vi.fn().mockResolvedValue({ patterns: [], extensionFilters: [], defaults: {} }) as any,
isUpdatingPatterns: false,
rebuildIndex: vi.fn().mockResolvedValue({ success: true }) as any,
isRebuildingIndex: false,
updateIndex: vi.fn().mockResolvedValue({ success: true }) as any,
isUpdatingIndex: false,
cancelIndexing: vi.fn().mockResolvedValue({ success: true }) as any,
isCancellingIndexing: false,
isMutating: false,
};
describe('ModelsTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when installed', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should render search input', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByPlaceholderText(/Search models/i)).toBeInTheDocument();
});
it('should render filter buttons with counts', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByText(/All/)).toBeInTheDocument();
expect(screen.getByText(/Embedding Models/)).toBeInTheDocument();
expect(screen.getByText(/Reranker Models/)).toBeInTheDocument();
expect(screen.getByText(/Downloaded/)).toBeInTheDocument();
expect(screen.getByText(/Available/)).toBeInTheDocument();
});
it('should render model list', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.getByText('sentence-transformers/all-MiniLM-L6-v2')).toBeInTheDocument();
});
it('should filter models by search query', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const searchInput = screen.getByPlaceholderText(/Search models/i);
await user.type(searchInput, 'bge');
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.queryByText('sentence-transformers/all-MiniLM-L6-v2')).not.toBeInTheDocument();
});
it('should filter by embedding type', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const embeddingButton = screen.getByText(/Embedding Models/i);
await user.click(embeddingButton);
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-reranker-v2-m3')).not.toBeInTheDocument();
});
it('should filter by reranker type', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const rerankerButton = screen.getByText(/Reranker Models/i);
await user.click(rerankerButton);
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-small-en-v1.5')).not.toBeInTheDocument();
});
it('should filter by downloaded status', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const downloadedButton = screen.getByText(/Downloaded/i);
await user.click(downloadedButton);
expect(screen.getByText('BAAI/bge-small-en-v1.5')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-reranker-v2-m3')).not.toBeInTheDocument();
});
it('should filter by available status', async () => {
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const availableButton = screen.getByText(/Available/i);
await user.click(availableButton);
expect(screen.getByText('BAAI/bge-reranker-v2-m3')).toBeInTheDocument();
expect(screen.queryByText('BAAI/bge-small-en-v1.5')).not.toBeInTheDocument();
});
it('should call downloadModel when download clicked', async () => {
const downloadModel = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
...mockMutations,
downloadModel,
});
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
// Filter to show available models
const availableButton = screen.getByText(/Available/i);
await user.click(availableButton);
const downloadButton = screen.getAllByText(/Download/i)[0];
await user.click(downloadButton);
await waitFor(() => {
expect(downloadModel).toHaveBeenCalled();
});
});
it('should refresh models on refresh button click', async () => {
const refetch = vi.fn();
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch,
});
const user = userEvent.setup();
render(<ModelsTab installed={true} />);
const refreshButton = screen.getByText(/Refresh/i);
await user.click(refreshButton);
expect(refetch).toHaveBeenCalledOnce();
});
});
describe('when not installed', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: undefined,
embeddingModels: undefined,
rerankerModels: undefined,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should show not installed message', () => {
render(<ModelsTab installed={false} />);
expect(screen.getByText(/CodexLens Not Installed/i)).toBeInTheDocument();
expect(screen.getByText(/Please install CodexLens to use model management features/i)).toBeInTheDocument();
});
});
describe('loading states', () => {
it('should show loading state', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: undefined,
embeddingModels: undefined,
rerankerModels: undefined,
isLoading: true,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
});
describe('empty states', () => {
it('should show empty state when no models', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
expect(screen.getByText(/No models found/i)).toBeInTheDocument();
expect(screen.getByText(/Try adjusting your search or filter criteria/i)).toBeInTheDocument();
});
it('should show empty state when search returns no results', async () => {
const user = userEvent.setup();
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
const searchInput = screen.getByPlaceholderText(/Search models/i);
await user.type(searchInput, 'nonexistent-model');
expect(screen.getByText(/No models found/i)).toBeInTheDocument();
});
});
describe('i18n - Chinese locale', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should display translated text', () => {
render(<ModelsTab installed={true} />, { locale: 'zh' });
expect(screen.getByPlaceholderText(/搜索模型/i)).toBeInTheDocument();
expect(screen.getByText(/筛选/i)).toBeInTheDocument();
expect(screen.getByText(/全部/i)).toBeInTheDocument();
expect(screen.getByText(/嵌入模型/i)).toBeInTheDocument();
expect(screen.getByText(/重排序模型/i)).toBeInTheDocument();
expect(screen.getByText(/已下载/i)).toBeInTheDocument();
expect(screen.getByText(/可用/i)).toBeInTheDocument();
expect(screen.getByText(/刷新/i)).toBeInTheDocument();
});
it('should translate empty state', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />, { locale: 'zh' });
expect(screen.getByText(/没有找到模型/i)).toBeInTheDocument();
expect(screen.getByText(/尝试调整搜索或筛选条件/i)).toBeInTheDocument();
});
it('should translate not installed state', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: undefined,
embeddingModels: undefined,
rerankerModels: undefined,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={false} />, { locale: 'zh' });
expect(screen.getByText(/CodexLens 未安装/i)).toBeInTheDocument();
});
});
describe('custom model input', () => {
beforeEach(() => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
});
it('should render custom model input section', () => {
render(<ModelsTab installed={true} />);
expect(screen.getByText(/Custom Model/i)).toBeInTheDocument();
});
it('should translate custom model section in Chinese', () => {
render(<ModelsTab installed={true} />, { locale: 'zh' });
expect(screen.getByText(/自定义模型/i)).toBeInTheDocument();
});
});
describe('error handling', () => {
it('should handle API errors gracefully', () => {
vi.mocked(useCodexLensModels).mockReturnValue({
models: mockModels,
embeddingModels: mockModels.filter(m => m.type === 'embedding'),
rerankerModels: mockModels.filter(m => m.type === 'reranker'),
isLoading: false,
error: new Error('API Error'),
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
render(<ModelsTab installed={true} />);
// Component should still render despite error
expect(screen.getByText(/BAAI\/bge-small-en-v1.5/i)).toBeInTheDocument();
});
});
});

View File

@@ -1,309 +0,0 @@
// ========================================
// Models Tab Component
// ========================================
// Model management tab with list, search, and download actions
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Search,
RefreshCw,
Package,
Filter,
AlertCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { ModelCard, CustomModelInput } from './ModelCard';
import { useCodexLensModels, useCodexLensMutations } from '@/hooks';
import type { CodexLensModel } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Types ==========
type FilterType = 'all' | 'embedding' | 'reranker' | 'downloaded' | 'available';
// ========== Helper Functions ==========
function filterModels(models: CodexLensModel[], filter: FilterType, search: string): CodexLensModel[] {
let filtered = models;
// Apply type/status filter
if (filter === 'embedding') {
filtered = filtered.filter(m => m.type === 'embedding');
} else if (filter === 'reranker') {
filtered = filtered.filter(m => m.type === 'reranker');
} else if (filter === 'downloaded') {
filtered = filtered.filter(m => m.installed);
} else if (filter === 'available') {
filtered = filtered.filter(m => !m.installed);
}
// Apply search filter
if (search.trim()) {
const query = search.toLowerCase();
filtered = filtered.filter(m =>
m.name.toLowerCase().includes(query) ||
m.profile.toLowerCase().includes(query) ||
(m.description?.toLowerCase().includes(query) ?? false)
);
}
return filtered;
}
// ========== Component ==========
export interface ModelsTabProps {
installed?: boolean;
}
export function ModelsTab({ installed = false }: ModelsTabProps) {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<FilterType>('all');
const [downloadingProfile, setDownloadingProfile] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState(0);
const {
models,
isLoading,
error,
refetch,
} = useCodexLensModels({
enabled: installed,
});
const {
downloadModel,
downloadCustomModel,
deleteModel,
isDownloading,
isDeleting,
} = useCodexLensMutations();
// Filter models based on search and filter
const filteredModels = useMemo(() => {
if (!models) return [];
return filterModels(models, filterType, searchQuery);
}, [models, filterType, searchQuery]);
// Count models by type and status
const stats = useMemo(() => {
if (!models) return null;
return {
total: models.length,
embedding: models.filter(m => m.type === 'embedding').length,
reranker: models.filter(m => m.type === 'reranker').length,
downloaded: models.filter(m => m.installed).length,
available: models.filter(m => !m.installed).length,
};
}, [models]);
// Handle model download
const handleDownload = async (profile: string) => {
setDownloadingProfile(profile);
setDownloadProgress(0);
// Simulate progress for demo (in real implementation, use WebSocket or polling)
const progressInterval = setInterval(() => {
setDownloadProgress(prev => {
if (prev >= 95) {
clearInterval(progressInterval);
return 95;
}
return prev + 5;
});
}, 500);
try {
const result = await downloadModel(profile);
if (result.success) {
setDownloadProgress(100);
setTimeout(() => {
setDownloadingProfile(null);
setDownloadProgress(0);
refetch();
}, 500);
} else {
setDownloadingProfile(null);
setDownloadProgress(0);
}
} catch (error) {
setDownloadingProfile(null);
setDownloadProgress(0);
} finally {
clearInterval(progressInterval);
}
};
// Handle custom model download
const handleCustomDownload = async (modelName: string, modelType: 'embedding' | 'reranker') => {
try {
const result = await downloadCustomModel(modelName, modelType);
if (result.success) {
refetch();
}
} catch (error) {
console.error('Failed to download custom model:', error);
}
};
// Handle model delete
const handleDelete = async (profile: string) => {
const result = await deleteModel(profile);
if (result.success) {
refetch();
}
};
// Filter buttons
const filterButtons: Array<{ type: FilterType; label: string; count: number | undefined }> = [
{ type: 'all', label: formatMessage({ id: 'codexlens.models.filters.all' }), count: stats?.total },
{ type: 'embedding', label: formatMessage({ id: 'codexlens.models.types.embedding' }), count: stats?.embedding },
{ type: 'reranker', label: formatMessage({ id: 'codexlens.models.types.reranker' }), count: stats?.reranker },
{ type: 'downloaded', label: formatMessage({ id: 'codexlens.models.status.downloaded' }), count: stats?.downloaded },
{ type: 'available', label: formatMessage({ id: 'codexlens.models.status.available' }), count: stats?.available },
];
if (!installed) {
return (
<Card className="p-12 text-center">
<Package className="w-16 h-16 mx-auto text-muted-foreground/30 mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2">
{formatMessage({ id: 'codexlens.models.notInstalled.title' })}
</h3>
<p className="text-muted-foreground">
{formatMessage({ id: 'codexlens.models.notInstalled.description' })}
</p>
</Card>
);
}
return (
<div className="space-y-4">
{/* Header with Search and Actions */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'codexlens.models.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className={cn('w-4 h-4 mr-1', isLoading && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
</div>
</div>
</Card>
{/* Stats and Filters */}
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'codexlens.models.filters.label' })}
</span>
</div>
<div className="flex flex-wrap gap-2">
{filterButtons.map(({ type, label, count }) => (
<Button
key={type}
variant={filterType === type ? 'default' : 'outline'}
size="sm"
onClick={() => setFilterType(type)}
className="relative"
>
{label}
{count !== undefined && (
<Badge variant={filterType === type ? 'secondary' : 'default'} className="ml-2">
{count}
</Badge>
)}
</Button>
))}
</div>
</Card>
{/* Custom Model Input */}
<CustomModelInput
isDownloading={isDownloading}
onDownload={handleCustomDownload}
/>
{/* Model List */}
{error ? (
<Card className="p-8 text-center">
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50 mb-3" />
<h3 className="text-sm font-medium text-destructive-foreground mb-1">
{formatMessage({ id: 'codexlens.models.error.title' })}
</h3>
<p className="text-xs text-muted-foreground mb-3">
{error.message || formatMessage({ id: 'codexlens.models.error.description' })}
</p>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
>
<RefreshCw className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</Card>
) : isLoading ? (
<Card className="p-8 text-center">
<p className="text-muted-foreground">{formatMessage({ id: 'common.actions.loading' })}</p>
</Card>
) : filteredModels.length === 0 ? (
<Card className="p-8 text-center">
<Package className="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
<h3 className="text-sm font-medium text-foreground mb-1">
{models && models.length > 0
? formatMessage({ id: 'codexlens.models.empty.filtered' })
: formatMessage({ id: 'codexlens.models.empty.title' })
}
</h3>
<p className="text-xs text-muted-foreground">
{models && models.length > 0
? formatMessage({ id: 'codexlens.models.empty.filteredDesc' })
: formatMessage({ id: 'codexlens.models.empty.description' })
}
</p>
</Card>
) : (
<div className="space-y-3">
{filteredModels.map((model) => (
<ModelCard
key={model.profile}
model={model}
isDownloading={downloadingProfile === model.profile}
downloadProgress={downloadProgress}
isDeleting={isDeleting && downloadingProfile !== model.profile}
onDownload={handleDownload}
onDelete={handleDelete}
onCancelDownload={() => {
setDownloadingProfile(null);
setDownloadProgress(0);
}}
/>
))}
</div>
)}
</div>
);
}
export default ModelsTab;

View File

@@ -1,276 +0,0 @@
// ========================================
// Overview Tab Component Tests
// ========================================
// Tests for CodexLens Overview Tab component
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { OverviewTab } from './OverviewTab';
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
const mockStatus: CodexLensVenvStatus = {
ready: true,
installed: true,
version: '1.0.0',
pythonVersion: '3.11.0',
venvPath: '/path/to/venv',
};
const mockConfig: CodexLensConfig = {
index_dir: '~/.codexlens/indexes',
index_count: 100,
};
// Mock window.alert
global.alert = vi.fn();
describe('OverviewTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when installed and ready', () => {
const defaultProps = {
installed: true,
status: mockStatus,
config: mockConfig,
isLoading: false,
};
it('should render status cards', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Installation Status/i)).toBeInTheDocument();
expect(screen.getByText(/Ready/i)).toBeInTheDocument();
expect(screen.getByText(/Version/i)).toBeInTheDocument();
expect(screen.getByText(/1.0.0/i)).toBeInTheDocument();
});
it('should render index path with full path in title', () => {
render(<OverviewTab {...defaultProps} />);
const indexPath = screen.getByText(/Index Path/i).nextElementSibling as HTMLElement;
expect(indexPath).toHaveTextContent('~/.codexlens/indexes');
expect(indexPath).toHaveAttribute('title', '~/.codexlens/indexes');
});
it('should render index count', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Index Count/i)).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
it('should render quick actions section', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Quick Actions/i)).toBeInTheDocument();
expect(screen.getByText(/FTS Full/i)).toBeInTheDocument();
expect(screen.getByText(/FTS Incremental/i)).toBeInTheDocument();
expect(screen.getByText(/Vector Full/i)).toBeInTheDocument();
expect(screen.getByText(/Vector Incremental/i)).toBeInTheDocument();
});
it('should render venv details section', () => {
render(<OverviewTab {...defaultProps} />);
expect(screen.getByText(/Python Virtual Environment Details/i)).toBeInTheDocument();
expect(screen.getByText(/Python Version/i)).toBeInTheDocument();
expect(screen.getByText(/3.11.0/i)).toBeInTheDocument();
});
it('should show coming soon alert when action clicked', async () => {
const user = userEvent.setup();
render(<OverviewTab {...defaultProps} />);
const ftsFullButton = screen.getByText(/FTS Full/i);
await user.click(ftsFullButton);
expect(global.alert).toHaveBeenCalledWith(expect.stringContaining('Coming Soon'));
});
});
describe('when installed but not ready', () => {
const notReadyProps = {
installed: true,
status: { ...mockStatus, ready: false },
config: mockConfig,
isLoading: false,
};
it('should show not ready status', () => {
render(<OverviewTab {...notReadyProps} />);
expect(screen.getByText(/Not Ready/i)).toBeInTheDocument();
});
it('should disable action buttons when not ready', () => {
render(<OverviewTab {...notReadyProps} />);
const ftsFullButton = screen.getByText(/FTS Full/i).closest('button');
expect(ftsFullButton).toBeDisabled();
});
});
describe('when not installed', () => {
const notInstalledProps = {
installed: false,
status: undefined,
config: undefined,
isLoading: false,
};
it('should show not installed message', () => {
render(<OverviewTab {...notInstalledProps} />);
expect(screen.getByText(/CodexLens Not Installed/i)).toBeInTheDocument();
expect(screen.getByText(/Please install CodexLens to use semantic code search features/i)).toBeInTheDocument();
});
});
describe('loading state', () => {
it('should show loading skeleton', () => {
const { container } = render(
<OverviewTab
installed={false}
status={undefined}
config={undefined}
isLoading={true}
/>
);
// Check for pulse/skeleton elements
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
});
describe('i18n - Chinese locale', () => {
const defaultProps = {
installed: true,
status: mockStatus,
config: mockConfig,
isLoading: false,
};
it('should display translated text in Chinese', () => {
render(<OverviewTab {...defaultProps} />, { locale: 'zh' });
expect(screen.getByText(/安装状态/i)).toBeInTheDocument();
expect(screen.getByText(/就绪/i)).toBeInTheDocument();
expect(screen.getByText(/版本/i)).toBeInTheDocument();
expect(screen.getByText(/索引路径/i)).toBeInTheDocument();
expect(screen.getByText(/索引数量/i)).toBeInTheDocument();
expect(screen.getByText(/快速操作/i)).toBeInTheDocument();
expect(screen.getByText(/Python 虚拟环境详情/i)).toBeInTheDocument();
});
it('should translate action buttons', () => {
render(<OverviewTab {...defaultProps} />, { locale: 'zh' });
expect(screen.getByText(/FTS 全量/i)).toBeInTheDocument();
expect(screen.getByText(/FTS 增量/i)).toBeInTheDocument();
expect(screen.getByText(/向量全量/i)).toBeInTheDocument();
expect(screen.getByText(/向量增量/i)).toBeInTheDocument();
});
});
describe('status card colors', () => {
it('should show success color when ready', () => {
const { container } = render(
<OverviewTab
installed={true}
status={mockStatus}
config={mockConfig}
isLoading={false}
/>
);
// Check for success/ready indication (check icon or success color)
const statusCard = container.querySelector('.bg-success\\/10');
expect(statusCard).toBeInTheDocument();
});
it('should show warning color when not ready', () => {
const { container } = render(
<OverviewTab
installed={true}
status={{ ...mockStatus, ready: false }}
config={mockConfig}
isLoading={false}
/>
);
// Check for warning/not ready indication
const statusCard = container.querySelector('.bg-warning\\/10');
expect(statusCard).toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle missing status gracefully', () => {
render(
<OverviewTab
installed={true}
status={undefined}
config={mockConfig}
isLoading={false}
/>
);
// Should not crash and render available data
expect(screen.getByText(/Version/i)).toBeInTheDocument();
});
it('should handle missing config gracefully', () => {
render(
<OverviewTab
installed={true}
status={mockStatus}
config={undefined}
isLoading={false}
/>
);
// Should not crash and render available data
expect(screen.getByText(/Installation Status/i)).toBeInTheDocument();
});
it('should handle empty index path', () => {
const emptyConfig: CodexLensConfig = {
index_dir: '',
index_count: 0,
};
render(
<OverviewTab
installed={true}
status={mockStatus}
config={emptyConfig}
isLoading={false}
/>
);
expect(screen.getByText(/Index Path/i)).toBeInTheDocument();
});
it('should handle unknown version', () => {
const unknownVersionStatus: CodexLensVenvStatus = {
...mockStatus,
version: '',
};
render(
<OverviewTab
installed={true}
status={unknownVersionStatus}
config={mockConfig}
isLoading={false}
/>
);
expect(screen.getByText(/Version/i)).toBeInTheDocument();
});
});
});

View File

@@ -1,184 +0,0 @@
// ========================================
// CodexLens Overview Tab
// ========================================
// Overview status display and quick actions for CodexLens
import { useIntl } from 'react-intl';
import {
Database,
FileText,
CheckCircle2,
XCircle,
Zap,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { cn } from '@/lib/utils';
import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api';
import { IndexOperations } from './IndexOperations';
import { FileWatcherCard } from './FileWatcherCard';
interface OverviewTabProps {
installed: boolean;
status?: CodexLensVenvStatus;
config?: CodexLensConfig;
isLoading: boolean;
onRefresh?: () => void;
}
export function OverviewTab({ installed, status, config, isLoading, onRefresh }: OverviewTabProps) {
const { formatMessage } = useIntl();
if (isLoading) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="p-4">
<div className="animate-pulse">
<div className="h-4 bg-muted rounded w-20 mb-2" />
<div className="h-8 bg-muted rounded w-16" />
</div>
</Card>
))}
</div>
</div>
);
}
if (!installed) {
return (
<Card className="p-8 text-center">
<Database className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'codexlens.overview.notInstalled.title' })}
</h3>
<p className="text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.notInstalled.message' })}
</p>
</Card>
);
}
const isReady = status?.ready ?? false;
const version = status?.version ?? 'Unknown';
const indexDir = config?.index_dir ?? '~/.codexlens/indexes';
const indexCount = config?.index_count ?? 0;
return (
<div className="space-y-6">
{/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Installation Status */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
isReady ? 'bg-success/10' : 'bg-warning/10'
)}>
{isReady ? (
<CheckCircle2 className="w-5 h-5 text-success" />
) : (
<XCircle className="w-5 h-5 text-warning" />
)}
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.installation' })}
</p>
<p className="text-lg font-semibold text-foreground">
{isReady
? formatMessage({ id: 'codexlens.overview.status.ready' })
: formatMessage({ id: 'codexlens.overview.status.notReady' })
}
</p>
</div>
</div>
</Card>
{/* Version */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Database className="w-5 h-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.version' })}
</p>
<p className="text-lg font-semibold text-foreground">{version}</p>
</div>
</div>
</Card>
{/* Index Path */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-info/10">
<FileText className="w-5 h-5 text-info" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.indexPath' })}
</p>
<p className="text-sm font-semibold text-foreground truncate" title={indexDir}>
{indexDir}
</p>
</div>
</div>
</Card>
{/* Index Count */}
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent/10">
<Zap className="w-5 h-5 text-accent" />
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.status.indexCount' })}
</p>
<p className="text-lg font-semibold text-foreground">{indexCount}</p>
</div>
</div>
</Card>
</div>
{/* Service Management */}
<div className="grid grid-cols-1 gap-4">
<FileWatcherCard disabled={!isReady} />
</div>
{/* Index Operations */}
<IndexOperations disabled={!isReady} onRefresh={onRefresh} />
{/* Venv Details */}
{status && (
<Card>
<CardHeader>
<CardTitle className="text-base">
{formatMessage({ id: 'codexlens.overview.venv.title' })}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.venv.pythonVersion' })}
</span>
<span className="text-foreground font-mono">{status.pythonVersion || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.overview.venv.venvPath' })}
</span>
<span className="text-foreground font-mono truncate ml-4" title={status.venvPath}>
{status.venvPath || 'Unknown'}
</span>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,286 +0,0 @@
// ========================================
// SchemaFormRenderer Component
// ========================================
// Renders structured form groups from EnvVarGroupsSchema definition
// Supports select, number, checkbox, text, and model-select field types
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Box,
ArrowUpDown,
Cpu,
GitBranch,
type LucideIcon,
} from 'lucide-react';
import { Label } from '@/components/ui/Label';
import { Input } from '@/components/ui/Input';
import { Checkbox } from '@/components/ui/Checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/Collapsible';
import { cn } from '@/lib/utils';
import { evaluateShowWhen } from './envVarSchema';
import { ModelSelectField } from './ModelSelectField';
import type { EnvVarGroupsSchema, EnvVarFieldSchema } from '@/types/codexlens';
import type { CodexLensModel } from '@/lib/api';
// Icon mapping for group icons
const iconMap: Record<string, LucideIcon> = {
box: Box,
'arrow-up-down': ArrowUpDown,
cpu: Cpu,
'git-branch': GitBranch,
};
interface SchemaFormRendererProps {
/** The schema defining all groups and fields */
groups: EnvVarGroupsSchema;
/** Current form values keyed by env var name */
values: Record<string, string>;
/** Called when a field value changes */
onChange: (key: string, value: string) => void;
/** Whether the form is disabled (loading state) */
disabled?: boolean;
/** Local embedding models (installed) for model-select */
localEmbeddingModels?: CodexLensModel[];
/** Local reranker models (installed) for model-select */
localRerankerModels?: CodexLensModel[];
}
export function SchemaFormRenderer({
groups,
values,
onChange,
disabled = false,
localEmbeddingModels = [],
localRerankerModels = [],
}: SchemaFormRendererProps) {
const { formatMessage } = useIntl();
const groupEntries = useMemo(() => Object.entries(groups), [groups]);
return (
<div className="space-y-3">
{groupEntries.map(([groupKey, group]) => {
const IconComponent = iconMap[group.icon] || Box;
return (
<Collapsible key={groupKey} defaultOpen>
<div className="border border-border rounded-lg">
<CollapsibleTrigger className="flex w-full items-center gap-2 p-3 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors">
<IconComponent className="w-3.5 h-3.5" />
{formatMessage({ id: group.labelKey, defaultMessage: groupKey })}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pb-3 space-y-2">
{Object.entries(group.vars).map(([varKey, field]) => {
const visible = evaluateShowWhen(field, values);
if (!visible) return null;
return (
<FieldRenderer
key={varKey}
field={field}
value={values[varKey] ?? field.default ?? ''}
onChange={(val) => onChange(varKey, val)}
allValues={values}
disabled={disabled}
localModels={
varKey.includes('EMBEDDING')
? localEmbeddingModels
: localRerankerModels
}
formatMessage={formatMessage}
/>
);
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
})}
</div>
);
}
// ========================================
// Individual Field Renderer
// ========================================
interface FieldRendererProps {
field: EnvVarFieldSchema;
value: string;
onChange: (value: string) => void;
allValues: Record<string, string>;
disabled: boolean;
localModels: CodexLensModel[];
formatMessage: (descriptor: { id: string; defaultMessage?: string }) => string;
}
function FieldRenderer({
field,
value,
onChange,
allValues,
disabled,
localModels,
formatMessage,
}: FieldRendererProps) {
const label = formatMessage({ id: field.labelKey, defaultMessage: field.key });
switch (field.type) {
case 'select':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Select
value={value}
onValueChange={onChange}
disabled={disabled}
>
<SelectTrigger className={cn('flex-1 h-8 text-xs')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(field.options || []).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
case 'number':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Input
type="number"
className="flex-1 h-8 text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
min={field.min}
max={field.max}
step={field.step ?? 1}
disabled={disabled}
/>
</div>
);
case 'checkbox':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<div className="flex-1 flex items-center h-8">
<Checkbox
checked={value === 'true'}
onCheckedChange={(checked) => onChange(checked ? 'true' : 'false')}
disabled={disabled}
/>
</div>
</div>
);
case 'model-select': {
// Determine backend type from related backend env var
const isEmbedding = field.key.includes('EMBED');
const backendKey = isEmbedding
? 'CODEXLENS_EMBEDDING_BACKEND'
: 'CODEXLENS_RERANKER_BACKEND';
const backendValue = allValues[backendKey];
const backendType = backendValue === 'api' ? 'api' : 'local';
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<ModelSelectField
field={field}
value={value}
onChange={onChange}
localModels={localModels}
backendType={backendType}
disabled={disabled}
/>
</div>
);
}
case 'password':
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Input
type="password"
className="flex-1 h-8 text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
autoComplete="off"
/>
</div>
);
case 'text':
default:
return (
<div className="flex items-center gap-2">
<Label
className="text-xs text-muted-foreground w-28 flex-shrink-0"
title={field.key}
>
{label}
</Label>
<Input
type="text"
className="flex-1 h-8 text-xs"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={disabled}
/>
</div>
);
}
}
export default SchemaFormRenderer;

View File

@@ -1,445 +0,0 @@
// ========================================
// CodexLens Search Tab
// ========================================
// Semantic code search interface with multiple search types
// Includes LSP availability check and hybrid search mode switching
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Search, FileCode, Code, Sparkles, CheckCircle, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
useCodexLensSearch,
useCodexLensFilesSearch,
useCodexLensSymbolSearch,
useCodexLensLspStatus,
useCodexLensSemanticSearch,
} from '@/hooks/useCodexLens';
import type {
CodexLensSearchParams,
CodexLensSemanticSearchMode,
CodexLensFusionStrategy,
CodexLensStagedStage2Mode,
} from '@/lib/api';
import { cn } from '@/lib/utils';
type SearchType = 'search' | 'search_files' | 'symbol' | 'semantic';
type SearchMode = 'dense_rerank' | 'fts' | 'fuzzy';
interface SearchTabProps {
enabled: boolean;
}
export function SearchTab({ enabled }: SearchTabProps) {
const { formatMessage } = useIntl();
const [searchType, setSearchType] = useState<SearchType>('search');
const [searchMode, setSearchMode] = useState<SearchMode>('dense_rerank');
const [semanticMode, setSemanticMode] = useState<CodexLensSemanticSearchMode>('fusion');
const [fusionStrategy, setFusionStrategy] = useState<CodexLensFusionStrategy>('rrf');
const [stagedStage2Mode, setStagedStage2Mode] = useState<CodexLensStagedStage2Mode>('precomputed');
const [query, setQuery] = useState('');
const [hasSearched, setHasSearched] = useState(false);
// LSP status check
const lspStatus = useCodexLensLspStatus({ enabled });
// Build search params based on search type
const searchParams: CodexLensSearchParams = {
query,
limit: 20,
mode: searchType !== 'symbol' && searchType !== 'semantic' ? searchMode : undefined,
max_content_length: 200,
extra_files_count: 10,
};
// Search hooks - only enable when hasSearched is true and query is not empty
const contentSearch = useCodexLensSearch(
searchParams,
{ enabled: enabled && hasSearched && searchType === 'search' && query.trim().length > 0 }
);
const fileSearch = useCodexLensFilesSearch(
searchParams,
{ enabled: enabled && hasSearched && searchType === 'search_files' && query.trim().length > 0 }
);
const symbolSearch = useCodexLensSymbolSearch(
{ query, limit: 20 },
{ enabled: enabled && hasSearched && searchType === 'symbol' && query.trim().length > 0 }
);
const semanticSearch = useCodexLensSemanticSearch(
{
query,
mode: semanticMode,
fusion_strategy: semanticMode === 'fusion' ? fusionStrategy : undefined,
staged_stage2_mode: semanticMode === 'fusion' && fusionStrategy === 'staged' ? stagedStage2Mode : undefined,
limit: 20,
include_match_reason: true,
},
{ enabled: enabled && hasSearched && searchType === 'semantic' && query.trim().length > 0 }
);
// Get loading state based on search type
const isLoading = searchType === 'search'
? contentSearch.isLoading
: searchType === 'search_files'
? fileSearch.isLoading
: searchType === 'symbol'
? symbolSearch.isLoading
: semanticSearch.isLoading;
const handleSearch = () => {
if (query.trim()) {
setHasSearched(true);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleSearchTypeChange = (value: SearchType) => {
setSearchType(value);
setHasSearched(false);
};
const handleSearchModeChange = (value: SearchMode) => {
setSearchMode(value);
setHasSearched(false);
};
const handleSemanticModeChange = (value: CodexLensSemanticSearchMode) => {
setSemanticMode(value);
setHasSearched(false);
};
const handleFusionStrategyChange = (value: CodexLensFusionStrategy) => {
setFusionStrategy(value);
setHasSearched(false);
};
const handleStagedStage2ModeChange = (value: CodexLensStagedStage2Mode) => {
setStagedStage2Mode(value);
setHasSearched(false);
};
const handleQueryChange = (value: string) => {
setQuery(value);
setHasSearched(false);
};
// Get result count for display
const getResultCount = (): string => {
if (searchType === 'symbol') {
return symbolSearch.data?.success
? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
if (searchType === 'search') {
return contentSearch.data?.success
? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
if (searchType === 'search_files') {
return fileSearch.data?.success
? `${fileSearch.data.files?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
if (searchType === 'semantic') {
return semanticSearch.data?.success
? `${semanticSearch.data.count ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
return '';
};
if (!enabled) {
return (
<div className="flex items-center justify-center p-12">
<div className="text-center">
<Search className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'codexlens.search.notInstalled.title' })}
</h3>
<p className="text-muted-foreground">
{formatMessage({ id: 'codexlens.search.notInstalled.description' })}
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* LSP Status Indicator */}
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.search.lspStatus' })}:</span>
{lspStatus.isLoading ? (
<span className="text-muted-foreground">...</span>
) : lspStatus.available ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspAvailable' })}
</span>
) : !lspStatus.semanticAvailable ? (
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspNoSemantic' })}
</span>
) : (
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspNoVector' })}
</span>
)}
</div>
{/* Search Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Search Type */}
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.type' })}</Label>
<Select value={searchType} onValueChange={handleSearchTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="search">
<div className="flex items-center gap-2">
<Search className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.content' })}
</div>
</SelectItem>
<SelectItem value="search_files">
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.files' })}
</div>
</SelectItem>
<SelectItem value="symbol">
<div className="flex items-center gap-2">
<Code className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.symbol' })}
</div>
</SelectItem>
<SelectItem value="semantic" disabled={!lspStatus.available}>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.semantic' })}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Search Mode - for CLI search types (content / file) */}
{(searchType === 'search' || searchType === 'search_files') && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.mode' })}</Label>
<Select value={searchMode} onValueChange={handleSearchModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dense_rerank">
{formatMessage({ id: 'codexlens.search.mode.semantic' })}
</SelectItem>
<SelectItem value="fts">
{formatMessage({ id: 'codexlens.search.mode.exact' })}
</SelectItem>
<SelectItem value="fuzzy">
{formatMessage({ id: 'codexlens.search.mode.fuzzy' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Semantic Search Mode - for semantic search type */}
{searchType === 'semantic' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.semanticMode' })}</Label>
<Select value={semanticMode} onValueChange={handleSemanticModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fusion">
{formatMessage({ id: 'codexlens.search.semanticMode.fusion' })}
</SelectItem>
<SelectItem value="vector">
{formatMessage({ id: 'codexlens.search.semanticMode.vector' })}
</SelectItem>
<SelectItem value="structural">
{formatMessage({ id: 'codexlens.search.semanticMode.structural' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Fusion Strategy - only when semantic + fusion mode */}
{searchType === 'semantic' && semanticMode === 'fusion' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.fusionStrategy' })}</Label>
<Select value={fusionStrategy} onValueChange={handleFusionStrategyChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rrf">
{formatMessage({ id: 'codexlens.search.fusionStrategy.rrf' })}
</SelectItem>
<SelectItem value="dense_rerank">
{formatMessage({ id: 'codexlens.search.fusionStrategy.dense_rerank' })}
</SelectItem>
<SelectItem value="binary">
{formatMessage({ id: 'codexlens.search.fusionStrategy.binary' })}
</SelectItem>
<SelectItem value="hybrid">
{formatMessage({ id: 'codexlens.search.fusionStrategy.hybrid' })}
</SelectItem>
<SelectItem value="staged">
{formatMessage({ id: 'codexlens.search.fusionStrategy.staged' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Staged Stage-2 Mode - only when semantic + fusion + staged */}
{searchType === 'semantic' && semanticMode === 'fusion' && fusionStrategy === 'staged' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.stagedStage2Mode' })}</Label>
<Select value={stagedStage2Mode} onValueChange={handleStagedStage2ModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="precomputed">
{formatMessage({ id: 'codexlens.search.stagedStage2Mode.precomputed' })}
</SelectItem>
<SelectItem value="realtime">
{formatMessage({ id: 'codexlens.search.stagedStage2Mode.realtime' })}
</SelectItem>
<SelectItem value="static_global_graph">
{formatMessage({ id: 'codexlens.search.stagedStage2Mode.static_global_graph' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Query Input */}
<div className="space-y-2">
<Label htmlFor="search-query">{formatMessage({ id: 'codexlens.search.query' })}</Label>
<Input
id="search-query"
placeholder={formatMessage({ id: 'codexlens.search.queryPlaceholder' })}
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onKeyPress={handleKeyPress}
disabled={isLoading}
/>
</div>
{/* Search Button */}
<Button
onClick={handleSearch}
disabled={!query.trim() || isLoading}
className="w-full"
>
<Search className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
{isLoading
? formatMessage({ id: 'codexlens.search.searching' })
: formatMessage({ id: 'codexlens.search.button' })
}
</Button>
{/* Results */}
{hasSearched && !isLoading && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">
{formatMessage({ id: 'codexlens.search.results' })}
</h3>
<span className="text-xs text-muted-foreground">
{getResultCount()}
</span>
</div>
{searchType === 'symbol' && symbolSearch.data && (
symbolSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(symbolSearch.data.symbols, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{symbolSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
{searchType === 'search' && contentSearch.data && (
contentSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(contentSearch.data.results, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{contentSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
{searchType === 'search_files' && fileSearch.data && (
fileSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(fileSearch.data.files, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{fileSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
{searchType === 'semantic' && semanticSearch.data && (
semanticSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(semanticSearch.data.results, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{semanticSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
</div>
)}
</div>
);
}
export default SearchTab;

View File

@@ -1,205 +0,0 @@
// ========================================
// CodexLens Semantic Install Dialog
// ========================================
// Dialog for installing semantic search dependencies with GPU mode selection
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Cpu,
Zap,
Monitor,
CheckCircle2,
AlertCircle,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
import { Label } from '@/components/ui/Label';
import { Card, CardContent } from '@/components/ui/Card';
import { useNotifications } from '@/hooks/useNotifications';
import { useCodexLensMutations } from '@/hooks';
import { cn } from '@/lib/utils';
type GpuMode = 'cpu' | 'cuda' | 'directml';
interface GpuModeOption {
value: GpuMode;
label: string;
description: string;
icon: React.ReactNode;
recommended?: boolean;
}
interface SemanticInstallDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function SemanticInstallDialog({ open, onOpenChange, onSuccess }: SemanticInstallDialogProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const { installSemantic, isInstallingSemantic } = useCodexLensMutations();
const [selectedMode, setSelectedMode] = useState<GpuMode>('cpu');
const gpuModes: GpuModeOption[] = [
{
value: 'cpu',
label: formatMessage({ id: 'codexlens.semantic.gpu.cpu' }),
description: formatMessage({ id: 'codexlens.semantic.gpu.cpuDesc' }),
icon: <Cpu className="w-5 h-5" />,
},
{
value: 'directml',
label: formatMessage({ id: 'codexlens.semantic.gpu.directml' }),
description: formatMessage({ id: 'codexlens.semantic.gpu.directmlDesc' }),
icon: <Monitor className="w-5 h-5" />,
recommended: true,
},
{
value: 'cuda',
label: formatMessage({ id: 'codexlens.semantic.gpu.cuda' }),
description: formatMessage({ id: 'codexlens.semantic.gpu.cudaDesc' }),
icon: <Zap className="w-5 h-5" />,
},
];
const handleInstall = async () => {
try {
const result = await installSemantic(selectedMode);
if (result.success) {
success(
formatMessage({ id: 'codexlens.semantic.installSuccess' }),
result.message || formatMessage({ id: 'codexlens.semantic.installSuccessDesc' }, { mode: selectedMode })
);
onSuccess?.();
onOpenChange(false);
} else {
throw new Error(result.message || 'Installation failed');
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.semantic.installFailed' }),
err instanceof Error ? err.message : formatMessage({ id: 'codexlens.semantic.unknownError' })
);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Zap className="w-5 h-5 text-primary" />
{formatMessage({ id: 'codexlens.semantic.installTitle' })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'codexlens.semantic.installDescription' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Info Card */}
<Card className="bg-muted/50 border-muted">
<CardContent className="p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-info mt-0.5 flex-shrink-0" />
<div className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.semantic.installInfo' })}
</div>
</CardContent>
</Card>
{/* GPU Mode Selection */}
<RadioGroup value={selectedMode} onValueChange={(v) => setSelectedMode(v as GpuMode)}>
<div className="grid grid-cols-1 gap-3">
{gpuModes.map((mode) => (
<Card
key={mode.value}
className={cn(
"cursor-pointer transition-colors hover:bg-accent/50",
selectedMode === mode.value && "border-primary bg-accent"
)}
>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<RadioGroupItem
value={mode.value}
id={`gpu-mode-${mode.value}`}
className="mt-1"
/>
<div className="flex items-start gap-3 flex-1">
<div className={cn(
"p-2 rounded-lg",
selectedMode === mode.value
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
)}>
{mode.icon}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<Label
htmlFor={`gpu-mode-${mode.value}`}
className="font-medium cursor-pointer"
>
{mode.label}
</Label>
{mode.recommended && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{formatMessage({ id: 'codexlens.semantic.recommended' })}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{mode.description}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</RadioGroup>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isInstallingSemantic}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleInstall}
disabled={isInstallingSemantic}
>
{isInstallingSemantic ? (
<>
<Zap className="w-4 h-4 mr-2 animate-pulse" />
{formatMessage({ id: 'codexlens.semantic.installing' })}
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.semantic.install' })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default SemanticInstallDialog;

View File

@@ -1,431 +0,0 @@
// ========================================
// Settings Tab Component Tests
// ========================================
// Tests for CodexLens Settings Tab component with schema-driven form
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { SettingsTab } from './SettingsTab';
import type { CodexLensConfig } from '@/lib/api';
// Mock hooks - use importOriginal to preserve all exports
vi.mock('@/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks')>();
return {
...actual,
useCodexLensConfig: vi.fn(),
useCodexLensEnv: vi.fn(),
useUpdateCodexLensEnv: vi.fn(),
useCodexLensModels: vi.fn(),
useNotifications: vi.fn(() => ({
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
isPanelVisible: false,
persistentNotifications: [],
addToast: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
setWsStatus: vi.fn(),
setWsLastMessage: vi.fn(),
togglePanel: vi.fn(),
setPanelVisible: vi.fn(),
addPersistentNotification: vi.fn(),
removePersistentNotification: vi.fn(),
clearPersistentNotifications: vi.fn(),
})),
};
});
import {
useCodexLensConfig,
useCodexLensEnv,
useUpdateCodexLensEnv,
useCodexLensModels,
useNotifications,
} from '@/hooks';
const mockConfig: CodexLensConfig = {
index_dir: '~/.codexlens/indexes',
index_count: 100,
};
const mockEnv: Record<string, string> = {
CODEXLENS_EMBEDDING_BACKEND: 'local',
CODEXLENS_EMBEDDING_MODEL: 'fast',
CODEXLENS_AUTO_EMBED_MISSING: 'true',
CODEXLENS_USE_GPU: 'true',
CODEXLENS_RERANKER_ENABLED: 'true',
CODEXLENS_RERANKER_BACKEND: 'onnx',
CODEXLENS_API_MAX_WORKERS: '4',
CODEXLENS_API_BATCH_SIZE: '8',
CODEXLENS_CASCADE_STRATEGY: 'dense_rerank',
};
function setupDefaultMocks() {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensEnv).mockReturnValue({
data: { success: true, env: mockEnv, settings: {}, path: '~/.codexlens/.env' },
env: mockEnv,
settings: {},
raw: '',
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: true, message: 'Saved' }),
isUpdating: false,
error: null,
});
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
}
describe('SettingsTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when enabled and config loaded', () => {
beforeEach(() => {
setupDefaultMocks();
});
it('should render current info card', () => {
render(<SettingsTab enabled={true} />);
expect(screen.getByText(/Current Index Count/i)).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText(/Current Workers/i)).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText(/Current Batch Size/i)).toBeInTheDocument();
expect(screen.getByText('8')).toBeInTheDocument();
});
it('should render configuration form with index directory', () => {
render(<SettingsTab enabled={true} />);
expect(screen.getByText(/Basic Configuration/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Index Directory/i)).toBeInTheDocument();
});
it('should render env var group sections', () => {
render(<SettingsTab enabled={true} />);
// Schema groups should be rendered (labels come from i18n, check for group icons/sections)
expect(screen.getByText(/Embedding/i)).toBeInTheDocument();
expect(screen.getByText(/Reranker/i)).toBeInTheDocument();
expect(screen.getByText(/Concurrency/i)).toBeInTheDocument();
expect(screen.getByText(/Cascade/i)).toBeInTheDocument();
expect(screen.getByText(/Chunking/i)).toBeInTheDocument();
expect(screen.getByText(/Auto Build Missing Vectors/i)).toBeInTheDocument();
});
it('should initialize index dir from config', () => {
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i) as HTMLInputElement;
expect(indexDirInput.value).toBe('~/.codexlens/indexes');
});
it('should show save button enabled when changes are made', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/index/path');
const saveButton = screen.getByText(/Save/i);
expect(saveButton).toBeEnabled();
});
it('should disable save and reset buttons when no changes', () => {
render(<SettingsTab enabled={true} />);
const saveButton = screen.getByText(/Save/i);
const resetButton = screen.getByText(/Reset/i);
expect(saveButton).toBeDisabled();
expect(resetButton).toBeDisabled();
});
it('should call updateEnv on save', async () => {
const updateEnv = vi.fn().mockResolvedValue({ success: true, message: 'Saved' });
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv,
isUpdating: false,
error: null,
});
const success = vi.fn();
vi.mocked(useNotifications).mockReturnValue({
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
isPanelVisible: false,
persistentNotifications: [],
addToast: vi.fn(),
info: vi.fn(),
success,
warning: vi.fn(),
error: vi.fn(),
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
setWsStatus: vi.fn(),
setWsLastMessage: vi.fn(),
togglePanel: vi.fn(),
setPanelVisible: vi.fn(),
addPersistentNotification: vi.fn(),
removePersistentNotification: vi.fn(),
clearPersistentNotifications: vi.fn(),
});
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/index/path');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
await waitFor(() => {
expect(updateEnv).toHaveBeenCalledWith({
env: expect.objectContaining({
CODEXLENS_EMBEDDING_BACKEND: 'local',
CODEXLENS_EMBEDDING_MODEL: 'fast',
}),
});
});
});
it('should reset form on reset button click', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i) as HTMLInputElement;
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/index/path');
expect(indexDirInput.value).toBe('/new/index/path');
const resetButton = screen.getByText(/Reset/i);
await user.click(resetButton);
expect(indexDirInput.value).toBe('~/.codexlens/indexes');
});
});
describe('form validation', () => {
beforeEach(() => {
setupDefaultMocks();
});
it('should validate index dir is required', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Index directory is required/i)).toBeInTheDocument();
});
it('should clear error when user fixes invalid input', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
expect(screen.getByText(/Index directory is required/i)).toBeInTheDocument();
await user.type(indexDirInput, '/valid/path');
expect(screen.queryByText(/Index directory is required/i)).not.toBeInTheDocument();
});
});
describe('when disabled', () => {
beforeEach(() => {
setupDefaultMocks();
});
it('should not render when enabled is false', () => {
render(<SettingsTab enabled={false} />);
// When not enabled, hooks are disabled so no config/env data
expect(screen.queryByText(/Basic Configuration/i)).not.toBeInTheDocument();
});
});
describe('loading states', () => {
it('should disable inputs when loading config', () => {
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,
indexDir: mockConfig.index_dir,
indexCount: 100,
isLoading: true,
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensEnv).mockReturnValue({
data: { success: true, env: mockEnv, settings: {}, path: '' },
env: mockEnv,
settings: {},
raw: '',
isLoading: false,
error: null,
refetch: vi.fn(),
});
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: true }),
isUpdating: false,
error: null,
});
vi.mocked(useCodexLensModels).mockReturnValue({
models: [],
embeddingModels: [],
rerankerModels: [],
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
expect(indexDirInput).toBeDisabled();
});
it('should show saving state when updating', async () => {
setupDefaultMocks();
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: true }),
isUpdating: true,
error: null,
});
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/path');
const saveButton = screen.getByText(/Saving/i);
expect(saveButton).toBeInTheDocument();
});
});
describe('i18n - Chinese locale', () => {
beforeEach(() => {
setupDefaultMocks();
});
it('should display translated labels', () => {
render(<SettingsTab enabled={true} />, { locale: 'zh' });
expect(screen.getByText(/当前索引数量/i)).toBeInTheDocument();
expect(screen.getByText(/当前工作线程/i)).toBeInTheDocument();
expect(screen.getByText(/当前批次大小/i)).toBeInTheDocument();
expect(screen.getByText(/基本配置/i)).toBeInTheDocument();
expect(screen.getByText(/索引目录/i)).toBeInTheDocument();
expect(screen.getByText(/保存/i)).toBeInTheDocument();
expect(screen.getByText(/重置/i)).toBeInTheDocument();
});
it('should display translated validation errors', async () => {
const user = userEvent.setup();
render(<SettingsTab enabled={true} />, { locale: 'zh' });
const indexDirInput = screen.getByLabelText(/索引目录/i);
await user.clear(indexDirInput);
const saveButton = screen.getByText(/保存/i);
await user.click(saveButton);
expect(screen.getByText(/索引目录不能为空/i)).toBeInTheDocument();
});
});
describe('error handling', () => {
it('should show error notification on save failure', async () => {
setupDefaultMocks();
const error = vi.fn();
vi.mocked(useNotifications).mockReturnValue({
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
isPanelVisible: false,
persistentNotifications: [],
addToast: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
error,
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
setWsStatus: vi.fn(),
setWsLastMessage: vi.fn(),
togglePanel: vi.fn(),
setPanelVisible: vi.fn(),
addPersistentNotification: vi.fn(),
removePersistentNotification: vi.fn(),
clearPersistentNotifications: vi.fn(),
});
vi.mocked(useUpdateCodexLensEnv).mockReturnValue({
updateEnv: vi.fn().mockResolvedValue({ success: false, message: 'Save failed' }),
isUpdating: false,
error: null,
});
const user = userEvent.setup();
render(<SettingsTab enabled={true} />);
const indexDirInput = screen.getByLabelText(/Index Directory/i);
await user.clear(indexDirInput);
await user.type(indexDirInput, '/new/path');
const saveButton = screen.getByText(/Save/i);
await user.click(saveButton);
await waitFor(() => {
expect(error).toHaveBeenCalledWith(
expect.stringContaining('Save failed'),
expect.any(String)
);
});
});
});
});

View File

@@ -1,276 +0,0 @@
// ========================================
// CodexLens Settings Tab
// ========================================
// Structured form for CodexLens v2 env configuration
// Renders 4 groups: embedding, reranker, search, indexing
// Plus a general config section (index_dir)
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Save, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import {
useCodexLensConfig,
useCodexLensEnv,
useUpdateCodexLensEnv,
useCodexLensModels,
} from '@/hooks';
import { useNotifications } from '@/hooks';
import { cn } from '@/lib/utils';
import { SchemaFormRenderer } from './SchemaFormRenderer';
import { envVarGroupsSchema, getSchemaDefaults } from './envVarSchema';
// ========== Settings Tab ==========
interface SettingsTabProps {
enabled?: boolean;
}
export function SettingsTab({ enabled = true }: SettingsTabProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
// Fetch current config (index_dir, index_count)
const {
config,
indexCount,
isLoading: isLoadingConfig,
refetch: refetchConfig,
} = useCodexLensConfig({ enabled });
// Fetch env vars and settings
const {
env: serverEnv,
settings: serverSettings,
isLoading: isLoadingEnv,
refetch: refetchEnv,
} = useCodexLensEnv({ enabled });
// Fetch local models for model-select fields
const {
embeddingModels: localEmbeddingModels,
rerankerModels: localRerankerModels,
} = useCodexLensModels({ enabled });
const { updateEnv, isUpdating } = useUpdateCodexLensEnv();
// General form state (index_dir)
const [indexDir, setIndexDir] = useState('');
const [indexDirError, setIndexDirError] = useState('');
// Schema-driven env var form state
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [hasChanges, setHasChanges] = useState(false);
// Store the initial values for change detection
const [initialEnvValues, setInitialEnvValues] = useState<Record<string, string>>({});
const [initialIndexDir, setInitialIndexDir] = useState('');
// Initialize form from server data
useEffect(() => {
if (config) {
setIndexDir(config.index_dir || '');
setInitialIndexDir(config.index_dir || '');
}
}, [config]);
useEffect(() => {
if (serverEnv || serverSettings) {
const defaults = getSchemaDefaults();
const merged: Record<string, string> = { ...defaults };
// Settings.json values override defaults
if (serverSettings) {
for (const [key, val] of Object.entries(serverSettings)) {
if (val) merged[key] = val;
}
}
// .env values override settings
if (serverEnv) {
for (const [key, val] of Object.entries(serverEnv)) {
if (val) merged[key] = val;
}
}
setEnvValues(merged);
setInitialEnvValues(merged);
setHasChanges(false);
}
}, [serverEnv, serverSettings]);
// Check for changes
const detectChanges = useCallback(
(currentEnv: Record<string, string>, currentIndexDir: string) => {
if (currentIndexDir !== initialIndexDir) return true;
for (const key of Object.keys(currentEnv)) {
if (currentEnv[key] !== initialEnvValues[key]) return true;
}
return false;
},
[initialEnvValues, initialIndexDir]
);
const handleEnvChange = useCallback(
(key: string, value: string) => {
setEnvValues((prev) => {
const next = { ...prev, [key]: value };
setHasChanges(detectChanges(next, indexDir));
return next;
});
},
[detectChanges, indexDir]
);
const handleIndexDirChange = useCallback(
(value: string) => {
setIndexDir(value);
setIndexDirError('');
setHasChanges(detectChanges(envValues, value));
},
[detectChanges, envValues]
);
// Installed local models filtered to installed-only
const installedEmbeddingModels = useMemo(
() => (localEmbeddingModels || []).filter((m) => m.installed),
[localEmbeddingModels]
);
const installedRerankerModels = useMemo(
() => (localRerankerModels || []).filter((m) => m.installed),
[localRerankerModels]
);
const handleSave = async () => {
// Validate index_dir
if (!indexDir.trim()) {
setIndexDirError(
formatMessage({ id: 'codexlens.settings.validation.indexDirRequired' })
);
return;
}
try {
const result = await updateEnv({ env: envValues });
if (result.success) {
success(
formatMessage({ id: 'codexlens.settings.saveSuccess' }),
result.message ||
formatMessage({ id: 'codexlens.settings.configUpdated' })
);
refetchEnv();
refetchConfig();
setHasChanges(false);
setInitialEnvValues(envValues);
setInitialIndexDir(indexDir);
} else {
showError(
formatMessage({ id: 'codexlens.settings.saveFailed' }),
result.message ||
formatMessage({ id: 'codexlens.settings.saveError' })
);
}
} catch (err) {
showError(
formatMessage({ id: 'codexlens.settings.saveFailed' }),
err instanceof Error
? err.message
: formatMessage({ id: 'codexlens.settings.unknownError' })
);
}
};
const handleReset = () => {
setEnvValues(initialEnvValues);
setIndexDir(initialIndexDir);
setIndexDirError('');
setHasChanges(false);
};
const isLoading = isLoadingConfig || isLoadingEnv;
return (
<div className="space-y-6">
{/* Current Info Card */}
<Card className="p-4 bg-muted/30">
<div className="text-sm">
<div>
<span className="text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.currentCount' })}
</span>
<p className="text-foreground font-medium">{indexCount}</p>
</div>
</div>
</Card>
{/* General Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
{formatMessage({ id: 'codexlens.settings.configTitle' })}
</h3>
{/* Index Directory */}
<div className="space-y-2 mb-4">
<Label htmlFor="index_dir">
{formatMessage({ id: 'codexlens.settings.indexDir.label' })}
</Label>
<Input
id="index_dir"
value={indexDir}
onChange={(e) => handleIndexDirChange(e.target.value)}
placeholder={formatMessage({
id: 'codexlens.settings.indexDir.placeholder',
})}
error={!!indexDirError}
disabled={isLoading}
/>
{indexDirError && (
<p className="text-sm text-destructive">{indexDirError}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.settings.indexDir.hint' })}
</p>
</div>
{/* Schema-driven Env Var Groups */}
<SchemaFormRenderer
groups={envVarGroupsSchema}
values={envValues}
onChange={handleEnvChange}
disabled={isLoading}
localEmbeddingModels={installedEmbeddingModels}
localRerankerModels={installedRerankerModels}
/>
{/* Action Buttons */}
<div className="flex items-center gap-2 mt-6">
<Button
onClick={handleSave}
disabled={isLoading || isUpdating || !hasChanges}
>
<Save
className={cn('w-4 h-4 mr-2', isUpdating && 'animate-spin')}
/>
{isUpdating
? formatMessage({ id: 'codexlens.settings.saving' })
: formatMessage({ id: 'codexlens.settings.save' })}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={isLoading || !hasChanges}
>
<RefreshCw className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.settings.reset' })}
</Button>
</div>
</Card>
</div>
);
}
export default SettingsTab;

View File

@@ -1,389 +0,0 @@
// ========================================
// CodexLens v2 Environment Variable Schema
// ========================================
// Defines structured groups for codexlens-search v2 configuration.
// Env var names match what the Python bridge CLI reads.
import type { EnvVarGroupsSchema } from '@/types/codexlens';
export const envVarGroupsSchema: EnvVarGroupsSchema = {
embedding: {
id: 'embedding',
labelKey: 'codexlens.envGroup.embedding',
icon: 'box',
vars: {
CODEXLENS_EMBEDDING_BACKEND: {
key: 'CODEXLENS_EMBEDDING_BACKEND',
labelKey: 'codexlens.envField.backend',
type: 'select',
options: ['local', 'api'],
default: 'local',
settingsPath: 'embedding.backend',
},
CODEXLENS_EMBED_API_URL: {
key: 'CODEXLENS_EMBED_API_URL',
labelKey: 'codexlens.envField.apiUrl',
type: 'text',
placeholder: 'https://api.siliconflow.cn/v1',
default: '',
settingsPath: 'embedding.api_url',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBED_API_KEY: {
key: 'CODEXLENS_EMBED_API_KEY',
labelKey: 'codexlens.envField.apiKey',
type: 'password',
placeholder: 'sk-...',
default: '',
settingsPath: 'embedding.api_key',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBED_API_MODEL: {
key: 'CODEXLENS_EMBED_API_MODEL',
labelKey: 'codexlens.envField.model',
type: 'model-select',
placeholder: 'Select or enter model...',
default: '',
settingsPath: 'embedding.api_model',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
localModels: [],
apiModels: [
{
group: 'SiliconFlow',
items: ['BAAI/bge-m3', 'BAAI/bge-large-zh-v1.5', 'BAAI/bge-large-en-v1.5'],
},
{
group: 'OpenAI',
items: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'],
},
{
group: 'Cohere',
items: ['embed-english-v3.0', 'embed-multilingual-v3.0', 'embed-english-light-v3.0'],
},
{
group: 'Voyage',
items: ['voyage-3', 'voyage-3-lite', 'voyage-code-3'],
},
{
group: 'Jina',
items: ['jina-embeddings-v3', 'jina-embeddings-v2-base-en'],
},
],
},
CODEXLENS_EMBED_API_ENDPOINTS: {
key: 'CODEXLENS_EMBED_API_ENDPOINTS',
labelKey: 'codexlens.envField.multiEndpoints',
type: 'text',
placeholder: 'url1|key1|model1,url2|key2|model2',
default: '',
settingsPath: 'embedding.api_endpoints',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBED_DIM: {
key: 'CODEXLENS_EMBED_DIM',
labelKey: 'codexlens.envField.embedDim',
type: 'number',
placeholder: '384',
default: '384',
settingsPath: 'embedding.dim',
min: 64,
max: 4096,
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBED_API_CONCURRENCY: {
key: 'CODEXLENS_EMBED_API_CONCURRENCY',
labelKey: 'codexlens.envField.apiConcurrency',
type: 'number',
placeholder: '4',
default: '4',
settingsPath: 'embedding.api_concurrency',
min: 1,
max: 32,
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBED_API_MAX_TOKENS: {
key: 'CODEXLENS_EMBED_API_MAX_TOKENS',
labelKey: 'codexlens.envField.maxTokensPerBatch',
type: 'number',
placeholder: '8192',
default: '8192',
settingsPath: 'embedding.api_max_tokens_per_batch',
min: 512,
max: 65536,
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] === 'api',
},
CODEXLENS_EMBEDDING_MODEL: {
key: 'CODEXLENS_EMBEDDING_MODEL',
labelKey: 'codexlens.envField.localModel',
type: 'model-select',
placeholder: 'Select local model...',
default: 'BAAI/bge-small-en-v1.5',
settingsPath: 'embedding.model',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] !== 'api',
localModels: [
{
group: 'FastEmbed Profiles',
items: ['small', 'base', 'large', 'code'],
},
],
apiModels: [],
},
CODEXLENS_USE_GPU: {
key: 'CODEXLENS_USE_GPU',
labelKey: 'codexlens.envField.useGpu',
type: 'select',
options: ['auto', 'cuda', 'cpu'],
default: 'auto',
settingsPath: 'embedding.device',
showWhen: (env) => env['CODEXLENS_EMBEDDING_BACKEND'] !== 'api',
},
CODEXLENS_EMBED_BATCH_SIZE: {
key: 'CODEXLENS_EMBED_BATCH_SIZE',
labelKey: 'codexlens.envField.batchSize',
type: 'number',
placeholder: '64',
default: '64',
settingsPath: 'embedding.batch_size',
min: 1,
max: 512,
},
},
},
reranker: {
id: 'reranker',
labelKey: 'codexlens.envGroup.reranker',
icon: 'arrow-up-down',
vars: {
CODEXLENS_RERANKER_BACKEND: {
key: 'CODEXLENS_RERANKER_BACKEND',
labelKey: 'codexlens.envField.backend',
type: 'select',
options: ['local', 'api'],
default: 'local',
settingsPath: 'reranker.backend',
},
CODEXLENS_RERANKER_API_URL: {
key: 'CODEXLENS_RERANKER_API_URL',
labelKey: 'codexlens.envField.apiUrl',
type: 'text',
placeholder: 'https://api.siliconflow.cn/v1',
default: '',
settingsPath: 'reranker.api_url',
showWhen: (env) => env['CODEXLENS_RERANKER_BACKEND'] === 'api',
},
CODEXLENS_RERANKER_API_KEY: {
key: 'CODEXLENS_RERANKER_API_KEY',
labelKey: 'codexlens.envField.apiKey',
type: 'password',
placeholder: 'sk-...',
default: '',
settingsPath: 'reranker.api_key',
showWhen: (env) => env['CODEXLENS_RERANKER_BACKEND'] === 'api',
},
CODEXLENS_RERANKER_API_MODEL: {
key: 'CODEXLENS_RERANKER_API_MODEL',
labelKey: 'codexlens.envField.model',
type: 'model-select',
placeholder: 'Select or enter model...',
default: '',
settingsPath: 'reranker.api_model',
showWhen: (env) => env['CODEXLENS_RERANKER_BACKEND'] === 'api',
localModels: [],
apiModels: [
{
group: 'SiliconFlow',
items: ['BAAI/bge-reranker-v2-m3', 'BAAI/bge-reranker-large', 'BAAI/bge-reranker-base'],
},
{
group: 'Cohere',
items: ['rerank-english-v3.0', 'rerank-multilingual-v3.0'],
},
{
group: 'Jina',
items: ['jina-reranker-v2-base-multilingual'],
},
],
},
CODEXLENS_RERANKER_MODEL: {
key: 'CODEXLENS_RERANKER_MODEL',
labelKey: 'codexlens.envField.localModel',
type: 'model-select',
placeholder: 'Select local model...',
default: 'Xenova/ms-marco-MiniLM-L-6-v2',
settingsPath: 'reranker.model',
showWhen: (env) => env['CODEXLENS_RERANKER_BACKEND'] !== 'api',
localModels: [
{
group: 'FastEmbed/ONNX',
items: [
'Xenova/ms-marco-MiniLM-L-6-v2',
'cross-encoder/ms-marco-MiniLM-L-6-v2',
'BAAI/bge-reranker-base',
],
},
],
apiModels: [],
},
CODEXLENS_RERANKER_TOP_K: {
key: 'CODEXLENS_RERANKER_TOP_K',
labelKey: 'codexlens.envField.topKResults',
type: 'number',
placeholder: '20',
default: '20',
settingsPath: 'reranker.top_k',
min: 5,
max: 200,
},
CODEXLENS_RERANKER_BATCH_SIZE: {
key: 'CODEXLENS_RERANKER_BATCH_SIZE',
labelKey: 'codexlens.envField.batchSize',
type: 'number',
placeholder: '32',
default: '32',
settingsPath: 'reranker.batch_size',
min: 1,
max: 128,
},
},
},
search: {
id: 'search',
labelKey: 'codexlens.envGroup.search',
icon: 'git-branch',
vars: {
CODEXLENS_BINARY_TOP_K: {
key: 'CODEXLENS_BINARY_TOP_K',
labelKey: 'codexlens.envField.binaryTopK',
type: 'number',
placeholder: '200',
default: '200',
settingsPath: 'search.binary_top_k',
min: 10,
max: 1000,
},
CODEXLENS_ANN_TOP_K: {
key: 'CODEXLENS_ANN_TOP_K',
labelKey: 'codexlens.envField.annTopK',
type: 'number',
placeholder: '50',
default: '50',
settingsPath: 'search.ann_top_k',
min: 5,
max: 500,
},
CODEXLENS_FTS_TOP_K: {
key: 'CODEXLENS_FTS_TOP_K',
labelKey: 'codexlens.envField.ftsTopK',
type: 'number',
placeholder: '50',
default: '50',
settingsPath: 'search.fts_top_k',
min: 5,
max: 500,
},
CODEXLENS_FUSION_K: {
key: 'CODEXLENS_FUSION_K',
labelKey: 'codexlens.envField.fusionK',
type: 'number',
placeholder: '60',
default: '60',
settingsPath: 'search.fusion_k',
min: 1,
max: 200,
},
},
},
indexing: {
id: 'indexing',
labelKey: 'codexlens.envGroup.indexing',
icon: 'cpu',
vars: {
CODEXLENS_CODE_AWARE_CHUNKING: {
key: 'CODEXLENS_CODE_AWARE_CHUNKING',
labelKey: 'codexlens.envField.codeAwareChunking',
type: 'checkbox',
default: 'true',
settingsPath: 'indexing.code_aware_chunking',
},
CODEXLENS_INDEX_WORKERS: {
key: 'CODEXLENS_INDEX_WORKERS',
labelKey: 'codexlens.envField.indexWorkers',
type: 'number',
placeholder: '2',
default: '2',
settingsPath: 'indexing.workers',
min: 1,
max: 16,
},
CODEXLENS_MAX_FILE_SIZE: {
key: 'CODEXLENS_MAX_FILE_SIZE',
labelKey: 'codexlens.envField.maxFileSize',
type: 'number',
placeholder: '1000000',
default: '1000000',
settingsPath: 'indexing.max_file_size_bytes',
min: 10000,
max: 10000000,
},
CODEXLENS_HNSW_EF: {
key: 'CODEXLENS_HNSW_EF',
labelKey: 'codexlens.envField.hnswEf',
type: 'number',
placeholder: '150',
default: '150',
settingsPath: 'indexing.hnsw_ef',
min: 10,
max: 500,
},
CODEXLENS_HNSW_M: {
key: 'CODEXLENS_HNSW_M',
labelKey: 'codexlens.envField.hnswM',
type: 'number',
placeholder: '32',
default: '32',
settingsPath: 'indexing.hnsw_M',
min: 4,
max: 128,
},
},
},
};
/**
* Get all env var keys from the schema
*/
export function getAllEnvVarKeys(): string[] {
const keys: string[] = [];
for (const group of Object.values(envVarGroupsSchema)) {
for (const key of Object.keys(group.vars)) {
keys.push(key);
}
}
return keys;
}
/**
* Evaluate showWhen condition for a field
*/
export function evaluateShowWhen(
field: { showWhen?: (env: Record<string, string>) => boolean },
values: Record<string, string>
): boolean {
if (!field.showWhen) return true;
return field.showWhen(values);
}
/**
* Get default values for all env vars in the schema
*/
export function getSchemaDefaults(): Record<string, string> {
const defaults: Record<string, string> = {};
for (const group of Object.values(envVarGroupsSchema)) {
for (const [key, field] of Object.entries(group.vars)) {
if (field.default !== undefined) {
defaults[key] = field.default;
}
}
}
return defaults;
}

View File

@@ -290,84 +290,15 @@ export type {
WorkspaceQueryKeys, WorkspaceQueryKeys,
} from './useWorkspaceQueryKeys'; } from './useWorkspaceQueryKeys';
// ========== CodexLens ========== // ========== CodexLens (v2) ==========
export { export {
useCodexLensDashboard, useV2SearchManager,
useCodexLensStatus, } from './useV2SearchManager';
useCodexLensWorkspaceStatus,
useCodexLensConfig,
useCodexLensModels,
useCodexLensModelInfo,
useCodexLensEnv,
useCodexLensGpu,
useCodexLensIgnorePatterns,
useUpdateCodexLensConfig,
useBootstrapCodexLens,
useUninstallCodexLens,
useDownloadModel,
useDeleteModel,
useUpdateCodexLensEnv,
useSelectGpu,
useUpdateIgnorePatterns,
useCodexLensMutations,
codexLensKeys,
useCodexLensIndexes,
useCodexLensIndexingStatus,
useRebuildIndex,
useUpdateIndex,
useCancelIndexing,
useCodexLensWatcher,
useCodexLensWatcherMutations,
useCodexLensLspStatus,
useCodexLensLspMutations,
useCodexLensRerankerConfig,
useUpdateRerankerConfig,
useCcwToolsList,
} from './useCodexLens';
export type { export type {
UseCodexLensDashboardOptions, V2IndexStatus,
UseCodexLensDashboardReturn, V2SearchTestResult,
UseCodexLensStatusOptions, UseV2SearchManagerReturn,
UseCodexLensStatusReturn, } from './useV2SearchManager';
UseCodexLensWorkspaceStatusOptions,
UseCodexLensWorkspaceStatusReturn,
UseCodexLensConfigOptions,
UseCodexLensConfigReturn,
UseCodexLensModelsOptions,
UseCodexLensModelsReturn,
UseCodexLensModelInfoOptions,
UseCodexLensModelInfoReturn,
UseCodexLensEnvOptions,
UseCodexLensEnvReturn,
UseCodexLensGpuOptions,
UseCodexLensGpuReturn,
UseCodexLensIgnorePatternsOptions,
UseCodexLensIgnorePatternsReturn,
UseUpdateCodexLensConfigReturn,
UseBootstrapCodexLensReturn,
UseUninstallCodexLensReturn,
UseDownloadModelReturn,
UseDeleteModelReturn,
UseUpdateCodexLensEnvReturn,
UseSelectGpuReturn,
UseUpdateIgnorePatternsReturn,
UseCodexLensIndexesOptions,
UseCodexLensIndexesReturn,
UseCodexLensIndexingStatusReturn,
UseRebuildIndexReturn,
UseUpdateIndexReturn,
UseCancelIndexingReturn,
UseCodexLensWatcherOptions,
UseCodexLensWatcherReturn,
UseCodexLensWatcherMutationsReturn,
UseCodexLensLspStatusOptions,
UseCodexLensLspStatusReturn,
UseCodexLensLspMutationsReturn,
UseCodexLensRerankerConfigOptions,
UseCodexLensRerankerConfigReturn,
UseUpdateRerankerConfigReturn,
UseCcwToolsListReturn,
} from './useCodexLens';
// ========== Skill Hub ========== // ========== Skill Hub ==========
export { export {

View File

@@ -1,418 +0,0 @@
// ========================================
// useCodexLens Hook Tests
// ========================================
// Tests for all CodexLens TanStack Query hooks
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as api from '../lib/api';
import {
useCodexLensDashboard,
useCodexLensStatus,
useCodexLensConfig,
useCodexLensModels,
useCodexLensEnv,
useCodexLensGpu,
useUpdateCodexLensConfig,
useBootstrapCodexLens,
useUninstallCodexLens,
useDownloadModel,
useDeleteModel,
useUpdateCodexLensEnv,
useSelectGpu,
} from './useCodexLens';
// Mock api module
vi.mock('../lib/api', () => ({
fetchCodexLensDashboardInit: vi.fn(),
fetchCodexLensStatus: vi.fn(),
fetchCodexLensConfig: vi.fn(),
updateCodexLensConfig: vi.fn(),
bootstrapCodexLens: vi.fn(),
uninstallCodexLens: vi.fn(),
fetchCodexLensModels: vi.fn(),
fetchCodexLensModelInfo: vi.fn(),
downloadCodexLensModel: vi.fn(),
downloadCodexLensCustomModel: vi.fn(),
deleteCodexLensModel: vi.fn(),
deleteCodexLensModelByPath: vi.fn(),
fetchCodexLensEnv: vi.fn(),
updateCodexLensEnv: vi.fn(),
fetchCodexLensGpuDetect: vi.fn(),
fetchCodexLensGpuList: vi.fn(),
selectCodexLensGpu: vi.fn(),
resetCodexLensGpu: vi.fn(),
fetchCodexLensIgnorePatterns: vi.fn(),
updateCodexLensIgnorePatterns: vi.fn(),
}));
// Mock workflowStore
vi.mock('../stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => () => '/test/project'),
selectProjectPath: vi.fn(() => '/test/project'),
}));
const mockDashboardData = {
installed: true,
status: {
ready: true,
installed: true,
version: '1.0.0',
pythonVersion: '3.11.0',
venvPath: '/path/to/venv',
},
config: {
index_dir: '~/.codexlens/indexes',
index_count: 100,
},
semantic: { available: true },
};
const mockModelsData = {
models: [
{
profile: 'model1',
name: 'Embedding Model 1',
type: 'embedding',
backend: 'onnx',
installed: true,
cache_path: '/path/to/cache1',
},
{
profile: 'model2',
name: 'Reranker Model 1',
type: 'reranker',
backend: 'onnx',
installed: false,
cache_path: '/path/to/cache2',
},
],
};
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
function wrapper({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
describe('useCodexLens Hook', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useCodexLensDashboard', () => {
it('should fetch dashboard data', async () => {
vi.mocked(api.fetchCodexLensDashboardInit).mockResolvedValue(mockDashboardData);
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledOnce();
expect(result.current.installed).toBe(true);
expect(result.current.status?.ready).toBe(true);
expect(result.current.config?.index_dir).toBe('~/.codexlens/indexes');
});
it('should handle errors', async () => {
vi.mocked(api.fetchCodexLensDashboardInit).mockRejectedValue(new Error('API Error'));
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.error).toBeTruthy();
// TanStack Query wraps errors, so just check error exists
expect(result.current.error).toBeDefined();
});
it('should be disabled when enabled is false', async () => {
const { result } = renderHook(() => useCodexLensDashboard({ enabled: false }), { wrapper });
expect(api.fetchCodexLensDashboardInit).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
});
});
describe('useCodexLensStatus', () => {
it('should fetch status data', async () => {
const mockStatus = { ready: true, installed: true, version: '1.0.0' };
vi.mocked(api.fetchCodexLensStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useCodexLensStatus(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensStatus).toHaveBeenCalledOnce();
expect(result.current.ready).toBe(true);
expect(result.current.installed).toBe(true);
});
});
describe('useCodexLensConfig', () => {
it('should fetch config data', async () => {
const mockConfig = {
index_dir: '~/.codexlens/indexes',
index_count: 100,
};
vi.mocked(api.fetchCodexLensConfig).mockResolvedValue(mockConfig);
const { result } = renderHook(() => useCodexLensConfig(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensConfig).toHaveBeenCalledOnce();
expect(result.current.indexDir).toBe('~/.codexlens/indexes');
expect(result.current.indexCount).toBe(100);
});
});
describe('useCodexLensModels', () => {
it('should fetch and filter models by type', async () => {
vi.mocked(api.fetchCodexLensModels).mockResolvedValue(mockModelsData as any);
const { result } = renderHook(() => useCodexLensModels(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.models).toHaveLength(2);
expect(result.current.embeddingModels).toHaveLength(1);
expect(result.current.rerankerModels).toHaveLength(1);
expect(result.current.embeddingModels?.[0].type).toBe('embedding');
});
});
describe('useCodexLensEnv', () => {
it('should fetch environment variables', async () => {
const mockEnv = {
env: { KEY1: 'value1', KEY2: 'value2' },
settings: { SETTING1: 'setting1' },
raw: 'KEY1=value1\nKEY2=value2',
};
vi.mocked(api.fetchCodexLensEnv).mockResolvedValue(mockEnv as any);
const { result } = renderHook(() => useCodexLensEnv(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensEnv).toHaveBeenCalledOnce();
expect(result.current.env).toEqual({ KEY1: 'value1', KEY2: 'value2' });
expect(result.current.settings).toEqual({ SETTING1: 'setting1' });
expect(result.current.raw).toBe('KEY1=value1\nKEY2=value2');
});
});
describe('useCodexLensGpu', () => {
it('should fetch GPU detect and list data', async () => {
const mockDetect = { supported: true, has_cuda: true };
const mockList = {
devices: [
{ id: 0, name: 'GPU 0', type: 'cuda', driver: '12.0', memory: '8GB' },
],
selected_device_id: 0,
};
vi.mocked(api.fetchCodexLensGpuDetect).mockResolvedValue(mockDetect as any);
vi.mocked(api.fetchCodexLensGpuList).mockResolvedValue(mockList as any);
const { result } = renderHook(() => useCodexLensGpu(), { wrapper });
await waitFor(() => expect(result.current.isLoadingDetect).toBe(false));
await waitFor(() => expect(result.current.isLoadingList).toBe(false));
expect(api.fetchCodexLensGpuDetect).toHaveBeenCalledOnce();
expect(api.fetchCodexLensGpuList).toHaveBeenCalledOnce();
expect(result.current.supported).toBe(true);
expect(result.current.devices).toHaveLength(1);
expect(result.current.selectedDeviceId).toBe(0);
});
});
describe('useUpdateCodexLensConfig', () => {
it('should update config and invalidate queries', async () => {
vi.mocked(api.updateCodexLensConfig).mockResolvedValue({
success: true,
message: 'Config updated',
});
const { result } = renderHook(() => useUpdateCodexLensConfig(), { wrapper });
const updateResult = await result.current.updateConfig({
index_dir: '~/.codexlens/indexes',
});
expect(api.updateCodexLensConfig).toHaveBeenCalledWith({
index_dir: '~/.codexlens/indexes',
});
expect(updateResult.success).toBe(true);
expect(updateResult.message).toBe('Config updated');
});
});
describe('useBootstrapCodexLens', () => {
it('should bootstrap CodexLens and invalidate queries', async () => {
vi.mocked(api.bootstrapCodexLens).mockResolvedValue({
success: true,
version: '1.0.0',
});
const { result } = renderHook(() => useBootstrapCodexLens(), { wrapper });
const bootstrapResult = await result.current.bootstrap();
expect(api.bootstrapCodexLens).toHaveBeenCalledOnce();
expect(bootstrapResult.success).toBe(true);
expect(bootstrapResult.version).toBe('1.0.0');
});
});
describe('useUninstallCodexLens', () => {
it('should uninstall CodexLens and invalidate queries', async () => {
vi.mocked(api.uninstallCodexLens).mockResolvedValue({
success: true,
message: 'CodexLens uninstalled',
});
const { result } = renderHook(() => useUninstallCodexLens(), { wrapper });
const uninstallResult = await result.current.uninstall();
expect(api.uninstallCodexLens).toHaveBeenCalledOnce();
expect(uninstallResult.success).toBe(true);
});
});
describe('useDownloadModel', () => {
it('should download model by profile', async () => {
vi.mocked(api.downloadCodexLensModel).mockResolvedValue({
success: true,
message: 'Model downloaded',
});
const { result } = renderHook(() => useDownloadModel(), { wrapper });
const downloadResult = await result.current.downloadModel('model1');
expect(api.downloadCodexLensModel).toHaveBeenCalledWith('model1');
expect(downloadResult.success).toBe(true);
});
it('should download custom model', async () => {
vi.mocked(api.downloadCodexLensCustomModel).mockResolvedValue({
success: true,
message: 'Custom model downloaded',
});
const { result } = renderHook(() => useDownloadModel(), { wrapper });
const downloadResult = await result.current.downloadCustomModel('custom/model', 'embedding');
expect(api.downloadCodexLensCustomModel).toHaveBeenCalledWith('custom/model', 'embedding');
expect(downloadResult.success).toBe(true);
});
});
describe('useDeleteModel', () => {
it('should delete model by profile', async () => {
vi.mocked(api.deleteCodexLensModel).mockResolvedValue({
success: true,
message: 'Model deleted',
});
const { result } = renderHook(() => useDeleteModel(), { wrapper });
const deleteResult = await result.current.deleteModel('model1');
expect(api.deleteCodexLensModel).toHaveBeenCalledWith('model1');
expect(deleteResult.success).toBe(true);
});
it('should delete model by path', async () => {
vi.mocked(api.deleteCodexLensModelByPath).mockResolvedValue({
success: true,
message: 'Model deleted',
});
const { result } = renderHook(() => useDeleteModel(), { wrapper });
const deleteResult = await result.current.deleteModelByPath('/path/to/model');
expect(api.deleteCodexLensModelByPath).toHaveBeenCalledWith('/path/to/model');
expect(deleteResult.success).toBe(true);
});
});
describe('useUpdateCodexLensEnv', () => {
it('should update environment variables', async () => {
vi.mocked(api.updateCodexLensEnv).mockResolvedValue({
success: true,
env: { KEY1: 'newvalue' },
settings: {},
raw: 'KEY1=newvalue',
} as any);
const { result } = renderHook(() => useUpdateCodexLensEnv(), { wrapper });
const updateResult = await result.current.updateEnv({
raw: 'KEY1=newvalue',
} as any);
expect(api.updateCodexLensEnv).toHaveBeenCalledWith({ raw: 'KEY1=newvalue' });
expect(updateResult.success).toBe(true);
});
});
describe('useSelectGpu', () => {
it('should select GPU', async () => {
vi.mocked(api.selectCodexLensGpu).mockResolvedValue({
success: true,
message: 'GPU selected',
});
const { result } = renderHook(() => useSelectGpu(), { wrapper });
const selectResult = await result.current.selectGpu(0);
expect(api.selectCodexLensGpu).toHaveBeenCalledWith(0);
expect(selectResult.success).toBe(true);
});
it('should reset GPU', async () => {
vi.mocked(api.resetCodexLensGpu).mockResolvedValue({
success: true,
message: 'GPU reset',
});
const { result } = renderHook(() => useSelectGpu(), { wrapper });
const resetResult = await result.current.resetGpu();
expect(api.resetCodexLensGpu).toHaveBeenCalledOnce();
expect(resetResult.success).toBe(true);
});
});
describe('query refetch', () => {
it('should refetch dashboard data', async () => {
vi.mocked(api.fetchCodexLensDashboardInit).mockResolvedValue(mockDashboardData);
const { result } = renderHook(() => useCodexLensDashboard(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledTimes(1);
await result.current.refetch();
expect(api.fetchCodexLensDashboardInit).toHaveBeenCalledTimes(2);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
// ========================================
// useV2SearchManager Hook
// ========================================
// React hook for v2 search management via smart_search tool
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// ========== Types ==========
export interface V2IndexStatus {
indexed: boolean;
totalFiles: number;
totalChunks: number;
lastIndexedAt: string | null;
dbSizeBytes: number;
vectorDimension: number | null;
ftsEnabled: boolean;
}
export interface V2SearchTestResult {
query: string;
results: Array<{
file: string;
score: number;
snippet: string;
}>;
timingMs: number;
totalResults: number;
}
export interface UseV2SearchManagerReturn {
status: V2IndexStatus | null;
isLoadingStatus: boolean;
statusError: Error | null;
refetchStatus: () => void;
search: (query: string) => Promise<V2SearchTestResult>;
isSearching: boolean;
searchResult: V2SearchTestResult | null;
reindex: () => Promise<void>;
isReindexing: boolean;
}
// ========== API helpers ==========
async function fetchWithJson<T>(url: string, body?: Record<string, unknown>): Promise<T> {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}
async function fetchV2Status(): Promise<V2IndexStatus> {
const data = await fetchWithJson<{ result?: V2IndexStatus; error?: string }>('/api/tools', {
tool_name: 'smart_search',
action: 'status',
});
if (data.error) {
throw new Error(data.error);
}
// Provide defaults for fields that may be missing
return {
indexed: false,
totalFiles: 0,
totalChunks: 0,
lastIndexedAt: null,
dbSizeBytes: 0,
vectorDimension: null,
ftsEnabled: false,
...data.result,
};
}
async function fetchV2Search(query: string): Promise<V2SearchTestResult> {
const data = await fetchWithJson<{ result?: V2SearchTestResult; error?: string }>('/api/tools', {
tool_name: 'smart_search',
action: 'search',
params: { query, limit: 10 },
});
if (data.error) {
throw new Error(data.error);
}
return data.result ?? { query, results: [], timingMs: 0, totalResults: 0 };
}
async function fetchV2Reindex(): Promise<void> {
const data = await fetchWithJson<{ error?: string }>('/api/tools', {
tool_name: 'smart_search',
action: 'reindex',
});
if (data.error) {
throw new Error(data.error);
}
}
// ========== Query Keys ==========
export const v2SearchKeys = {
all: ['v2-search'] as const,
status: () => [...v2SearchKeys.all, 'status'] as const,
};
// ========== Hook ==========
export function useV2SearchManager(): UseV2SearchManagerReturn {
const queryClient = useQueryClient();
const [searchResult, setSearchResult] = useState<V2SearchTestResult | null>(null);
// Status query
const statusQuery = useQuery({
queryKey: v2SearchKeys.status(),
queryFn: fetchV2Status,
staleTime: 30_000,
retry: 1,
});
// Search mutation
const searchMutation = useMutation({
mutationFn: (query: string) => fetchV2Search(query),
onSuccess: (data) => {
setSearchResult(data);
},
});
// Reindex mutation
const reindexMutation = useMutation({
mutationFn: fetchV2Reindex,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: v2SearchKeys.status() });
},
});
const search = useCallback(async (query: string) => {
const result = await searchMutation.mutateAsync(query);
return result;
}, [searchMutation]);
const reindex = useCallback(async () => {
await reindexMutation.mutateAsync();
}, [reindexMutation]);
return {
status: statusQuery.data ?? null,
isLoadingStatus: statusQuery.isLoading,
statusError: statusQuery.error as Error | null,
refetchStatus: () => statusQuery.refetch(),
search,
isSearching: searchMutation.isPending,
searchResult,
reindex,
isReindexing: reindexMutation.isPending,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,390 +1,28 @@
{ {
"title": "CodexLens", "title": "Search Manager",
"description": "Semantic code search engine", "description": "V2 semantic search index management",
"bootstrap": "Bootstrap", "reindex": "Reindex",
"bootstrapping": "Bootstrapping...", "reindexing": "Reindexing...",
"uninstall": "Uninstall", "statusError": "Failed to load search index status",
"uninstalling": "Uninstalling...", "indexStatus": {
"confirmUninstall": "Are you sure you want to uninstall CodexLens? This action cannot be undone.", "title": "Index Status",
"confirmUninstallTitle": "Confirm Uninstall", "status": "Status",
"notInstalled": "CodexLens is not installed", "ready": "Ready",
"comingSoon": "Coming Soon", "notIndexed": "Not Indexed",
"tabs": { "files": "Files",
"overview": "Overview", "dbSize": "DB Size",
"settings": "Settings", "lastIndexed": "Last Indexed",
"models": "Models", "chunks": "Chunks",
"search": "Search", "vectorDim": "Vector Dim",
"advanced": "Advanced"
},
"overview": {
"status": {
"installation": "Installation Status",
"ready": "Ready",
"notReady": "Not Ready",
"version": "Version",
"indexPath": "Index Path",
"indexCount": "Index Count"
},
"notInstalled": {
"title": "CodexLens Not Installed",
"message": "Please install CodexLens to use semantic code search features."
},
"actions": {
"title": "Quick Actions",
"ftsFull": "FTS Full",
"ftsFullDesc": "Rebuild full-text index",
"ftsIncremental": "FTS Incremental",
"ftsIncrementalDesc": "Incremental update full-text index",
"vectorFull": "Vector Full",
"vectorFullDesc": "Rebuild vector index",
"vectorIncremental": "Vector Incremental",
"vectorIncrementalDesc": "Incremental update vector index"
},
"venv": {
"title": "Python Virtual Environment Details",
"pythonVersion": "Python Version",
"venvPath": "Virtual Environment Path",
"lastCheck": "Last Check Time"
}
},
"index": {
"operationComplete": "Index Operation Complete",
"operationFailed": "Index Operation Failed",
"noProject": "No Project Selected",
"noProjectDesc": "Please open a project to perform index operations.",
"starting": "Starting index operation...",
"cancelFailed": "Failed to cancel operation",
"unknownError": "An unknown error occurred",
"complete": "Complete",
"failed": "Failed",
"cancelled": "Cancelled",
"inProgress": "In Progress"
},
"semantic": {
"installTitle": "Install Semantic Search",
"installDescription": "Install FastEmbed and semantic search dependencies with GPU acceleration support.",
"installInfo": "GPU acceleration requires compatible hardware. CPU mode works on all systems but is slower.",
"gpu": {
"cpu": "CPU Mode",
"cpuDesc": "Universal compatibility, slower processing. Works on all systems.",
"directml": "DirectML (Windows GPU)",
"directmlDesc": "Best for Windows with AMD/Intel GPUs. Recommended for most users.",
"cuda": "CUDA (NVIDIA GPU)",
"cudaDesc": "Best performance with NVIDIA GPUs. Requires CUDA toolkit."
},
"recommended": "Recommended",
"install": "Install",
"installing": "Installing...",
"installSuccess": "Installation Complete",
"installSuccessDesc": "Semantic search installed successfully with {mode} mode",
"installFailed": "Installation Failed",
"unknownError": "An unknown error occurred"
},
"settings": {
"currentCount": "Current Index Count",
"currentWorkers": "Current Workers",
"currentBatchSize": "Current Batch Size",
"configTitle": "Basic Configuration",
"indexDir": {
"label": "Index Directory",
"placeholder": "~/.codexlens/indexes",
"hint": "Directory path for storing code indexes"
},
"maxWorkers": {
"label": "Max Workers",
"hint": "API concurrent processing threads (1-32)"
},
"batchSize": {
"label": "Batch Size",
"hint": "Number of files processed per batch (1-64)"
},
"validation": {
"indexDirRequired": "Index directory is required",
"maxWorkersRange": "Workers must be between 1 and 32",
"batchSizeRange": "Batch size must be between 1 and 64"
},
"save": "Save",
"saving": "Saving...",
"reset": "Reset",
"saveSuccess": "Configuration saved",
"saveFailed": "Save failed",
"configUpdated": "Configuration updated successfully",
"saveError": "Error saving configuration",
"unknownError": "An unknown error occurred"
},
"gpu": {
"title": "GPU Settings",
"status": "GPU Status",
"enabled": "Enabled", "enabled": "Enabled",
"available": "Available", "disabled": "Disabled",
"unavailable": "Unavailable", "unavailable": "Index status unavailable"
"supported": "Your system supports GPU acceleration",
"notSupported": "Your system does not support GPU acceleration",
"detect": "Detect",
"detectSuccess": "GPU detection completed",
"detectFailed": "GPU detection failed",
"detectComplete": "Detected {count} GPU devices",
"detectError": "Error detecting GPU",
"select": "Select",
"selected": "Selected",
"active": "Current",
"selectSuccess": "GPU selected",
"selectFailed": "GPU selection failed",
"gpuSelected": "GPU device enabled",
"selectError": "Error selecting GPU",
"reset": "Reset",
"resetSuccess": "GPU reset",
"resetFailed": "GPU reset failed",
"gpuReset": "GPU disabled, will use CPU",
"resetError": "Error resetting GPU",
"unknownError": "An unknown error occurred",
"noDevices": "No GPU devices detected",
"notAvailable": "GPU functionality not available",
"unknownDevice": "Unknown device",
"type": "Type",
"discrete": "Discrete GPU",
"integrated": "Integrated GPU",
"driver": "Driver Version",
"memory": "Memory"
}, },
"advanced": { "searchTest": {
"warningTitle": "Sensitive Operations Warning", "title": "Search Test",
"warningMessage": "Modifying environment variables may affect CodexLens operation. Ensure you understand each variable's purpose.", "placeholder": "Enter search query...",
"loadError": "Failed to load environment variables",
"loadErrorDesc": "Unable to fetch environment configuration. Please check if CodexLens is properly installed.",
"currentVars": "Current Environment Variables",
"settingsVars": "Settings Variables",
"customVars": "Custom Variables",
"envEditor": "Environment Variable Editor",
"envFile": "File",
"envContent": "Environment Variable Content",
"envPlaceholder": "# Comment lines start with #\nKEY=value\nANOTHER_KEY=\"another value\"",
"envHint": "One variable per line, format: KEY=value. Comment lines start with #",
"save": "Save",
"saving": "Saving...",
"reset": "Reset",
"saveSuccess": "Environment variables saved",
"saveFailed": "Save failed",
"envUpdated": "Environment variables updated, restart service to take effect",
"saveError": "Error saving environment variables",
"unknownError": "An unknown error occurred",
"validation": {
"invalidKeys": "Invalid variable names: {keys}"
},
"helpTitle": "Format Help",
"helpComment": "Comment lines start with #",
"helpFormat": "Variable format: KEY=value",
"helpQuotes": "Values with spaces should use quotes",
"helpRestart": "Restart service after changes to take effect"
},
"downloadedModels": "Downloaded Models",
"noConfiguredModels": "No models configured",
"noLocalModels": "No models downloaded",
"models": {
"title": "Model Management",
"searchPlaceholder": "Search models...",
"downloading": "Downloading...",
"status": {
"downloaded": "Downloaded",
"available": "Available"
},
"types": {
"embedding": "Embedding Models",
"reranker": "Reranker Models"
},
"filters": {
"label": "Filter",
"all": "All"
},
"actions": {
"download": "Download",
"delete": "Delete",
"cancel": "Cancel"
},
"custom": {
"title": "Custom Model",
"placeholder": "HuggingFace model name (e.g., BAAI/bge-small-zh-v1.5)",
"description": "Download custom models from HuggingFace. Ensure the model name is correct."
},
"deleteConfirm": "Are you sure you want to delete model {modelName}?",
"notInstalled": {
"title": "CodexLens Not Installed",
"description": "Please install CodexLens to use model management features."
},
"error": {
"title": "Failed to load models",
"description": "Unable to fetch model list. Please check if CodexLens is properly installed."
},
"empty": {
"title": "No models found",
"description": "No models are available. Try downloading models from the list.",
"filtered": "No models match your filter",
"filteredDesc": "Try adjusting your search or filter criteria"
}
},
"search": {
"type": "Search Type",
"content": "Content Search",
"files": "File Search",
"symbol": "Symbol Search",
"semantic": "Semantic Search (LSP)",
"mode": "Mode",
"mode.semantic": "Semantic (default)",
"mode.exact": "Exact (FTS)",
"mode.fuzzy": "Fuzzy",
"semanticMode": "Search Mode",
"semanticMode.fusion": "Fusion Search",
"semanticMode.vector": "Vector Search",
"semanticMode.structural": "Structural Search",
"fusionStrategy": "Fusion Strategy",
"fusionStrategy.rrf": "RRF (default)",
"fusionStrategy.dense_rerank": "Dense Rerank",
"fusionStrategy.binary": "Binary",
"fusionStrategy.hybrid": "Hybrid",
"fusionStrategy.staged": "Staged",
"stagedStage2Mode": "Staged Stage 2",
"stagedStage2Mode.precomputed": "Precomputed (graph_neighbors)",
"stagedStage2Mode.realtime": "Realtime (LSP)",
"stagedStage2Mode.static_global_graph": "Static Global Graph",
"lspStatus": "LSP Status",
"lspAvailable": "Semantic search available",
"lspUnavailable": "Semantic search unavailable",
"lspNoVector": "Vector index required",
"lspNoSemantic": "Semantic dependencies required",
"query": "Query",
"queryPlaceholder": "Enter search query...",
"button": "Search", "button": "Search",
"searching": "Searching...", "results": "results",
"results": "Results", "noResults": "No results found"
"resultsCount": "results",
"notInstalled": {
"title": "CodexLens Not Installed",
"description": "Please install CodexLens to use semantic code search features."
}
},
"reranker": {
"title": "Reranker Configuration",
"description": "Configure the reranker backend, model, and provider for search result ranking.",
"backend": "Backend",
"backendHint": "Inference backend for reranking",
"model": "Model",
"modelHint": "Reranker model name or LiteLLM endpoint",
"provider": "API Provider",
"providerHint": "API provider for reranker service",
"apiKeyStatus": "API Key",
"apiKeySet": "Configured",
"apiKeyNotSet": "Not configured",
"configSource": "Config Source",
"save": "Save Reranker Config",
"saving": "Saving...",
"saveSuccess": "Reranker configuration saved",
"saveFailed": "Failed to save reranker configuration",
"noBackends": "No backends available",
"noModels": "No models available",
"noProviders": "No providers available",
"litellmModels": "LiteLLM Models",
"selectBackend": "Select backend...",
"selectModel": "Select model...",
"selectProvider": "Select provider..."
},
"envGroup": {
"embedding": "Embedding",
"reranker": "Reranker",
"search": "Search Pipeline",
"indexing": "Indexing"
},
"envField": {
"backend": "Backend",
"model": "Model",
"localModel": "Local Model",
"apiUrl": "API URL",
"apiKey": "API Key",
"multiEndpoints": "Multi-Endpoint",
"embedDim": "Embed Dimension",
"apiConcurrency": "Concurrency",
"maxTokensPerBatch": "Max Tokens/Batch",
"useGpu": "Device",
"topKResults": "Top K Results",
"batchSize": "Batch Size",
"binaryTopK": "Binary Top K",
"annTopK": "ANN Top K",
"ftsTopK": "FTS Top K",
"fusionK": "Fusion K",
"codeAwareChunking": "Code-Aware Chunking",
"indexWorkers": "Index Workers",
"maxFileSize": "Max File Size (bytes)",
"hnswEf": "HNSW ef",
"hnswM": "HNSW M"
},
"install": {
"title": "Install CodexLens",
"description": "Set up Python virtual environment and install CodexLens package.",
"checklist": "What will be installed",
"pythonVenv": "Python Virtual Environment",
"pythonVenvDesc": "Isolated Python environment for CodexLens",
"codexlensPackage": "CodexLens Package",
"codexlensPackageDesc": "Core semantic code search engine",
"sqliteFts": "SQLite FTS5",
"sqliteFtsDesc": "Full-text search extension for fast code lookup",
"location": "Install Location",
"locationPath": "~/.codexlens/venv",
"timeEstimate": "Installation may take 1-3 minutes depending on network speed.",
"stage": {
"creatingVenv": "Creating Python virtual environment...",
"installingPip": "Installing pip dependencies...",
"installingPackage": "Installing CodexLens package...",
"settingUpDeps": "Setting up dependencies...",
"finalizing": "Finalizing installation...",
"complete": "Installation complete!"
},
"installNow": "Install Now",
"installing": "Installing..."
},
"mcp": {
"title": "CCW Tools Registry",
"loading": "Loading tools...",
"error": "Failed to load tools",
"errorDesc": "Unable to fetch CCW tools list. Please check if the server is running.",
"emptyDesc": "No tools are currently registered.",
"totalCount": "{count} tools",
"codexLensSection": "CodexLens Tools",
"otherSection": "Other Tools"
},
"watcher": {
"title": "File Watcher",
"status": {
"running": "Running",
"stopped": "Stopped"
},
"eventsProcessed": "Events Processed",
"uptime": "Uptime",
"start": "Start Watcher",
"starting": "Starting...",
"stop": "Stop Watcher",
"stopping": "Stopping...",
"started": "File watcher started",
"stopped": "File watcher stopped"
},
"lsp": {
"title": "LSP Server",
"status": {
"running": "Running",
"stopped": "Stopped"
},
"projects": "Projects",
"embeddings": "Embeddings",
"modes": "Modes",
"semanticAvailable": "Semantic",
"available": "Available",
"unavailable": "Unavailable",
"start": "Start Server",
"starting": "Starting...",
"stop": "Stop Server",
"stopping": "Stopping...",
"restart": "Restart",
"restarting": "Restarting...",
"started": "LSP server started",
"stopped": "LSP server stopped",
"restarted": "LSP server restarted"
} }
} }

View File

@@ -27,7 +27,7 @@
"prompts": "Prompt History", "prompts": "Prompt History",
"settings": "Settings", "settings": "Settings",
"mcp": "MCP Servers", "mcp": "MCP Servers",
"codexlens": "CodexLens", "codexlens": "Search Manager",
"apiSettings": "API Settings", "apiSettings": "API Settings",
"endpoints": "CLI Endpoints", "endpoints": "CLI Endpoints",
"installations": "Installations", "installations": "Installations",

View File

@@ -1,390 +1,28 @@
{ {
"title": "CodexLens", "title": "搜索管理",
"description": "语义代码搜索引擎", "description": "V2 语义搜索索引管理",
"bootstrap": "引导安装", "reindex": "重建索引",
"bootstrapping": "安装中...", "reindexing": "重建中...",
"uninstall": "卸载", "statusError": "加载搜索索引状态失败",
"uninstalling": "卸载中...", "indexStatus": {
"confirmUninstall": "确定要卸载 CodexLens 吗?此操作无法撤销。", "title": "索引状态",
"confirmUninstallTitle": "确认卸载", "status": "状态",
"notInstalled": "CodexLens 尚未安装", "ready": "就绪",
"comingSoon": "即将推出", "notIndexed": "未索引",
"tabs": { "files": "文件数",
"overview": "概览", "dbSize": "数据库大小",
"settings": "设置", "lastIndexed": "上次索引",
"models": "模型", "chunks": "分块数",
"search": "搜索", "vectorDim": "向量维度",
"advanced": "高级"
},
"overview": {
"status": {
"installation": "安装状态",
"ready": "就绪",
"notReady": "未就绪",
"version": "版本",
"indexPath": "索引路径",
"indexCount": "索引数量"
},
"notInstalled": {
"title": "CodexLens 未安装",
"message": "请先安装 CodexLens 以使用语义代码搜索功能。"
},
"actions": {
"title": "快速操作",
"ftsFull": "FTS 全量",
"ftsFullDesc": "重建全文索引",
"ftsIncremental": "FTS 增量",
"ftsIncrementalDesc": "增量更新全文索引",
"vectorFull": "向量全量",
"vectorFullDesc": "重建向量索引",
"vectorIncremental": "向量增量",
"vectorIncrementalDesc": "增量更新向量索引"
},
"venv": {
"title": "Python 虚拟环境详情",
"pythonVersion": "Python 版本",
"venvPath": "虚拟环境路径",
"lastCheck": "最后检查时间"
}
},
"index": {
"operationComplete": "索引操作完成",
"operationFailed": "索引操作失败",
"noProject": "未选择项目",
"noProjectDesc": "请打开一个项目以执行索引操作。",
"starting": "正在启动索引操作...",
"cancelFailed": "取消操作失败",
"unknownError": "发生未知错误",
"complete": "完成",
"failed": "失败",
"cancelled": "已取消",
"inProgress": "进行中"
},
"semantic": {
"installTitle": "安装语义搜索",
"installDescription": "安装 FastEmbed 和语义搜索依赖,支持 GPU 加速。",
"installInfo": "GPU 加速需要兼容的硬件。CPU 模式在所有系统上都可用,但速度较慢。",
"gpu": {
"cpu": "CPU 模式",
"cpuDesc": "通用兼容,处理较慢。适用于所有系统。",
"directml": "DirectMLWindows GPU",
"directmlDesc": "最适合带 AMD/Intel GPU 的 Windows 系统。推荐大多数用户使用。",
"cuda": "CUDANVIDIA GPU",
"cudaDesc": "NVIDIA GPU 性能最佳。需要 CUDA 工具包。"
},
"recommended": "推荐",
"install": "安装",
"installing": "安装中...",
"installSuccess": "安装完成",
"installSuccessDesc": "语义搜索已成功安装,使用 {mode} 模式",
"installFailed": "安装失败",
"unknownError": "发生未知错误"
},
"settings": {
"currentCount": "当前索引数量",
"currentWorkers": "当前工作线程",
"currentBatchSize": "当前批次大小",
"configTitle": "基本配置",
"indexDir": {
"label": "索引目录",
"placeholder": "~/.codexlens/indexes",
"hint": "存储代码索引的目录路径"
},
"maxWorkers": {
"label": "最大工作线程",
"hint": "API 并发处理线程数 (1-32)"
},
"batchSize": {
"label": "批次大小",
"hint": "每次批量处理的文件数量 (1-64)"
},
"validation": {
"indexDirRequired": "索引目录不能为空",
"maxWorkersRange": "工作线程数必须在 1-32 之间",
"batchSizeRange": "批次大小必须在 1-64 之间"
},
"save": "保存",
"saving": "保存中...",
"reset": "重置",
"saveSuccess": "配置已保存",
"saveFailed": "保存失败",
"configUpdated": "配置更新成功",
"saveError": "保存配置时出错",
"unknownError": "发生未知错误"
},
"gpu": {
"title": "GPU 设置",
"status": "GPU 状态",
"enabled": "已启用", "enabled": "已启用",
"available": "用", "disabled": "已禁用",
"unavailable": "不可用", "unavailable": "索引状态不可用"
"supported": "您的系统支持 GPU 加速",
"notSupported": "您的系统不支持 GPU 加速",
"detect": "检测",
"detectSuccess": "GPU 检测完成",
"detectFailed": "GPU 检测失败",
"detectComplete": "检测到 {count} 个 GPU 设备",
"detectError": "检测 GPU 时出错",
"select": "选择",
"selected": "已选择",
"active": "当前",
"selectSuccess": "GPU 已选择",
"selectFailed": "GPU 选择失败",
"gpuSelected": "GPU 设备已启用",
"selectError": "选择 GPU 时出错",
"reset": "重置",
"resetSuccess": "GPU 已重置",
"resetFailed": "GPU 重置失败",
"gpuReset": "GPU 已禁用,将使用 CPU",
"resetError": "重置 GPU 时出错",
"unknownError": "发生未知错误",
"noDevices": "未检测到 GPU 设备",
"notAvailable": "GPU 功能不可用",
"unknownDevice": "未知设备",
"type": "类型",
"discrete": "独立显卡",
"integrated": "集成显卡",
"driver": "驱动版本",
"memory": "显存"
}, },
"advanced": { "searchTest": {
"warningTitle": "敏感操作警告", "title": "搜索测试",
"warningMessage": "修改环境变量可能影响 CodexLens 的正常运行。请确保您了解每个变量的作用。", "placeholder": "输入搜索查询...",
"loadError": "加载环境变量失败",
"loadErrorDesc": "无法获取环境配置。请检查 CodexLens 是否正确安装。",
"currentVars": "当前环境变量",
"settingsVars": "设置变量",
"customVars": "自定义变量",
"envEditor": "环境变量编辑器",
"envFile": "文件",
"envContent": "环境变量内容",
"envPlaceholder": "# 注释行以 # 开头\nKEY=value\nANOTHER_KEY=\"another value\"",
"envHint": "每行一个变量格式KEY=value。注释行以 # 开头",
"save": "保存",
"saving": "保存中...",
"reset": "重置",
"saveSuccess": "环境变量已保存",
"saveFailed": "保存失败",
"envUpdated": "环境变量更新成功,重启服务后生效",
"saveError": "保存环境变量时出错",
"unknownError": "发生未知错误",
"validation": {
"invalidKeys": "无效的变量名: {keys}"
},
"helpTitle": "格式说明",
"helpComment": "注释行以 # 开头",
"helpFormat": "变量格式KEY=value",
"helpQuotes": "包含空格的值建议使用引号",
"helpRestart": "修改后需要重启服务才能生效"
},
"downloadedModels": "已下载模型",
"noConfiguredModels": "无已配置模型",
"noLocalModels": "无已下载模型",
"models": {
"title": "模型管理",
"searchPlaceholder": "搜索模型...",
"downloading": "下载中...",
"status": {
"downloaded": "已下载",
"available": "可用"
},
"types": {
"embedding": "嵌入模型",
"reranker": "重排序模型"
},
"filters": {
"label": "筛选",
"all": "全部"
},
"actions": {
"download": "下载",
"delete": "删除",
"cancel": "取消"
},
"custom": {
"title": "自定义模型",
"placeholder": "HuggingFace 模型名称 (如: BAAI/bge-small-zh-v1.5)",
"description": "从 HuggingFace 下载自定义模型。请确保模型名称正确。"
},
"deleteConfirm": "确定要删除模型 {modelName} 吗?",
"notInstalled": {
"title": "CodexLens 未安装",
"description": "请先安装 CodexLens 以使用模型管理功能。"
},
"error": {
"title": "加载模型失败",
"description": "无法获取模型列表。请检查 CodexLens 是否正确安装。"
},
"empty": {
"title": "没有找到模型",
"description": "当前没有可用模型。请从列表中下载模型。",
"filtered": "没有匹配的模型",
"filteredDesc": "尝试调整搜索或筛选条件"
}
},
"search": {
"type": "搜索类型",
"content": "内容搜索",
"files": "文件搜索",
"symbol": "符号搜索",
"semantic": "语义搜索 (LSP)",
"mode": "模式",
"mode.semantic": "语义(默认)",
"mode.exact": "精确FTS",
"mode.fuzzy": "模糊",
"semanticMode": "搜索模式",
"semanticMode.fusion": "融合搜索",
"semanticMode.vector": "向量搜索",
"semanticMode.structural": "结构搜索",
"fusionStrategy": "融合策略",
"fusionStrategy.rrf": "RRF默认",
"fusionStrategy.dense_rerank": "Dense Rerank",
"fusionStrategy.binary": "Binary",
"fusionStrategy.hybrid": "Hybrid",
"fusionStrategy.staged": "Staged",
"stagedStage2Mode": "Stage 2 扩展",
"stagedStage2Mode.precomputed": "预计算 (graph_neighbors)",
"stagedStage2Mode.realtime": "实时 (LSP)",
"stagedStage2Mode.static_global_graph": "静态全局图",
"lspStatus": "LSP 状态",
"lspAvailable": "语义搜索可用",
"lspUnavailable": "语义搜索不可用",
"lspNoVector": "需要先建立向量索引",
"lspNoSemantic": "需要先安装语义依赖",
"query": "查询",
"queryPlaceholder": "输入搜索查询...",
"button": "搜索", "button": "搜索",
"searching": "搜索中...", "results": "个结果",
"results": "结果", "noResults": "未找到结果"
"resultsCount": "个结果",
"notInstalled": {
"title": "CodexLens 未安装",
"description": "请先安装 CodexLens 以使用语义代码搜索功能。"
}
},
"reranker": {
"title": "重排序配置",
"description": "配置重排序后端、模型和提供商,用于搜索结果排序。",
"backend": "后端",
"backendHint": "重排序推理后端",
"model": "模型",
"modelHint": "重排序模型名称或 LiteLLM 端点",
"provider": "API 提供商",
"providerHint": "重排序服务的 API 提供商",
"apiKeyStatus": "API 密钥",
"apiKeySet": "已配置",
"apiKeyNotSet": "未配置",
"configSource": "配置来源",
"save": "保存重排序配置",
"saving": "保存中...",
"saveSuccess": "重排序配置已保存",
"saveFailed": "保存重排序配置失败",
"noBackends": "无可用后端",
"noModels": "无可用模型",
"noProviders": "无可用提供商",
"litellmModels": "LiteLLM 模型",
"selectBackend": "选择后端...",
"selectModel": "选择模型...",
"selectProvider": "选择提供商..."
},
"envGroup": {
"embedding": "嵌入模型",
"reranker": "重排序",
"search": "搜索流水线",
"indexing": "索引"
},
"envField": {
"backend": "后端",
"model": "模型",
"localModel": "本地模型",
"apiUrl": "API 地址",
"apiKey": "API 密钥",
"multiEndpoints": "多端点",
"embedDim": "向量维度",
"apiConcurrency": "并发数",
"maxTokensPerBatch": "每批最大Token数",
"useGpu": "设备",
"topKResults": "Top K 结果数",
"batchSize": "批次大小",
"binaryTopK": "二值粗筛 K",
"annTopK": "ANN 精筛 K",
"ftsTopK": "全文搜索 K",
"fusionK": "融合 K",
"codeAwareChunking": "代码感知分块",
"indexWorkers": "索引线程数",
"maxFileSize": "最大文件大小(字节)",
"hnswEf": "HNSW ef",
"hnswM": "HNSW M"
},
"install": {
"title": "安装 CodexLens",
"description": "设置 Python 虚拟环境并安装 CodexLens 包。",
"checklist": "将要安装的内容",
"pythonVenv": "Python 虚拟环境",
"pythonVenvDesc": "CodexLens 的隔离 Python 环境",
"codexlensPackage": "CodexLens 包",
"codexlensPackageDesc": "核心语义代码搜索引擎",
"sqliteFts": "SQLite FTS5",
"sqliteFtsDesc": "用于快速代码查找的全文搜索扩展",
"location": "安装位置",
"locationPath": "~/.codexlens/venv",
"timeEstimate": "安装可能需要 1-3 分钟,取决于网络速度。",
"stage": {
"creatingVenv": "正在创建 Python 虚拟环境...",
"installingPip": "正在安装 pip 依赖...",
"installingPackage": "正在安装 CodexLens 包...",
"settingUpDeps": "正在设置依赖项...",
"finalizing": "正在完成安装...",
"complete": "安装完成!"
},
"installNow": "立即安装",
"installing": "安装中..."
},
"mcp": {
"title": "CCW 工具注册表",
"loading": "加载工具中...",
"error": "加载工具失败",
"errorDesc": "无法获取 CCW 工具列表。请检查服务器是否正在运行。",
"emptyDesc": "当前没有已注册的工具。",
"totalCount": "{count} 个工具",
"codexLensSection": "CodexLens 工具",
"otherSection": "其他工具"
},
"watcher": {
"title": "文件监听器",
"status": {
"running": "运行中",
"stopped": "已停止"
},
"eventsProcessed": "已处理事件",
"uptime": "运行时间",
"start": "启动监听",
"starting": "启动中...",
"stop": "停止监听",
"stopping": "停止中...",
"started": "文件监听器已启动",
"stopped": "文件监听器已停止"
},
"lsp": {
"title": "LSP 服务器",
"status": {
"running": "运行中",
"stopped": "已停止"
},
"projects": "项目数",
"embeddings": "嵌入模型",
"modes": "模式",
"semanticAvailable": "语义搜索",
"available": "可用",
"unavailable": "不可用",
"start": "启动服务",
"starting": "启动中...",
"stop": "停止服务",
"stopping": "停止中...",
"restart": "重启",
"restarting": "重启中...",
"started": "LSP 服务器已启动",
"stopped": "LSP 服务器已停止",
"restarted": "LSP 服务器已重启"
} }
} }

View File

@@ -27,7 +27,7 @@
"prompts": "提示历史", "prompts": "提示历史",
"settings": "设置", "settings": "设置",
"mcp": "MCP 服务器", "mcp": "MCP 服务器",
"codexlens": "CodexLens", "codexlens": "搜索管理",
"apiSettings": "API 设置", "apiSettings": "API 设置",
"endpoints": "CLI 端点", "endpoints": "CLI 端点",
"installations": "安装", "installations": "安装",

View File

@@ -1,361 +1,196 @@
// ======================================== // ========================================
// CodexLens Manager Page Tests // CodexLens Manager Page Tests (v2)
// ======================================== // ========================================
// Integration tests for CodexLens manager page with tabs // Tests for v2 search management page
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n'; import { render, screen } from '@/test/i18n';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { CodexLensManagerPage } from './CodexLensManagerPage'; import { CodexLensManagerPage } from './CodexLensManagerPage';
// Mock api module // Mock the v2 search manager hook
vi.mock('@/lib/api', () => ({ vi.mock('@/hooks/useV2SearchManager', () => ({
fetchCodexLensDashboardInit: vi.fn(), useV2SearchManager: vi.fn(),
bootstrapCodexLens: vi.fn(),
uninstallCodexLens: vi.fn(),
})); }));
// Mock hooks import { useV2SearchManager } from '@/hooks/useV2SearchManager';
vi.mock('@/hooks/useCodexLens', () => ({
useCodexLensDashboard: vi.fn(),
}));
vi.mock('@/hooks/useCodexLens', () => ({ const mockStatus = {
useCodexLensDashboard: vi.fn(), indexed: true,
})); totalFiles: 150,
totalChunks: 1200,
vi.mock('@/hooks/useNotifications', () => ({ lastIndexedAt: '2026-03-17T10:00:00Z',
useNotifications: vi.fn(() => ({ dbSizeBytes: 5242880,
success: vi.fn(), vectorDimension: 384,
error: vi.fn(), ftsEnabled: true,
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
addToast: vi.fn(),
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
connectWebSocket: vi.fn(),
disconnectWebSocket: vi.fn(),
})),
}));
// Mock the mutations hook separately
vi.mock('@/hooks/useCodexLens', async () => {
return {
useCodexLensDashboard: (await import('@/hooks/useCodexLens')).useCodexLensDashboard,
useCodexLensMutations: vi.fn(),
};
});
// Mock window.confirm
global.confirm = vi.fn(() => true);
const mockDashboardData = {
installed: true,
status: {
ready: true,
installed: true,
version: '1.0.0',
pythonVersion: '3.11.0',
venvPath: '/path/to/venv',
},
config: {
index_dir: '~/.codexlens/indexes',
index_count: 100,
},
semantic: { available: true },
}; };
const mockMutations = { const defaultHookReturn = {
bootstrap: vi.fn().mockResolvedValue({ success: true }), status: mockStatus,
uninstall: vi.fn().mockResolvedValue({ success: true }), isLoadingStatus: false,
isBootstrapping: false, statusError: null,
isUninstalling: false, refetchStatus: vi.fn(),
search: vi.fn().mockResolvedValue({
query: 'test',
results: [],
timingMs: 12.5,
totalResults: 0,
}),
isSearching: false,
searchResult: null,
reindex: vi.fn().mockResolvedValue(undefined),
isReindexing: false,
}; };
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks'; describe('CodexLensManagerPage (v2)', () => {
describe('CodexLensManagerPage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(true); (vi.mocked(useV2SearchManager) as any).mockReturnValue(defaultHookReturn);
}); });
describe('when installed', () => { it('should render page title', () => {
beforeEach(() => { render(<CodexLensManagerPage />);
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({ // The title comes from i18n codexlens.title
installed: true, expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
});
it('should render page title and description', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
expect(screen.getByText(/Semantic code search engine/i)).toBeInTheDocument();
});
it('should render all tabs', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/Overview/i)).toBeInTheDocument();
expect(screen.getByText(/Settings/i)).toBeInTheDocument();
expect(screen.getByText(/Models/i)).toBeInTheDocument();
expect(screen.getByText(/Advanced/i)).toBeInTheDocument();
});
it('should show uninstall button when installed', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/Uninstall/i)).toBeInTheDocument();
});
it('should switch between tabs', async () => {
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const settingsTab = screen.getByText(/Settings/i);
await user.click(settingsTab);
expect(settingsTab).toHaveAttribute('data-state', 'active');
});
it('should call refresh on button click', async () => {
const refetch = vi.fn();
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const refreshButton = screen.getByText(/Refresh/i);
await user.click(refreshButton);
expect(refetch).toHaveBeenCalledOnce();
});
}); });
describe('when not installed', () => { it('should render index status section', () => {
beforeEach(() => { render(<CodexLensManagerPage />);
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({ // Check for file count display
installed: false, expect(screen.getByText('150')).toBeInTheDocument();
status: undefined,
config: undefined,
semantic: undefined,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
});
it('should show bootstrap button', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/Bootstrap/i)).toBeInTheDocument();
});
it('should show not installed alert', () => {
render(<CodexLensManagerPage />);
expect(screen.getByText(/CodexLens is not installed/i)).toBeInTheDocument();
});
it('should call bootstrap on button click', async () => {
const bootstrap = vi.fn().mockResolvedValue({ success: true });
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
bootstrap,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const bootstrapButton = screen.getByText(/Bootstrap/i);
await user.click(bootstrapButton);
await waitFor(() => {
expect(bootstrap).toHaveBeenCalledOnce();
});
});
}); });
describe('uninstall flow', () => { it('should render search input', () => {
beforeEach(() => { render(<CodexLensManagerPage />);
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({ const input = screen.getByPlaceholderText(/search query/i);
installed: true, expect(input).toBeInTheDocument();
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
});
it('should show confirmation dialog on uninstall', async () => {
const uninstall = vi.fn().mockResolvedValue({ success: true });
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
uninstall,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const uninstallButton = screen.getByText(/Uninstall/i);
await user.click(uninstallButton);
expect(global.confirm).toHaveBeenCalledWith(expect.stringContaining('uninstall'));
});
it('should call uninstall when confirmed', async () => {
const uninstall = vi.fn().mockResolvedValue({ success: true });
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
uninstall,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const uninstallButton = screen.getByText(/Uninstall/i);
await user.click(uninstallButton);
await waitFor(() => {
expect(uninstall).toHaveBeenCalledOnce();
});
});
it('should not call uninstall when cancelled', async () => {
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(false);
const uninstall = vi.fn().mockResolvedValue({ success: true });
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
uninstall,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const uninstallButton = screen.getByText(/Uninstall/i);
await user.click(uninstallButton);
expect(uninstall).not.toHaveBeenCalled();
});
}); });
describe('loading states', () => { it('should call refetchStatus on refresh click', async () => {
it('should show loading skeleton when loading', () => { const refetchStatus = vi.fn();
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({ (vi.mocked(useV2SearchManager) as any).mockReturnValue({
installed: false, ...defaultHookReturn,
status: undefined, refetchStatus,
config: undefined,
semantic: undefined,
isLoading: true,
isFetching: true,
error: null,
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />);
// Check for skeleton or loading indicator
const refreshButton = screen.getByText(/Refresh/i);
expect(refreshButton).toBeDisabled();
}); });
it('should disable refresh button when fetching', () => { const user = userEvent.setup();
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({ render(<CodexLensManagerPage />);
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: true,
error: null,
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />); const refreshButton = screen.getByText(/Refresh/i);
await user.click(refreshButton);
const refreshButton = screen.getByText(/Refresh/i); expect(refetchStatus).toHaveBeenCalledOnce();
expect(refreshButton).toBeDisabled(); });
it('should call search when clicking search button', async () => {
const searchFn = vi.fn().mockResolvedValue({
query: 'test query',
results: [],
timingMs: 5,
totalResults: 0,
}); });
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
search: searchFn,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const input = screen.getByPlaceholderText(/search query/i);
await user.type(input, 'test query');
const searchButton = screen.getByText(/Search/i);
await user.click(searchButton);
expect(searchFn).toHaveBeenCalledWith('test query');
});
it('should display search results', () => {
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
searchResult: {
query: 'auth',
results: [
{ file: 'src/auth.ts', score: 0.95, snippet: 'export function authenticate()' },
],
timingMs: 8.2,
totalResults: 1,
},
});
render(<CodexLensManagerPage />);
expect(screen.getByText('src/auth.ts')).toBeInTheDocument();
expect(screen.getByText('95.0%')).toBeInTheDocument();
expect(screen.getByText('export function authenticate()')).toBeInTheDocument();
});
it('should call reindex on button click', async () => {
const reindexFn = vi.fn().mockResolvedValue(undefined);
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
reindex: reindexFn,
});
const user = userEvent.setup();
render(<CodexLensManagerPage />);
const reindexButton = screen.getByText(/Reindex/i);
await user.click(reindexButton);
expect(reindexFn).toHaveBeenCalledOnce();
});
it('should show loading skeleton when status is loading', () => {
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
status: null,
isLoadingStatus: true,
});
render(<CodexLensManagerPage />);
// Should have pulse animation elements
const pulseElements = document.querySelectorAll('.animate-pulse');
expect(pulseElements.length).toBeGreaterThan(0);
});
it('should show error alert when status fetch fails', () => {
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
status: null,
statusError: new Error('Network error'),
});
render(<CodexLensManagerPage />);
// Error message should be visible
expect(screen.getByText(/Failed to load/i)).toBeInTheDocument();
});
it('should show not indexed state', () => {
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
...defaultHookReturn,
status: {
...mockStatus,
indexed: false,
totalFiles: 0,
totalChunks: 0,
},
});
render(<CodexLensManagerPage />);
expect(screen.getByText(/Not Indexed/i)).toBeInTheDocument();
}); });
describe('i18n - Chinese locale', () => { describe('i18n - Chinese locale', () => {
beforeEach(() => {
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
semantic: mockDashboardData.semantic,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
});
it('should display translated text in Chinese', () => { it('should display translated text in Chinese', () => {
render(<CodexLensManagerPage />, { locale: 'zh' }); render(<CodexLensManagerPage />, { locale: 'zh' });
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument(); // Page title from zh codexlens.json
expect(screen.getByText(/语义代码搜索引擎/i)).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
expect(screen.getByText(/概览/i)).toBeInTheDocument();
expect(screen.getByText(/设置/i)).toBeInTheDocument();
expect(screen.getByText(/模型/i)).toBeInTheDocument();
expect(screen.getByText(/高级/i)).toBeInTheDocument();
});
it('should display translated uninstall button', () => {
render(<CodexLensManagerPage />, { locale: 'zh' });
expect(screen.getByText(/卸载/i)).toBeInTheDocument();
});
});
describe('error states', () => {
it('should handle API errors gracefully', () => {
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: false,
status: undefined,
config: undefined,
semantic: undefined,
isLoading: false,
isFetching: false,
error: new Error('API Error'),
refetch: vi.fn(),
});
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />);
// Page should still render even with error
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
}); });
}); });
}); });

View File

@@ -1,85 +1,67 @@
// ======================================== // ========================================
// CodexLens Manager Page // CodexLens Manager Page (v2)
// ======================================== // ========================================
// Manage CodexLens semantic code search with tabbed interface // V2 search management interface with index status, search test, and configuration
// Supports Overview, Settings, Models, and Advanced tabs
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { import {
Sparkles, Search,
RefreshCw, RefreshCw,
Download, Database,
Trash2,
Zap, Zap,
AlertCircle,
CheckCircle2,
Clock,
FileText,
HardDrive,
} from 'lucide-react'; } from 'lucide-react';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { TabsNavigation } from '@/components/ui/TabsNavigation'; import { useV2SearchManager } from '@/hooks';
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/AlertDialog';
import { OverviewTab } from '@/components/codexlens/OverviewTab';
import { SettingsTab } from '@/components/codexlens/SettingsTab';
import { AdvancedTab } from '@/components/codexlens/AdvancedTab';
import { ModelsTab } from '@/components/codexlens/ModelsTab';
import { SearchTab } from '@/components/codexlens/SearchTab';
import { SemanticInstallDialog } from '@/components/codexlens/SemanticInstallDialog';
import { InstallProgressOverlay } from '@/components/codexlens/InstallProgressOverlay';
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
try {
return new Date(dateStr).toLocaleString();
} catch {
return dateStr;
}
}
export function CodexLensManagerPage() { export function CodexLensManagerPage() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState('overview'); const [searchQuery, setSearchQuery] = useState('');
const [isUninstallDialogOpen, setIsUninstallDialogOpen] = useState(false);
const [isSemanticInstallOpen, setIsSemanticInstallOpen] = useState(false);
const [isInstallOverlayOpen, setIsInstallOverlayOpen] = useState(false);
const { const {
installed,
status, status,
config, isLoadingStatus,
semantic, statusError,
isLoading, refetchStatus,
isFetching, search,
refetch, isSearching,
} = useCodexLensDashboard(); searchResult,
reindex,
isReindexing,
} = useV2SearchManager();
const { const handleSearch = async () => {
bootstrap, if (!searchQuery.trim()) return;
isBootstrapping, await search(searchQuery.trim());
uninstall,
isUninstalling,
} = useCodexLensMutations();
const handleRefresh = () => {
refetch();
}; };
const handleBootstrap = () => { const handleKeyDown = (e: React.KeyboardEvent) => {
setIsInstallOverlayOpen(true); if (e.key === 'Enter') {
}; handleSearch();
const handleBootstrapInstall = async () => {
const result = await bootstrap();
return result;
};
const handleUninstall = async () => {
const result = await uninstall();
if (result.success) {
refetch();
} }
setIsUninstallDialogOpen(false);
}; };
return ( return (
@@ -88,7 +70,7 @@ export function CodexLensManagerPage() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2"> <h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Sparkles className="w-6 h-6 text-primary" /> <Search className="w-6 h-6 text-primary" />
{formatMessage({ id: 'codexlens.title' })} {formatMessage({ id: 'codexlens.title' })}
</h1> </h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
@@ -98,150 +80,196 @@ export function CodexLensManagerPage() {
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={handleRefresh} onClick={refetchStatus}
disabled={isFetching} disabled={isLoadingStatus}
> >
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} /> <RefreshCw className={cn('w-4 h-4 mr-2', isLoadingStatus && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })} {formatMessage({ id: 'common.actions.refresh' })}
</Button> </Button>
{!installed ? ( <Button
<Button onClick={() => reindex()}
onClick={handleBootstrap} disabled={isReindexing}
disabled={isBootstrapping} >
> <Zap className={cn('w-4 h-4 mr-2', isReindexing && 'animate-spin')} />
<Download className={cn('w-4 h-4 mr-2', isBootstrapping && 'animate-spin')} /> {isReindexing
{isBootstrapping ? formatMessage({ id: 'codexlens.reindexing' })
? formatMessage({ id: 'codexlens.bootstrapping' }) : formatMessage({ id: 'codexlens.reindex' })
: formatMessage({ id: 'codexlens.bootstrap' }) }
} </Button>
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => setIsSemanticInstallOpen(true)}
disabled={!semantic?.available}
>
<Zap className="w-4 h-4 mr-2" />
{formatMessage({ id: 'codexlens.semantic.install' })}
</Button>
<AlertDialog open={isUninstallDialogOpen} onOpenChange={setIsUninstallDialogOpen}>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={isUninstalling}
>
<Trash2 className={cn('w-4 h-4 mr-2', isUninstalling && 'animate-spin')} />
{isUninstalling
? formatMessage({ id: 'codexlens.uninstalling' })
: formatMessage({ id: 'codexlens.uninstall' })
}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage({ id: 'codexlens.confirmUninstallTitle' })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage({ id: 'codexlens.confirmUninstall' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isUninstalling}>
{formatMessage({ id: 'common.actions.cancel' })}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleUninstall}
disabled={isUninstalling}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isUninstalling
? formatMessage({ id: 'codexlens.uninstalling' })
: formatMessage({ id: 'common.actions.confirm' })
}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div> </div>
</div> </div>
{/* Installation Status Alert */} {/* Error Alert */}
{!installed && !isLoading && ( {statusError && (
<Card className="p-4 bg-warning/10 border-warning/20"> <Card className="p-4 bg-destructive/10 border-destructive/20">
<p className="text-sm text-warning-foreground"> <div className="flex items-center gap-2">
{formatMessage({ id: 'codexlens.notInstalled' })} <AlertCircle className="w-4 h-4 text-destructive" />
</p> <p className="text-sm text-destructive">
{formatMessage({ id: 'codexlens.statusError' })}
</p>
</div>
</Card> </Card>
)} )}
{/* Tabbed Interface */} {/* Index Status Section */}
<TabsNavigation <Card className="p-6">
value={activeTab} <h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
onValueChange={setActiveTab} <Database className="w-5 h-5 text-primary" />
tabs={[ {formatMessage({ id: 'codexlens.indexStatus.title' })}
{ value: 'overview', label: formatMessage({ id: 'codexlens.tabs.overview' }) }, </h2>
{ value: 'settings', label: formatMessage({ id: 'codexlens.tabs.settings' }) },
{ value: 'models', label: formatMessage({ id: 'codexlens.tabs.models' }) },
{ value: 'search', label: formatMessage({ id: 'codexlens.tabs.search' }) },
{ value: 'advanced', label: formatMessage({ id: 'codexlens.tabs.advanced' }) },
]}
/>
{/* Tab Content */} {isLoadingStatus ? (
{activeTab === 'overview' && ( <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="mt-4"> {[1, 2, 3, 4].map((i) => (
<OverviewTab <div key={i} className="h-16 bg-muted/50 rounded-lg animate-pulse" />
installed={installed} ))}
status={status} </div>
config={config} ) : status ? (
isLoading={isLoading} <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
onRefresh={handleRefresh} <div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
{status.indexed ? (
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5" />
) : (
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5" />
)}
<div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.indexStatus.status' })}
</p>
<p className="text-sm font-medium">
{status.indexed
? formatMessage({ id: 'codexlens.indexStatus.ready' })
: formatMessage({ id: 'codexlens.indexStatus.notIndexed' })
}
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
<FileText className="w-5 h-5 text-blue-500 mt-0.5" />
<div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.indexStatus.files' })}
</p>
<p className="text-sm font-medium">{status.totalFiles.toLocaleString()}</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
<HardDrive className="w-5 h-5 text-purple-500 mt-0.5" />
<div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.indexStatus.dbSize' })}
</p>
<p className="text-sm font-medium">{formatBytes(status.dbSizeBytes)}</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
<Clock className="w-5 h-5 text-orange-500 mt-0.5" />
<div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'codexlens.indexStatus.lastIndexed' })}
</p>
<p className="text-sm font-medium">{formatDate(status.lastIndexedAt)}</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'codexlens.indexStatus.unavailable' })}
</p>
)}
{status && (
<div className="mt-4 flex gap-4 text-xs text-muted-foreground">
<span>
{formatMessage({ id: 'codexlens.indexStatus.chunks' })}: {status.totalChunks.toLocaleString()}
</span>
{status.vectorDimension && (
<span>
{formatMessage({ id: 'codexlens.indexStatus.vectorDim' })}: {status.vectorDimension}
</span>
)}
<span>
FTS: {status.ftsEnabled
? formatMessage({ id: 'codexlens.indexStatus.enabled' })
: formatMessage({ id: 'codexlens.indexStatus.disabled' })
}
</span>
</div>
)}
</Card>
{/* Search Test Section */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Search className="w-5 h-5 text-primary" />
{formatMessage({ id: 'codexlens.searchTest.title' })}
</h2>
<div className="flex gap-2 mb-4">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={formatMessage({ id: 'codexlens.searchTest.placeholder' })}
className="flex-1 px-3 py-2 border border-input rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/> />
<Button
onClick={handleSearch}
disabled={isSearching || !searchQuery.trim()}
>
{isSearching ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Search className="w-4 h-4 mr-2" />
)}
{formatMessage({ id: 'codexlens.searchTest.button' })}
</Button>
</div> </div>
)}
{activeTab === 'settings' && ( {searchResult && (
<div className="mt-4"> <div>
<SettingsTab enabled={installed} /> <div className="flex items-center justify-between mb-2">
</div> <p className="text-sm text-muted-foreground">
)} {searchResult.totalResults} {formatMessage({ id: 'codexlens.searchTest.results' })}
</p>
<p className="text-xs text-muted-foreground">
{searchResult.timingMs.toFixed(1)}ms
</p>
</div>
{activeTab === 'models' && ( {searchResult.results.length > 0 ? (
<div className="mt-4"> <div className="space-y-2 max-h-96 overflow-y-auto">
<ModelsTab installed={installed} /> {searchResult.results.map((result, idx) => (
</div> <div
)} key={idx}
className="p-3 rounded-lg border border-border bg-muted/20 hover:bg-muted/40 transition-colors"
{activeTab === 'search' && ( >
<div className="mt-4"> <div className="flex items-center justify-between mb-1">
<SearchTab enabled={installed} /> <span className="text-sm font-mono text-primary truncate">
</div> {result.file}
)} </span>
<span className="text-xs text-muted-foreground ml-2 shrink-0">
{activeTab === 'advanced' && ( {(result.score * 100).toFixed(1)}%
<div className="mt-4"> </span>
<AdvancedTab enabled={installed} /> </div>
</div> <pre className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-3">
)} {result.snippet}
</pre>
{/* Semantic Install Dialog */} </div>
<SemanticInstallDialog ))}
open={isSemanticInstallOpen} </div>
onOpenChange={setIsSemanticInstallOpen} ) : (
onSuccess={() => refetch()} <p className="text-sm text-muted-foreground text-center py-4">
/> {formatMessage({ id: 'codexlens.searchTest.noResults' })}
</p>
{/* Install Progress Overlay */} )}
<InstallProgressOverlay </div>
open={isInstallOverlayOpen} )}
onOpenChange={setIsInstallOverlayOpen} </Card>
onInstall={handleBootstrapInstall}
onSuccess={() => refetch()}
/>
</div> </div>
); );
} }

View File

@@ -118,173 +118,29 @@ const mockMessages: Record<Locale, Record<string, string>> = {
'issues.discovery.status.failed': 'Failed', 'issues.discovery.status.failed': 'Failed',
'issues.discovery.progress': 'Progress', 'issues.discovery.progress': 'Progress',
'issues.discovery.findings': 'Findings', 'issues.discovery.findings': 'Findings',
// CodexLens // CodexLens (v2)
'codexlens.title': 'CodexLens', 'codexlens.title': 'Search Manager',
'codexlens.description': 'Semantic code search engine', 'codexlens.description': 'V2 semantic search index management',
'codexlens.bootstrap': 'Bootstrap', 'codexlens.reindex': 'Reindex',
'codexlens.bootstrapping': 'Bootstrapping...', 'codexlens.reindexing': 'Reindexing...',
'codexlens.uninstall': 'Uninstall', 'codexlens.statusError': 'Failed to load search index status',
'codexlens.uninstalling': 'Uninstalling...', 'codexlens.indexStatus.title': 'Index Status',
'codexlens.confirmUninstall': 'Are you sure you want to uninstall CodexLens?', 'codexlens.indexStatus.status': 'Status',
'codexlens.notInstalled': 'CodexLens is not installed', 'codexlens.indexStatus.ready': 'Ready',
'codexlens.comingSoon': 'Coming Soon', 'codexlens.indexStatus.notIndexed': 'Not Indexed',
'codexlens.tabs.overview': 'Overview', 'codexlens.indexStatus.files': 'Files',
'codexlens.tabs.settings': 'Settings', 'codexlens.indexStatus.dbSize': 'DB Size',
'codexlens.tabs.models': 'Models', 'codexlens.indexStatus.lastIndexed': 'Last Indexed',
'codexlens.tabs.advanced': 'Advanced', 'codexlens.indexStatus.chunks': 'Chunks',
'codexlens.overview.status.installation': 'Installation Status', 'codexlens.indexStatus.vectorDim': 'Vector Dim',
'codexlens.overview.status.ready': 'Ready', 'codexlens.indexStatus.enabled': 'Enabled',
'codexlens.overview.status.notReady': 'Not Ready', 'codexlens.indexStatus.disabled': 'Disabled',
'codexlens.overview.status.version': 'Version', 'codexlens.indexStatus.unavailable': 'Index status unavailable',
'codexlens.overview.status.indexPath': 'Index Path', 'codexlens.searchTest.title': 'Search Test',
'codexlens.overview.status.indexCount': 'Index Count', 'codexlens.searchTest.placeholder': 'Enter search query...',
'codexlens.overview.notInstalled.title': 'CodexLens Not Installed', 'codexlens.searchTest.button': 'Search',
'codexlens.overview.notInstalled.message': 'Please install CodexLens to use semantic code search features.', 'codexlens.searchTest.results': 'results',
'codexlens.overview.actions.title': 'Quick Actions', 'codexlens.searchTest.noResults': 'No results found',
'codexlens.overview.actions.ftsFull': 'FTS Full',
'codexlens.overview.actions.ftsFullDesc': 'Rebuild full-text index',
'codexlens.overview.actions.ftsIncremental': 'FTS Incremental',
'codexlens.overview.actions.ftsIncrementalDesc': 'Incremental update full-text index',
'codexlens.overview.actions.vectorFull': 'Vector Full',
'codexlens.overview.actions.vectorFullDesc': 'Rebuild vector index',
'codexlens.overview.actions.vectorIncremental': 'Vector Incremental',
'codexlens.overview.actions.vectorIncrementalDesc': 'Incremental update vector index',
'codexlens.overview.venv.title': 'Python Virtual Environment Details',
'codexlens.overview.venv.pythonVersion': 'Python Version',
'codexlens.overview.venv.venvPath': 'Virtual Environment Path',
'codexlens.overview.venv.lastCheck': 'Last Check Time',
'codexlens.install.title': 'Install CodexLens',
// Env Groups & Fields
'codexlens.envGroup.embedding': 'Embedding',
'codexlens.envGroup.reranker': 'Reranker',
'codexlens.envGroup.concurrency': 'Concurrency',
'codexlens.envGroup.cascade': 'Cascade Search',
'codexlens.envGroup.indexing': 'Indexing',
'codexlens.envGroup.chunking': 'Chunking',
'codexlens.envField.backend': 'Backend',
'codexlens.envField.model': 'Model',
'codexlens.envField.useGpu': 'Use GPU',
'codexlens.envField.highAvailability': 'High Availability',
'codexlens.envField.loadBalanceStrategy': 'Load Balance Strategy',
'codexlens.envField.rateLimitCooldown': 'Rate Limit Cooldown',
'codexlens.envField.enabled': 'Enabled',
'codexlens.envField.topKResults': 'Top K Results',
'codexlens.envField.maxWorkers': 'Max Workers',
'codexlens.envField.batchSize': 'Batch Size',
'codexlens.envField.dynamicBatchSize': 'Dynamic Batch Size',
'codexlens.envField.batchSizeUtilization': 'Utilization Factor',
'codexlens.envField.batchSizeMax': 'Max Batch Size',
'codexlens.envField.charsPerToken': 'Chars Per Token',
'codexlens.envField.searchStrategy': 'Search Strategy',
'codexlens.envField.coarseK': 'Coarse K',
'codexlens.envField.fineK': 'Fine K',
'codexlens.envField.stagedStage2Mode': 'Stage-2 Mode',
'codexlens.envField.stagedClusteringStrategy': 'Clustering Strategy',
'codexlens.envField.stagedClusteringMinSize': 'Cluster Min Size',
'codexlens.envField.enableStagedRerank': 'Enable Rerank',
'codexlens.envField.useAstGrep': 'Use ast-grep',
'codexlens.envField.staticGraphEnabled': 'Static Graph',
'codexlens.envField.staticGraphRelationshipTypes': 'Relationship Types',
'codexlens.envField.stripComments': 'Strip Comments',
'codexlens.envField.stripDocstrings': 'Strip Docstrings',
'codexlens.envField.testFilePenalty': 'Test File Penalty',
'codexlens.envField.docstringWeight': 'Docstring Weight',
'codexlens.install.description': 'Set up Python virtual environment and install CodexLens package.',
'codexlens.install.checklist': 'What will be installed',
'codexlens.install.pythonVenv': 'Python Virtual Environment',
'codexlens.install.pythonVenvDesc': 'Isolated Python environment for CodexLens',
'codexlens.install.codexlensPackage': 'CodexLens Package',
'codexlens.install.codexlensPackageDesc': 'Core semantic code search engine',
'codexlens.install.sqliteFts': 'SQLite FTS5',
'codexlens.install.sqliteFtsDesc': 'Full-text search extension for fast code lookup',
'codexlens.install.location': 'Install Location',
'codexlens.install.locationPath': '~/.codexlens/venv',
'codexlens.install.timeEstimate': 'Installation may take 1-3 minutes depending on network speed.',
'codexlens.install.stage.creatingVenv': 'Creating Python virtual environment...',
'codexlens.install.stage.installingPip': 'Installing pip dependencies...',
'codexlens.install.stage.installingPackage': 'Installing CodexLens package...',
'codexlens.install.stage.settingUpDeps': 'Setting up dependencies...',
'codexlens.install.stage.finalizing': 'Finalizing installation...',
'codexlens.install.stage.complete': 'Installation complete!',
'codexlens.install.installNow': 'Install Now',
'codexlens.install.installing': 'Installing...',
'codexlens.watcher.title': 'File Watcher',
'codexlens.watcher.status.running': 'Running',
'codexlens.watcher.status.stopped': 'Stopped',
'codexlens.watcher.eventsProcessed': 'Events Processed',
'codexlens.watcher.uptime': 'Uptime',
'codexlens.watcher.start': 'Start Watcher',
'codexlens.watcher.starting': 'Starting...',
'codexlens.watcher.stop': 'Stop Watcher',
'codexlens.watcher.stopping': 'Stopping...',
'codexlens.watcher.started': 'File watcher started',
'codexlens.watcher.stopped': 'File watcher stopped',
'codexlens.settings.currentCount': 'Current Index Count',
'codexlens.settings.currentWorkers': 'Current Workers',
'codexlens.settings.currentBatchSize': 'Current Batch Size',
'codexlens.settings.configTitle': 'Basic Configuration',
'codexlens.settings.indexDir.label': 'Index Directory',
'codexlens.settings.indexDir.placeholder': '~/.codexlens/indexes',
'codexlens.settings.indexDir.hint': 'Directory path for storing code indexes',
'codexlens.settings.maxWorkers.label': 'Max Workers',
'codexlens.settings.maxWorkers.hint': 'API concurrent processing threads (1-32)',
'codexlens.settings.batchSize.label': 'Batch Size',
'codexlens.settings.batchSize.hint': 'Number of files processed per batch (1-64)',
'codexlens.settings.validation.indexDirRequired': 'Index directory is required',
'codexlens.settings.validation.maxWorkersRange': 'Workers must be between 1 and 32',
'codexlens.settings.validation.batchSizeRange': 'Batch size must be between 1 and 64',
'codexlens.settings.save': 'Save',
'codexlens.settings.saving': 'Saving...',
'codexlens.settings.reset': 'Reset',
'codexlens.settings.saveSuccess': 'Configuration saved',
'codexlens.settings.saveFailed': 'Save failed',
'codexlens.settings.configUpdated': 'Configuration updated successfully',
'codexlens.settings.saveError': 'Error saving configuration',
'codexlens.settings.unknownError': 'An unknown error occurred',
'codexlens.models.title': 'Model Management',
'codexlens.models.searchPlaceholder': 'Search models...',
'codexlens.models.downloading': 'Downloading...',
'codexlens.models.status.downloaded': 'Downloaded',
'codexlens.models.status.available': 'Available',
'codexlens.models.types.embedding': 'Embedding Models',
'codexlens.models.types.reranker': 'Reranker Models',
'codexlens.models.filters.label': 'Filter',
'codexlens.models.filters.all': 'All',
'codexlens.models.actions.download': 'Download',
'codexlens.models.actions.delete': 'Delete',
'codexlens.models.actions.cancel': 'Cancel',
'codexlens.models.custom.title': 'Custom Model',
'codexlens.models.custom.placeholder': 'HuggingFace model name (e.g., BAAI/bge-small-zh-v1.5)',
'codexlens.models.custom.description': 'Download custom models from HuggingFace. Ensure the model name is correct.',
'codexlens.models.deleteConfirm': 'Are you sure you want to delete model {modelName}?',
'codexlens.models.notInstalled.title': 'CodexLens Not Installed',
'codexlens.models.notInstalled.description': 'Please install CodexLens to use model management features.',
'codexlens.models.empty.title': 'No models found',
'codexlens.models.empty.description': 'Try adjusting your search or filter criteria',
// Reranker
'codexlens.reranker.title': 'Reranker Configuration',
'codexlens.reranker.description': 'Configure the reranker backend, model, and provider for search result ranking.',
'codexlens.reranker.backend': 'Backend',
'codexlens.reranker.backendHint': 'Inference backend for reranking',
'codexlens.reranker.model': 'Model',
'codexlens.reranker.modelHint': 'Reranker model name or LiteLLM endpoint',
'codexlens.reranker.provider': 'API Provider',
'codexlens.reranker.providerHint': 'API provider for reranker service',
'codexlens.reranker.apiKeyStatus': 'API Key',
'codexlens.reranker.apiKeySet': 'Configured',
'codexlens.reranker.apiKeyNotSet': 'Not configured',
'codexlens.reranker.configSource': 'Config Source',
'codexlens.reranker.save': 'Save Reranker Config',
'codexlens.reranker.saving': 'Saving...',
'codexlens.reranker.saveSuccess': 'Reranker configuration saved',
'codexlens.reranker.saveFailed': 'Failed to save reranker configuration',
'codexlens.reranker.noBackends': 'No backends available',
'codexlens.reranker.noModels': 'No models available',
'codexlens.reranker.noProviders': 'No providers available',
'codexlens.reranker.litellmModels': 'LiteLLM Models',
'codexlens.reranker.selectBackend': 'Select backend...',
'codexlens.reranker.selectModel': 'Select model...',
'codexlens.reranker.selectProvider': 'Select provider...',
// MCP - CCW Tools // MCP - CCW Tools
'mcp.ccw.title': 'CCW MCP Server', 'mcp.ccw.title': 'CCW MCP Server',
'mcp.ccw.description': 'Configure CCW MCP tools and paths', 'mcp.ccw.description': 'Configure CCW MCP tools and paths',
@@ -438,173 +294,29 @@ const mockMessages: Record<Locale, Record<string, string>> = {
'issues.discovery.status.failed': '失败', 'issues.discovery.status.failed': '失败',
'issues.discovery.progress': '进度', 'issues.discovery.progress': '进度',
'issues.discovery.findings': '发现', 'issues.discovery.findings': '发现',
// CodexLens // CodexLens (v2)
'codexlens.title': 'CodexLens', 'codexlens.title': '搜索管理',
'codexlens.description': '语义代码搜索引擎', 'codexlens.description': 'V2 语义搜索索引管理',
'codexlens.bootstrap': '引导安装', 'codexlens.reindex': '重建索引',
'codexlens.bootstrapping': '安装中...', 'codexlens.reindexing': '重建中...',
'codexlens.uninstall': '卸载', 'codexlens.statusError': '加载搜索索引状态失败',
'codexlens.uninstalling': '卸载中...', 'codexlens.indexStatus.title': '索引状态',
'codexlens.confirmUninstall': '确定要卸载 CodexLens 吗?', 'codexlens.indexStatus.status': '状态',
'codexlens.notInstalled': 'CodexLens 尚未安装', 'codexlens.indexStatus.ready': '就绪',
'codexlens.comingSoon': '即将推出', 'codexlens.indexStatus.notIndexed': '未索引',
'codexlens.tabs.overview': '概览', 'codexlens.indexStatus.files': '文件数',
'codexlens.tabs.settings': '设置', 'codexlens.indexStatus.dbSize': '数据库大小',
'codexlens.tabs.models': '模型', 'codexlens.indexStatus.lastIndexed': '上次索引',
'codexlens.tabs.advanced': '高级', 'codexlens.indexStatus.chunks': '分块数',
'codexlens.overview.status.installation': '安装状态', 'codexlens.indexStatus.vectorDim': '向量维度',
'codexlens.overview.status.ready': '就绪', 'codexlens.indexStatus.enabled': '已启用',
'codexlens.overview.status.notReady': '未就绪', 'codexlens.indexStatus.disabled': '已禁用',
'codexlens.overview.status.version': '版本', 'codexlens.indexStatus.unavailable': '索引状态不可用',
'codexlens.overview.status.indexPath': '索引路径', 'codexlens.searchTest.title': '搜索测试',
'codexlens.overview.status.indexCount': '索引数量', 'codexlens.searchTest.placeholder': '输入搜索查询...',
'codexlens.overview.notInstalled.title': 'CodexLens 未安装', 'codexlens.searchTest.button': '搜索',
'codexlens.overview.notInstalled.message': '请先安装 CodexLens 以使用语义代码搜索功能。', 'codexlens.searchTest.results': '个结果',
'codexlens.overview.actions.title': '快速操作', 'codexlens.searchTest.noResults': '未找到结果',
'codexlens.overview.actions.ftsFull': 'FTS 全量',
'codexlens.overview.actions.ftsFullDesc': '重建全文索引',
'codexlens.overview.actions.ftsIncremental': 'FTS 增量',
'codexlens.overview.actions.ftsIncrementalDesc': '增量更新全文索引',
'codexlens.overview.actions.vectorFull': '向量全量',
'codexlens.overview.actions.vectorFullDesc': '重建向量索引',
'codexlens.overview.actions.vectorIncremental': '向量增量',
'codexlens.overview.actions.vectorIncrementalDesc': '增量更新向量索引',
'codexlens.overview.venv.title': 'Python 虚拟环境详情',
'codexlens.overview.venv.pythonVersion': 'Python 版本',
'codexlens.overview.venv.venvPath': '虚拟环境路径',
'codexlens.overview.venv.lastCheck': '最后检查时间',
'codexlens.install.title': '安装 CodexLens',
// Env Groups & Fields
'codexlens.envGroup.embedding': '嵌入模型',
'codexlens.envGroup.reranker': '重排序',
'codexlens.envGroup.concurrency': '并发',
'codexlens.envGroup.cascade': '级联搜索',
'codexlens.envGroup.indexing': '索引与解析',
'codexlens.envGroup.chunking': '分块',
'codexlens.envField.backend': '后端',
'codexlens.envField.model': '模型',
'codexlens.envField.useGpu': '使用 GPU',
'codexlens.envField.highAvailability': '高可用',
'codexlens.envField.loadBalanceStrategy': '负载均衡策略',
'codexlens.envField.rateLimitCooldown': '限流冷却时间',
'codexlens.envField.enabled': '启用',
'codexlens.envField.topKResults': 'Top K 结果数',
'codexlens.envField.maxWorkers': '最大工作线程',
'codexlens.envField.batchSize': '批次大小',
'codexlens.envField.dynamicBatchSize': '动态批次大小',
'codexlens.envField.batchSizeUtilization': '利用率因子',
'codexlens.envField.batchSizeMax': '最大批次大小',
'codexlens.envField.charsPerToken': '每 Token 字符数',
'codexlens.envField.searchStrategy': '搜索策略',
'codexlens.envField.coarseK': '粗筛 K 值',
'codexlens.envField.fineK': '精筛 K 值',
'codexlens.envField.stagedStage2Mode': 'Stage-2 模式',
'codexlens.envField.stagedClusteringStrategy': '聚类策略',
'codexlens.envField.stagedClusteringMinSize': '最小聚类大小',
'codexlens.envField.enableStagedRerank': '启用重排序',
'codexlens.envField.useAstGrep': '使用 ast-grep',
'codexlens.envField.staticGraphEnabled': '启用静态图',
'codexlens.envField.staticGraphRelationshipTypes': '关系类型',
'codexlens.envField.stripComments': '去除注释',
'codexlens.envField.stripDocstrings': '去除文档字符串',
'codexlens.envField.testFilePenalty': '测试文件惩罚',
'codexlens.envField.docstringWeight': '文档字符串权重',
'codexlens.install.description': '设置 Python 虚拟环境并安装 CodexLens 包。',
'codexlens.install.checklist': '将要安装的内容',
'codexlens.install.pythonVenv': 'Python 虚拟环境',
'codexlens.install.pythonVenvDesc': 'CodexLens 的隔离 Python 环境',
'codexlens.install.codexlensPackage': 'CodexLens 包',
'codexlens.install.codexlensPackageDesc': '核心语义代码搜索引擎',
'codexlens.install.sqliteFts': 'SQLite FTS5',
'codexlens.install.sqliteFtsDesc': '用于快速代码查找的全文搜索扩展',
'codexlens.install.location': '安装位置',
'codexlens.install.locationPath': '~/.codexlens/venv',
'codexlens.install.timeEstimate': '安装可能需要 1-3 分钟,取决于网络速度。',
'codexlens.install.stage.creatingVenv': '正在创建 Python 虚拟环境...',
'codexlens.install.stage.installingPip': '正在安装 pip 依赖...',
'codexlens.install.stage.installingPackage': '正在安装 CodexLens 包...',
'codexlens.install.stage.settingUpDeps': '正在设置依赖项...',
'codexlens.install.stage.finalizing': '正在完成安装...',
'codexlens.install.stage.complete': '安装完成!',
'codexlens.install.installNow': '立即安装',
'codexlens.install.installing': '安装中...',
'codexlens.watcher.title': '文件监听器',
'codexlens.watcher.status.running': '运行中',
'codexlens.watcher.status.stopped': '已停止',
'codexlens.watcher.eventsProcessed': '已处理事件',
'codexlens.watcher.uptime': '运行时间',
'codexlens.watcher.start': '启动监听',
'codexlens.watcher.starting': '启动中...',
'codexlens.watcher.stop': '停止监听',
'codexlens.watcher.stopping': '停止中...',
'codexlens.watcher.started': '文件监听器已启动',
'codexlens.watcher.stopped': '文件监听器已停止',
'codexlens.settings.currentCount': '当前索引数量',
'codexlens.settings.currentWorkers': '当前工作线程',
'codexlens.settings.currentBatchSize': '当前批次大小',
'codexlens.settings.configTitle': '基本配置',
'codexlens.settings.indexDir.label': '索引目录',
'codexlens.settings.indexDir.placeholder': '~/.codexlens/indexes',
'codexlens.settings.indexDir.hint': '存储代码索引的目录路径',
'codexlens.settings.maxWorkers.label': '最大工作线程',
'codexlens.settings.maxWorkers.hint': 'API 并发处理线程数 (1-32)',
'codexlens.settings.batchSize.label': '批次大小',
'codexlens.settings.batchSize.hint': '每次批量处理的文件数量 (1-64)',
'codexlens.settings.validation.indexDirRequired': '索引目录不能为空',
'codexlens.settings.validation.maxWorkersRange': '工作线程数必须在 1-32 之间',
'codexlens.settings.validation.batchSizeRange': '批次大小必须在 1-64 之间',
'codexlens.settings.save': '保存',
'codexlens.settings.saving': '保存中...',
'codexlens.settings.reset': '重置',
'codexlens.settings.saveSuccess': '配置已保存',
'codexlens.settings.saveFailed': '保存失败',
'codexlens.settings.configUpdated': '配置更新成功',
'codexlens.settings.saveError': '保存配置时出错',
'codexlens.settings.unknownError': '发生未知错误',
'codexlens.models.title': '模型管理',
'codexlens.models.searchPlaceholder': '搜索模型...',
'codexlens.models.downloading': '下载中...',
'codexlens.models.status.downloaded': '已下载',
'codexlens.models.status.available': '可用',
'codexlens.models.types.embedding': '嵌入模型',
'codexlens.models.types.reranker': '重排序模型',
'codexlens.models.filters.label': '筛选',
'codexlens.models.filters.all': '全部',
'codexlens.models.actions.download': '下载',
'codexlens.models.actions.delete': '删除',
'codexlens.models.actions.cancel': '取消',
'codexlens.models.custom.title': '自定义模型',
'codexlens.models.custom.placeholder': 'HuggingFace 模型名称 (如: BAAI/bge-small-zh-v1.5)',
'codexlens.models.custom.description': '从 HuggingFace 下载自定义模型。请确保模型名称正确。',
'codexlens.models.deleteConfirm': '确定要删除模型 {modelName} 吗?',
'codexlens.models.notInstalled.title': 'CodexLens 未安装',
'codexlens.models.notInstalled.description': '请先安装 CodexLens 以使用模型管理功能。',
'codexlens.models.empty.title': '没有找到模型',
'codexlens.models.empty.description': '尝试调整搜索或筛选条件',
// Reranker
'codexlens.reranker.title': '重排序配置',
'codexlens.reranker.description': '配置重排序后端、模型和提供商,用于搜索结果排序。',
'codexlens.reranker.backend': '后端',
'codexlens.reranker.backendHint': '重排序推理后端',
'codexlens.reranker.model': '模型',
'codexlens.reranker.modelHint': '重排序模型名称或 LiteLLM 端点',
'codexlens.reranker.provider': 'API 提供商',
'codexlens.reranker.providerHint': '重排序服务的 API 提供商',
'codexlens.reranker.apiKeyStatus': 'API 密钥',
'codexlens.reranker.apiKeySet': '已配置',
'codexlens.reranker.apiKeyNotSet': '未配置',
'codexlens.reranker.configSource': '配置来源',
'codexlens.reranker.save': '保存重排序配置',
'codexlens.reranker.saving': '保存中...',
'codexlens.reranker.saveSuccess': '重排序配置已保存',
'codexlens.reranker.saveFailed': '保存重排序配置失败',
'codexlens.reranker.noBackends': '无可用后端',
'codexlens.reranker.noModels': '无可用模型',
'codexlens.reranker.noProviders': '无可用提供商',
'codexlens.reranker.litellmModels': 'LiteLLM 模型',
'codexlens.reranker.selectBackend': '选择后端...',
'codexlens.reranker.selectModel': '选择模型...',
'codexlens.reranker.selectProvider': '选择提供商...',
// MCP - CCW Tools // MCP - CCW Tools
'mcp.ccw.title': 'CCW MCP 服务器', 'mcp.ccw.title': 'CCW MCP 服务器',
'mcp.ccw.description': '配置 CCW MCP 工具与路径', 'mcp.ccw.description': '配置 CCW MCP 工具与路径',

View File

@@ -1,63 +0,0 @@
// ========================================
// CodexLens Type Definitions
// ========================================
// TypeScript interfaces for structured env var form schema
/**
* Model group definition for model-select fields
*/
export interface ModelGroup {
group: string;
items: string[];
}
/**
* Schema for a single environment variable field
*/
export interface EnvVarFieldSchema {
/** Environment variable key (e.g. CODEXLENS_EMBEDDING_BACKEND) */
key: string;
/** i18n label key */
labelKey: string;
/** Field type determines which control to render */
type: 'select' | 'model-select' | 'number' | 'checkbox' | 'text' | 'password';
/** Options for select type */
options?: string[];
/** Default value */
default?: string;
/** Placeholder text */
placeholder?: string;
/** Conditional visibility based on current env values */
showWhen?: (env: Record<string, string>) => boolean;
/** Mapped path in settings.json (e.g. embedding.backend) */
settingsPath?: string;
/** Min value for number type */
min?: number;
/** Max value for number type */
max?: number;
/** Step value for number type */
step?: number;
/** Preset local models for model-select */
localModels?: ModelGroup[];
/** Preset API models for model-select */
apiModels?: ModelGroup[];
}
/**
* Schema for a group of related environment variables
*/
export interface EnvVarGroup {
/** Unique group identifier */
id: string;
/** i18n label key for group title */
labelKey: string;
/** Lucide icon name */
icon: string;
/** Ordered map of env var key to field schema */
vars: Record<string, EnvVarFieldSchema>;
}
/**
* Complete schema for all env var groups
*/
export type EnvVarGroupsSchema = Record<string, EnvVarGroup>;

View File

@@ -1,37 +1,13 @@
/** /**
* Memory Embedder Bridge - TypeScript interface to Python memory embedder * Memory Embedder Bridge - STUB (v1 Python bridge removed)
* *
* This module provides a TypeScript bridge to the Python memory_embedder.py script, * The Python memory_embedder.py bridge has been removed. This module provides
* which generates and searches embeddings for memory chunks using CodexLens's embedder. * no-op stubs so that existing consumers compile without errors.
*
* Features:
* - Reuses CodexLens venv at ~/.codexlens/venv
* - JSON protocol communication
* - Three commands: embed, search, status
* - Automatic availability checking
* - Stage1 output embedding for V2 pipeline
*/ */
import { spawn } from 'child_process'; const V1_REMOVED = 'Memory embedder Python bridge has been removed (v1 cleanup).';
import { join, dirname } from 'path';
import { existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
import { getCoreMemoryStore } from './core-memory-store.js';
import type { Stage1Output } from './core-memory-store.js';
import { StoragePaths } from '../config/storage-paths.js';
// Get directory of this module // Types (kept for backward compatibility)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Venv paths (reuse CodexLens venv)
const VENV_PYTHON = getCodexLensHiddenPython();
// Script path
const EMBEDDER_SCRIPT = join(__dirname, '..', '..', 'scripts', 'memory_embedder.py');
// Types
export interface EmbedResult { export interface EmbedResult {
success: boolean; success: boolean;
chunks_processed: number; chunks_processed: number;
@@ -78,197 +54,6 @@ export interface SearchOptions {
sourceType?: 'core_memory' | 'workflow' | 'cli_history'; sourceType?: 'core_memory' | 'workflow' | 'cli_history';
} }
/**
* Check if embedder is available (venv and script exist)
* @returns True if embedder is available
*/
export function isEmbedderAvailable(): boolean {
// Check venv python exists
if (!existsSync(VENV_PYTHON)) {
return false;
}
// Check script exists
if (!existsSync(EMBEDDER_SCRIPT)) {
return false;
}
return true;
}
/**
* Run Python script with arguments
* @param args - Command line arguments
* @param timeout - Timeout in milliseconds
* @returns JSON output from script
*/
function runPython(args: string[], timeout: number = 300000): Promise<string> {
return new Promise((resolve, reject) => {
// Check availability
if (!isEmbedderAvailable()) {
reject(
new Error(
'Memory embedder not available. Ensure CodexLens venv exists at ~/.codexlens/venv'
)
);
return;
}
// Spawn Python process
const child = spawn(VENV_PYTHON, [EMBEDDER_SCRIPT, ...args], {
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
timeout,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
reject(new Error(`Python script failed (exit code ${code}): ${stderr || stdout}`));
}
});
child.on('error', (err) => {
if ((err as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
reject(new Error('Python script timed out'));
} else {
reject(new Error(`Failed to spawn Python: ${err.message}`));
}
});
});
}
/**
* Generate embeddings for memory chunks
* @param dbPath - Path to SQLite database
* @param options - Embedding options
* @returns Embedding result
*/
export async function generateEmbeddings(
dbPath: string,
options: EmbedOptions = {}
): Promise<EmbedResult> {
const { sourceId, batchSize = 8, force = false } = options;
// Build arguments
const args = ['embed', dbPath];
if (sourceId) {
args.push('--source-id', sourceId);
}
if (batchSize !== 8) {
args.push('--batch-size', batchSize.toString());
}
if (force) {
args.push('--force');
}
try {
// Default timeout: 5 minutes
const output = await runPython(args, 300000);
const result = JSON.parse(output) as EmbedResult;
return result;
} catch (err) {
return {
success: false,
chunks_processed: 0,
chunks_failed: 0,
elapsed_time: 0,
error: (err as Error).message,
};
}
}
/**
* Search memory chunks using semantic search
* @param dbPath - Path to SQLite database
* @param query - Search query text
* @param options - Search options
* @returns Search results
*/
export async function searchMemories(
dbPath: string,
query: string,
options: SearchOptions = {}
): Promise<SearchResult> {
const { topK = 10, minScore = 0.3, sourceType } = options;
// Build arguments
const args = ['search', dbPath, query];
if (topK !== 10) {
args.push('--top-k', topK.toString());
}
if (minScore !== 0.3) {
args.push('--min-score', minScore.toString());
}
if (sourceType) {
args.push('--type', sourceType);
}
try {
// Default timeout: 30 seconds
const output = await runPython(args, 30000);
const result = JSON.parse(output) as SearchResult;
return result;
} catch (err) {
return {
success: false,
matches: [],
error: (err as Error).message,
};
}
}
/**
* Get embedding status statistics
* @param dbPath - Path to SQLite database
* @returns Embedding status
*/
export async function getEmbeddingStatus(dbPath: string): Promise<EmbeddingStatus> {
// Build arguments
const args = ['status', dbPath];
try {
// Default timeout: 30 seconds
const output = await runPython(args, 30000);
const result = JSON.parse(output) as EmbeddingStatus;
return { ...result, success: true };
} catch (err) {
return {
success: false,
total_chunks: 0,
embedded_chunks: 0,
pending_chunks: 0,
by_type: {},
error: (err as Error).message,
};
}
}
// ============================================================================
// Memory V2: Stage1 Output Embedding
// ============================================================================
/** Result of stage1 embedding operation */
export interface Stage1EmbedResult { export interface Stage1EmbedResult {
success: boolean; success: boolean;
chunksCreated: number; chunksCreated: number;
@@ -276,98 +61,54 @@ export interface Stage1EmbedResult {
error?: string; error?: string;
} }
/** export function isEmbedderAvailable(): boolean {
* Chunk and embed stage1_outputs (raw_memory + rollout_summary) for semantic search. return false;
*
* Reads all stage1_outputs from the DB, chunks their raw_memory and rollout_summary
* content, inserts chunks into memory_chunks with source_type='cli_history' and
* metadata indicating the V2 origin, then triggers embedding generation.
*
* Uses source_id format: "s1:{thread_id}" to differentiate from regular cli_history chunks.
*
* @param projectPath - Project root path
* @param force - Force re-chunking even if chunks exist
* @returns Embedding result
*/
export async function embedStage1Outputs(
projectPath: string,
force: boolean = false
): Promise<Stage1EmbedResult> {
try {
const store = getCoreMemoryStore(projectPath);
const stage1Outputs = store.listStage1Outputs();
if (stage1Outputs.length === 0) {
return { success: true, chunksCreated: 0, chunksEmbedded: 0 };
}
let totalChunksCreated = 0;
for (const output of stage1Outputs) {
const sourceId = `s1:${output.thread_id}`;
// Check if already chunked
const existingChunks = store.getChunks(sourceId);
if (existingChunks.length > 0 && !force) continue;
// Delete old chunks if force
if (force && existingChunks.length > 0) {
store.deleteChunks(sourceId);
}
// Combine raw_memory and rollout_summary for richer semantic content
const combinedContent = [
output.rollout_summary ? `## Summary\n${output.rollout_summary}` : '',
output.raw_memory ? `## Raw Memory\n${output.raw_memory}` : '',
].filter(Boolean).join('\n\n');
if (!combinedContent.trim()) continue;
// Chunk using the store's built-in chunking
const chunks = store.chunkContent(combinedContent, sourceId, 'cli_history');
// Insert chunks with V2 metadata
for (let i = 0; i < chunks.length; i++) {
store.insertChunk({
source_id: sourceId,
source_type: 'cli_history',
chunk_index: i,
content: chunks[i],
metadata: JSON.stringify({
v2_source: 'stage1_output',
thread_id: output.thread_id,
generated_at: output.generated_at,
}),
created_at: new Date().toISOString(),
});
totalChunksCreated++;
}
}
// If we created chunks, generate embeddings
let chunksEmbedded = 0;
if (totalChunksCreated > 0) {
const paths = StoragePaths.project(projectPath);
const dbPath = join(paths.root, 'core-memory', 'core_memory.db');
const embedResult = await generateEmbeddings(dbPath, { force: false });
if (embedResult.success) {
chunksEmbedded = embedResult.chunks_processed;
}
}
return {
success: true,
chunksCreated: totalChunksCreated,
chunksEmbedded,
};
} catch (err) {
return {
success: false,
chunksCreated: 0,
chunksEmbedded: 0,
error: (err as Error).message,
};
}
} }
export async function generateEmbeddings(
_dbPath: string,
_options: EmbedOptions = {}
): Promise<EmbedResult> {
return {
success: false,
chunks_processed: 0,
chunks_failed: 0,
elapsed_time: 0,
error: V1_REMOVED,
};
}
export async function searchMemories(
_dbPath: string,
_query: string,
_options: SearchOptions = {}
): Promise<SearchResult> {
return {
success: false,
matches: [],
error: V1_REMOVED,
};
}
export async function getEmbeddingStatus(_dbPath: string): Promise<EmbeddingStatus> {
return {
success: false,
total_chunks: 0,
embedded_chunks: 0,
pending_chunks: 0,
by_type: {},
error: V1_REMOVED,
};
}
export async function embedStage1Outputs(
_projectPath: string,
_force: boolean = false
): Promise<Stage1EmbedResult> {
return {
success: false,
chunksCreated: 0,
chunksEmbedded: 0,
error: V1_REMOVED,
};
}

View File

@@ -1,23 +0,0 @@
/**
* CodexLens Routes Module
* Handles all CodexLens-related API endpoints.
*/
import type { RouteContext } from './types.js';
import { handleCodexLensConfigRoutes } from './codexlens/config-handlers.js';
import { handleCodexLensIndexRoutes } from './codexlens/index-handlers.js';
import { handleCodexLensSemanticRoutes } from './codexlens/semantic-handlers.js';
import { handleCodexLensWatcherRoutes } from './codexlens/watcher-handlers.js';
/**
* Handle CodexLens routes
* @returns true if route was handled, false otherwise
*/
export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean> {
if (await handleCodexLensIndexRoutes(ctx)) return true;
if (await handleCodexLensConfigRoutes(ctx)) return true;
if (await handleCodexLensSemanticRoutes(ctx)) return true;
if (await handleCodexLensWatcherRoutes(ctx)) return true;
return false;
}

View File

@@ -1,37 +0,0 @@
# CodexLens Routes
CodexLens-related HTTP endpoints are handled by `ccw/src/core/routes/codexlens-routes.ts`, which delegates to handler modules in this directory. Each handler returns `true` when it handles the current request.
## File Map
- `ccw/src/core/routes/codexlens/utils.ts` shared helpers (ANSI stripping + robust JSON extraction from CLI output).
- `ccw/src/core/routes/codexlens/index-handlers.ts` index/project management endpoints:
- `GET /api/codexlens/indexes`
- `POST /api/codexlens/clean`
- `POST /api/codexlens/init`
- `POST /api/codexlens/cancel`
- `GET /api/codexlens/indexing-status`
- `ccw/src/core/routes/codexlens/config-handlers.ts` install/config/environment endpoints:
- `GET /api/codexlens/status`
- `GET /api/codexlens/dashboard-init`
- `POST /api/codexlens/bootstrap`
- `POST /api/codexlens/uninstall`
- `GET /api/codexlens/config`
- `POST /api/codexlens/config`
- GPU: `GET /api/codexlens/gpu/detect`, `GET /api/codexlens/gpu/list`, `POST /api/codexlens/gpu/select`, `POST /api/codexlens/gpu/reset`
- Models: `GET /api/codexlens/models`, `POST /api/codexlens/models/download`, `POST /api/codexlens/models/delete`, `GET /api/codexlens/models/info`
- Env: `GET /api/codexlens/env`, `POST /api/codexlens/env`
- `ccw/src/core/routes/codexlens/semantic-handlers.ts` semantic search + reranker + SPLADE endpoints:
- Semantic: `GET /api/codexlens/semantic/status`, `GET /api/codexlens/semantic/metadata`, `POST /api/codexlens/semantic/install`
- Search: `GET /api/codexlens/search`, `GET /api/codexlens/search_files`, `GET /api/codexlens/symbol`, `POST /api/codexlens/enhance`
- Reranker: `GET /api/codexlens/reranker/config`, `POST /api/codexlens/reranker/config`, `GET /api/codexlens/reranker/models`, `POST /api/codexlens/reranker/models/download`, `POST /api/codexlens/reranker/models/delete`, `GET /api/codexlens/reranker/models/info`
- SPLADE: `GET /api/codexlens/splade/status`, `POST /api/codexlens/splade/install`, `GET /api/codexlens/splade/index-status`, `POST /api/codexlens/splade/rebuild`
- `ccw/src/core/routes/codexlens/watcher-handlers.ts` file watcher endpoints:
- `GET /api/codexlens/watch/status`
- `POST /api/codexlens/watch/start`
- `POST /api/codexlens/watch/stop`
- Also exports `stopWatcherForUninstall()` used during uninstall flow.
## Notes
- CodexLens CLI output may include logging + ANSI escapes even with `--json`; handlers use `extractJSON()` from `utils.ts` to parse reliably.

File diff suppressed because it is too large Load Diff

View File

@@ -1,459 +0,0 @@
/**
* CodexLens index management handlers.
*/
import {
cancelIndexing,
checkVenvStatus,
checkSemanticStatus,
ensureLiteLLMEmbedderReady,
executeCodexLens,
isIndexingInProgress,
} from '../../../tools/codex-lens.js';
import type { ProgressInfo } from '../../../tools/codex-lens.js';
import type { RouteContext } from '../types.js';
import { extractJSON, formatSize } from './utils.js';
/**
* Handle CodexLens index routes
* @returns true if route was handled, false otherwise
*/
export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: CodexLens Index List - Get all indexed projects with details
if (pathname === '/api/codexlens/indexes') {
try {
// Check if CodexLens is installed first (without auto-installing)
const venvStatus = await checkVenvStatus();
if (!venvStatus.ready) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, indexes: [], totalSize: 0, totalSizeFormatted: '0 B' }));
return true;
}
// Execute all CLI commands in parallel
const [configResult, projectsResult, statusResult] = await Promise.all([
executeCodexLens(['config', '--json']),
executeCodexLens(['projects', 'list', '--json']),
executeCodexLens(['status', '--json'])
]);
let indexDir = '';
if (configResult.success) {
try {
const config = extractJSON(configResult.output ?? '');
if (config.success && config.result) {
// CLI returns index_dir (not index_root)
indexDir = config.result.index_dir || config.result.index_root || '';
}
} catch (e: unknown) {
console.error('[CodexLens] Failed to parse config for index list:', e instanceof Error ? e.message : String(e));
}
}
let indexes: any[] = [];
let totalSize = 0;
let vectorIndexCount = 0;
let normalIndexCount = 0;
if (projectsResult.success) {
try {
const projectsData = extractJSON(projectsResult.output ?? '');
if (projectsData.success && Array.isArray(projectsData.result)) {
const { stat, readdir } = await import('fs/promises');
const { existsSync } = await import('fs');
const { basename, join } = await import('path');
for (const project of projectsData.result) {
// Skip test/temp projects
if (project.source_root && (
project.source_root.includes('\\Temp\\') ||
project.source_root.includes('/tmp/') ||
project.total_files === 0
)) {
continue;
}
let projectSize = 0;
let hasVectorIndex = false;
let hasNormalIndex = true; // All projects have FTS index
let lastModified = null;
// Try to get actual index size from index_root
if (project.index_root && existsSync(project.index_root)) {
try {
const files = await readdir(project.index_root);
for (const file of files) {
try {
const filePath = join(project.index_root, file);
const fileStat = await stat(filePath);
projectSize += fileStat.size;
if (!lastModified || fileStat.mtime > lastModified) {
lastModified = fileStat.mtime;
}
// Check for vector/embedding files
if (file.includes('vector') || file.includes('embedding') ||
file.endsWith('.faiss') || file.endsWith('.npy') ||
file.includes('semantic_chunks')) {
hasVectorIndex = true;
}
} catch {
// Skip files we can't stat
}
}
} catch {
// Can't read index directory
}
}
if (hasVectorIndex) vectorIndexCount++;
if (hasNormalIndex) normalIndexCount++;
totalSize += projectSize;
// Use source_root as the display name
const displayName = project.source_root ? basename(project.source_root) : `project_${project.id}`;
indexes.push({
id: displayName,
path: project.source_root || '',
indexPath: project.index_root || '',
size: projectSize,
sizeFormatted: formatSize(projectSize),
fileCount: project.total_files || 0,
dirCount: project.total_dirs || 0,
hasVectorIndex,
hasNormalIndex,
status: project.status || 'active',
lastModified: lastModified ? lastModified.toISOString() : null
});
}
// Sort by file count (most files first), then by name
indexes.sort((a, b) => {
if (b.fileCount !== a.fileCount) return b.fileCount - a.fileCount;
return a.id.localeCompare(b.id);
});
}
} catch (e: unknown) {
console.error('[CodexLens] Failed to parse projects list:', e instanceof Error ? e.message : String(e));
}
}
// Parse summary stats from status command (already fetched in parallel)
let statusSummary: any = {};
if (statusResult.success) {
try {
const status = extractJSON(statusResult.output ?? '');
if (status.success && status.result) {
statusSummary = {
totalProjects: status.result.projects_count || indexes.length,
totalFiles: status.result.total_files || 0,
totalDirs: status.result.total_dirs || 0,
// Keep calculated totalSize for consistency with per-project sizes
// status.index_size_bytes includes shared resources (models, cache)
indexSizeBytes: totalSize,
indexSizeMb: totalSize / (1024 * 1024),
embeddings: status.result.embeddings || {},
// Store full index dir size separately for reference
fullIndexDirSize: status.result.index_size_bytes || 0,
fullIndexDirSizeFormatted: formatSize(status.result.index_size_bytes || 0)
};
}
} catch (e: unknown) {
console.error('[CodexLens] Failed to parse status:', e instanceof Error ? e.message : String(e));
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
indexDir,
indexes,
summary: {
totalProjects: indexes.length,
totalSize,
totalSizeFormatted: formatSize(totalSize),
vectorIndexCount,
normalIndexCount,
...statusSummary
}
}));
} catch (err: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) }));
}
return true;
}
// API: CodexLens Clean (Clean indexes)
if (pathname === '/api/codexlens/clean' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { all = false, path } = body as { all?: unknown; path?: unknown };
try {
const args = ['clean'];
if (all === true) {
args.push('--all');
} else if (typeof path === 'string' && path.trim().length > 0) {
// Path is passed as a positional argument, not as a flag
args.push(path);
}
args.push('--json');
const result = await executeCodexLens(args);
if (result.success) {
return { success: true, message: 'Indexes cleaned successfully' };
} else {
return { success: false, error: result.error || 'Failed to clean indexes', status: 500 };
}
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
// 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', embeddingBackend = 'fastembed', maxWorkers = 1 } = body as {
path?: unknown;
indexType?: unknown;
embeddingModel?: unknown;
embeddingBackend?: unknown;
maxWorkers?: unknown;
};
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
const resolvedIndexType = indexType === 'normal' ? 'normal' : 'vector';
const resolvedEmbeddingModel = typeof embeddingModel === 'string' && embeddingModel.trim().length > 0 ? embeddingModel : 'code';
const resolvedEmbeddingBackend = typeof embeddingBackend === 'string' && embeddingBackend.trim().length > 0 ? embeddingBackend : 'fastembed';
const resolvedMaxWorkers = typeof maxWorkers === 'number' ? maxWorkers : Number(maxWorkers);
// Pre-check: Verify embedding backend availability before proceeding with vector indexing
// This prevents silent degradation where vector indexing is skipped without error
if (resolvedIndexType !== 'normal') {
if (resolvedEmbeddingBackend === 'litellm') {
// For litellm backend, ensure ccw-litellm is installed
const installResult = await ensureLiteLLMEmbedderReady();
if (!installResult.success) {
return {
success: false,
error: installResult.error || 'LiteLLM embedding backend is not available. Please install ccw-litellm first.',
status: 500
};
}
} else {
// For fastembed backend (default), check semantic dependencies
const semanticStatus = await checkSemanticStatus();
if (!semanticStatus.available) {
return {
success: false,
error: semanticStatus.error || 'FastEmbed semantic backend is not available. Please install semantic dependencies first (CodeLens Settings → Install Semantic).',
status: 500
};
}
}
}
// Build CLI arguments based on index type
// Use 'index init' subcommand (new CLI structure)
// --force flag ensures full reindex (not incremental)
const args = ['index', 'init', targetPath, '--force', '--json'];
if (resolvedIndexType === 'normal') {
args.push('--no-embeddings');
} else {
// Add embedding model selection for vector index (use --model, not --embedding-model)
args.push('--model', resolvedEmbeddingModel);
// Add embedding backend if not using default fastembed (use --backend, not --embedding-backend)
if (resolvedEmbeddingBackend && resolvedEmbeddingBackend !== 'fastembed') {
args.push('--backend', resolvedEmbeddingBackend);
}
// Add max workers for concurrent API calls (useful for litellm backend)
if (!Number.isNaN(resolvedMaxWorkers) && resolvedMaxWorkers > 1) {
args.push('--max-workers', String(resolvedMaxWorkers));
}
}
// Broadcast start event
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'start', message: 'Starting index...', percent: 0, path: targetPath, indexType: resolvedIndexType }
});
try {
const result = await executeCodexLens(args, {
cwd: targetPath,
timeout: 1800000, // 30 minutes for large codebases
onProgress: (progress: ProgressInfo) => {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { ...progress, path: targetPath }
});
}
});
if (result.success) {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'complete', message: 'Index complete', percent: 100, path: targetPath }
});
try {
const parsed = extractJSON(result.output ?? '');
return { success: true, result: parsed };
} catch {
return { success: true, output: result.output ?? '' };
}
} else {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'error', message: result.error || 'Unknown error', percent: 0, path: targetPath }
});
return { success: false, error: result.error, status: 500 };
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'error', message, percent: 0, path: targetPath }
});
return { success: false, error: message, status: 500 };
}
});
return true;
}
// API: Cancel CodexLens Indexing
if (pathname === '/api/codexlens/cancel' && req.method === 'POST') {
const result = cancelIndexing();
// Broadcast cancellation event
if (result.success) {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'cancelled', message: 'Indexing cancelled by user', percent: 0 }
});
}
res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
return true;
}
// API: CodexLens Update (Incremental index update)
if (pathname === '/api/codexlens/update' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: projectPath, indexType = 'vector', embeddingModel = 'code', embeddingBackend = 'fastembed', maxWorkers = 1 } = body as {
path?: unknown;
indexType?: unknown;
embeddingModel?: unknown;
embeddingBackend?: unknown;
maxWorkers?: unknown;
};
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
const resolvedIndexType = indexType === 'normal' ? 'normal' : 'vector';
const resolvedEmbeddingModel = typeof embeddingModel === 'string' && embeddingModel.trim().length > 0 ? embeddingModel : 'code';
const resolvedEmbeddingBackend = typeof embeddingBackend === 'string' && embeddingBackend.trim().length > 0 ? embeddingBackend : 'fastembed';
const resolvedMaxWorkers = typeof maxWorkers === 'number' ? maxWorkers : Number(maxWorkers);
// Pre-check: Verify embedding backend availability before proceeding with vector indexing
if (resolvedIndexType !== 'normal') {
if (resolvedEmbeddingBackend === 'litellm') {
const installResult = await ensureLiteLLMEmbedderReady();
if (!installResult.success) {
return {
success: false,
error: installResult.error || 'LiteLLM embedding backend is not available. Please install ccw-litellm first.',
status: 500
};
}
} else {
const semanticStatus = await checkSemanticStatus();
if (!semanticStatus.available) {
return {
success: false,
error: semanticStatus.error || 'FastEmbed semantic backend is not available. Please install semantic dependencies first.',
status: 500
};
}
}
}
// Build CLI arguments for incremental update using 'index init' without --force
// 'index init' defaults to incremental mode (skip unchanged files)
// 'index update' is only for single-file updates in hooks
const args = ['index', 'init', targetPath, '--json'];
if (resolvedIndexType === 'normal') {
args.push('--no-embeddings');
} else {
args.push('--model', resolvedEmbeddingModel);
if (resolvedEmbeddingBackend && resolvedEmbeddingBackend !== 'fastembed') {
args.push('--backend', resolvedEmbeddingBackend);
}
if (!Number.isNaN(resolvedMaxWorkers) && resolvedMaxWorkers > 1) {
args.push('--max-workers', String(resolvedMaxWorkers));
}
}
// Broadcast start event
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'start', message: 'Starting incremental index update...', percent: 0, path: targetPath, indexType: resolvedIndexType }
});
try {
const result = await executeCodexLens(args, {
cwd: targetPath,
timeout: 1800000,
onProgress: (progress: ProgressInfo) => {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { ...progress, path: targetPath }
});
}
});
if (result.success) {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'complete', message: 'Incremental update complete', percent: 100, path: targetPath }
});
try {
const parsed = extractJSON(result.output ?? '');
return { success: true, result: parsed };
} catch {
return { success: true, output: result.output ?? '' };
}
} else {
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'error', message: result.error || 'Unknown error', percent: 0, path: targetPath }
});
return { success: false, error: result.error, status: 500 };
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
broadcastToClients({
type: 'CODEXLENS_INDEX_PROGRESS',
payload: { stage: 'error', message, percent: 0, path: targetPath }
});
return { success: false, error: message, status: 500 };
}
});
return true;
}
// API: Check if indexing is in progress
if (pathname === '/api/codexlens/indexing-status') {
const inProgress = isIndexingInProgress();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, inProgress }));
return true;
}
return false;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,96 +0,0 @@
/**
* CodexLens route utilities.
*
* CodexLens CLI can emit logging + ANSI escapes even with --json, so helpers
* here normalize output for reliable JSON parsing.
*/
/**
* Strip ANSI color codes from string.
* Rich library adds color codes even with --json flag.
*/
export function stripAnsiCodes(str: string): string {
// ANSI escape code pattern: \x1b[...m or \x1b]...
return str.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/\x1b\][0-9;]*\x07/g, '')
.replace(/\x1b\][^\x07]*\x07/g, '');
}
/**
* Format file size to human readable string.
*/
export function formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(i < 2 ? 0 : 1));
return size + ' ' + units[i];
}
/**
* Extract JSON from CLI output that may contain logging messages.
* CodexLens CLI outputs logs like "INFO ..." before the JSON.
* Also strips ANSI color codes that Rich library adds.
* Handles trailing content after JSON (e.g., "INFO: Done" messages).
*/
export function extractJSON(output: string): any {
// Strip ANSI color codes first
const cleanOutput = stripAnsiCodes(output);
// Find the first { or [ character (start of JSON)
const jsonStart = cleanOutput.search(/[{\[]/);
if (jsonStart === -1) {
throw new Error('No JSON found in output');
}
const startChar = cleanOutput[jsonStart];
const endChar = startChar === '{' ? '}' : ']';
// Find matching closing brace/bracket using a simple counter
let depth = 0;
let inString = false;
let escapeNext = false;
let jsonEnd = -1;
for (let i = jsonStart; i < cleanOutput.length; i++) {
const char = cleanOutput[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\' && inString) {
escapeNext = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (char === startChar) {
depth++;
} else if (char === endChar) {
depth--;
if (depth === 0) {
jsonEnd = i + 1;
break;
}
}
}
}
if (jsonEnd === -1) {
// Fallback: try to parse from start to end (original behavior)
const jsonString = cleanOutput.substring(jsonStart);
return JSON.parse(jsonString);
}
const jsonString = cleanOutput.substring(jsonStart, jsonEnd);
return JSON.parse(jsonString);
}

View File

@@ -1,322 +0,0 @@
/**
* CodexLens file watcher handlers.
*
* Maintains watcher process state across requests to support dashboard controls.
*/
import {
checkVenvStatus,
executeCodexLens,
getVenvPythonPath,
useCodexLensV2,
} from '../../../tools/codex-lens.js';
import type { RouteContext } from '../types.js';
import { extractJSON, stripAnsiCodes } from './utils.js';
import type { ChildProcess } from 'child_process';
// File watcher state (persisted across requests)
let watcherProcess: any = null;
let watcherStats = {
running: false,
root_path: '',
events_processed: 0,
start_time: null as Date | null
};
export async function stopWatcherForUninstall(): Promise<void> {
if (!watcherStats.running || !watcherProcess) return;
try {
watcherProcess.kill('SIGTERM');
await new Promise(resolve => setTimeout(resolve, 500));
if (watcherProcess && !watcherProcess.killed) {
watcherProcess.kill('SIGKILL');
}
} catch {
// Ignore errors stopping watcher
}
watcherStats = {
running: false,
root_path: '',
events_processed: 0,
start_time: null
};
watcherProcess = null;
}
/**
* Spawn v2 bridge watcher subprocess.
* Runs 'codexlens-search watch --root X --debounce-ms Y' and reads JSONL stdout.
* @param root - Root directory to watch
* @param debounceMs - Debounce interval in milliseconds
* @returns Spawned child process
*/
function spawnV2Watcher(root: string, debounceMs: number): ChildProcess {
const { spawn } = require('child_process') as typeof import('child_process');
return spawn('codexlens-search', [
'watch',
'--root', root,
'--debounce-ms', String(debounceMs),
'--db-path', require('path').join(root, '.codexlens'),
], {
cwd: root,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
}
/**
* Handle CodexLens watcher routes
* @returns true if route was handled, false otherwise
*/
export async function handleCodexLensWatcherRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: Get File Watcher Status
if (pathname === '/api/codexlens/watch/status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
running: watcherStats.running,
root_path: watcherStats.root_path,
events_processed: watcherStats.events_processed,
start_time: watcherStats.start_time?.toISOString() || null,
uptime_seconds: watcherStats.start_time
? Math.floor((Date.now() - watcherStats.start_time.getTime()) / 1000)
: 0
}));
return true;
}
// API: Start File Watcher
if (pathname === '/api/codexlens/watch/start' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: watchPath, debounce_ms = 1000 } = body as { path?: unknown; debounce_ms?: unknown };
const targetPath = typeof watchPath === 'string' && watchPath.trim().length > 0 ? watchPath : initialPath;
const resolvedDebounceMs = typeof debounce_ms === 'number' ? debounce_ms : Number(debounce_ms);
const debounceMs = !Number.isNaN(resolvedDebounceMs) && resolvedDebounceMs > 0 ? resolvedDebounceMs : 1000;
if (watcherStats.running) {
return { success: false, error: 'Watcher already running', status: 400 };
}
try {
const { spawn } = await import('child_process');
const { existsSync, statSync } = await import('fs');
// Validate path exists and is a directory
if (!existsSync(targetPath)) {
return { success: false, error: `Path does not exist: ${targetPath}`, status: 400 };
}
const pathStat = statSync(targetPath);
if (!pathStat.isDirectory()) {
return { success: false, error: `Path is not a directory: ${targetPath}`, status: 400 };
}
// Route to v2 or v1 watcher based on feature flag
if (useCodexLensV2()) {
// v2 bridge watcher: codexlens-search watch
console.log('[CodexLens] Using v2 bridge watcher');
watcherProcess = spawnV2Watcher(targetPath, debounceMs);
} else {
// v1 watcher: python -m codexlens watch
const venvStatus = await checkVenvStatus();
if (!venvStatus.ready) {
return { success: false, error: 'CodexLens not installed', status: 400 };
}
// Verify directory is indexed before starting watcher
try {
const statusResult = await executeCodexLens(['projects', 'list', '--json']);
if (statusResult.success && statusResult.output) {
const parsed = extractJSON(statusResult.output);
const projects = parsed.result || parsed || [];
const normalizedTarget = targetPath.toLowerCase().replace(/\\/g, '/');
const isIndexed = Array.isArray(projects) && projects.some((p: { source_root?: string }) =>
p.source_root && p.source_root.toLowerCase().replace(/\\/g, '/') === normalizedTarget
);
if (!isIndexed) {
return {
success: false,
error: `Directory is not indexed: ${targetPath}. Run 'codexlens init' first.`,
status: 400
};
}
}
} catch (err) {
console.warn('[CodexLens] Could not verify index status:', err);
// Continue anyway - watcher will fail with proper error if not indexed
}
// Spawn watch process using Python (no shell: true for security)
const pythonPath = getVenvPythonPath();
const args = ['-m', 'codexlens', 'watch', targetPath, '--debounce', String(debounceMs)];
watcherProcess = spawn(pythonPath, args, {
cwd: targetPath,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' }
});
}
watcherStats = {
running: true,
root_path: targetPath,
events_processed: 0,
start_time: new Date()
};
// Capture stderr for error messages (capped at 4KB to prevent memory leak)
const MAX_STDERR_SIZE = 4096;
let stderrBuffer = '';
if (watcherProcess.stderr) {
watcherProcess.stderr.on('data', (data: Buffer) => {
stderrBuffer += data.toString();
// Cap buffer size to prevent memory leak in long-running watchers
if (stderrBuffer.length > MAX_STDERR_SIZE) {
stderrBuffer = stderrBuffer.slice(-MAX_STDERR_SIZE);
}
});
}
// Handle process output for event counting
const isV2Watcher = useCodexLensV2();
let stdoutLineBuffer = '';
if (watcherProcess.stdout) {
watcherProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString();
if (isV2Watcher) {
// v2 bridge outputs JSONL - parse line by line
stdoutLineBuffer += output;
const lines = stdoutLineBuffer.split('\n');
// Keep incomplete last line in buffer
stdoutLineBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const event = JSON.parse(trimmed);
// Count file change events (created, modified, deleted, moved)
if (event.event && event.event !== 'watching') {
watcherStats.events_processed += 1;
}
} catch {
// Not valid JSON, skip
}
}
} else {
// v1 watcher: count text-based event messages
const matches = output.match(/Processed \d+ events?/g);
if (matches) {
watcherStats.events_processed += matches.length;
}
}
});
}
// Handle spawn errors (e.g., ENOENT)
watcherProcess.on('error', (err: Error) => {
console.error(`[CodexLens] Watcher spawn error: ${err.message}`);
watcherStats.running = false;
watcherProcess = null;
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false, error: `Spawn error: ${err.message}` }
});
});
// Handle process exit
watcherProcess.on('exit', (code: number) => {
watcherStats.running = false;
watcherProcess = null;
console.log(`[CodexLens] Watcher exited with code ${code}`);
// Broadcast error if exited with non-zero code
if (code !== 0) {
const errorMsg = stderrBuffer.trim() || `Exited with code ${code}`;
const cleanError = stripAnsiCodes(errorMsg);
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false, error: cleanError }
});
} else {
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false }
});
}
});
// Broadcast watcher started
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: true, path: targetPath }
});
return {
success: true,
message: 'Watcher started',
path: targetPath,
pid: watcherProcess.pid
};
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
// API: Stop File Watcher
if (pathname === '/api/codexlens/watch/stop' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
if (!watcherStats.running || !watcherProcess) {
return { success: false, error: 'Watcher not running', status: 400 };
}
try {
watcherProcess.kill('SIGTERM');
await new Promise(resolve => setTimeout(resolve, 500));
if (watcherProcess && !watcherProcess.killed) {
watcherProcess.kill('SIGKILL');
}
const finalStats = {
events_processed: watcherStats.events_processed,
uptime_seconds: watcherStats.start_time
? Math.floor((Date.now() - watcherStats.start_time.getTime()) / 1000)
: 0
};
watcherStats = {
running: false,
root_path: '',
events_processed: 0,
start_time: null
};
watcherProcess = null;
broadcastToClients({
type: 'CODEXLENS_WATCHER_STATUS',
payload: { running: false }
});
return {
success: true,
message: 'Watcher stopped',
...finalStats
};
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
return false;
}

View File

@@ -3,17 +3,6 @@
* Handles LiteLLM provider management, endpoint configuration, and cache management * Handles LiteLLM provider management, endpoint configuration, and cache management
*/ */
import { z } from 'zod'; import { z } from 'zod';
import { spawn } from 'child_process';
import {
getSystemPythonCommand,
parsePythonCommandSpec,
type PythonCommandSpec,
} from '../../utils/python-utils.js';
import {
isUvAvailable,
createCodexLensUvManager
} from '../../utils/uv-manager.js';
import { ensureLiteLLMEmbedderReady } from '../../tools/codex-lens.js';
import type { RouteContext } from './types.js'; import type { RouteContext } from './types.js';
// ========== Input Validation Schemas ========== // ========== Input Validation Schemas ==========
@@ -81,106 +70,13 @@ import {
type EmbeddingPoolConfig, type EmbeddingPoolConfig,
} from '../../config/litellm-api-config-manager.js'; } from '../../config/litellm-api-config-manager.js';
import { getContextCacheStore } from '../../tools/context-cache-store.js'; import { getContextCacheStore } from '../../tools/context-cache-store.js';
import { getLiteLLMClient } from '../../tools/litellm-client.js';
import { testApiKeyConnection, getDefaultApiBase } from '../services/api-key-tester.js'; import { testApiKeyConnection, getDefaultApiBase } from '../services/api-key-tester.js';
interface CcwLitellmEnvCheck { const V1_REMOVED = 'Python bridge has been removed (v1 cleanup).';
python: string;
installed: boolean;
version?: string;
error?: string;
}
interface CcwLitellmStatusResponse { // Clear cache (no-op stub, kept for backward compatibility)
/**
* Whether ccw-litellm is installed in the CodexLens venv.
* This is the environment used for the LiteLLM embedding backend.
*/
installed: boolean;
version?: string;
error?: string;
checks?: {
codexLensVenv: CcwLitellmEnvCheck;
systemPython?: CcwLitellmEnvCheck;
};
}
function checkCcwLitellmImport(
pythonCmd: string | PythonCommandSpec,
options: { timeout: number }
): Promise<CcwLitellmEnvCheck> {
const { timeout } = options;
const pythonSpec = typeof pythonCmd === 'string' ? parsePythonCommandSpec(pythonCmd) : pythonCmd;
const sanitizePythonError = (stderrText: string): string | undefined => {
const trimmed = stderrText.trim();
if (!trimmed) return undefined;
const lines = trimmed
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean);
// Prefer the final exception line (avoids leaking full traceback + file paths)
return lines[lines.length - 1] || undefined;
};
return new Promise((resolve) => {
const child = spawn(pythonSpec.command, [...pythonSpec.args, '-c', 'import ccw_litellm; print(ccw_litellm.__version__)'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout,
windowsHide: true,
shell: false,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.on('close', (code: number | null) => {
const version = stdout.trim();
const error = sanitizePythonError(stderr);
if (code === 0 && version) {
resolve({ python: pythonSpec.display, installed: true, version });
return;
}
if (code === null) {
resolve({ python: pythonSpec.display, installed: false, error: `Timed out after ${timeout}ms` });
return;
}
resolve({ python: pythonSpec.display, installed: false, error: error || undefined });
});
child.on('error', (err) => {
resolve({ python: pythonSpec.display, installed: false, error: err.message });
});
});
}
// Cache for ccw-litellm status check
let ccwLitellmStatusCache: {
data: CcwLitellmStatusResponse | null;
timestamp: number;
ttl: number;
} = {
data: null,
timestamp: 0,
ttl: 5 * 60 * 1000, // 5 minutes
};
// Clear cache (call after install)
export function clearCcwLitellmStatusCache() { export function clearCcwLitellmStatusCache() {
ccwLitellmStatusCache.data = null; // no-op: Python bridge removed
ccwLitellmStatusCache.timestamp = 0;
} }
function sanitizeProviderForResponse(provider: any): any { function sanitizeProviderForResponse(provider: any): any {
@@ -922,57 +818,10 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
// CCW-LiteLLM Package Management // CCW-LiteLLM Package Management
// =========================== // ===========================
// GET /api/litellm-api/ccw-litellm/status - Check ccw-litellm installation status // GET /api/litellm-api/ccw-litellm/status - Stub (v1 Python bridge removed)
// Supports ?refresh=true to bypass cache
if (pathname === '/api/litellm-api/ccw-litellm/status' && req.method === 'GET') { if (pathname === '/api/litellm-api/ccw-litellm/status' && req.method === 'GET') {
const forceRefresh = url.searchParams.get('refresh') === 'true'; res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ installed: false, error: V1_REMOVED }));
// Check cache first (unless force refresh)
if (!forceRefresh && ccwLitellmStatusCache.data &&
Date.now() - ccwLitellmStatusCache.timestamp < ccwLitellmStatusCache.ttl) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(ccwLitellmStatusCache.data));
return true;
}
try {
const uv = createCodexLensUvManager();
const venvPython = uv.getVenvPython();
const statusTimeout = process.platform === 'win32' ? 15000 : 10000;
const codexLensVenv = uv.isVenvValid()
? await checkCcwLitellmImport(venvPython, { timeout: statusTimeout })
: { python: venvPython, installed: false, error: 'CodexLens venv not valid' };
// Diagnostics only: if not installed in venv, also check system python so users understand mismatches.
// NOTE: `installed` flag remains the CodexLens venv status (we want isolated venv dependencies).
const systemPython = !codexLensVenv.installed
? await checkCcwLitellmImport(getSystemPythonCommand(), { timeout: statusTimeout })
: undefined;
const result: CcwLitellmStatusResponse = {
installed: codexLensVenv.installed,
version: codexLensVenv.version,
error: codexLensVenv.error,
checks: {
codexLensVenv,
...(systemPython ? { systemPython } : {}),
},
};
// Update cache
ccwLitellmStatusCache = {
data: result,
timestamp: Date.now(),
ttl: 5 * 60 * 1000,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
const errorResult = { installed: false, error: (err as Error).message };
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(errorResult));
}
return true; return true;
} }
@@ -1367,96 +1216,18 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
return true; return true;
} }
// POST /api/litellm-api/ccw-litellm/install - Install ccw-litellm package // POST /api/litellm-api/ccw-litellm/install - Stub (v1 Python bridge removed)
if (pathname === '/api/litellm-api/ccw-litellm/install' && req.method === 'POST') { if (pathname === '/api/litellm-api/ccw-litellm/install' && req.method === 'POST') {
handlePostRequest(req, res, async () => { handlePostRequest(req, res, async () => {
try { return { success: false, error: V1_REMOVED };
// Delegate entirely to ensureLiteLLMEmbedderReady for consistent installation
// This uses unified package discovery and handles UV → pip fallback
const result = await ensureLiteLLMEmbedderReady();
if (result.success) {
clearCcwLitellmStatusCache();
broadcastToClients({
type: 'CCW_LITELLM_INSTALLED',
payload: { timestamp: new Date().toISOString(), method: 'unified' }
});
}
return result;
} catch (err) {
return { success: false, error: (err as Error).message };
}
}); });
return true; return true;
} }
// POST /api/litellm-api/ccw-litellm/uninstall - Uninstall ccw-litellm package // POST /api/litellm-api/ccw-litellm/uninstall - Stub (v1 Python bridge removed)
if (pathname === '/api/litellm-api/ccw-litellm/uninstall' && req.method === 'POST') { if (pathname === '/api/litellm-api/ccw-litellm/uninstall' && req.method === 'POST') {
handlePostRequest(req, res, async () => { handlePostRequest(req, res, async () => {
try { return { success: false, error: V1_REMOVED };
// Priority 1: Use UV to uninstall from CodexLens venv
if (await isUvAvailable()) {
const uv = createCodexLensUvManager();
if (uv.isVenvValid()) {
console.log('[ccw-litellm uninstall] Using UV to uninstall from CodexLens venv...');
const uvResult = await uv.uninstall(['ccw-litellm']);
clearCcwLitellmStatusCache();
if (uvResult.success) {
broadcastToClients({
type: 'CCW_LITELLM_UNINSTALLED',
payload: { timestamp: new Date().toISOString() }
});
return { success: true, message: 'ccw-litellm uninstalled successfully via UV' };
}
console.log('[ccw-litellm uninstall] UV uninstall failed, falling back to pip:', uvResult.error);
}
}
// Priority 2: Fallback to system pip uninstall
console.log('[ccw-litellm uninstall] Using pip fallback...');
const pythonCmd = getSystemPythonCommand();
return new Promise((resolve) => {
const proc = spawn(
pythonCmd.command,
[...pythonCmd.args, '-m', 'pip', 'uninstall', '-y', 'ccw-litellm'],
{
shell: false,
timeout: 120000,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
},
);
let output = '';
let error = '';
proc.stdout?.on('data', (data) => { output += data.toString(); });
proc.stderr?.on('data', (data) => { error += data.toString(); });
proc.on('close', (code) => {
// Clear status cache after uninstallation attempt
clearCcwLitellmStatusCache();
if (code === 0) {
broadcastToClients({
type: 'CCW_LITELLM_UNINSTALLED',
payload: { timestamp: new Date().toISOString() }
});
resolve({ success: true, message: 'ccw-litellm uninstalled successfully' });
} else {
// Check if package was not installed
if (error.includes('not installed') || output.includes('not installed')) {
resolve({ success: true, message: 'ccw-litellm was not installed' });
} else {
resolve({ success: false, error: error || output || 'Uninstallation failed' });
}
}
});
proc.on('error', (err) => resolve({ success: false, error: err.message }));
});
} catch (err) {
return { success: false, error: (err as Error).message };
}
}); });
return true; return true;
} }

View File

@@ -6,7 +6,6 @@ import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { getCliToolsStatus } from '../../tools/cli-executor.js'; import { getCliToolsStatus } from '../../tools/cli-executor.js';
import { checkVenvStatus, checkSemanticStatus } from '../../tools/codex-lens.js';
import type { RouteContext } from './types.js'; import type { RouteContext } from './types.js';
// Performance logging helper // Performance logging helper
@@ -80,36 +79,14 @@ export async function handleStatusRoutes(ctx: RouteContext): Promise<boolean> {
const ccwInstallStatus = checkCcwInstallStatus(); const ccwInstallStatus = checkCcwInstallStatus();
perfLog('checkCcwInstallStatus', ccwStart); perfLog('checkCcwInstallStatus', ccwStart);
// Execute all status checks in parallel with individual timing // Execute async status checks
const cliStart = Date.now(); const cliStart = Date.now();
const codexStart = Date.now();
const semanticStart = Date.now();
const [cliStatus, codexLensStatus, semanticStatus] = await Promise.all([ const cliStatus = await getCliToolsStatus();
getCliToolsStatus().then(result => { perfLog('getCliToolsStatus', cliStart, { toolCount: Object.keys(cliStatus).length });
perfLog('getCliToolsStatus', cliStart, { toolCount: Object.keys(result).length });
return result;
}),
checkVenvStatus().then(result => {
perfLog('checkVenvStatus', codexStart, { ready: result.ready });
return result;
}),
// Always check semantic status (will return available: false if CodexLens not ready)
checkSemanticStatus()
.then(result => {
perfLog('checkSemanticStatus', semanticStart, { available: result.available });
return result;
})
.catch(() => {
perfLog('checkSemanticStatus (error)', semanticStart);
return { available: false, backend: null };
})
]);
const response = { const response = {
cli: cliStatus, cli: cliStatus,
codexLens: codexLensStatus,
semantic: semanticStatus,
ccwInstall: ccwInstallStatus, ccwInstall: ccwInstallStatus,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };

View File

@@ -16,7 +16,6 @@ import { handleUnifiedMemoryRoutes } from './routes/unified-memory-routes.js';
import { handleMcpRoutes } from './routes/mcp-routes.js'; import { handleMcpRoutes } from './routes/mcp-routes.js';
import { handleHooksRoutes } from './routes/hooks-routes.js'; import { handleHooksRoutes } from './routes/hooks-routes.js';
import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js'; import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js';
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
import { handleGraphRoutes } from './routes/graph-routes.js'; import { handleGraphRoutes } from './routes/graph-routes.js';
import { handleSystemRoutes } from './routes/system-routes.js'; import { handleSystemRoutes } from './routes/system-routes.js';
import { handleFilesRoutes } from './routes/files-routes.js'; import { handleFilesRoutes } from './routes/files-routes.js';
@@ -66,7 +65,6 @@ import { getCliSessionManager } from './services/cli-session-manager.js';
import { QueueSchedulerService } from './services/queue-scheduler-service.js'; import { QueueSchedulerService } from './services/queue-scheduler-service.js';
// Import status check functions for warmup // Import status check functions for warmup
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
import { getCliToolsStatus } from '../tools/cli-executor.js'; import { getCliToolsStatus } from '../tools/cli-executor.js';
import type { ServerConfig } from '../types/config.js'; import type { ServerConfig } from '../types/config.js';
@@ -302,28 +300,6 @@ async function warmupCaches(initialPath: string): Promise<void> {
// Run all warmup tasks in parallel for faster startup // Run all warmup tasks in parallel for faster startup
const warmupTasks = [ const warmupTasks = [
// Warmup semantic status cache (Python process startup - can be slow first time)
(async () => {
const taskStart = Date.now();
try {
const semanticStatus = await checkSemanticStatus();
console.log(`[WARMUP] Semantic status: ${semanticStatus.available ? 'available' : 'not available'} (${Date.now() - taskStart}ms)`);
} catch (err) {
console.warn(`[WARMUP] Semantic status check failed: ${(err as Error).message}`);
}
})(),
// Warmup venv status cache
(async () => {
const taskStart = Date.now();
try {
const venvStatus = await checkVenvStatus();
console.log(`[WARMUP] Venv status: ${venvStatus.ready ? 'ready' : 'not ready'} (${Date.now() - taskStart}ms)`);
} catch (err) {
console.warn(`[WARMUP] Venv status check failed: ${(err as Error).message}`);
}
})(),
// Warmup CLI tools status cache // Warmup CLI tools status cache
(async () => { (async () => {
const taskStart = Date.now(); const taskStart = Date.now();
@@ -598,11 +574,6 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleUnsplashRoutes(routeContext)) return; if (await handleUnsplashRoutes(routeContext)) return;
} }
// CodexLens routes (/api/codexlens/*)
if (pathname.startsWith('/api/codexlens/')) {
if (await handleCodexLensRoutes(routeContext)) return;
}
// LiteLLM routes (/api/litellm/*) // LiteLLM routes (/api/litellm/*)
if (pathname.startsWith('/api/litellm/')) { if (pathname.startsWith('/api/litellm/')) {
if (await handleLiteLLMRoutes(routeContext)) return; if (await handleLiteLLMRoutes(routeContext)) return;

View File

@@ -1,79 +1,37 @@
/** /**
* Unified Vector Index - TypeScript bridge to unified_memory_embedder.py * Unified Vector Index - STUB (v1 Python bridge removed)
* *
* Provides HNSW-backed vector indexing and search for all memory content * The Python unified_memory_embedder.py bridge has been removed. This module
* (core_memory, cli_history, workflow, entity, pattern) via CodexLens VectorStore. * provides no-op stubs so that existing consumers compile without errors.
*
* Features:
* - JSON stdin/stdout protocol to Python embedder
* - Content chunking (paragraph -> sentence splitting, CHUNK_SIZE=1500, OVERLAP=200)
* - Batch embedding via CodexLens EmbedderFactory
* - HNSW approximate nearest neighbor search (sub-10ms for 1000 chunks)
* - Category-based filtering
*/ */
import { spawn } from 'child_process'; const V1_REMOVED = 'Unified vector index Python bridge has been removed (v1 cleanup).';
import { join, dirname } from 'path';
import { existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
// Get directory of this module // ---------------------------------------------------------------------------
const __filename = fileURLToPath(import.meta.url); // Types (kept for backward compatibility)
const __dirname = dirname(__filename); // ---------------------------------------------------------------------------
// Venv python path (reuse CodexLens venv)
const VENV_PYTHON = getCodexLensHiddenPython();
// Script path
const EMBEDDER_SCRIPT = join(__dirname, '..', '..', 'scripts', 'unified_memory_embedder.py');
// Chunking constants (match existing core-memory-store.ts)
const CHUNK_SIZE = 1500;
const OVERLAP = 200;
// =============================================================================
// Types
// =============================================================================
/** Valid source types for vector content */
export type SourceType = 'core_memory' | 'workflow' | 'cli_history'; export type SourceType = 'core_memory' | 'workflow' | 'cli_history';
/** Valid category values for vector filtering */
export type VectorCategory = 'core_memory' | 'cli_history' | 'workflow' | 'entity' | 'pattern'; export type VectorCategory = 'core_memory' | 'cli_history' | 'workflow' | 'entity' | 'pattern';
/** Metadata attached to each chunk in the vector store */
export interface ChunkMetadata { export interface ChunkMetadata {
/** Source identifier (e.g., memory ID, session ID) */
source_id: string; source_id: string;
/** Source type */
source_type: SourceType; source_type: SourceType;
/** Category for filtering */
category: VectorCategory; category: VectorCategory;
/** Chunk index within the source */
chunk_index?: number; chunk_index?: number;
/** Additional metadata */
[key: string]: unknown; [key: string]: unknown;
} }
/** A chunk to be embedded and indexed */
export interface VectorChunk { export interface VectorChunk {
/** Text content */
content: string; content: string;
/** Source identifier */
source_id: string; source_id: string;
/** Source type */
source_type: SourceType; source_type: SourceType;
/** Category for filtering */
category: VectorCategory; category: VectorCategory;
/** Chunk index */
chunk_index: number; chunk_index: number;
/** Additional metadata */
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
/** Result of an embed operation */
export interface EmbedResult { export interface EmbedResult {
success: boolean; success: boolean;
chunks_processed: number; chunks_processed: number;
@@ -82,7 +40,6 @@ export interface EmbedResult {
error?: string; error?: string;
} }
/** A single search match */
export interface VectorSearchMatch { export interface VectorSearchMatch {
content: string; content: string;
score: number; score: number;
@@ -93,7 +50,6 @@ export interface VectorSearchMatch {
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
} }
/** Result of a search operation */
export interface VectorSearchResult { export interface VectorSearchResult {
success: boolean; success: boolean;
matches: VectorSearchMatch[]; matches: VectorSearchMatch[];
@@ -102,14 +58,12 @@ export interface VectorSearchResult {
error?: string; error?: string;
} }
/** Search options */
export interface VectorSearchOptions { export interface VectorSearchOptions {
topK?: number; topK?: number;
minScore?: number; minScore?: number;
category?: VectorCategory; category?: VectorCategory;
} }
/** Index status information */
export interface VectorIndexStatus { export interface VectorIndexStatus {
success: boolean; success: boolean;
total_chunks: number; total_chunks: number;
@@ -126,7 +80,6 @@ export interface VectorIndexStatus {
error?: string; error?: string;
} }
/** Reindex result */
export interface ReindexResult { export interface ReindexResult {
success: boolean; success: boolean;
hnsw_count?: number; hnsw_count?: number;
@@ -134,344 +87,73 @@ export interface ReindexResult {
error?: string; error?: string;
} }
// ============================================================================= // ---------------------------------------------------------------------------
// Python Bridge // No-op implementations
// ============================================================================= // ---------------------------------------------------------------------------
/**
* Check if the unified embedder is available (venv and script exist)
*/
export function isUnifiedEmbedderAvailable(): boolean { export function isUnifiedEmbedderAvailable(): boolean {
if (!existsSync(VENV_PYTHON)) { return false;
return false;
}
if (!existsSync(EMBEDDER_SCRIPT)) {
return false;
}
return true;
} }
/**
* Run Python script with JSON stdin/stdout protocol.
*
* @param request - JSON request object to send via stdin
* @param timeout - Timeout in milliseconds (default: 5 minutes)
* @returns Parsed JSON response
*/
function runPython<T>(request: Record<string, unknown>, timeout: number = 300000): Promise<T> {
return new Promise((resolve, reject) => {
if (!isUnifiedEmbedderAvailable()) {
reject(
new Error(
'Unified embedder not available. Ensure CodexLens venv exists at ~/.codexlens/venv'
)
);
return;
}
const child = spawn(VENV_PYTHON, [EMBEDDER_SCRIPT], {
shell: false,
stdio: ['pipe', 'pipe', 'pipe'],
timeout,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0 && stdout.trim()) {
try {
resolve(JSON.parse(stdout.trim()) as T);
} catch {
reject(new Error(`Failed to parse Python output: ${stdout.substring(0, 500)}`));
}
} else {
reject(new Error(`Python script failed (exit code ${code}): ${stderr || stdout}`));
}
});
child.on('error', (err) => {
if ((err as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
reject(new Error('Python script timed out'));
} else {
reject(new Error(`Failed to spawn Python: ${err.message}`));
}
});
// Write JSON request to stdin and close
const jsonInput = JSON.stringify(request);
child.stdin.write(jsonInput);
child.stdin.end();
});
}
// =============================================================================
// Content Chunking
// =============================================================================
/**
* Chunk content into smaller pieces for embedding.
* Uses paragraph-first, sentence-fallback strategy with overlap.
*
* Matches the chunking logic in core-memory-store.ts:
* - CHUNK_SIZE = 1500 characters
* - OVERLAP = 200 characters
* - Split by paragraph boundaries (\n\n) first
* - Fall back to sentence boundaries (. ) for oversized paragraphs
*
* @param content - Text content to chunk
* @returns Array of chunk strings
*/
export function chunkContent(content: string): string[] { export function chunkContent(content: string): string[] {
const chunks: string[] = []; // Minimal chunking for backward compat - just return the content as-is
if (!content.trim()) return [];
// Split by paragraph boundaries first return [content];
const paragraphs = content.split(/\n\n+/);
let currentChunk = '';
for (const paragraph of paragraphs) {
// If adding this paragraph would exceed chunk size
if (currentChunk.length + paragraph.length > CHUNK_SIZE && currentChunk.length > 0) {
chunks.push(currentChunk.trim());
// Start new chunk with overlap
const overlapText = currentChunk.slice(-OVERLAP);
currentChunk = overlapText + '\n\n' + paragraph;
} else {
currentChunk += (currentChunk ? '\n\n' : '') + paragraph;
}
}
// Add remaining chunk
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
// If chunks are still too large, split by sentences
const finalChunks: string[] = [];
for (const chunk of chunks) {
if (chunk.length <= CHUNK_SIZE) {
finalChunks.push(chunk);
} else {
// Split by sentence boundaries
const sentences = chunk.split(/\. +/);
let sentenceChunk = '';
for (const sentence of sentences) {
const sentenceWithPeriod = sentence + '. ';
if (
sentenceChunk.length + sentenceWithPeriod.length > CHUNK_SIZE &&
sentenceChunk.length > 0
) {
finalChunks.push(sentenceChunk.trim());
const overlapText = sentenceChunk.slice(-OVERLAP);
sentenceChunk = overlapText + sentenceWithPeriod;
} else {
sentenceChunk += sentenceWithPeriod;
}
}
if (sentenceChunk.trim()) {
finalChunks.push(sentenceChunk.trim());
}
}
}
return finalChunks.length > 0 ? finalChunks : [content];
} }
// =============================================================================
// UnifiedVectorIndex Class
// =============================================================================
/**
* Unified vector index backed by CodexLens VectorStore (HNSW).
*
* Provides content chunking, embedding, storage, and search for all
* memory content types through a single interface.
*/
export class UnifiedVectorIndex { export class UnifiedVectorIndex {
private storePath: string; constructor(_projectPath: string) {}
/**
* Create a UnifiedVectorIndex for a project.
*
* @param projectPath - Project root path (used to resolve storage location)
*/
constructor(projectPath: string) {
const paths = StoragePaths.project(projectPath);
this.storePath = paths.unifiedVectors.root;
ensureStorageDir(this.storePath);
}
/**
* Index content by chunking, embedding, and storing in VectorStore.
*
* @param content - Text content to index
* @param metadata - Metadata for all chunks (source_id, source_type, category)
* @returns Embed result
*/
async indexContent( async indexContent(
content: string, _content: string,
metadata: ChunkMetadata _metadata: ChunkMetadata
): Promise<EmbedResult> { ): Promise<EmbedResult> {
if (!content.trim()) { return {
return { success: false,
success: true, chunks_processed: 0,
chunks_processed: 0, chunks_failed: 0,
chunks_failed: 0, elapsed_time: 0,
elapsed_time: 0, error: V1_REMOVED,
}; };
}
// Chunk content
const textChunks = chunkContent(content);
// Build chunk objects for Python
const chunks: VectorChunk[] = textChunks.map((text, index) => ({
content: text,
source_id: metadata.source_id,
source_type: metadata.source_type,
category: metadata.category,
chunk_index: metadata.chunk_index != null ? metadata.chunk_index + index : index,
metadata: { ...metadata },
}));
try {
const result = await runPython<EmbedResult>({
operation: 'embed',
store_path: this.storePath,
chunks,
batch_size: 8,
});
return result;
} catch (err) {
return {
success: false,
chunks_processed: 0,
chunks_failed: textChunks.length,
elapsed_time: 0,
error: (err as Error).message,
};
}
} }
/**
* Search the vector index using semantic similarity.
*
* @param query - Natural language search query
* @param options - Search options (topK, minScore, category)
* @returns Search results sorted by relevance
*/
async search( async search(
query: string, _query: string,
options: VectorSearchOptions = {} _options: VectorSearchOptions = {}
): Promise<VectorSearchResult> { ): Promise<VectorSearchResult> {
const { topK = 10, minScore = 0.3, category } = options; return {
success: false,
try { matches: [],
const result = await runPython<VectorSearchResult>({ error: V1_REMOVED,
operation: 'search', };
store_path: this.storePath,
query,
top_k: topK,
min_score: minScore,
category: category || null,
});
return result;
} catch (err) {
return {
success: false,
matches: [],
error: (err as Error).message,
};
}
} }
/**
* Search the vector index using a pre-computed embedding vector.
* Bypasses text embedding, directly querying HNSW with a raw vector.
*
* @param vector - Pre-computed embedding vector (array of floats)
* @param options - Search options (topK, minScore, category)
* @returns Search results sorted by relevance
*/
async searchByVector( async searchByVector(
vector: number[], _vector: number[],
options: VectorSearchOptions = {} _options: VectorSearchOptions = {}
): Promise<VectorSearchResult> { ): Promise<VectorSearchResult> {
const { topK = 10, minScore = 0.3, category } = options; return {
success: false,
try { matches: [],
const result = await runPython<VectorSearchResult>({ error: V1_REMOVED,
operation: 'search_by_vector', };
store_path: this.storePath,
vector,
top_k: topK,
min_score: minScore,
category: category || null,
});
return result;
} catch (err) {
return {
success: false,
matches: [],
error: (err as Error).message,
};
}
} }
/**
* Rebuild the HNSW index from scratch.
*
* @returns Reindex result
*/
async reindexAll(): Promise<ReindexResult> { async reindexAll(): Promise<ReindexResult> {
try { return {
const result = await runPython<ReindexResult>({ success: false,
operation: 'reindex', error: V1_REMOVED,
store_path: this.storePath, };
});
return result;
} catch (err) {
return {
success: false,
error: (err as Error).message,
};
}
} }
/**
* Get the current status of the vector index.
*
* @returns Index status including chunk counts, HNSW availability, dimension
*/
async getStatus(): Promise<VectorIndexStatus> { async getStatus(): Promise<VectorIndexStatus> {
try { return {
const result = await runPython<VectorIndexStatus>({ success: false,
operation: 'status', total_chunks: 0,
store_path: this.storePath, hnsw_available: false,
}); hnsw_count: 0,
return result; dimension: 0,
} catch (err) { error: V1_REMOVED,
return { };
success: false,
total_chunks: 0,
hnsw_available: false,
hnsw_count: 0,
dimension: 0,
error: (err as Error).message,
};
}
} }
} }

View File

@@ -1,405 +0,0 @@
/**
* CodexLens LSP Tool - Provides LSP-like code intelligence via CodexLens Python API
*
* Features:
* - symbol_search: Search symbols across workspace
* - find_definition: Go to symbol definition
* - find_references: Find all symbol references
* - get_hover: Get hover information for symbols
*/
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { spawn } from 'child_process';
import { join } from 'path';
import { getProjectRoot } from '../utils/path-validator.js';
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
// CodexLens venv configuration
const CODEXLENS_VENV = getCodexLensHiddenPython();
// Define Zod schema for validation
const ParamsSchema = z.object({
action: z.enum(['symbol_search', 'find_definition', 'find_references', 'get_hover']),
project_root: z.string().optional().describe('Project root directory (auto-detected if not provided)'),
symbol_name: z.string().describe('Symbol name to search/query'),
symbol_kind: z.string().optional().describe('Symbol kind filter (class, function, method, etc.)'),
file_context: z.string().optional().describe('Current file path for proximity ranking'),
limit: z.number().default(50).describe('Maximum number of results to return'),
kind_filter: z.array(z.string()).optional().describe('List of symbol kinds to filter (for symbol_search)'),
file_pattern: z.string().optional().describe('Glob pattern to filter files (for symbol_search)'),
});
type Params = z.infer<typeof ParamsSchema>;
/**
* Result types
*/
interface SymbolInfo {
name: string;
kind: string;
file_path: string;
range: {
start_line: number;
end_line: number;
};
score?: number;
}
interface DefinitionResult {
name: string;
kind: string;
file_path: string;
range: {
start_line: number;
end_line: number;
};
}
interface ReferenceResult {
file_path: string;
line: number;
column: number;
}
interface HoverInfo {
name: string;
kind: string;
signature: string;
file_path: string;
start_line: number;
}
type LSPResult = {
success: boolean;
results?: SymbolInfo[] | DefinitionResult[] | ReferenceResult[] | HoverInfo;
error?: string;
action: string;
metadata?: Record<string, unknown>;
};
/**
* Execute CodexLens Python API call
*/
async function executeCodexLensAPI(
apiFunction: string,
args: Record<string, unknown>,
timeout: number = 30000
): Promise<LSPResult> {
return new Promise((resolve) => {
// Build Python script to call API function
const pythonScript = `
import json
import sys
from dataclasses import is_dataclass, asdict
from codexlens.api import ${apiFunction}
def to_serializable(obj):
"""Recursively convert dataclasses to dicts for JSON serialization."""
if obj is None:
return None
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
if isinstance(obj, list):
return [to_serializable(item) for item in obj]
if isinstance(obj, dict):
return {key: to_serializable(value) for key, value in obj.items()}
if isinstance(obj, tuple):
return tuple(to_serializable(item) for item in obj)
return obj
try:
args = ${JSON.stringify(args)}
result = ${apiFunction}(**args)
# Convert result to JSON-serializable format
output = to_serializable(result)
print(json.dumps({"success": True, "result": output}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}), file=sys.stderr)
sys.exit(1)
`;
const child = spawn(CODEXLENS_VENV, ['-c', pythonScript], {
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
timeout,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code !== 0) {
try {
const errorData = JSON.parse(stderr);
resolve({
success: false,
error: errorData.error || 'Unknown error',
action: apiFunction,
});
} catch {
resolve({
success: false,
error: stderr || `Process exited with code ${code}`,
action: apiFunction,
});
}
return;
}
try {
const data = JSON.parse(stdout);
resolve({
success: data.success,
results: data.result,
action: apiFunction,
});
} catch (err) {
resolve({
success: false,
error: `Failed to parse output: ${(err as Error).message}`,
action: apiFunction,
});
}
});
child.on('error', (err) => {
resolve({
success: false,
error: `Failed to execute: ${err.message}`,
action: apiFunction,
});
});
});
}
/**
* Handler: symbol_search
*/
async function handleSymbolSearch(params: Params): Promise<LSPResult> {
const projectRoot = params.project_root || getProjectRoot();
const args: Record<string, unknown> = {
project_root: projectRoot,
query: params.symbol_name,
limit: params.limit,
};
if (params.kind_filter) {
args.kind_filter = params.kind_filter;
}
if (params.file_pattern) {
args.file_pattern = params.file_pattern;
}
return executeCodexLensAPI('workspace_symbols', args);
}
/**
* Handler: find_definition
*/
async function handleFindDefinition(params: Params): Promise<LSPResult> {
const projectRoot = params.project_root || getProjectRoot();
const args: Record<string, unknown> = {
project_root: projectRoot,
symbol_name: params.symbol_name,
limit: params.limit,
};
if (params.symbol_kind) {
args.symbol_kind = params.symbol_kind;
}
if (params.file_context) {
args.file_context = params.file_context;
}
return executeCodexLensAPI('find_definition', args);
}
/**
* Handler: find_references
*/
async function handleFindReferences(params: Params): Promise<LSPResult> {
const projectRoot = params.project_root || getProjectRoot();
const args: Record<string, unknown> = {
project_root: projectRoot,
symbol_name: params.symbol_name,
limit: params.limit,
};
if (params.symbol_kind) {
args.symbol_kind = params.symbol_kind;
}
return executeCodexLensAPI('find_references', args);
}
/**
* Handler: get_hover
*/
async function handleGetHover(params: Params): Promise<LSPResult> {
const projectRoot = params.project_root || getProjectRoot();
const args: Record<string, unknown> = {
project_root: projectRoot,
symbol_name: params.symbol_name,
};
if (params.file_context) {
args.file_path = params.file_context;
}
return executeCodexLensAPI('get_hover', args);
}
/**
* Main handler function
*/
export async function handler(params: Record<string, unknown>): Promise<ToolResult<LSPResult>> {
try {
// Validate parameters
const validatedParams = ParamsSchema.parse(params);
// Route to appropriate handler based on action
let result: LSPResult;
switch (validatedParams.action) {
case 'symbol_search':
result = await handleSymbolSearch(validatedParams);
break;
case 'find_definition':
result = await handleFindDefinition(validatedParams);
break;
case 'find_references':
result = await handleFindReferences(validatedParams);
break;
case 'get_hover':
result = await handleGetHover(validatedParams);
break;
default:
return {
success: false,
error: `Unknown action: ${(validatedParams as any).action}`,
result: null as any,
};
}
if (!result.success) {
return {
success: false,
error: result.error || 'Unknown error',
result: null as any,
};
}
return {
success: true,
result,
};
} catch (err) {
if (err instanceof z.ZodError) {
return {
success: false,
error: `Parameter validation failed: ${err.issues.map((e: z.ZodIssue) => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
result: null as any,
};
}
return {
success: false,
error: `Execution failed: ${(err as Error).message}`,
result: null as any,
};
}
}
/**
* Tool schema for MCP
*/
export const schema: ToolSchema = {
name: 'codex_lens_lsp',
description: `LSP-like code intelligence tool powered by CodexLens indexing.
**Actions:**
- symbol_search: Search for symbols across the workspace
- find_definition: Find the definition of a symbol
- find_references: Find all references to a symbol
- get_hover: Get hover information for a symbol
**Usage Examples:**
Search symbols:
codex_lens_lsp(action="symbol_search", symbol_name="MyClass")
codex_lens_lsp(action="symbol_search", symbol_name="auth", kind_filter=["function", "method"])
codex_lens_lsp(action="symbol_search", symbol_name="User", file_pattern="*.py")
Find definition:
codex_lens_lsp(action="find_definition", symbol_name="authenticate")
codex_lens_lsp(action="find_definition", symbol_name="User", symbol_kind="class")
Find references:
codex_lens_lsp(action="find_references", symbol_name="login")
Get hover info:
codex_lens_lsp(action="get_hover", symbol_name="processPayment")
**Requirements:**
- CodexLens must be installed and indexed: run smart_search(action="init") first
- Python environment with codex-lens package available`,
inputSchema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['symbol_search', 'find_definition', 'find_references', 'get_hover'],
description: 'LSP action to perform',
},
symbol_name: {
type: 'string',
description: 'Symbol name to search/query (required)',
},
project_root: {
type: 'string',
description: 'Project root directory (auto-detected if not provided)',
},
symbol_kind: {
type: 'string',
description: 'Symbol kind filter: class, function, method, variable, etc. (optional)',
},
file_context: {
type: 'string',
description: 'Current file path for proximity ranking (optional)',
},
limit: {
type: 'number',
description: 'Maximum number of results to return (default: 50)',
default: 50,
},
kind_filter: {
type: 'array',
items: { type: 'string' },
description: 'List of symbol kinds to include (for symbol_search)',
},
file_pattern: {
type: 'string',
description: 'Glob pattern to filter files (for symbol_search)',
},
},
required: ['action', 'symbol_name'],
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ import * as cliExecutorMod from './cli-executor.js';
import * as smartSearchMod from './smart-search.js'; import * as smartSearchMod from './smart-search.js';
import { executeInitWithProgress } from './smart-search.js'; import { executeInitWithProgress } from './smart-search.js';
// codex_lens removed - functionality integrated into smart_search // codex_lens removed - functionality integrated into smart_search
import * as codexLensLspMod from './codex-lens-lsp.js'; // codex_lens_lsp removed - v1 LSP bridge removed
import * as readFileMod from './read-file.js'; import * as readFileMod from './read-file.js';
import * as readManyFilesMod from './read-many-files.js'; import * as readManyFilesMod from './read-many-files.js';
import * as readOutlineMod from './read-outline.js'; import * as readOutlineMod from './read-outline.js';
@@ -365,7 +365,7 @@ registerTool(toLegacyTool(sessionManagerMod));
registerTool(toLegacyTool(cliExecutorMod)); registerTool(toLegacyTool(cliExecutorMod));
registerTool(toLegacyTool(smartSearchMod)); registerTool(toLegacyTool(smartSearchMod));
// codex_lens removed - functionality integrated into smart_search // codex_lens removed - functionality integrated into smart_search
registerTool(toLegacyTool(codexLensLspMod)); // codex_lens_lsp removed - v1 LSP bridge removed
registerTool(toLegacyTool(readFileMod)); registerTool(toLegacyTool(readFileMod));
registerTool(toLegacyTool(readManyFilesMod)); registerTool(toLegacyTool(readManyFilesMod));
registerTool(toLegacyTool(readOutlineMod)); registerTool(toLegacyTool(readOutlineMod));

View File

@@ -1,64 +1,23 @@
/** /**
* LiteLLM Client - Bridge between CCW and ccw-litellm Python package * LiteLLM Client - STUB (v1 Python bridge removed)
* Provides LLM chat and embedding capabilities via spawned Python process
* *
* Features: * The Python ccw-litellm bridge has been removed. This module provides
* - Chat completions with multiple models * no-op stubs so that existing consumers compile without errors.
* - Text embeddings generation
* - Configuration management
* - JSON protocol communication
*/ */
import { spawn } from 'child_process'; const V1_REMOVED = 'LiteLLM Python bridge has been removed (v1 cleanup).';
import { existsSync } from 'fs';
import { join } from 'path';
import { getCodexLensPython, getCodexLensHiddenPython, getCodexLensVenvDir } from '../utils/codexlens-path.js';
export interface LiteLLMConfig { export interface LiteLLMConfig {
pythonPath?: string; // Default: CodexLens venv Python pythonPath?: string;
configPath?: string; // Configuration file path configPath?: string;
timeout?: number; // Default 60000ms timeout?: number;
} }
// Platform-specific constants for CodexLens venv
const IS_WINDOWS = process.platform === 'win32';
const CODEXLENS_VENV = getCodexLensVenvDir();
const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin';
const PYTHON_EXECUTABLE = IS_WINDOWS ? 'pythonw.exe' : 'python';
/**
* Get the Python path from CodexLens venv
* Falls back to system 'python' if venv doesn't exist
* @returns Path to Python executable
*/
export function getCodexLensVenvPython(): string { export function getCodexLensVenvPython(): string {
const venvPython = join(CODEXLENS_VENV, VENV_BIN_DIR, PYTHON_EXECUTABLE);
if (existsSync(venvPython)) {
return venvPython;
}
const hiddenPython = getCodexLensHiddenPython();
if (existsSync(hiddenPython)) {
return hiddenPython;
}
// Fallback to system Python if venv not available
return 'python'; return 'python';
} }
/**
* Get the Python path from CodexLens venv using centralized path utility
* Falls back to system 'python' if venv doesn't exist
* @returns Path to Python executable
*/
export function getCodexLensPythonPath(): string { export function getCodexLensPythonPath(): string {
const codexLensPython = getCodexLensHiddenPython();
if (existsSync(codexLensPython)) {
return codexLensPython;
}
const fallbackPython = getCodexLensPython();
if (existsSync(fallbackPython)) {
return fallbackPython;
}
// Fallback to system Python if venv not available
return 'python'; return 'python';
} }
@@ -90,179 +49,35 @@ export interface LiteLLMStatus {
} }
export class LiteLLMClient { export class LiteLLMClient {
private pythonPath: string; constructor(_config: LiteLLMConfig = {}) {}
private configPath?: string;
private timeout: number;
constructor(config: LiteLLMConfig = {}) {
this.pythonPath = config.pythonPath || getCodexLensVenvPython();
this.configPath = config.configPath;
this.timeout = config.timeout || 60000;
}
/**
* Execute Python ccw-litellm command
*/
private async executePython(args: string[], options: { timeout?: number } = {}): Promise<string> {
const timeout = options.timeout || this.timeout;
return new Promise((resolve, reject) => {
const proc = spawn(this.pythonPath, ['-m', 'ccw_litellm.cli', ...args], {
shell: false,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PYTHONIOENCODING: 'utf-8' }
});
let stdout = '';
let stderr = '';
let timedOut = false;
// Set up timeout
const timeoutId = setTimeout(() => {
timedOut = true;
proc.kill('SIGTERM');
reject(new Error(`Command timed out after ${timeout}ms`));
}, timeout);
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('error', (error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to spawn Python process: ${error.message}`));
});
proc.on('close', (code) => {
clearTimeout(timeoutId);
if (timedOut) {
return; // Already rejected
}
if (code === 0) {
resolve(stdout.trim());
} else {
const errorMsg = stderr.trim() || `Process exited with code ${code}`;
reject(new Error(errorMsg));
}
});
});
}
/**
* Check if ccw-litellm is available
*/
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
try { return false;
// Increased timeout to 15s for Python cold start
await this.executePython(['version'], { timeout: 15000 });
return true;
} catch {
return false;
}
} }
/**
* Get status information
*/
async getStatus(): Promise<LiteLLMStatus> { async getStatus(): Promise<LiteLLMStatus> {
try { return { available: false, error: V1_REMOVED };
// Increased timeout to 15s for Python cold start
const output = await this.executePython(['version'], { timeout: 15000 });
// Parse "ccw-litellm 0.1.0" format
const versionMatch = output.trim().match(/ccw-litellm\s+([\d.]+)/);
const version = versionMatch ? versionMatch[1] : output.trim();
return {
available: true,
version
};
} catch (error: any) {
return {
available: false,
error: error.message
};
}
} }
/** async getConfig(): Promise<unknown> {
* Get current configuration return { error: V1_REMOVED };
*/
async getConfig(): Promise<any> {
// config command outputs JSON by default, no --json flag needed
const output = await this.executePython(['config']);
return JSON.parse(output);
} }
/** async embed(_texts: string[], _model?: string): Promise<EmbedResponse> {
* Generate embeddings for texts throw new Error(V1_REMOVED);
*/
async embed(texts: string[], model: string = 'default'): Promise<EmbedResponse> {
if (!texts || texts.length === 0) {
throw new Error('texts array cannot be empty');
}
const args = ['embed', '--model', model, '--output', 'json'];
// Add texts as arguments
for (const text of texts) {
args.push(text);
}
const output = await this.executePython(args, { timeout: this.timeout * 2 });
const vectors = JSON.parse(output);
return {
vectors,
dimensions: vectors[0]?.length || 0,
model
};
} }
/** async chat(_message: string, _model?: string): Promise<string> {
* Chat with LLM throw new Error(V1_REMOVED);
*/
async chat(message: string, model: string = 'default'): Promise<string> {
if (!message) {
throw new Error('message cannot be empty');
}
const args = ['chat', '--model', model, message];
return this.executePython(args, { timeout: this.timeout * 2 });
} }
/** async chatMessages(_messages: ChatMessage[], _model?: string): Promise<ChatResponse> {
* Multi-turn chat with messages array throw new Error(V1_REMOVED);
*/
async chatMessages(messages: ChatMessage[], model: string = 'default'): Promise<ChatResponse> {
if (!messages || messages.length === 0) {
throw new Error('messages array cannot be empty');
}
// For now, just use the last user message
// TODO: Implement full message history support in ccw-litellm
const lastMessage = messages[messages.length - 1];
const content = await this.chat(lastMessage.content, model);
return {
content,
model,
usage: undefined // TODO: Add usage tracking
};
} }
} }
// Singleton instance
let _client: LiteLLMClient | null = null; let _client: LiteLLMClient | null = null;
/**
* Get or create singleton LiteLLM client
*/
export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient { export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
if (!_client) { if (!_client) {
_client = new LiteLLMClient(config); _client = new LiteLLMClient(config);
@@ -270,29 +85,10 @@ export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
return _client; return _client;
} }
/**
* Check if LiteLLM is available
*/
export async function checkLiteLLMAvailable(): Promise<boolean> { export async function checkLiteLLMAvailable(): Promise<boolean> {
try { return false;
const client = getLiteLLMClient();
return await client.isAvailable();
} catch {
return false;
}
} }
/**
* Get LiteLLM status
*/
export async function getLiteLLMStatus(): Promise<LiteLLMStatus> { export async function getLiteLLMStatus(): Promise<LiteLLMStatus> {
try { return { available: false, error: V1_REMOVED };
const client = getLiteLLMClient();
return await client.getStatus();
} catch (error: any) {
return {
available: false,
error: error.message
};
}
} }

View File

@@ -9,7 +9,6 @@
* 2. Default: ~/.codexlens * 2. Default: ~/.codexlens
*/ */
import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
@@ -26,56 +25,3 @@ export function getCodexLensDataDir(): string {
} }
return join(homedir(), '.codexlens'); return join(homedir(), '.codexlens');
} }
/**
* Get the CodexLens virtual environment path.
*
* @returns Path to CodexLens venv directory
*/
export function getCodexLensVenvDir(): string {
return join(getCodexLensDataDir(), 'venv');
}
/**
* Get the Python executable path in the CodexLens venv.
*
* @returns Path to python executable
*/
export function getCodexLensPython(): string {
const venvDir = getCodexLensVenvDir();
return process.platform === 'win32'
? join(venvDir, 'Scripts', 'python.exe')
: join(venvDir, 'bin', 'python');
}
/**
* Get the preferred Python executable for hidden/windowless CodexLens subprocesses.
* On Windows this prefers pythonw.exe when available to avoid transient console windows.
*
* @returns Path to the preferred hidden-subprocess Python executable
*/
export function getCodexLensHiddenPython(): string {
if (process.platform !== 'win32') {
return getCodexLensPython();
}
const venvDir = getCodexLensVenvDir();
const pythonwPath = join(venvDir, 'Scripts', 'pythonw.exe');
if (existsSync(pythonwPath)) {
return pythonwPath;
}
return getCodexLensPython();
}
/**
* Get the pip executable path in the CodexLens venv.
*
* @returns Path to pip executable
*/
export function getCodexLensPip(): string {
const venvDir = getCodexLensVenvDir();
return process.platform === 'win32'
? join(venvDir, 'Scripts', 'pip.exe')
: join(venvDir, 'bin', 'pip');
}

View File

@@ -1,327 +0,0 @@
/**
* Unified Package Discovery for local Python packages (codex-lens, ccw-litellm)
*
* Provides a single, transparent path discovery mechanism with:
* - Environment variable overrides (highest priority)
* - ~/.codexlens/config.json configuration
* - Extended search paths (npm global, PACKAGE_ROOT, siblings, etc.)
* - Full search result transparency for diagnostics
*/
import { existsSync, readFileSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { homedir } from 'os';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
import { getCodexLensDataDir } from './codexlens-path.js';
import { EXEC_TIMEOUTS } from './exec-constants.js';
// Get directory of this module (src/utils/)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ========================================
// Types
// ========================================
/** Source that found the package path */
export type PackageSource =
| 'env' // Environment variable override
| 'config' // ~/.codexlens/config.json
| 'sibling' // Sibling directory to ccw project root
| 'npm-global' // npm global prefix
| 'cwd' // Current working directory
| 'cwd-parent' // Parent of current working directory
| 'homedir' // User home directory
| 'package-root'; // npm package internal path
/** A single search attempt result */
export interface SearchAttempt {
path: string;
source: PackageSource;
exists: boolean;
}
/** Result of package discovery */
export interface PackageDiscoveryResult {
/** Resolved package path, or null if not found */
path: string | null;
/** Source that found the package */
source: PackageSource | null;
/** All paths searched (for diagnostics) */
searchedPaths: SearchAttempt[];
/** Whether the found path is inside node_modules */
insideNodeModules: boolean;
}
/** Known local package names */
export type LocalPackageName = 'codex-lens' | 'ccw-litellm' | 'codexlens-search';
/** Environment variable mapping for each package */
const PACKAGE_ENV_VARS: Record<LocalPackageName, string> = {
'codex-lens': 'CODEXLENS_PACKAGE_PATH',
'ccw-litellm': 'CCW_LITELLM_PATH',
'codexlens-search': 'CODEXLENS_SEARCH_PATH',
};
/** Config key mapping for each package */
const PACKAGE_CONFIG_KEYS: Record<LocalPackageName, string> = {
'codex-lens': 'codexLensPath',
'ccw-litellm': 'ccwLitellmPath',
'codexlens-search': 'codexlensSearchPath',
};
// ========================================
// Helpers
// ========================================
/**
* Check if a path is inside node_modules
*/
export function isInsideNodeModules(pathToCheck: string): boolean {
const normalized = pathToCheck.replace(/\\/g, '/').toLowerCase();
return normalized.includes('/node_modules/');
}
/**
* Check if running in a development environment (not from node_modules)
*/
export function isDevEnvironment(): boolean {
// Yarn PnP detection
if ((process.versions as Record<string, unknown>).pnp) {
return false;
}
return !isInsideNodeModules(__dirname);
}
/**
* Read package paths from ~/.codexlens/config.json
*/
function readConfigPath(packageName: LocalPackageName): string | null {
try {
const configPath = join(getCodexLensDataDir(), 'config.json');
if (!existsSync(configPath)) return null;
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const key = PACKAGE_CONFIG_KEYS[packageName];
const value = config?.packagePaths?.[key];
return typeof value === 'string' && value.trim() ? value.trim() : null;
} catch {
return null;
}
}
/**
* Get npm global prefix directory
*/
let _npmGlobalPrefix: string | null | undefined;
function getNpmGlobalPrefix(): string | null {
if (_npmGlobalPrefix !== undefined) return _npmGlobalPrefix;
try {
const result = execSync('npm prefix -g', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUTS.SYSTEM_INFO,
stdio: ['pipe', 'pipe', 'pipe'],
});
_npmGlobalPrefix = result.trim() || null;
} catch {
_npmGlobalPrefix = null;
}
return _npmGlobalPrefix;
}
/**
* Check if a directory contains a valid Python package (has pyproject.toml)
*/
function isValidPackageDir(dir: string): boolean {
return existsSync(join(dir, 'pyproject.toml'));
}
// ========================================
// Main Discovery Function
// ========================================
/**
* Find a local Python package path with unified search logic.
*
* Search priority:
* 1. Environment variable (CODEXLENS_PACKAGE_PATH / CCW_LITELLM_PATH)
* 2. ~/.codexlens/config.json packagePaths
* 3. Sibling directory to ccw project root (src/utils -> ../../..)
* 4. npm global prefix node_modules path
* 5. Current working directory
* 6. Parent of current working directory
* 7. Home directory
*
* Two-pass search: first pass skips node_modules paths, second pass allows them.
*
* @param packageName - Package to find ('codex-lens' or 'ccw-litellm')
* @returns Discovery result with path, source, and all searched paths
*/
export function findPackagePath(packageName: LocalPackageName): PackageDiscoveryResult {
const searched: SearchAttempt[] = [];
// Helper to check and record a path
const check = (path: string, source: PackageSource): boolean => {
const resolvedPath = resolve(path);
const exists = isValidPackageDir(resolvedPath);
searched.push({ path: resolvedPath, source, exists });
return exists;
};
// 1. Environment variable (highest priority, skip two-pass)
const envKey = PACKAGE_ENV_VARS[packageName];
const envPath = process.env[envKey];
if (envPath) {
if (check(envPath, 'env')) {
return {
path: resolve(envPath),
source: 'env',
searchedPaths: searched,
insideNodeModules: isInsideNodeModules(envPath),
};
}
// Env var set but path invalid — continue searching but warn
console.warn(`[PackageDiscovery] ${envKey}="${envPath}" set but pyproject.toml not found, continuing search...`);
}
// 2. Config file
const configPath = readConfigPath(packageName);
if (configPath) {
if (check(configPath, 'config')) {
return {
path: resolve(configPath),
source: 'config',
searchedPaths: searched,
insideNodeModules: isInsideNodeModules(configPath),
};
}
}
// Build candidate paths for two-pass search
const candidates: { path: string; source: PackageSource }[] = [];
// 3. Sibling directory to ccw project root
// __dirname = src/utils/ → project root = ../../..
// Also try one more level up for nested structures
const projectRoot = join(__dirname, '..', '..', '..');
candidates.push({ path: join(projectRoot, packageName), source: 'sibling' });
candidates.push({ path: join(projectRoot, '..', packageName), source: 'sibling' });
// 4. npm global prefix
const npmPrefix = getNpmGlobalPrefix();
if (npmPrefix) {
// npm global: prefix/node_modules/claude-code-workflow/<packageName>
candidates.push({
path: join(npmPrefix, 'node_modules', 'claude-code-workflow', packageName),
source: 'npm-global',
});
// npm global: prefix/lib/node_modules/claude-code-workflow/<packageName> (Linux/Mac)
candidates.push({
path: join(npmPrefix, 'lib', 'node_modules', 'claude-code-workflow', packageName),
source: 'npm-global',
});
// npm global sibling: prefix/node_modules/<packageName>
candidates.push({
path: join(npmPrefix, 'node_modules', packageName),
source: 'npm-global',
});
}
// 5. Current working directory
const cwd = process.cwd();
candidates.push({ path: join(cwd, packageName), source: 'cwd' });
// 6. Parent of cwd (common workspace layout)
const cwdParent = dirname(cwd);
if (cwdParent !== cwd) {
candidates.push({ path: join(cwdParent, packageName), source: 'cwd-parent' });
}
// 7. Home directory
candidates.push({ path: join(homedir(), packageName), source: 'homedir' });
// Two-pass search: prefer non-node_modules paths first
// First pass: skip node_modules
for (const candidate of candidates) {
const resolvedPath = resolve(candidate.path);
if (isInsideNodeModules(resolvedPath)) continue;
if (check(resolvedPath, candidate.source)) {
console.log(`[PackageDiscovery] Found ${packageName} at: ${resolvedPath} (source: ${candidate.source})`);
return {
path: resolvedPath,
source: candidate.source,
searchedPaths: searched,
insideNodeModules: false,
};
}
}
// Second pass: allow node_modules paths
for (const candidate of candidates) {
const resolvedPath = resolve(candidate.path);
if (!isInsideNodeModules(resolvedPath)) continue;
// Skip if already checked in first pass
if (searched.some(s => s.path === resolvedPath)) continue;
if (check(resolvedPath, candidate.source)) {
console.log(`[PackageDiscovery] Found ${packageName} in node_modules at: ${resolvedPath} (source: ${candidate.source})`);
return {
path: resolvedPath,
source: candidate.source,
searchedPaths: searched,
insideNodeModules: true,
};
}
}
// Not found
return {
path: null,
source: null,
searchedPaths: searched,
insideNodeModules: false,
};
}
/**
* Find codex-lens package path (convenience wrapper)
*/
export function findCodexLensPath(): PackageDiscoveryResult {
return findPackagePath('codex-lens');
}
/**
* Find ccw-litellm package path (convenience wrapper)
*/
export function findCcwLitellmPath(): PackageDiscoveryResult {
return findPackagePath('ccw-litellm');
}
/**
* Find codexlens-search (v2) package path (convenience wrapper)
*/
export function findCodexLensSearchPath(): PackageDiscoveryResult {
return findPackagePath('codexlens-search');
}
/**
* Format search results for error messages
*/
export function formatSearchResults(result: PackageDiscoveryResult, packageName: string): string {
const lines = [`Cannot find '${packageName}' package directory.\n`];
lines.push('Searched locations:');
for (const attempt of result.searchedPaths) {
const status = attempt.exists ? '✓' : '✗';
lines.push(` ${status} [${attempt.source}] ${attempt.path}`);
}
lines.push('');
lines.push('To fix this:');
const envKey = PACKAGE_ENV_VARS[packageName as LocalPackageName] || `${packageName.toUpperCase().replace(/-/g, '_')}_PATH`;
lines.push(` 1. Set environment variable: ${envKey}=/path/to/${packageName}`);
lines.push(` 2. Or add to ~/.codexlens/config.json: { "packagePaths": { "${PACKAGE_CONFIG_KEYS[packageName as LocalPackageName] || packageName}": "/path/to/${packageName}" } }`);
lines.push(` 3. Or ensure '${packageName}' directory exists as a sibling to the ccw project`);
return lines.join('\n');
}

View File

@@ -1,269 +0,0 @@
/**
* Python detection and version compatibility utilities
* Shared module for consistent Python discovery across the application
*/
import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'child_process';
import { EXEC_TIMEOUTS } from './exec-constants.js';
export interface PythonCommandSpec {
command: string;
args: string[];
display: string;
}
type HiddenPythonProbeOptions = Omit<SpawnSyncOptionsWithStringEncoding, 'encoding'> & {
encoding?: BufferEncoding;
};
function isExecTimeoutError(error: unknown): boolean {
const err = error as { code?: unknown; errno?: unknown; message?: unknown } | null;
const code = err?.code ?? err?.errno;
if (code === 'ETIMEDOUT') return true;
const message = typeof err?.message === 'string' ? err.message : '';
return message.includes('ETIMEDOUT');
}
function quoteCommandPart(value: string): string {
if (!/[\s"]/.test(value)) {
return value;
}
return `"${value.replaceAll('"', '\\"')}"`;
}
function formatPythonCommandDisplay(command: string, args: string[]): string {
return [quoteCommandPart(command), ...args.map(quoteCommandPart)].join(' ');
}
function buildPythonCommandSpec(command: string, args: string[] = []): PythonCommandSpec {
return {
command,
args: [...args],
display: formatPythonCommandDisplay(command, args),
};
}
function tokenizeCommandSpec(raw: string): string[] {
const tokens: string[] = [];
const tokenPattern = /"((?:\\"|[^"])*)"|(\S+)/g;
for (const match of raw.matchAll(tokenPattern)) {
const quoted = match[1];
const plain = match[2];
if (quoted !== undefined) {
tokens.push(quoted.replaceAll('\\"', '"'));
} else if (plain !== undefined) {
tokens.push(plain);
}
}
return tokens;
}
export function parsePythonCommandSpec(raw: string): PythonCommandSpec {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error('Python command cannot be empty');
}
// Unquoted executable paths on Windows commonly contain spaces.
if (!trimmed.includes('"') && /[\\/]/.test(trimmed)) {
return buildPythonCommandSpec(trimmed);
}
const tokens = tokenizeCommandSpec(trimmed);
if (tokens.length === 0) {
return buildPythonCommandSpec(trimmed);
}
return buildPythonCommandSpec(tokens[0], tokens.slice(1));
}
function buildPythonProbeOptions(
overrides: HiddenPythonProbeOptions = {},
): SpawnSyncOptionsWithStringEncoding {
const { env, encoding, ...rest } = overrides;
return {
shell: false,
windowsHide: true,
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...env },
...rest,
encoding: encoding ?? 'utf8',
};
}
export function probePythonCommandVersion(
pythonCommand: PythonCommandSpec,
runner: typeof spawnSync = spawnSync,
): string {
const result = runner(
pythonCommand.command,
[...pythonCommand.args, '--version'],
buildPythonProbeOptions(),
);
if (result.error) {
throw result.error;
}
const versionOutput = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
if (result.status !== 0) {
throw new Error(versionOutput || `Python version probe exited with code ${String(result.status)}`);
}
return versionOutput;
}
/**
* Parse Python version string to major.minor numbers
* @param versionStr - Version string like "Python 3.11.5"
* @returns Object with major and minor version numbers, or null if parsing fails
*/
export function parsePythonVersion(versionStr: string): { major: number; minor: number } | null {
const match = versionStr.match(/Python\s+(\d+)\.(\d+)/);
if (match) {
return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) };
}
return null;
}
/**
* Check if Python version is compatible with onnxruntime (3.9-3.12)
* @param major - Major version number
* @param minor - Minor version number
* @returns true if compatible
*/
export function isPythonVersionCompatible(major: number, minor: number): boolean {
// onnxruntime currently supports Python 3.9-3.12
return major === 3 && minor >= 9 && minor <= 12;
}
/**
* Detect available Python 3 executable
* Supports CCW_PYTHON environment variable for custom Python path
* On Windows, uses py launcher to find compatible versions
* @returns Python executable command spec
*/
export function getSystemPythonCommand(runner: typeof spawnSync = spawnSync): PythonCommandSpec {
const customPython = process.env.CCW_PYTHON?.trim();
if (customPython) {
const customSpec = parsePythonCommandSpec(customPython);
try {
const version = probePythonCommandVersion(customSpec, runner);
if (version.includes('Python 3')) {
const parsed = parsePythonVersion(version);
if (parsed && !isPythonVersionCompatible(parsed.major, parsed.minor)) {
console.warn(
`[Python] Warning: CCW_PYTHON points to Python ${parsed.major}.${parsed.minor}, which may not be compatible with onnxruntime (requires 3.9-3.12)`,
);
}
return customSpec;
}
} catch (err: unknown) {
if (isExecTimeoutError(err)) {
console.warn(
`[Python] Warning: CCW_PYTHON version check timed out after ${EXEC_TIMEOUTS.PYTHON_VERSION}ms, falling back to system Python`,
);
} else {
console.warn(
`[Python] Warning: CCW_PYTHON="${customPython}" is not a valid Python executable, falling back to system Python`,
);
}
}
}
if (process.platform === 'win32') {
const compatibleVersions = ['3.12', '3.11', '3.10', '3.9'];
for (const ver of compatibleVersions) {
const launcherSpec = buildPythonCommandSpec('py', [`-${ver}`]);
try {
const version = probePythonCommandVersion(launcherSpec, runner);
if (version.includes(`Python ${ver}`)) {
console.log(`[Python] Found compatible Python ${ver} via py launcher`);
return launcherSpec;
}
} catch (err: unknown) {
if (isExecTimeoutError(err)) {
console.warn(
`[Python] Warning: py -${ver} version check timed out after ${EXEC_TIMEOUTS.PYTHON_VERSION}ms`,
);
}
}
}
}
const commands = process.platform === 'win32' ? ['python', 'py', 'python3'] : ['python3', 'python'];
let fallbackCmd: PythonCommandSpec | null = null;
let fallbackVersion: { major: number; minor: number } | null = null;
for (const cmd of commands) {
const pythonSpec = buildPythonCommandSpec(cmd);
try {
const version = probePythonCommandVersion(pythonSpec, runner);
if (version.includes('Python 3')) {
const parsed = parsePythonVersion(version);
if (parsed) {
if (isPythonVersionCompatible(parsed.major, parsed.minor)) {
return pythonSpec;
}
if (!fallbackCmd) {
fallbackCmd = pythonSpec;
fallbackVersion = parsed;
}
}
}
} catch (err: unknown) {
if (isExecTimeoutError(err)) {
console.warn(`[Python] Warning: ${cmd} --version timed out after ${EXEC_TIMEOUTS.PYTHON_VERSION}ms`);
}
}
}
if (fallbackCmd && fallbackVersion) {
console.warn(
`[Python] Warning: Only Python ${fallbackVersion.major}.${fallbackVersion.minor} found, which may not be compatible with onnxruntime (requires 3.9-3.12).`,
);
console.warn('[Python] Semantic search may fail with ImportError for onnxruntime.');
console.warn('[Python] To use a specific Python version, set CCW_PYTHON environment variable:');
console.warn(' Windows: set CCW_PYTHON=C:\\path\\to\\python.exe');
console.warn(' Unix: export CCW_PYTHON=/path/to/python3.11');
console.warn('[Python] Alternatively, use LiteLLM embedding backend which has no Python version restrictions.');
return fallbackCmd;
}
throw new Error(
'Python 3 not found. Please install Python 3.9-3.12 and ensure it is in PATH, or set CCW_PYTHON environment variable.',
);
}
/**
* Detect available Python 3 executable
* Supports CCW_PYTHON environment variable for custom Python path
* On Windows, uses py launcher to find compatible versions
* @returns Python executable command
*/
export function getSystemPython(): string {
return getSystemPythonCommand().display;
}
/**
* Get the Python command for pip operations (uses -m pip for reliability)
* @returns Array of command arguments for spawn
*/
export function getPipCommand(): { pythonCmd: string; pipArgs: string[] } {
const pythonCmd = getSystemPython();
return {
pythonCmd,
pipArgs: ['-m', 'pip'],
};
}
export const __testables = {
buildPythonCommandSpec,
buildPythonProbeOptions,
formatPythonCommandDisplay,
parsePythonCommandSpec,
probePythonCommandVersion,
};

View File

@@ -1,902 +0,0 @@
/**
* UV Package Manager Tool
* Provides unified UV (https://github.com/astral-sh/uv) tool management capabilities
*
* Features:
* - Cross-platform UV binary discovery and installation
* - Virtual environment creation and management
* - Python dependency installation with UV's fast resolver
* - Support for local project installs with extras
*/
import { spawn, spawnSync, type SpawnOptions, type SpawnSyncOptionsWithStringEncoding } from 'child_process';
import { existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir, platform, arch } from 'os';
import { EXEC_TIMEOUTS } from './exec-constants.js';
import { getCodexLensDataDir, getCodexLensVenvDir } from './codexlens-path.js';
/**
* Configuration for UvManager
*/
export interface UvManagerConfig {
/** Path to the virtual environment directory */
venvPath: string;
/** Python version requirement (e.g., ">=3.10", "3.11") */
pythonVersion?: string;
}
/**
* Result of UV operations
*/
export interface UvInstallResult {
/** Whether the operation succeeded */
success: boolean;
/** Error message if operation failed */
error?: string;
/** Duration of the operation in milliseconds */
duration?: number;
}
/**
* UV binary search locations in priority order
*/
interface UvSearchLocation {
path: string;
description: string;
}
// Platform-specific constants
const IS_WINDOWS = platform() === 'win32';
const UV_BINARY_NAME = IS_WINDOWS ? 'uv.exe' : 'uv';
const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin';
const PYTHON_EXECUTABLE = IS_WINDOWS ? 'python.exe' : 'python';
type HiddenUvSpawnSyncOptions = Omit<SpawnSyncOptionsWithStringEncoding, 'encoding'> & {
encoding?: BufferEncoding;
};
function buildUvSpawnOptions(overrides: SpawnOptions = {}): SpawnOptions {
const { env, ...rest } = overrides;
return {
shell: false,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...env },
...rest,
};
}
function buildUvSpawnSyncOptions(
overrides: HiddenUvSpawnSyncOptions = {},
): SpawnSyncOptionsWithStringEncoding {
const { env, encoding, ...rest } = overrides;
return {
shell: false,
windowsHide: true,
env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...env },
...rest,
encoding: encoding ?? 'utf-8',
};
}
function findExecutableOnPath(executable: string, runner: typeof spawnSync = spawnSync): string | null {
const lookupCommand = IS_WINDOWS ? 'where' : 'which';
const result = runner(
lookupCommand,
[executable],
buildUvSpawnSyncOptions({
timeout: EXEC_TIMEOUTS.SYSTEM_INFO,
stdio: ['ignore', 'pipe', 'pipe'],
}),
);
if (result.error || result.status !== 0) {
return null;
}
const output = `${result.stdout ?? ''}`.trim();
if (!output) {
return null;
}
return output.split(/\r?\n/)[0] || null;
}
function hasWindowsPythonLauncherVersion(version: string, runner: typeof spawnSync = spawnSync): boolean {
const result = runner(
'py',
[`-${version}`, '--version'],
buildUvSpawnSyncOptions({
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
stdio: ['ignore', 'pipe', 'pipe'],
}),
);
if (result.error || result.status !== 0) {
return false;
}
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
return output.includes(`Python ${version}`);
}
/**
* Get the path to the UV binary
* Search order:
* 1. CCW_UV_PATH environment variable
* 2. Project vendor/uv/ directory
* 3. User local directories (~/.local/bin, ~/.cargo/bin)
* 4. System PATH
*
* @returns Path to the UV binary
*/
export function getUvBinaryPath(): string {
const searchLocations: UvSearchLocation[] = [];
// 1. Environment variable (highest priority)
const envPath = process.env.CCW_UV_PATH;
if (envPath) {
searchLocations.push({ path: envPath, description: 'CCW_UV_PATH environment variable' });
}
// 2. Project vendor directory
const vendorPaths = [
join(process.cwd(), 'vendor', 'uv', UV_BINARY_NAME),
join(dirname(process.cwd()), 'vendor', 'uv', UV_BINARY_NAME),
];
for (const vendorPath of vendorPaths) {
searchLocations.push({ path: vendorPath, description: 'Project vendor directory' });
}
// 3. User local directories
const home = homedir();
if (IS_WINDOWS) {
// Windows: AppData\Local\uv and .cargo\bin
searchLocations.push(
{ path: join(home, 'AppData', 'Local', 'uv', 'bin', UV_BINARY_NAME), description: 'UV AppData' },
{ path: join(home, '.cargo', 'bin', UV_BINARY_NAME), description: 'Cargo bin' },
{ path: join(home, '.local', 'bin', UV_BINARY_NAME), description: 'Local bin' },
);
} else {
// Unix: ~/.local/bin and ~/.cargo/bin
searchLocations.push(
{ path: join(home, '.local', 'bin', UV_BINARY_NAME), description: 'Local bin' },
{ path: join(home, '.cargo', 'bin', UV_BINARY_NAME), description: 'Cargo bin' },
);
}
// Check each location
for (const location of searchLocations) {
if (existsSync(location.path)) {
return location.path;
}
}
// 4. Try system PATH using 'which' or 'where'
const foundPath = findExecutableOnPath('uv');
if (foundPath && existsSync(foundPath)) {
return foundPath;
}
// Return default path (may not exist)
if (IS_WINDOWS) {
return join(home, 'AppData', 'Local', 'uv', 'bin', UV_BINARY_NAME);
}
return join(home, '.local', 'bin', UV_BINARY_NAME);
}
/**
* Check if UV is available and working
* @returns True if UV is installed and functional
*/
export async function isUvAvailable(): Promise<boolean> {
const uvPath = getUvBinaryPath();
if (!existsSync(uvPath)) {
return false;
}
return new Promise((resolve) => {
const child = spawn(uvPath, ['--version'], buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
}));
child.on('close', (code) => {
resolve(code === 0);
});
child.on('error', () => {
resolve(false);
});
});
}
/**
* Get UV version string
* @returns UV version or null if not available
*/
export async function getUvVersion(): Promise<string | null> {
const uvPath = getUvBinaryPath();
if (!existsSync(uvPath)) {
return null;
}
return new Promise((resolve) => {
const child = spawn(uvPath, ['--version'], buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
}));
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
// Parse "uv 0.4.0" -> "0.4.0"
const match = stdout.match(/uv\s+(\S+)/);
resolve(match ? match[1] : stdout.trim());
} else {
resolve(null);
}
});
child.on('error', () => {
resolve(null);
});
});
}
/**
* Download and install UV using the official installation script
* @returns True if installation succeeded
*/
export async function ensureUvInstalled(): Promise<boolean> {
// Check if already installed
if (await isUvAvailable()) {
return true;
}
console.log('[UV] Installing UV package manager...');
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
if (IS_WINDOWS) {
// Windows: Use PowerShell to run the install script
const installCmd = 'irm https://astral.sh/uv/install.ps1 | iex';
child = spawn('powershell', ['-ExecutionPolicy', 'ByPass', '-Command', installCmd], buildUvSpawnOptions({
stdio: ['pipe', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
}));
} else {
// Unix: Use curl and sh
const installCmd = 'curl -LsSf https://astral.sh/uv/install.sh | sh';
child = spawn('sh', ['-c', installCmd], buildUvSpawnOptions({
stdio: ['pipe', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
}));
}
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) console.log(`[UV] ${line}`);
});
child.stderr?.on('data', (data) => {
const line = data.toString().trim();
if (line) console.log(`[UV] ${line}`);
});
child.on('close', (code) => {
if (code === 0) {
console.log('[UV] UV installed successfully');
resolve(true);
} else {
console.error(`[UV] Installation failed with code ${code}`);
resolve(false);
}
});
child.on('error', (err) => {
console.error(`[UV] Installation failed: ${err.message}`);
resolve(false);
});
});
}
/**
* UvManager class for virtual environment and package management
*/
export class UvManager {
private readonly venvPath: string;
private readonly pythonVersion?: string;
/**
* Create a new UvManager instance
* @param config - Configuration options
*/
constructor(config: UvManagerConfig) {
this.venvPath = config.venvPath;
this.pythonVersion = config.pythonVersion;
}
/**
* Get the path to the Python executable inside the virtual environment
* @returns Path to the Python executable
*/
getVenvPython(): string {
return join(this.venvPath, VENV_BIN_DIR, PYTHON_EXECUTABLE);
}
/**
* Get the path to pip inside the virtual environment
* @returns Path to the pip executable
*/
getVenvPip(): string {
const pipName = IS_WINDOWS ? 'pip.exe' : 'pip';
return join(this.venvPath, VENV_BIN_DIR, pipName);
}
/**
* Check if the virtual environment exists and is valid
* @returns True if the venv exists and has a working Python
*/
isVenvValid(): boolean {
const pythonPath = this.getVenvPython();
return existsSync(pythonPath);
}
/**
* Create a virtual environment using UV
* @returns Installation result
*/
async createVenv(): Promise<UvInstallResult> {
const startTime = Date.now();
// Ensure UV is available
if (!(await isUvAvailable())) {
const installed = await ensureUvInstalled();
if (!installed) {
return { success: false, error: 'Failed to install UV' };
}
}
const uvPath = getUvBinaryPath();
// Ensure parent directory exists
const parentDir = dirname(this.venvPath);
if (!existsSync(parentDir)) {
mkdirSync(parentDir, { recursive: true });
}
return new Promise((resolve) => {
const args = ['venv', this.venvPath];
// Add Python version constraint if specified
if (this.pythonVersion) {
args.push('--python', this.pythonVersion);
}
console.log(`[UV] Creating virtual environment at ${this.venvPath}`);
if (this.pythonVersion) {
console.log(`[UV] Python version: ${this.pythonVersion}`);
}
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PROCESS_SPAWN,
}));
let stderr = '';
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
console.log(`[UV] Virtual environment created successfully (${duration}ms)`);
resolve({ success: true, duration });
} else {
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
}
});
child.on('error', (err) => {
const duration = Date.now() - startTime;
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
});
});
}
/**
* Install packages from a local project with optional extras
* Uses `uv pip install` for standard installs, or `-e` for editable installs
* @param projectPath - Path to the project directory (must contain pyproject.toml or setup.py)
* @param extras - Optional array of extras to install (e.g., ['semantic', 'dev'])
* @param editable - Whether to install in editable mode (default: false for stability)
* @returns Installation result
*/
async installFromProject(projectPath: string, extras?: string[], editable = false): Promise<UvInstallResult> {
const startTime = Date.now();
// Ensure UV is available
if (!(await isUvAvailable())) {
return { success: false, error: 'UV is not available' };
}
// Ensure venv exists
if (!this.isVenvValid()) {
return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' };
}
const uvPath = getUvBinaryPath();
// Build the install specifier
let installSpec = projectPath;
if (extras && extras.length > 0) {
installSpec = `${projectPath}[${extras.join(',')}]`;
}
return new Promise((resolve) => {
const args = editable
? ['pip', 'install', '-e', installSpec, '--python', this.getVenvPython()]
: ['pip', 'install', installSpec, '--python', this.getVenvPython()];
console.log(`[UV] Installing from project: ${installSpec} (editable: ${editable})`);
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
cwd: projectPath,
}));
let stderr = '';
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
const line = data.toString().trim();
if (line && !line.startsWith('Resolved') && !line.startsWith('Prepared') && !line.startsWith('Installed')) {
// Only log non-progress lines to stderr
console.log(`[UV] ${line}`);
}
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
console.log(`[UV] Project installation successful (${duration}ms)`);
resolve({ success: true, duration });
} else {
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
}
});
child.on('error', (err) => {
const duration = Date.now() - startTime;
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
});
});
}
/**
* Install a list of packages
* @param packages - Array of package specifiers (e.g., ['numpy>=1.24', 'requests'])
* @returns Installation result
*/
async install(packages: string[]): Promise<UvInstallResult> {
const startTime = Date.now();
if (packages.length === 0) {
return { success: true, duration: 0 };
}
// Ensure UV is available
if (!(await isUvAvailable())) {
return { success: false, error: 'UV is not available' };
}
// Ensure venv exists
if (!this.isVenvValid()) {
return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' };
}
const uvPath = getUvBinaryPath();
return new Promise((resolve) => {
const args = ['pip', 'install', ...packages, '--python', this.getVenvPython()];
console.log(`[UV] Installing packages: ${packages.join(', ')}`);
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
}));
let stderr = '';
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
console.log(`[UV] Package installation successful (${duration}ms)`);
resolve({ success: true, duration });
} else {
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
}
});
child.on('error', (err) => {
const duration = Date.now() - startTime;
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
});
});
}
/**
* Uninstall packages
* @param packages - Array of package names to uninstall
* @returns Uninstall result
*/
async uninstall(packages: string[]): Promise<UvInstallResult> {
const startTime = Date.now();
if (packages.length === 0) {
return { success: true, duration: 0 };
}
// Ensure UV is available
if (!(await isUvAvailable())) {
return { success: false, error: 'UV is not available' };
}
// Ensure venv exists
if (!this.isVenvValid()) {
return { success: false, error: 'Virtual environment does not exist.' };
}
const uvPath = getUvBinaryPath();
return new Promise((resolve) => {
const args = ['pip', 'uninstall', ...packages, '--python', this.getVenvPython()];
console.log(`[UV] Uninstalling packages: ${packages.join(', ')}`);
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
}));
let stderr = '';
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
console.log(`[UV] Package uninstallation successful (${duration}ms)`);
resolve({ success: true, duration });
} else {
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
}
});
child.on('error', (err) => {
const duration = Date.now() - startTime;
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
});
});
}
/**
* Sync dependencies from a requirements file or pyproject.toml
* Uses `uv pip sync` for deterministic installs
* @param requirementsPath - Path to requirements.txt or pyproject.toml
* @returns Sync result
*/
async sync(requirementsPath: string): Promise<UvInstallResult> {
const startTime = Date.now();
// Ensure UV is available
if (!(await isUvAvailable())) {
return { success: false, error: 'UV is not available' };
}
// Ensure venv exists
if (!this.isVenvValid()) {
return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' };
}
const uvPath = getUvBinaryPath();
return new Promise((resolve) => {
const args = ['pip', 'sync', requirementsPath, '--python', this.getVenvPython()];
console.log(`[UV] Syncing dependencies from: ${requirementsPath}`);
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
}));
let stderr = '';
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
console.log(`[UV] ${line}`);
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
console.log(`[UV] Sync successful (${duration}ms)`);
resolve({ success: true, duration });
} else {
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
}
});
child.on('error', (err) => {
const duration = Date.now() - startTime;
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
});
});
}
/**
* List installed packages in the virtual environment
* @returns List of installed packages or null on error
*/
async list(): Promise<{ name: string; version: string }[] | null> {
// Ensure UV is available
if (!(await isUvAvailable())) {
return null;
}
// Ensure venv exists
if (!this.isVenvValid()) {
return null;
}
const uvPath = getUvBinaryPath();
return new Promise((resolve) => {
const args = ['pip', 'list', '--format', 'json', '--python', this.getVenvPython()];
const child = spawn(uvPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: EXEC_TIMEOUTS.PROCESS_SPAWN,
}));
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
try {
const packages = JSON.parse(stdout);
resolve(packages);
} catch {
resolve(null);
}
} else {
resolve(null);
}
});
child.on('error', () => {
resolve(null);
});
});
}
/**
* Check if a specific package is installed
* @param packageName - Name of the package to check
* @returns True if the package is installed
*/
async isPackageInstalled(packageName: string): Promise<boolean> {
const packages = await this.list();
if (!packages) {
return false;
}
const normalizedName = packageName.toLowerCase().replace(/-/g, '_');
return packages.some(
(pkg) => pkg.name.toLowerCase().replace(/-/g, '_') === normalizedName
);
}
/**
* Run a Python command in the virtual environment
* @param args - Arguments to pass to Python
* @param options - Spawn options
* @returns Result with stdout/stderr
*/
async runPython(
args: string[],
options: { timeout?: number; cwd?: string } = {}
): Promise<{ success: boolean; stdout: string; stderr: string }> {
const pythonPath = this.getVenvPython();
if (!existsSync(pythonPath)) {
return { success: false, stdout: '', stderr: 'Virtual environment does not exist' };
}
return new Promise((resolve) => {
const child = spawn(pythonPath, args, buildUvSpawnOptions({
stdio: ['ignore', 'pipe', 'pipe'],
timeout: options.timeout ?? EXEC_TIMEOUTS.PROCESS_SPAWN,
cwd: options.cwd,
}));
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
resolve({ success: code === 0, stdout: stdout.trim(), stderr: stderr.trim() });
});
child.on('error', (err) => {
resolve({ success: false, stdout: '', stderr: err.message });
});
});
}
/**
* Get Python version in the virtual environment
* @returns Python version string or null
*/
async getPythonVersion(): Promise<string | null> {
const result = await this.runPython(['--version']);
if (result.success) {
const match = result.stdout.match(/Python\s+(\S+)/);
return match ? match[1] : null;
}
return null;
}
/**
* Delete the virtual environment
* @returns True if deletion succeeded
*/
async deleteVenv(): Promise<boolean> {
if (!existsSync(this.venvPath)) {
return true;
}
try {
const fs = await import('fs');
fs.rmSync(this.venvPath, { recursive: true, force: true });
console.log(`[UV] Virtual environment deleted: ${this.venvPath}`);
return true;
} catch (err) {
console.error(`[UV] Failed to delete venv: ${(err as Error).message}`);
return false;
}
}
}
export function getPreferredCodexLensPythonSpec(): string {
const override = process.env.CCW_PYTHON?.trim();
if (override) {
return override;
}
if (!IS_WINDOWS) {
return '>=3.10,<3.13';
}
// Prefer 3.11/3.10 on Windows because current CodexLens semantic GPU extras
// depend on onnxruntime 1.15.x wheels, which are not consistently available for cp312.
const preferredVersions = ['3.11', '3.10', '3.12'];
for (const version of preferredVersions) {
if (hasWindowsPythonLauncherVersion(version)) {
return version;
}
}
return '>=3.10,<3.13';
}
/**
* Create a UvManager with default settings for CodexLens
* @param dataDir - Base data directory (defaults to ~/.codexlens)
* @returns Configured UvManager instance
*/
export function createCodexLensUvManager(dataDir?: string): UvManager {
const baseDir = dataDir ?? getCodexLensDataDir();
void baseDir;
return new UvManager({
venvPath: getCodexLensVenvDir(),
pythonVersion: getPreferredCodexLensPythonSpec(),
});
}
/**
* Quick bootstrap function: ensure UV is installed and create a venv
* @param venvPath - Path to the virtual environment
* @param pythonVersion - Optional Python version constraint
* @returns Installation result
*/
export async function bootstrapUvVenv(
venvPath: string,
pythonVersion?: string
): Promise<UvInstallResult> {
// Ensure UV is installed first
const uvInstalled = await ensureUvInstalled();
if (!uvInstalled) {
return { success: false, error: 'Failed to install UV' };
}
// Create the venv
const manager = new UvManager({ venvPath, pythonVersion });
return manager.createVenv();
}
export const __testables = {
buildUvSpawnOptions,
buildUvSpawnSyncOptions,
findExecutableOnPath,
hasWindowsPythonLauncherVersion,
};

View File

@@ -1,120 +0,0 @@
/**
* Regression test: CodexLens bootstrap should recover when UV bootstrap fails
* and the existing venv is missing pip (common with UV-created venvs).
*
* We simulate "UV available but broken" by pointing CCW_UV_PATH to the current Node
* executable. `node --version` exits 0 so isUvAvailable() returns true, but any
* `node pip install ...` calls fail, forcing bootstrapVenv() to fall back to pip.
*
* Before running bootstrapVenv(), we pre-create the venv and delete its pip entrypoint
* to mimic a venv that has Python but no pip executable. bootstrapVenv() should
* re-bootstrap pip (ensurepip) or recreate the venv, then succeed.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// repo root: <repo>/ccw/tests -> <repo>
const REPO_ROOT = join(__dirname, '..', '..');
function runNodeEvalModule(script, env) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
cwd: REPO_ROOT,
env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('error', (err) => reject(err));
child.on('close', (code) => resolve({ code, stdout, stderr }));
});
}
describe('CodexLens bootstrap pip repair', () => {
it('repairs missing pip in existing venv during pip fallback', { timeout: 10 * 60 * 1000 }, async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'codexlens-bootstrap-pip-missing-'));
try {
const script = `
import { execSync } from 'node:child_process';
import { existsSync, rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { getSystemPython } from './ccw/dist/utils/python-utils.js';
import { bootstrapVenv } from './ccw/dist/tools/codex-lens.js';
const dataDir = process.env.CODEXLENS_DATA_DIR;
if (!dataDir) throw new Error('Missing CODEXLENS_DATA_DIR');
mkdirSync(dataDir, { recursive: true });
const venvDir = join(dataDir, 'venv');
// Create a venv up-front so UV bootstrap will skip venv creation and fail on install.
const pythonCmd = getSystemPython();
execSync(pythonCmd + ' -m venv "' + venvDir + '"', { stdio: 'inherit' });
// Simulate a "pip-less" venv by deleting the pip entrypoint.
const pipPath = process.platform === 'win32'
? join(venvDir, 'Scripts', 'pip.exe')
: join(venvDir, 'bin', 'pip');
if (existsSync(pipPath)) {
rmSync(pipPath, { force: true });
}
const result = await bootstrapVenv();
const pipRestored = existsSync(pipPath);
console.log('@@RESULT@@' + JSON.stringify({ result, pipRestored }));
`.trim();
const env = {
...process.env,
// Isolate test venv + dependencies from user/global CodexLens state.
CODEXLENS_DATA_DIR: dataDir,
// Make isUvAvailable() return true, but installFromProject() fail.
CCW_UV_PATH: process.execPath,
};
const { code, stdout, stderr } = await runNodeEvalModule(script, env);
assert.equal(code, 0, `bootstrapVenv child process failed:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`);
const marker = '@@RESULT@@';
const idx = stdout.lastIndexOf(marker);
assert.ok(idx !== -1, `Missing result marker in stdout:\n${stdout}`);
const jsonText = stdout.slice(idx + marker.length).trim();
const parsed = JSON.parse(jsonText);
assert.equal(parsed?.result?.success, true, `Expected success=true, got:\n${jsonText}`);
assert.equal(parsed?.pipRestored, true, `Expected pipRestored=true, got:\n${jsonText}`);
// Best-effort: confirm we exercised the missing-pip repair path.
assert.ok(
String(stderr).includes('pip not found at:') || String(stdout).includes('pip not found at:'),
`Expected missing-pip warning in output. STDERR:\n${stderr}\nSTDOUT:\n${stdout}`
);
} finally {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {
// Best effort cleanup; leave artifacts only if Windows locks prevent removal.
}
}
});
});

View File

@@ -1,372 +0,0 @@
/**
* Integration tests for CodexLens UV installation functionality.
*
* Notes:
* - Targets the runtime implementation shipped in `ccw/dist`.
* - Tests real package installation (fastembed, hnswlib, onnxruntime, ccw-litellm, codex-lens).
* - Verifies Python import success for installed packages.
* - Tests UV's dependency conflict auto-resolution capability.
* - Uses temporary directories with cleanup after tests.
*/
import { after, before, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const uvManagerUrl = new URL('../dist/utils/uv-manager.js', import.meta.url);
uvManagerUrl.searchParams.set('t', String(Date.now()));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mod: any;
// Test venv path with unique timestamp
const TEST_VENV_PATH = join(tmpdir(), `codexlens-install-test-${Date.now()}`);
// Track UV availability for conditional tests
let uvAvailable = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let manager: any;
describe('CodexLens UV Installation Tests', async () => {
mod = await import(uvManagerUrl.href);
before(async () => {
uvAvailable = await mod.isUvAvailable();
if (!uvAvailable) {
console.log('[Test] UV not available, attempting to install...');
uvAvailable = await mod.ensureUvInstalled();
}
if (uvAvailable) {
manager = new mod.UvManager({
venvPath: TEST_VENV_PATH,
pythonVersion: '>=3.10,<3.13', // onnxruntime compatibility range
});
console.log(`[Test] Created UvManager with venv path: ${TEST_VENV_PATH}`);
}
});
after(() => {
// Clean up test venv
if (existsSync(TEST_VENV_PATH)) {
console.log(`[Test] Cleaning up test venv: ${TEST_VENV_PATH}`);
try {
rmSync(TEST_VENV_PATH, { recursive: true, force: true });
} catch (err) {
console.log(`[Test] Failed to remove venv: ${(err as Error).message}`);
}
}
});
describe('Virtual Environment Setup', () => {
it('should create venv with correct Python version', async () => {
if (!uvAvailable) {
console.log('[Test] Skipping - UV not available');
return;
}
const result = await manager.createVenv();
console.log(`[Test] Create venv result:`, result);
assert.ok(result.success, `Venv creation failed: ${result.error}`);
// Verify Python version
const version = await manager.getPythonVersion();
console.log(`[Test] Python version: ${version}`);
const match = version?.match(/3\.(\d+)/);
assert.ok(match, 'Should be Python 3.x');
const minor = parseInt(match[1]);
assert.ok(minor >= 10 && minor < 13, `Python version should be 3.10-3.12, got 3.${minor}`);
});
});
describe('Semantic Search Dependencies (fastembed)', () => {
it('should install fastembed and hnswlib', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
console.log('[Test] Installing fastembed and hnswlib...');
const startTime = Date.now();
const result = await manager.install([
'numpy>=1.24',
'fastembed>=0.5',
'hnswlib>=0.8.0',
]);
const duration = Date.now() - startTime;
console.log(`[Test] Installation result:`, result);
console.log(`[Test] Installation took ${duration}ms`);
assert.ok(result.success, `fastembed installation failed: ${result.error}`);
});
it('should verify fastembed is importable', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const result = await manager.runPython([
'-c',
'import fastembed; print(f"fastembed version: {fastembed.__version__}")',
]);
console.log(`[Test] fastembed import:`, result);
assert.ok(result.success, `fastembed import failed: ${result.stderr}`);
assert.ok(result.stdout.includes('fastembed version'), 'Should print fastembed version');
});
it('should verify hnswlib is importable', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const result = await manager.runPython(['-c', 'import hnswlib; print("hnswlib imported successfully")']);
console.log(`[Test] hnswlib import:`, result);
assert.ok(result.success, `hnswlib import failed: ${result.stderr}`);
});
});
describe('ONNX Runtime Installation', () => {
it('should install onnxruntime (CPU)', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
console.log('[Test] Installing onnxruntime...');
const result = await manager.install(['onnxruntime>=1.18.0']);
console.log(`[Test] onnxruntime installation:`, result);
assert.ok(result.success, `onnxruntime installation failed: ${result.error}`);
});
it('should verify onnxruntime providers', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const result = await manager.runPython([
'-c',
'import onnxruntime; print("Providers:", onnxruntime.get_available_providers())',
]);
console.log(`[Test] onnxruntime providers:`, result);
assert.ok(result.success, `onnxruntime import failed: ${result.stderr}`);
assert.ok(result.stdout.includes('CPUExecutionProvider'), 'Should have CPU provider');
});
});
describe('ccw-litellm Installation', () => {
it('should install ccw-litellm from local path', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
// Find local ccw-litellm package
const possiblePaths = [join(process.cwd(), 'ccw-litellm'), 'D:\\Claude_dms3\\ccw-litellm'];
let localPath: string | null = null;
for (const p of possiblePaths) {
if (existsSync(join(p, 'pyproject.toml'))) {
localPath = p;
break;
}
}
if (!localPath) {
console.log('[Test] ccw-litellm local path not found, installing from PyPI...');
const result = await manager.install(['ccw-litellm']);
console.log(`[Test] PyPI installation:`, result);
// PyPI may not have it published, skip
return;
}
console.log(`[Test] Installing ccw-litellm from: ${localPath}`);
const result = await manager.installFromProject(localPath);
console.log(`[Test] ccw-litellm installation:`, result);
assert.ok(result.success, `ccw-litellm installation failed: ${result.error}`);
});
it('should verify ccw-litellm is importable', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const result = await manager.runPython([
'-c',
'import ccw_litellm; print(f"ccw-litellm version: {ccw_litellm.__version__}")',
]);
console.log(`[Test] ccw-litellm import:`, result);
// If installation failed (PyPI doesn't have it), skip validation
if (!result.success && result.stderr.includes('No module named')) {
console.log('[Test] ccw-litellm not installed, skipping import test');
return;
}
assert.ok(result.success, `ccw-litellm import failed: ${result.stderr}`);
});
});
describe('Full codex-lens Installation', () => {
it('should install codex-lens with semantic extras from local path', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
// Find local codex-lens package
const possiblePaths = [join(process.cwd(), 'codex-lens'), 'D:\\Claude_dms3\\codex-lens'];
let localPath: string | null = null;
for (const p of possiblePaths) {
if (existsSync(join(p, 'pyproject.toml'))) {
localPath = p;
break;
}
}
if (!localPath) {
console.log('[Test] codex-lens local path not found, skipping');
return;
}
console.log(`[Test] Installing codex-lens[semantic] from: ${localPath}`);
const startTime = Date.now();
const result = await manager.installFromProject(localPath, ['semantic']);
const duration = Date.now() - startTime;
console.log(`[Test] codex-lens installation:`, result);
console.log(`[Test] Installation took ${duration}ms`);
assert.ok(result.success, `codex-lens installation failed: ${result.error}`);
});
it('should verify codex-lens CLI is available', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const result = await manager.runPython(['-m', 'codexlens', '--help']);
console.log(`[Test] codexlens CLI help output length: ${result.stdout.length}`);
// CLI may fail due to dependency issues, log but don't force failure
if (!result.success) {
console.log(`[Test] codexlens CLI failed: ${result.stderr}`);
}
});
});
describe('Dependency Conflict Resolution', () => {
it('should handle onnxruntime version conflicts automatically', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
// UV should auto-resolve conflicts between fastembed and onnxruntime
// Install onnxruntime first, then fastembed, verify no errors
console.log('[Test] Testing conflict resolution...');
// Check current onnxruntime version
const result = await manager.runPython(['-c', 'import onnxruntime; print(f"onnxruntime: {onnxruntime.__version__}")']);
console.log(`[Test] Current onnxruntime:`, result.stdout.trim());
// Reinstall fastembed, UV should handle dependencies
const installResult = await manager.install(['fastembed>=0.5']);
console.log(`[Test] Reinstall fastembed:`, installResult);
// Check onnxruntime again
const result2 = await manager.runPython(['-c', 'import onnxruntime; print(f"onnxruntime: {onnxruntime.__version__}")']);
console.log(`[Test] After reinstall onnxruntime:`, result2.stdout.trim());
assert.ok(result2.success, 'onnxruntime should still be importable after fastembed reinstall');
});
});
describe('Package List Verification', () => {
it('should list all installed packages', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const packages = await manager.list();
console.log(`[Test] Total installed packages: ${packages?.length ?? 0}`);
if (packages !== null) {
assert.ok(Array.isArray(packages), 'list() should return array');
// Check for expected packages
const packageNames = packages.map((p: { name: string }) => p.name.toLowerCase().replace(/-/g, '_'));
console.log(`[Test] Package names: ${packageNames.slice(0, 10).join(', ')}...`);
// Verify core packages are present
const hasNumpy = packageNames.includes('numpy');
const hasFastembed = packageNames.includes('fastembed');
const hasHnswlib = packageNames.includes('hnswlib');
console.log(`[Test] numpy: ${hasNumpy}, fastembed: ${hasFastembed}, hnswlib: ${hasHnswlib}`);
assert.ok(hasNumpy, 'numpy should be installed');
assert.ok(hasFastembed, 'fastembed should be installed');
assert.ok(hasHnswlib, 'hnswlib should be installed');
}
});
it('should check individual package installation status', async () => {
if (!uvAvailable || !manager?.isVenvValid()) {
console.log('[Test] Skipping - venv not ready');
return;
}
const numpyInstalled = await manager.isPackageInstalled('numpy');
const fastembedInstalled = await manager.isPackageInstalled('fastembed');
const nonexistentInstalled = await manager.isPackageInstalled('this-package-does-not-exist-12345');
console.log(`[Test] numpy installed: ${numpyInstalled}`);
console.log(`[Test] fastembed installed: ${fastembedInstalled}`);
console.log(`[Test] nonexistent installed: ${nonexistentInstalled}`);
assert.ok(numpyInstalled, 'numpy should be installed');
assert.ok(fastembedInstalled, 'fastembed should be installed');
assert.equal(nonexistentInstalled, false, 'nonexistent package should not be installed');
});
});
describe('CodexLens UV Manager Factory', () => {
it('should create CodexLens UV manager with default settings', () => {
const codexLensManager = mod.createCodexLensUvManager();
console.log(`[Test] CodexLens manager created`);
assert.ok(codexLensManager !== null, 'createCodexLensUvManager should return manager');
assert.ok(codexLensManager.getVenvPython, 'Manager should have getVenvPython method');
// Verify Python path is in default location
const pythonPath = codexLensManager.getVenvPython();
console.log(`[Test] Default CodexLens Python path: ${pythonPath}`);
assert.ok(pythonPath.includes('.codexlens'), 'Python path should be in .codexlens directory');
});
it('should create CodexLens UV manager with custom data dir', () => {
const customDir = join(tmpdir(), 'custom-codexlens-test');
const codexLensManager = mod.createCodexLensUvManager(customDir);
const pythonPath = codexLensManager.getVenvPython();
console.log(`[Test] Custom CodexLens manager Python path: ${pythonPath}`);
assert.ok(pythonPath.includes(customDir), 'Python path should use custom dir');
});
});
});

View File

@@ -1,66 +0,0 @@
import { after, afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { createRequire, syncBuiltinESMExports } from 'node:module';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const require = createRequire(import.meta.url);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('node:fs');
const originalExistsSync = fs.existsSync;
const originalCodexLensDataDir = process.env.CODEXLENS_DATA_DIR;
const tempDirs = [];
afterEach(() => {
fs.existsSync = originalExistsSync;
syncBuiltinESMExports();
if (originalCodexLensDataDir === undefined) {
delete process.env.CODEXLENS_DATA_DIR;
} else {
process.env.CODEXLENS_DATA_DIR = originalCodexLensDataDir;
}
});
after(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { recursive: true, force: true });
}
});
describe('codexlens-path hidden python selection', () => {
it('prefers pythonw.exe for hidden Windows subprocesses when available', async () => {
if (process.platform !== 'win32') {
return;
}
const dataDir = mkdtempSync(join(tmpdir(), 'ccw-codexlens-hidden-python-'));
tempDirs.push(dataDir);
process.env.CODEXLENS_DATA_DIR = dataDir;
const expectedPythonw = join(dataDir, 'venv', 'Scripts', 'pythonw.exe');
fs.existsSync = (path) => String(path) === expectedPythonw;
syncBuiltinESMExports();
const moduleUrl = new URL(`../dist/utils/codexlens-path.js?t=${Date.now()}`, import.meta.url);
const mod = await import(moduleUrl.href);
assert.equal(mod.getCodexLensHiddenPython(), expectedPythonw);
});
it('falls back to python.exe when pythonw.exe is unavailable', async () => {
const dataDir = mkdtempSync(join(tmpdir(), 'ccw-codexlens-hidden-fallback-'));
tempDirs.push(dataDir);
process.env.CODEXLENS_DATA_DIR = dataDir;
fs.existsSync = () => false;
syncBuiltinESMExports();
const moduleUrl = new URL(`../dist/utils/codexlens-path.js?t=${Date.now()}`, import.meta.url);
const mod = await import(moduleUrl.href);
assert.equal(mod.getCodexLensHiddenPython(), mod.getCodexLensPython());
});
});

View File

@@ -1,121 +0,0 @@
import { afterEach, before, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
const uvManagerPath = new URL('../dist/utils/uv-manager.js', import.meta.url).href;
const pythonUtilsPath = new URL('../dist/utils/python-utils.js', import.meta.url).href;
describe('CodexLens UV python preference', async () => {
let mod;
let pythonUtils;
const originalPython = process.env.CCW_PYTHON;
before(async () => {
mod = await import(uvManagerPath);
pythonUtils = await import(pythonUtilsPath);
});
afterEach(() => {
if (originalPython === undefined) {
delete process.env.CCW_PYTHON;
return;
}
process.env.CCW_PYTHON = originalPython;
});
it('honors CCW_PYTHON override', () => {
process.env.CCW_PYTHON = 'C:/Custom/Python/python.exe';
assert.equal(mod.getPreferredCodexLensPythonSpec(), 'C:/Custom/Python/python.exe');
});
it('parses py launcher commands into spawn-safe command specs', () => {
const spec = pythonUtils.parsePythonCommandSpec('py -3.11');
assert.equal(spec.command, 'py');
assert.deepEqual(spec.args, ['-3.11']);
assert.equal(spec.display, 'py -3.11');
});
it('treats unquoted Windows-style executable paths as a single command', () => {
const spec = pythonUtils.parsePythonCommandSpec('C:/Program Files/Python311/python.exe');
assert.equal(spec.command, 'C:/Program Files/Python311/python.exe');
assert.deepEqual(spec.args, []);
assert.equal(spec.display, '"C:/Program Files/Python311/python.exe"');
});
it('probes Python launcher versions without opening a shell window', () => {
const probeCalls = [];
const version = pythonUtils.probePythonCommandVersion(
{ command: 'py', args: ['-3.11'], display: 'py -3.11' },
(command, args, options) => {
probeCalls.push({ command, args, options });
return { status: 0, stdout: '', stderr: 'Python 3.11.9\n' };
},
);
assert.equal(version, 'Python 3.11.9');
assert.equal(probeCalls.length, 1);
assert.equal(probeCalls[0].command, 'py');
assert.deepEqual(probeCalls[0].args, ['-3.11', '--version']);
assert.equal(probeCalls[0].options.shell, false);
assert.equal(probeCalls[0].options.windowsHide, true);
assert.equal(probeCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
});
it('looks up uv on PATH without spawning a visible shell window', () => {
const lookupCalls = [];
const found = mod.__testables.findExecutableOnPath('uv', (command, args, options) => {
lookupCalls.push({ command, args, options });
return { status: 0, stdout: 'C:/Tools/uv.exe\n', stderr: '' };
});
assert.equal(found, 'C:/Tools/uv.exe');
assert.equal(lookupCalls.length, 1);
assert.equal(lookupCalls[0].command, process.platform === 'win32' ? 'where' : 'which');
assert.deepEqual(lookupCalls[0].args, ['uv']);
assert.equal(lookupCalls[0].options.shell, false);
assert.equal(lookupCalls[0].options.windowsHide, true);
assert.equal(lookupCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
});
it('checks Windows launcher preferences with hidden subprocess options', () => {
const probeCalls = [];
const available = mod.__testables.hasWindowsPythonLauncherVersion('3.11', (command, args, options) => {
probeCalls.push({ command, args, options });
return { status: 0, stdout: '', stderr: 'Python 3.11.9\n' };
});
assert.equal(available, true);
assert.equal(probeCalls.length, 1);
assert.equal(probeCalls[0].command, 'py');
assert.deepEqual(probeCalls[0].args, ['-3.11', '--version']);
assert.equal(probeCalls[0].options.shell, false);
assert.equal(probeCalls[0].options.windowsHide, true);
assert.equal(probeCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
});
it('prefers Python 3.11 or 3.10 on Windows when available', () => {
if (process.platform !== 'win32') return;
delete process.env.CCW_PYTHON;
let installed = '';
try {
installed = execSync('py -0p', { encoding: 'utf-8' });
} catch {
return;
}
const has311 = installed.includes('-V:3.11');
const has310 = installed.includes('-V:3.10');
if (!has311 && !has310) {
return;
}
const preferred = mod.getPreferredCodexLensPythonSpec();
assert.ok(
preferred === '3.11' || preferred === '3.10',
`expected Windows preference to avoid 3.12 when 3.11/3.10 exists, got ${preferred}`,
);
});
});

View File

@@ -1,414 +0,0 @@
/**
* Unit tests for uv-manager utility module.
*
* Notes:
* - Targets the runtime implementation shipped in `ccw/dist`.
* - Tests UV binary detection, installation, and virtual environment management.
* - Gracefully handles cases where UV is not installed.
*/
import { after, before, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const uvManagerUrl = new URL('../dist/utils/uv-manager.js', import.meta.url);
uvManagerUrl.searchParams.set('t', String(Date.now()));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mod: any;
// Test venv path with unique timestamp
const TEST_VENV_PATH = join(tmpdir(), `uv-test-venv-${Date.now()}`);
// Track UV availability for conditional tests
let uvAvailable = false;
describe('UV Manager Tests', async () => {
mod = await import(uvManagerUrl.href);
// Cleanup after all tests
after(() => {
if (existsSync(TEST_VENV_PATH)) {
console.log(`[Cleanup] Removing test venv: ${TEST_VENV_PATH}`);
try {
rmSync(TEST_VENV_PATH, { recursive: true, force: true });
} catch (err) {
console.log(`[Cleanup] Failed to remove venv: ${(err as Error).message}`);
}
}
});
describe('UV Binary Detection', () => {
it('should check UV availability', async () => {
uvAvailable = await mod.isUvAvailable();
console.log(`[Test] UV available: ${uvAvailable}`);
assert.equal(typeof uvAvailable, 'boolean', 'isUvAvailable should return boolean');
});
it('should get UV version when available', async () => {
if (uvAvailable) {
const version = await mod.getUvVersion();
console.log(`[Test] UV version: ${version}`);
assert.ok(version !== null, 'getUvVersion should return version string');
assert.ok(version.length > 0, 'Version string should not be empty');
} else {
console.log('[Test] UV not installed, skipping version test');
const version = await mod.getUvVersion();
assert.equal(version, null, 'getUvVersion should return null when UV not available');
}
});
it('should get UV binary path', async () => {
const path = mod.getUvBinaryPath();
console.log(`[Test] UV path: ${path}`);
assert.equal(typeof path, 'string', 'getUvBinaryPath should return string');
assert.ok(path.length > 0, 'Path should not be empty');
if (uvAvailable) {
assert.ok(existsSync(path), 'UV binary should exist when UV is available');
}
});
});
describe('UV Installation', () => {
it('should ensure UV is installed', async () => {
const installed = await mod.ensureUvInstalled();
console.log(`[Test] UV ensured: ${installed}`);
assert.equal(typeof installed, 'boolean', 'ensureUvInstalled should return boolean');
// Update availability after potential installation
if (installed) {
uvAvailable = await mod.isUvAvailable();
assert.ok(uvAvailable, 'UV should be available after ensureUvInstalled returns true');
}
});
});
describe('UvManager Class', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let manager: any;
before(async () => {
// Ensure UV is available for class tests
if (!uvAvailable) {
console.log('[Test] UV not available, attempting installation...');
await mod.ensureUvInstalled();
uvAvailable = await mod.isUvAvailable();
}
manager = new mod.UvManager({
venvPath: TEST_VENV_PATH,
pythonVersion: '>=3.10',
});
console.log(`[Test] Created UvManager with venv path: ${TEST_VENV_PATH}`);
});
it('should get venv Python path', () => {
const pythonPath = manager.getVenvPython();
console.log(`[Test] Venv Python path: ${pythonPath}`);
assert.equal(typeof pythonPath, 'string', 'getVenvPython should return string');
assert.ok(pythonPath.includes(TEST_VENV_PATH), 'Python path should be inside venv');
});
it('should get venv pip path', () => {
const pipPath = manager.getVenvPip();
console.log(`[Test] Venv pip path: ${pipPath}`);
assert.equal(typeof pipPath, 'string', 'getVenvPip should return string');
assert.ok(pipPath.includes(TEST_VENV_PATH), 'Pip path should be inside venv');
});
it('should report venv as invalid before creation', () => {
const valid = manager.isVenvValid();
console.log(`[Test] Venv valid (before create): ${valid}`);
assert.equal(valid, false, 'Venv should not be valid before creation');
});
it('should create virtual environment', async () => {
if (!uvAvailable) {
console.log('[Test] Skipping venv creation - UV not available');
return;
}
const result = await manager.createVenv();
console.log(`[Test] Create venv result:`, result);
if (result.success) {
assert.ok(existsSync(TEST_VENV_PATH), 'Venv directory should exist');
assert.ok(result.duration !== undefined, 'Duration should be reported');
console.log(`[Test] Venv created in ${result.duration}ms`);
} else {
// May fail if Python is not installed
console.log(`[Test] Venv creation failed: ${result.error}`);
assert.equal(typeof result.error, 'string', 'Error should be a string');
}
});
it('should check if venv is valid after creation', () => {
const valid = manager.isVenvValid();
console.log(`[Test] Venv valid (after create): ${valid}`);
assert.equal(typeof valid, 'boolean', 'isVenvValid should return boolean');
});
it('should get Python version in venv', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping Python version check - venv not valid');
return;
}
const version = await manager.getPythonVersion();
console.log(`[Test] Python version: ${version}`);
assert.ok(version !== null, 'getPythonVersion should return version');
assert.ok(version.startsWith('3.'), 'Should be Python 3.x');
});
it('should list installed packages', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping package list - venv not valid');
return;
}
const packages = await manager.list();
console.log(`[Test] Installed packages count: ${packages?.length ?? 0}`);
if (packages !== null) {
assert.ok(Array.isArray(packages), 'list() should return array');
// UV creates minimal venvs without pip by default
console.log(`[Test] Packages in venv: ${packages.map((p: { name: string }) => p.name).join(', ') || '(empty)'}`);
}
});
it('should check if package is installed', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping package check - venv not valid');
return;
}
// First install a package, then check if it's installed
const installResult = await manager.install(['six']);
if (installResult.success) {
const installed = await manager.isPackageInstalled('six');
console.log(`[Test] six installed: ${installed}`);
assert.ok(installed, 'six should be installed after install');
// Clean up
await manager.uninstall(['six']);
} else {
console.log('[Test] Could not install test package, skipping check');
}
});
it('should install a simple package', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping package install - venv not valid');
return;
}
// Install a small, fast-installing package
const result = await manager.install(['pip-install-test']);
console.log(`[Test] Install result:`, result);
assert.equal(typeof result.success, 'boolean', 'success should be boolean');
if (result.success) {
console.log(`[Test] Package installed in ${result.duration}ms`);
} else {
console.log(`[Test] Package install failed: ${result.error}`);
}
});
it('should uninstall a package', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping uninstall - venv not valid');
return;
}
const result = await manager.uninstall(['pip-install-test']);
console.log(`[Test] Uninstall result:`, result);
assert.equal(typeof result.success, 'boolean', 'success should be boolean');
if (result.success) {
console.log(`[Test] Package uninstalled in ${result.duration}ms`);
} else {
console.log(`[Test] Package uninstall failed: ${result.error}`);
}
});
it('should handle empty package list for install', async () => {
const result = await manager.install([]);
console.log(`[Test] Empty install result:`, result);
assert.ok(result.success, 'Empty install should succeed');
assert.equal(result.duration, 0, 'Empty install should have 0 duration');
});
it('should handle empty package list for uninstall', async () => {
const result = await manager.uninstall([]);
console.log(`[Test] Empty uninstall result:`, result);
assert.ok(result.success, 'Empty uninstall should succeed');
assert.equal(result.duration, 0, 'Empty uninstall should have 0 duration');
});
it('should run Python command in venv', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping Python command - venv not valid');
return;
}
const result = await manager.runPython(['-c', 'print("hello from venv")']);
console.log(`[Test] Run Python result:`, result);
if (result.success) {
assert.ok(result.stdout.includes('hello from venv'), 'Output should contain expected text');
} else {
console.log(`[Test] Python command failed: ${result.stderr}`);
}
});
it('should delete virtual environment', async () => {
if (!manager.isVenvValid()) {
console.log('[Test] Skipping delete - venv not valid');
return;
}
const result = await manager.deleteVenv();
console.log(`[Test] Delete venv result: ${result}`);
if (result) {
assert.ok(!existsSync(TEST_VENV_PATH), 'Venv directory should be deleted');
}
});
it('should handle deleteVenv when venv does not exist', async () => {
const result = await manager.deleteVenv();
console.log(`[Test] Delete non-existent venv result: ${result}`);
assert.ok(result, 'Deleting non-existent venv should succeed');
});
});
describe('Helper Functions', () => {
it('should create CodexLens UV manager with defaults', () => {
const codexLensManager = mod.createCodexLensUvManager();
console.log(`[Test] CodexLens manager created`);
assert.ok(codexLensManager !== null, 'createCodexLensUvManager should return manager');
assert.ok(codexLensManager.getVenvPython, 'Manager should have getVenvPython method');
});
it('should create CodexLens UV manager with custom data dir', () => {
const customDir = join(tmpdir(), 'custom-codexlens');
const codexLensManager = mod.createCodexLensUvManager(customDir);
const pythonPath = codexLensManager.getVenvPython();
console.log(`[Test] Custom CodexLens manager Python path: ${pythonPath}`);
assert.ok(pythonPath.includes(customDir), 'Python path should use custom dir');
});
it('should bootstrap UV venv', async () => {
if (!uvAvailable) {
console.log('[Test] Skipping bootstrap - UV not available');
return;
}
const bootstrapPath = join(tmpdir(), `uv-bootstrap-test-${Date.now()}`);
console.log(`[Test] Bootstrap venv path: ${bootstrapPath}`);
try {
const result = await mod.bootstrapUvVenv(bootstrapPath, '>=3.10');
console.log(`[Test] Bootstrap result:`, result);
assert.equal(typeof result.success, 'boolean', 'success should be boolean');
if (result.success) {
assert.ok(existsSync(bootstrapPath), 'Bootstrap venv should exist');
}
} finally {
// Cleanup bootstrap venv
if (existsSync(bootstrapPath)) {
rmSync(bootstrapPath, { recursive: true, force: true });
}
}
});
});
describe('Error Handling', () => {
it('should handle install when UV not available gracefully', async () => {
// Create manager pointing to non-existent venv
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const result = await badManager.install(['some-package']);
console.log(`[Test] Install with invalid venv:`, result);
assert.equal(result.success, false, 'Install should fail with invalid venv');
assert.ok(result.error, 'Error message should be present');
});
it('should handle uninstall when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const result = await badManager.uninstall(['some-package']);
console.log(`[Test] Uninstall with invalid venv:`, result);
assert.equal(result.success, false, 'Uninstall should fail with invalid venv');
assert.ok(result.error, 'Error message should be present');
});
it('should handle list when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const packages = await badManager.list();
console.log(`[Test] List with invalid venv: ${packages}`);
assert.equal(packages, null, 'list() should return null for invalid venv');
});
it('should handle isPackageInstalled when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const installed = await badManager.isPackageInstalled('pip');
console.log(`[Test] isPackageInstalled with invalid venv: ${installed}`);
assert.equal(installed, false, 'isPackageInstalled should return false for invalid venv');
});
it('should handle runPython when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const result = await badManager.runPython(['--version']);
console.log(`[Test] runPython with invalid venv:`, result);
assert.equal(result.success, false, 'runPython should fail for invalid venv');
assert.ok(result.stderr.length > 0, 'Error message should be present');
});
it('should handle sync when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const result = await badManager.sync('requirements.txt');
console.log(`[Test] sync with invalid venv:`, result);
assert.equal(result.success, false, 'sync should fail for invalid venv');
assert.ok(result.error, 'Error message should be present');
});
it('should handle installFromProject when venv not valid', async () => {
const badManager = new mod.UvManager({
venvPath: join(tmpdir(), 'non-existent-venv'),
pythonVersion: '>=3.10',
});
const result = await badManager.installFromProject('/some/project');
console.log(`[Test] installFromProject with invalid venv:`, result);
assert.equal(result.success, false, 'installFromProject should fail for invalid venv');
assert.ok(result.error, 'Error message should be present');
});
});
});

View File

@@ -1 +1,33 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
*.egg
# Virtual environments
.venv/
venv/
# IDE
.idea/
.vscode/
*.swp
# Testing
.pytest_cache/
.coverage
htmlcov/
# Index / cache
.codexlens/
.index_cache/
.ace-tool/ .ace-tool/
# Workflow (internal)
.workflow/
# OS
.DS_Store
Thumbs.db

View File

@@ -1,143 +1,221 @@
# codexlens-search # codexlens-search
Lightweight semantic code search engine with 2-stage vector search, full-text search, and Reciprocal Rank Fusion. Semantic code search engine with MCP server for Claude Code.
## Overview 2-stage vector search + FTS + RRF fusion + reranking — install once, configure API keys, ready to use.
codexlens-search provides fast, accurate code search through a multi-stage retrieval pipeline: ## Quick Start (Claude Code MCP)
1. **Binary coarse search** - Hamming-distance filtering narrows candidates quickly Add to your project `.mcp.json`:
2. **ANN fine search** - HNSW or FAISS refines the candidate set with float vectors
3. **Full-text search** - SQLite FTS5 handles exact and fuzzy keyword matching
4. **RRF fusion** - Reciprocal Rank Fusion merges vector and text results
5. **Reranking** - Optional cross-encoder or API-based reranker for final ordering
The core library has **zero required dependencies**. Install optional extras to enable semantic search, GPU acceleration, or FAISS backends. ```json
{
"mcpServers": {
"codexlens": {
"command": "uvx",
"args": ["--from", "codexlens-search[mcp]", "codexlens-mcp"],
"env": {
"CODEXLENS_EMBED_API_URL": "https://api.openai.com/v1",
"CODEXLENS_EMBED_API_KEY": "${OPENAI_API_KEY}",
"CODEXLENS_EMBED_API_MODEL": "text-embedding-3-small",
"CODEXLENS_EMBED_DIM": "1536"
}
}
}
}
```
## Installation That's it. Claude Code will auto-discover the tools: `index_project``search_code`.
## Install
```bash ```bash
# Core only (FTS search, no vector search) # Standard install (includes vector search + API clients)
pip install codexlens-search pip install codexlens-search
# With semantic search (recommended) # With MCP server for Claude Code
pip install codexlens-search[semantic] pip install codexlens-search[mcp]
# Semantic search + GPU acceleration
pip install codexlens-search[semantic-gpu]
# With FAISS backend (CPU)
pip install codexlens-search[faiss-cpu]
# With API-based reranker
pip install codexlens-search[reranker-api]
# Everything (semantic + GPU + FAISS + reranker)
pip install codexlens-search[semantic-gpu,faiss-gpu,reranker-api]
``` ```
## Quick Start Optional extras for advanced use:
```python | Extra | Description |
from codexlens_search import Config, IndexingPipeline, SearchPipeline |-------|-------------|
from codexlens_search.core import create_ann_index, create_binary_index | `mcp` | MCP server (`codexlens-mcp` command) |
from codexlens_search.embed.local import FastEmbedEmbedder | `gpu` | GPU-accelerated embedding (onnxruntime-gpu) |
from codexlens_search.rerank.local import LocalReranker | `faiss-cpu` | FAISS ANN backend |
from codexlens_search.search.fts import FTSEngine | `watcher` | File watcher for auto-indexing |
# 1. Configure ## MCP Tools
config = Config(embed_model="BAAI/bge-small-en-v1.5", embed_dim=384)
# 2. Create components | Tool | Description |
embedder = FastEmbedEmbedder(config) |------|-------------|
binary_store = create_binary_index(config, db_path="index/binary.db") | `search_code` | Semantic search with hybrid fusion + reranking |
ann_index = create_ann_index(config, index_path="index/ann.bin") | `index_project` | Build or rebuild the search index |
fts = FTSEngine("index/fts.db") | `index_status` | Show index statistics |
reranker = LocalReranker() | `index_update` | Incremental sync (only changed files) |
| `find_files` | Glob file discovery |
| `list_models` | List models with cache status |
| `download_models` | Download local fastembed models |
# 3. Index files ## MCP Configuration Examples
indexer = IndexingPipeline(embedder, binary_store, ann_index, fts, config)
stats = indexer.index_directory("./src")
print(f"Indexed {stats.files_processed} files, {stats.chunks_created} chunks")
# 4. Search ### API Embedding Only (simplest)
pipeline = SearchPipeline(embedder, binary_store, ann_index, reranker, fts, config)
results = pipeline.search("authentication handler", top_k=10) ```json
for r in results: {
print(f" {r.path} (score={r.score:.3f})") "mcpServers": {
"codexlens": {
"command": "uvx",
"args": ["--from", "codexlens-search[mcp]", "codexlens-mcp"],
"env": {
"CODEXLENS_EMBED_API_URL": "https://api.openai.com/v1",
"CODEXLENS_EMBED_API_KEY": "${OPENAI_API_KEY}",
"CODEXLENS_EMBED_API_MODEL": "text-embedding-3-small",
"CODEXLENS_EMBED_DIM": "1536"
}
}
}
}
``` ```
## Extras ### API Embedding + API Reranker (best quality)
| Extra | Dependencies | Description | ```json
|-------|-------------|-------------| {
| `semantic` | hnswlib, numpy, fastembed | Vector search with local embeddings | "mcpServers": {
| `gpu` | onnxruntime-gpu | GPU-accelerated embedding inference | "codexlens": {
| `semantic-gpu` | semantic + gpu combined | Vector search with GPU acceleration | "command": "uvx",
| `faiss-cpu` | faiss-cpu | FAISS ANN backend (CPU) | "args": ["--from", "codexlens-search[mcp]", "codexlens-mcp"],
| `faiss-gpu` | faiss-gpu | FAISS ANN backend (GPU) | "env": {
| `reranker-api` | httpx | Remote reranker API client | "CODEXLENS_EMBED_API_URL": "https://api.openai.com/v1",
| `dev` | pytest, pytest-cov | Development and testing | "CODEXLENS_EMBED_API_KEY": "${OPENAI_API_KEY}",
"CODEXLENS_EMBED_API_MODEL": "text-embedding-3-small",
"CODEXLENS_EMBED_DIM": "1536",
"CODEXLENS_RERANKER_API_URL": "https://api.jina.ai/v1",
"CODEXLENS_RERANKER_API_KEY": "${JINA_API_KEY}",
"CODEXLENS_RERANKER_API_MODEL": "jina-reranker-v2-base-multilingual"
}
}
}
}
```
### Multi-Endpoint Load Balancing
```json
{
"mcpServers": {
"codexlens": {
"command": "uvx",
"args": ["--from", "codexlens-search[mcp]", "codexlens-mcp"],
"env": {
"CODEXLENS_EMBED_API_ENDPOINTS": "https://api1.example.com/v1|sk-key1|model,https://api2.example.com/v1|sk-key2|model",
"CODEXLENS_EMBED_DIM": "1536"
}
}
}
}
```
Format: `url|key|model,url|key|model,...`
### Local Models (Offline, No API)
```bash
pip install codexlens-search[mcp]
codexlens-search download-models
```
```json
{
"mcpServers": {
"codexlens": {
"command": "codexlens-mcp",
"env": {}
}
}
}
```
### Pre-installed (no uvx)
```json
{
"mcpServers": {
"codexlens": {
"command": "codexlens-mcp",
"env": {
"CODEXLENS_EMBED_API_URL": "https://api.openai.com/v1",
"CODEXLENS_EMBED_API_KEY": "${OPENAI_API_KEY}",
"CODEXLENS_EMBED_API_MODEL": "text-embedding-3-small",
"CODEXLENS_EMBED_DIM": "1536"
}
}
}
}
```
## CLI
```bash
codexlens-search --db-path .codexlens sync --root ./src
codexlens-search --db-path .codexlens search -q "auth handler" -k 10
codexlens-search --db-path .codexlens status
codexlens-search list-models
codexlens-search download-models
```
## Environment Variables
### Embedding
| Variable | Description | Example |
|----------|-------------|---------|
| `CODEXLENS_EMBED_API_URL` | Embedding API base URL | `https://api.openai.com/v1` |
| `CODEXLENS_EMBED_API_KEY` | API key | `sk-xxx` |
| `CODEXLENS_EMBED_API_MODEL` | Model name | `text-embedding-3-small` |
| `CODEXLENS_EMBED_API_ENDPOINTS` | Multi-endpoint: `url\|key\|model,...` | See above |
| `CODEXLENS_EMBED_DIM` | Vector dimension | `1536` |
### Reranker
| Variable | Description | Example |
|----------|-------------|---------|
| `CODEXLENS_RERANKER_API_URL` | Reranker API base URL | `https://api.jina.ai/v1` |
| `CODEXLENS_RERANKER_API_KEY` | API key | `jina-xxx` |
| `CODEXLENS_RERANKER_API_MODEL` | Model name | `jina-reranker-v2-base-multilingual` |
### Tuning
| Variable | Default | Description |
|----------|---------|-------------|
| `CODEXLENS_BINARY_TOP_K` | `200` | Binary coarse search candidates |
| `CODEXLENS_ANN_TOP_K` | `50` | ANN fine search candidates |
| `CODEXLENS_FTS_TOP_K` | `50` | FTS results per method |
| `CODEXLENS_FUSION_K` | `60` | RRF fusion k parameter |
| `CODEXLENS_RERANKER_TOP_K` | `20` | Results to rerank |
| `CODEXLENS_INDEX_WORKERS` | `2` | Parallel indexing workers |
| `CODEXLENS_MAX_FILE_SIZE` | `1000000` | Max file size in bytes |
## Architecture ## Architecture
``` ```
Query Query → [Embedder] → query vector
| ├→ [BinaryStore] → candidates (Hamming)
v │ └→ [ANNIndex] → ranked IDs (cosine)
[Embedder] --> query vector ├→ [FTS exact] → exact matches
| └→ [FTS fuzzy] → fuzzy matches
+---> [BinaryStore.coarse_search] --> candidate IDs (Hamming distance) └→ [RRF Fusion] → merged ranking
| | └→ [Reranker] → final top-k
| v
+---> [ANNIndex.fine_search] ------> ranked IDs (cosine/L2)
| |
| v (intersect)
| vector_results
|
+---> [FTSEngine.exact_search] ----> exact text matches
+---> [FTSEngine.fuzzy_search] ----> fuzzy text matches
|
v
[RRF Fusion] --> merged ranking (adaptive weights by query intent)
|
v
[Reranker] --> final top-k results
```
### Key Design Decisions
- **2-stage vector search**: Binary coarse search (fast Hamming distance on binarized vectors) filters candidates before the more expensive ANN search. This keeps memory usage low and search fast even on large corpora.
- **Parallel retrieval**: Vector search and FTS run concurrently via ThreadPoolExecutor.
- **Adaptive fusion weights**: Query intent detection adjusts RRF weights between vector and text signals.
- **Backend abstraction**: ANN index supports both hnswlib and FAISS backends via a factory function.
- **Zero core dependencies**: The base package requires only Python 3.10+. All heavy dependencies are optional.
## Configuration
The `Config` dataclass controls all pipeline parameters:
```python
from codexlens_search import Config
config = Config(
embed_model="BAAI/bge-small-en-v1.5", # embedding model name
embed_dim=384, # embedding dimension
embed_batch_size=64, # batch size for embedding
ann_backend="auto", # 'auto', 'faiss', 'hnswlib'
binary_top_k=200, # binary coarse search candidates
ann_top_k=50, # ANN fine search candidates
fts_top_k=50, # FTS results per method
device="auto", # 'auto', 'cuda', 'cpu'
)
``` ```
## Development ## Development
```bash ```bash
git clone https://github.com/nicepkg/codexlens-search.git git clone https://github.com/catlog22/codexlens-search.git
cd codexlens-search cd codexlens-search
pip install -e ".[dev,semantic]" pip install -e ".[dev]"
pytest pytest
``` ```

Binary file not shown.

View File

@@ -4,10 +4,15 @@ build-backend = "hatchling.build"
[project] [project]
name = "codexlens-search" name = "codexlens-search"
version = "0.2.0" version = "0.3.0"
description = "Lightweight semantic code search engine — 2-stage vector + FTS + RRF fusion" description = "Lightweight semantic code search engine — 2-stage vector + FTS + RRF fusion + MCP server"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [] dependencies = [
"hnswlib>=0.8.0",
"numpy>=1.26",
"fastembed>=0.4.0,<2.0",
"httpx>=0.25",
]
license = {text = "MIT"} license = {text = "MIT"}
readme = "README.md" readme = "README.md"
authors = [ authors = [
@@ -26,14 +31,12 @@ classifiers = [
] ]
[project.urls] [project.urls]
Homepage = "https://github.com/nicepkg/codexlens-search" Homepage = "https://github.com/catlog22/codexlens-search"
Repository = "https://github.com/nicepkg/codexlens-search" Repository = "https://github.com/catlog22/codexlens-search"
[project.optional-dependencies] [project.optional-dependencies]
semantic = [ mcp = [
"hnswlib>=0.8.0", "mcp[cli]>=1.0.0",
"numpy>=1.26",
"fastembed>=0.4.0,<2.0",
] ]
gpu = [ gpu = [
"onnxruntime-gpu>=1.16", "onnxruntime-gpu>=1.16",
@@ -44,21 +47,9 @@ faiss-cpu = [
faiss-gpu = [ faiss-gpu = [
"faiss-gpu>=1.7.4", "faiss-gpu>=1.7.4",
] ]
embed-api = [
"httpx>=0.25",
]
reranker-api = [
"httpx>=0.25",
]
watcher = [ watcher = [
"watchdog>=3.0", "watchdog>=3.0",
] ]
semantic-gpu = [
"hnswlib>=0.8.0",
"numpy>=1.26",
"fastembed>=0.4.0,<2.0",
"onnxruntime-gpu>=1.16",
]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"pytest-cov", "pytest-cov",
@@ -66,6 +57,7 @@ dev = [
[project.scripts] [project.scripts]
codexlens-search = "codexlens_search.bridge:main" codexlens-search = "codexlens_search.bridge:main"
codexlens-mcp = "codexlens_search.mcp_server:main"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/codexlens_search"] packages = ["src/codexlens_search"]

View File

@@ -50,21 +50,19 @@ def _resolve_db_path(args: argparse.Namespace) -> Path:
return db_path return db_path
def _create_config(args: argparse.Namespace) -> "Config": def create_config_from_env(db_path: str | Path, **overrides: object) -> "Config":
"""Build Config from CLI args.""" """Build Config from environment variables and optional overrides.
Used by both CLI bridge and MCP server.
"""
from codexlens_search.config import Config from codexlens_search.config import Config
kwargs: dict = {} kwargs: dict = {}
if hasattr(args, "embed_model") and args.embed_model: # Apply explicit overrides first
kwargs["embed_model"] = args.embed_model for key in ("embed_model", "embed_api_url", "embed_api_key", "embed_api_model"):
# API embedding overrides if overrides.get(key):
if hasattr(args, "embed_api_url") and args.embed_api_url: kwargs[key] = overrides[key]
kwargs["embed_api_url"] = args.embed_api_url # Env vars as fallback
if hasattr(args, "embed_api_key") and args.embed_api_key:
kwargs["embed_api_key"] = args.embed_api_key
if hasattr(args, "embed_api_model") and args.embed_api_model:
kwargs["embed_api_model"] = args.embed_api_model
# Also check env vars as fallback
if "embed_api_url" not in kwargs and os.environ.get("CODEXLENS_EMBED_API_URL"): if "embed_api_url" not in kwargs and os.environ.get("CODEXLENS_EMBED_API_URL"):
kwargs["embed_api_url"] = os.environ["CODEXLENS_EMBED_API_URL"] kwargs["embed_api_url"] = os.environ["CODEXLENS_EMBED_API_URL"]
if "embed_api_key" not in kwargs and os.environ.get("CODEXLENS_EMBED_API_KEY"): if "embed_api_key" not in kwargs and os.environ.get("CODEXLENS_EMBED_API_KEY"):
@@ -124,18 +122,33 @@ def _create_config(args: argparse.Namespace) -> "Config":
kwargs["hnsw_ef"] = int(os.environ["CODEXLENS_HNSW_EF"]) kwargs["hnsw_ef"] = int(os.environ["CODEXLENS_HNSW_EF"])
if os.environ.get("CODEXLENS_HNSW_M"): if os.environ.get("CODEXLENS_HNSW_M"):
kwargs["hnsw_M"] = int(os.environ["CODEXLENS_HNSW_M"]) kwargs["hnsw_M"] = int(os.environ["CODEXLENS_HNSW_M"])
db_path = Path(args.db_path).resolve() resolved = Path(db_path).resolve()
kwargs["metadata_db_path"] = str(db_path / "metadata.db") kwargs["metadata_db_path"] = str(resolved / "metadata.db")
return Config(**kwargs) return Config(**kwargs)
def _create_pipeline( def _create_config(args: argparse.Namespace) -> "Config":
args: argparse.Namespace, """Build Config from CLI args (delegates to create_config_from_env)."""
overrides: dict = {}
if hasattr(args, "embed_model") and args.embed_model:
overrides["embed_model"] = args.embed_model
if hasattr(args, "embed_api_url") and args.embed_api_url:
overrides["embed_api_url"] = args.embed_api_url
if hasattr(args, "embed_api_key") and args.embed_api_key:
overrides["embed_api_key"] = args.embed_api_key
if hasattr(args, "embed_api_model") and args.embed_api_model:
overrides["embed_api_model"] = args.embed_api_model
return create_config_from_env(args.db_path, **overrides)
def create_pipeline(
db_path: str | Path,
config: "Config | None" = None,
) -> tuple: ) -> tuple:
"""Lazily construct pipeline components from CLI args. """Construct pipeline components from db_path and config.
Returns (indexing_pipeline, search_pipeline, config). Returns (indexing_pipeline, search_pipeline, config).
Only loads embedder/reranker models when needed. Used by both CLI bridge and MCP server.
""" """
from codexlens_search.config import Config from codexlens_search.config import Config
from codexlens_search.core.factory import create_ann_index, create_binary_index from codexlens_search.core.factory import create_ann_index, create_binary_index
@@ -144,8 +157,10 @@ def _create_pipeline(
from codexlens_search.search.fts import FTSEngine from codexlens_search.search.fts import FTSEngine
from codexlens_search.search.pipeline import SearchPipeline from codexlens_search.search.pipeline import SearchPipeline
config = _create_config(args) if config is None:
db_path = _resolve_db_path(args) config = create_config_from_env(db_path)
resolved = Path(db_path).resolve()
resolved.mkdir(parents=True, exist_ok=True)
# Select embedder: API if configured, otherwise local fastembed # Select embedder: API if configured, otherwise local fastembed
if config.embed_api_url: if config.embed_api_url:
@@ -163,10 +178,10 @@ def _create_pipeline(
from codexlens_search.embed.local import FastEmbedEmbedder from codexlens_search.embed.local import FastEmbedEmbedder
embedder = FastEmbedEmbedder(config) embedder = FastEmbedEmbedder(config)
binary_store = create_binary_index(db_path, config.embed_dim, config) binary_store = create_binary_index(resolved, config.embed_dim, config)
ann_index = create_ann_index(db_path, config.embed_dim, config) ann_index = create_ann_index(resolved, config.embed_dim, config)
fts = FTSEngine(db_path / "fts.db") fts = FTSEngine(resolved / "fts.db")
metadata = MetadataStore(db_path / "metadata.db") metadata = MetadataStore(resolved / "metadata.db")
# Select reranker: API if configured, otherwise local fastembed # Select reranker: API if configured, otherwise local fastembed
if config.reranker_api_url: if config.reranker_api_url:
@@ -199,6 +214,15 @@ def _create_pipeline(
return indexing, search, config return indexing, search, config
def _create_pipeline(
args: argparse.Namespace,
) -> tuple:
"""CLI wrapper: construct pipeline from argparse args."""
config = _create_config(args)
db_path = _resolve_db_path(args)
return create_pipeline(db_path, config)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Subcommand handlers # Subcommand handlers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -269,14 +293,14 @@ def cmd_remove_file(args: argparse.Namespace) -> None:
}) })
_DEFAULT_EXCLUDES = frozenset({ DEFAULT_EXCLUDES = frozenset({
"node_modules", ".git", "__pycache__", "dist", "build", "node_modules", ".git", "__pycache__", "dist", "build",
".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache", ".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
".next", ".nuxt", "coverage", ".eggs", "*.egg-info", ".next", ".nuxt", "coverage", ".eggs", "*.egg-info",
}) })
def _should_exclude(path: Path, exclude_dirs: frozenset[str]) -> bool: def should_exclude(path: Path, exclude_dirs: frozenset[str]) -> bool:
"""Check if any path component matches an exclude pattern.""" """Check if any path component matches an exclude pattern."""
parts = path.parts parts = path.parts
return any(part in exclude_dirs for part in parts) return any(part in exclude_dirs for part in parts)
@@ -290,11 +314,11 @@ def cmd_sync(args: argparse.Namespace) -> None:
if not root.is_dir(): if not root.is_dir():
_error_exit(f"Root directory not found: {root}") _error_exit(f"Root directory not found: {root}")
exclude_dirs = frozenset(args.exclude) if args.exclude else _DEFAULT_EXCLUDES exclude_dirs = frozenset(args.exclude) if args.exclude else DEFAULT_EXCLUDES
pattern = args.glob or "**/*" pattern = args.glob or "**/*"
file_paths = [ file_paths = [
p for p in root.glob(pattern) p for p in root.glob(pattern)
if p.is_file() and not _should_exclude(p.relative_to(root), exclude_dirs) if p.is_file() and not should_exclude(p.relative_to(root), exclude_dirs)
] ]
log.debug("Sync: %d files after exclusion (root=%s, pattern=%s)", len(file_paths), root, pattern) log.debug("Sync: %d files after exclusion (root=%s, pattern=%s)", len(file_paths), root, pattern)

View File

@@ -0,0 +1,367 @@
"""MCP server for codexlens-search.
Exposes semantic code search tools via FastMCP for Claude Code integration.
Run as: codexlens-mcp (entry point) or python -m codexlens_search.mcp_server
## .mcp.json Configuration Examples
### API embedding + API reranker (single endpoint):
{
"mcpServers": {
"codexlens": {
"command": "codexlens-mcp",
"env": {
"CODEXLENS_EMBED_API_URL": "https://api.openai.com/v1",
"CODEXLENS_EMBED_API_KEY": "sk-xxx",
"CODEXLENS_EMBED_API_MODEL": "text-embedding-3-small",
"CODEXLENS_EMBED_DIM": "1536",
"CODEXLENS_RERANKER_API_URL": "https://api.jina.ai/v1",
"CODEXLENS_RERANKER_API_KEY": "jina-xxx",
"CODEXLENS_RERANKER_API_MODEL": "jina-reranker-v2-base-multilingual"
}
}
}
}
### API embedding (multi-endpoint load balancing):
{
"mcpServers": {
"codexlens": {
"command": "codexlens-mcp",
"env": {
"CODEXLENS_EMBED_API_ENDPOINTS": "url1|key1|model1,url2|key2|model2",
"CODEXLENS_EMBED_DIM": "1536",
"CODEXLENS_RERANKER_API_URL": "https://api.jina.ai/v1",
"CODEXLENS_RERANKER_API_KEY": "jina-xxx",
"CODEXLENS_RERANKER_API_MODEL": "jina-reranker-v2-base-multilingual"
}
}
}
}
### Local fastembed model (no API, requires codexlens-search[semantic]):
{
"mcpServers": {
"codexlens": {
"command": "codexlens-mcp",
"env": {}
}
}
}
Pre-download models via CLI: codexlens-search download-models
### Env vars reference:
Embedding: CODEXLENS_EMBED_API_URL, _KEY, _MODEL, _ENDPOINTS (multi), _DIM
Reranker: CODEXLENS_RERANKER_API_URL, _KEY, _MODEL
Tuning: CODEXLENS_BINARY_TOP_K, _ANN_TOP_K, _FTS_TOP_K, _FUSION_K,
CODEXLENS_RERANKER_TOP_K, _RERANKER_BATCH_SIZE
"""
from __future__ import annotations
import logging
import threading
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from codexlens_search.bridge import (
DEFAULT_EXCLUDES,
create_config_from_env,
create_pipeline,
should_exclude,
)
log = logging.getLogger("codexlens_search.mcp_server")
mcp = FastMCP("codexlens-search")
# Pipeline cache: keyed by resolved project_path -> (indexing, search, config)
_pipelines: dict[str, tuple] = {}
_lock = threading.Lock()
def _db_path_for_project(project_path: str) -> Path:
"""Return the index database path for a project."""
return Path(project_path).resolve() / ".codexlens"
def _get_pipelines(project_path: str) -> tuple:
"""Get or create cached (indexing_pipeline, search_pipeline, config) for a project."""
resolved = str(Path(project_path).resolve())
with _lock:
if resolved not in _pipelines:
db_path = _db_path_for_project(resolved)
config = create_config_from_env(db_path)
_pipelines[resolved] = create_pipeline(db_path, config)
return _pipelines[resolved]
# ---------------------------------------------------------------------------
# Search tools
# ---------------------------------------------------------------------------
@mcp.tool()
def search_code(project_path: str, query: str, top_k: int = 10) -> str:
"""Semantic code search with hybrid fusion (vector + FTS + reranking).
Args:
project_path: Absolute path to the project root directory.
query: Natural language or code search query.
top_k: Maximum number of results to return (default 10).
Returns:
Search results as formatted text with file paths, line numbers, scores, and code snippets.
"""
root = Path(project_path).resolve()
if not root.is_dir():
return f"Error: project path not found: {root}"
db_path = _db_path_for_project(project_path)
if not (db_path / "metadata.db").exists():
return f"Error: no index found at {db_path}. Run index_project first."
_, search, _ = _get_pipelines(project_path)
results = search.search(query, top_k=top_k)
if not results:
return "No results found."
lines = []
for i, r in enumerate(results, 1):
lines.append(f"## Result {i}{r.path} (L{r.line}-{r.end_line}, score: {r.score:.4f})")
lines.append(f"```\n{r.content}\n```")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Indexing tools
# ---------------------------------------------------------------------------
@mcp.tool()
def index_project(
project_path: str, glob_pattern: str = "**/*", force: bool = False
) -> str:
"""Build or rebuild the search index for a project.
Args:
project_path: Absolute path to the project root directory.
glob_pattern: Glob pattern for files to index (default "**/*").
force: If True, rebuild index from scratch even if it exists.
Returns:
Indexing summary with file count, chunk count, and duration.
"""
root = Path(project_path).resolve()
if not root.is_dir():
return f"Error: project path not found: {root}"
if force:
with _lock:
_pipelines.pop(str(root), None)
indexing, _, _ = _get_pipelines(project_path)
file_paths = [
p for p in root.glob(glob_pattern)
if p.is_file() and not should_exclude(p.relative_to(root), DEFAULT_EXCLUDES)
]
stats = indexing.sync(file_paths, root=root)
return (
f"Indexed {stats.files_processed} files, "
f"{stats.chunks_created} chunks in {stats.duration_seconds:.1f}s. "
f"DB: {_db_path_for_project(project_path)}"
)
@mcp.tool()
def index_status(project_path: str) -> str:
"""Show index statistics for a project.
Args:
project_path: Absolute path to the project root directory.
Returns:
Index statistics including file count, chunk count, and deleted chunks.
"""
from codexlens_search.indexing.metadata import MetadataStore
db_path = _db_path_for_project(project_path)
meta_path = db_path / "metadata.db"
if not meta_path.exists():
return f"No index found at {db_path}. Run index_project first."
metadata = MetadataStore(meta_path)
all_files = metadata.get_all_files()
deleted_ids = metadata.get_deleted_ids()
max_chunk = metadata.max_chunk_id()
total = max_chunk + 1 if max_chunk >= 0 else 0
return (
f"Index: {db_path}\n"
f"Files tracked: {len(all_files)}\n"
f"Total chunks: {total}\n"
f"Deleted chunks: {len(deleted_ids)}"
)
@mcp.tool()
def index_update(project_path: str, glob_pattern: str = "**/*") -> str:
"""Incrementally sync the index with current project files.
Only re-indexes files that changed since last indexing.
Args:
project_path: Absolute path to the project root directory.
glob_pattern: Glob pattern for files to sync (default "**/*").
Returns:
Sync summary with processed file count and duration.
"""
root = Path(project_path).resolve()
if not root.is_dir():
return f"Error: project path not found: {root}"
indexing, _, _ = _get_pipelines(project_path)
file_paths = [
p for p in root.glob(glob_pattern)
if p.is_file() and not should_exclude(p.relative_to(root), DEFAULT_EXCLUDES)
]
stats = indexing.sync(file_paths, root=root)
return (
f"Synced {stats.files_processed} files, "
f"{stats.chunks_created} chunks in {stats.duration_seconds:.1f}s."
)
# ---------------------------------------------------------------------------
# File discovery
# ---------------------------------------------------------------------------
@mcp.tool()
def find_files(
project_path: str, pattern: str = "**/*", max_results: int = 100
) -> str:
"""Find files in a project by glob pattern.
Args:
project_path: Absolute path to the project root directory.
pattern: Glob pattern to match files (default "**/*").
max_results: Maximum number of file paths to return (default 100).
Returns:
List of matching file paths (relative to project root), one per line.
"""
root = Path(project_path).resolve()
if not root.is_dir():
return f"Error: project path not found: {root}"
matches = []
for p in root.glob(pattern):
if p.is_file() and not should_exclude(p.relative_to(root), DEFAULT_EXCLUDES):
matches.append(str(p.relative_to(root)))
if len(matches) >= max_results:
break
if not matches:
return "No files found matching the pattern."
header = f"Found {len(matches)} files"
if len(matches) >= max_results:
header += f" (limited to {max_results})"
return header + ":\n" + "\n".join(matches)
# ---------------------------------------------------------------------------
# Model management tools
# ---------------------------------------------------------------------------
@mcp.tool()
def list_models() -> str:
"""List available embedding and reranker models with cache status.
Shows which models are downloaded locally and ready for use.
Models are needed when using local fastembed mode (no API URL configured).
Returns:
Table of models with name, type, and installed status.
"""
from codexlens_search import model_manager
from codexlens_search.config import Config
config = create_config_from_env(".")
models = model_manager.list_known_models(config)
if not models:
return "No known models found."
lines = ["| Model | Type | Installed |", "| --- | --- | --- |"]
for m in models:
status = "Yes" if m["installed"] else "No"
lines.append(f"| {m['name']} | {m['type']} | {status} |")
# Show current config
lines.append("")
if config.embed_api_url:
lines.append(f"Mode: API embedding ({config.embed_api_url})")
else:
lines.append(f"Mode: Local fastembed (model: {config.embed_model})")
return "\n".join(lines)
@mcp.tool()
def download_models(embed_model: str = "", reranker_model: str = "") -> str:
"""Download embedding and reranker models for local (fastembed) mode.
Not needed when using API embedding (CODEXLENS_EMBED_API_URL is set).
Downloads are cached — subsequent calls are no-ops if already downloaded.
Args:
embed_model: Embedding model name (default: BAAI/bge-small-en-v1.5).
reranker_model: Reranker model name (default: Xenova/ms-marco-MiniLM-L-6-v2).
Returns:
Download status for each model.
"""
from codexlens_search import model_manager
from codexlens_search.config import Config
config = create_config_from_env(".")
if embed_model:
config.embed_model = embed_model
if reranker_model:
config.reranker_model = reranker_model
results = []
for name, kind in [
(config.embed_model, "embedding"),
(config.reranker_model, "reranker"),
]:
try:
model_manager.ensure_model(name, config)
results.append(f"{kind}: {name} — ready")
except Exception as e:
results.append(f"{kind}: {name} — failed: {e}")
return "\n".join(results)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point for codexlens-mcp command."""
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s %(name)s: %(message)s",
)
mcp.run()
if __name__ == "__main__":
main()