mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-18 18:48:48 +08:00
feat: add MCP server for semantic code search with FastMCP integration
This commit is contained in:
153
.claude/skills/wf-composer/SKILL.md
Normal file
153
.claude/skills/wf-composer/SKILL.md
Normal 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 |
|
||||
93
.claude/skills/wf-composer/phases/01-parse.md
Normal file
93
.claude/skills/wf-composer/phases/01-parse.md
Normal 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
|
||||
89
.claude/skills/wf-composer/phases/02-resolve.md
Normal file
89
.claude/skills/wf-composer/phases/02-resolve.md
Normal 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
|
||||
104
.claude/skills/wf-composer/phases/03-enrich.md
Normal file
104
.claude/skills/wf-composer/phases/03-enrich.md
Normal 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
|
||||
97
.claude/skills/wf-composer/phases/04-confirm.md
Normal file
97
.claude/skills/wf-composer/phases/04-confirm.md
Normal 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
|
||||
107
.claude/skills/wf-composer/phases/05-persist.md
Normal file
107
.claude/skills/wf-composer/phases/05-persist.md
Normal 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
|
||||
96
.claude/skills/wf-composer/specs/node-catalog.md
Normal file
96
.claude/skills/wf-composer/specs/node-catalog.md
Normal 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)
|
||||
202
.claude/skills/wf-composer/specs/template-schema.md
Normal file
202
.claude/skills/wf-composer/specs/template-schema.md
Normal 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 |
|
||||
151
.claude/skills/wf-player/SKILL.md
Normal file
151
.claude/skills/wf-player/SKILL.md
Normal 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 |
|
||||
92
.claude/skills/wf-player/phases/01-load.md
Normal file
92
.claude/skills/wf-player/phases/01-load.md
Normal 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
|
||||
110
.claude/skills/wf-player/phases/02-instantiate.md
Normal file
110
.claude/skills/wf-player/phases/02-instantiate.md
Normal 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"
|
||||
211
.claude/skills/wf-player/phases/03-execute.md
Normal file
211
.claude/skills/wf-player/phases/03-execute.md
Normal 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)
|
||||
93
.claude/skills/wf-player/phases/04-complete.md
Normal file
93
.claude/skills/wf-player/phases/04-complete.md
Normal 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
|
||||
187
.claude/skills/wf-player/specs/node-executor.md
Normal file
187
.claude/skills/wf-player/specs/node-executor.md
Normal 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).
|
||||
136
.claude/skills/wf-player/specs/state-schema.md
Normal file
136
.claude/skills/wf-player/specs/state-schema.md
Normal 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
|
||||
```
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -290,84 +290,15 @@ export type {
|
||||
WorkspaceQueryKeys,
|
||||
} from './useWorkspaceQueryKeys';
|
||||
|
||||
// ========== CodexLens ==========
|
||||
// ========== CodexLens (v2) ==========
|
||||
export {
|
||||
useCodexLensDashboard,
|
||||
useCodexLensStatus,
|
||||
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';
|
||||
useV2SearchManager,
|
||||
} from './useV2SearchManager';
|
||||
export type {
|
||||
UseCodexLensDashboardOptions,
|
||||
UseCodexLensDashboardReturn,
|
||||
UseCodexLensStatusOptions,
|
||||
UseCodexLensStatusReturn,
|
||||
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';
|
||||
V2IndexStatus,
|
||||
V2SearchTestResult,
|
||||
UseV2SearchManagerReturn,
|
||||
} from './useV2SearchManager';
|
||||
|
||||
// ========== Skill Hub ==========
|
||||
export {
|
||||
|
||||
@@ -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
159
ccw/frontend/src/hooks/useV2SearchManager.ts
Normal file
159
ccw/frontend/src/hooks/useV2SearchManager.ts
Normal 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
@@ -1,390 +1,28 @@
|
||||
{
|
||||
"title": "CodexLens",
|
||||
"description": "Semantic code search engine",
|
||||
"bootstrap": "Bootstrap",
|
||||
"bootstrapping": "Bootstrapping...",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstalling": "Uninstalling...",
|
||||
"confirmUninstall": "Are you sure you want to uninstall CodexLens? This action cannot be undone.",
|
||||
"confirmUninstallTitle": "Confirm Uninstall",
|
||||
"notInstalled": "CodexLens is not installed",
|
||||
"comingSoon": "Coming Soon",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"settings": "Settings",
|
||||
"models": "Models",
|
||||
"search": "Search",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
"overview": {
|
||||
"status": {
|
||||
"installation": "Installation Status",
|
||||
"title": "Search Manager",
|
||||
"description": "V2 semantic search index management",
|
||||
"reindex": "Reindex",
|
||||
"reindexing": "Reindexing...",
|
||||
"statusError": "Failed to load search index status",
|
||||
"indexStatus": {
|
||||
"title": "Index Status",
|
||||
"status": "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",
|
||||
"notIndexed": "Not Indexed",
|
||||
"files": "Files",
|
||||
"dbSize": "DB Size",
|
||||
"lastIndexed": "Last Indexed",
|
||||
"chunks": "Chunks",
|
||||
"vectorDim": "Vector Dim",
|
||||
"enabled": "Enabled",
|
||||
"available": "Available",
|
||||
"unavailable": "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"
|
||||
"disabled": "Disabled",
|
||||
"unavailable": "Index status unavailable"
|
||||
},
|
||||
"advanced": {
|
||||
"warningTitle": "Sensitive Operations Warning",
|
||||
"warningMessage": "Modifying environment variables may affect CodexLens operation. Ensure you understand each variable's purpose.",
|
||||
"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...",
|
||||
"searchTest": {
|
||||
"title": "Search Test",
|
||||
"placeholder": "Enter search query...",
|
||||
"button": "Search",
|
||||
"searching": "Searching...",
|
||||
"results": "Results",
|
||||
"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"
|
||||
"results": "results",
|
||||
"noResults": "No results found"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"prompts": "Prompt History",
|
||||
"settings": "Settings",
|
||||
"mcp": "MCP Servers",
|
||||
"codexlens": "CodexLens",
|
||||
"codexlens": "Search Manager",
|
||||
"apiSettings": "API Settings",
|
||||
"endpoints": "CLI Endpoints",
|
||||
"installations": "Installations",
|
||||
|
||||
@@ -1,390 +1,28 @@
|
||||
{
|
||||
"title": "CodexLens",
|
||||
"description": "语义代码搜索引擎",
|
||||
"bootstrap": "引导安装",
|
||||
"bootstrapping": "安装中...",
|
||||
"uninstall": "卸载",
|
||||
"uninstalling": "卸载中...",
|
||||
"confirmUninstall": "确定要卸载 CodexLens 吗?此操作无法撤销。",
|
||||
"confirmUninstallTitle": "确认卸载",
|
||||
"notInstalled": "CodexLens 尚未安装",
|
||||
"comingSoon": "即将推出",
|
||||
"tabs": {
|
||||
"overview": "概览",
|
||||
"settings": "设置",
|
||||
"models": "模型",
|
||||
"search": "搜索",
|
||||
"advanced": "高级"
|
||||
},
|
||||
"overview": {
|
||||
"status": {
|
||||
"installation": "安装状态",
|
||||
"title": "搜索管理",
|
||||
"description": "V2 语义搜索索引管理",
|
||||
"reindex": "重建索引",
|
||||
"reindexing": "重建中...",
|
||||
"statusError": "加载搜索索引状态失败",
|
||||
"indexStatus": {
|
||||
"title": "索引状态",
|
||||
"status": "状态",
|
||||
"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": "DirectML(Windows GPU)",
|
||||
"directmlDesc": "最适合带 AMD/Intel GPU 的 Windows 系统。推荐大多数用户使用。",
|
||||
"cuda": "CUDA(NVIDIA 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 状态",
|
||||
"notIndexed": "未索引",
|
||||
"files": "文件数",
|
||||
"dbSize": "数据库大小",
|
||||
"lastIndexed": "上次索引",
|
||||
"chunks": "分块数",
|
||||
"vectorDim": "向量维度",
|
||||
"enabled": "已启用",
|
||||
"available": "可用",
|
||||
"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": "显存"
|
||||
"disabled": "已禁用",
|
||||
"unavailable": "索引状态不可用"
|
||||
},
|
||||
"advanced": {
|
||||
"warningTitle": "敏感操作警告",
|
||||
"warningMessage": "修改环境变量可能影响 CodexLens 的正常运行。请确保您了解每个变量的作用。",
|
||||
"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": "输入搜索查询...",
|
||||
"searchTest": {
|
||||
"title": "搜索测试",
|
||||
"placeholder": "输入搜索查询...",
|
||||
"button": "搜索",
|
||||
"searching": "搜索中...",
|
||||
"results": "结果",
|
||||
"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 服务器已重启"
|
||||
"results": "个结果",
|
||||
"noResults": "未找到结果"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"prompts": "提示历史",
|
||||
"settings": "设置",
|
||||
"mcp": "MCP 服务器",
|
||||
"codexlens": "CodexLens",
|
||||
"codexlens": "搜索管理",
|
||||
"apiSettings": "API 设置",
|
||||
"endpoints": "CLI 端点",
|
||||
"installations": "安装",
|
||||
|
||||
@@ -1,145 +1,76 @@
|
||||
// ========================================
|
||||
// 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 { render, screen, waitFor } from '@/test/i18n';
|
||||
import { render, screen } from '@/test/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CodexLensManagerPage } from './CodexLensManagerPage';
|
||||
|
||||
// Mock api module
|
||||
vi.mock('@/lib/api', () => ({
|
||||
fetchCodexLensDashboardInit: vi.fn(),
|
||||
bootstrapCodexLens: vi.fn(),
|
||||
uninstallCodexLens: vi.fn(),
|
||||
// Mock the v2 search manager hook
|
||||
vi.mock('@/hooks/useV2SearchManager', () => ({
|
||||
useV2SearchManager: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@/hooks/useCodexLens', () => ({
|
||||
useCodexLensDashboard: vi.fn(),
|
||||
}));
|
||||
import { useV2SearchManager } from '@/hooks/useV2SearchManager';
|
||||
|
||||
vi.mock('@/hooks/useCodexLens', () => ({
|
||||
useCodexLensDashboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useNotifications', () => ({
|
||||
useNotifications: vi.fn(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
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 mockStatus = {
|
||||
indexed: true,
|
||||
totalFiles: 150,
|
||||
totalChunks: 1200,
|
||||
lastIndexedAt: '2026-03-17T10:00:00Z',
|
||||
dbSizeBytes: 5242880,
|
||||
vectorDimension: 384,
|
||||
ftsEnabled: true,
|
||||
};
|
||||
|
||||
const mockMutations = {
|
||||
bootstrap: vi.fn().mockResolvedValue({ success: true }),
|
||||
uninstall: vi.fn().mockResolvedValue({ success: true }),
|
||||
isBootstrapping: false,
|
||||
isUninstalling: false,
|
||||
const defaultHookReturn = {
|
||||
status: mockStatus,
|
||||
isLoadingStatus: false,
|
||||
statusError: null,
|
||||
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', () => {
|
||||
describe('CodexLensManagerPage (v2)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue(defaultHookReturn);
|
||||
});
|
||||
|
||||
describe('when installed', () => {
|
||||
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 render page title and description', () => {
|
||||
it('should render page title', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Semantic code search engine/i)).toBeInTheDocument();
|
||||
// The title comes from i18n codexlens.title
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all tabs', () => {
|
||||
it('should render index status section', () => {
|
||||
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();
|
||||
// Check for file count display
|
||||
expect(screen.getByText('150')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show uninstall button when installed', () => {
|
||||
it('should render search input', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/Uninstall/i)).toBeInTheDocument();
|
||||
const input = screen.getByPlaceholderText(/search query/i);
|
||||
expect(input).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,
|
||||
it('should call refetchStatus on refresh click', async () => {
|
||||
const refetchStatus = vi.fn();
|
||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
refetchStatus,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
@@ -148,214 +79,118 @@ describe('CodexLensManagerPage', () => {
|
||||
const refreshButton = screen.getByText(/Refresh/i);
|
||||
await user.click(refreshButton);
|
||||
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(refetchStatus).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe('when not installed', () => {
|
||||
beforeEach(() => {
|
||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
||||
installed: false,
|
||||
status: undefined,
|
||||
config: undefined,
|
||||
semantic: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
it('should call search when clicking search button', async () => {
|
||||
const searchFn = vi.fn().mockResolvedValue({
|
||||
query: 'test query',
|
||||
results: [],
|
||||
timingMs: 5,
|
||||
totalResults: 0,
|
||||
});
|
||||
(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,
|
||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
search: searchFn,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const bootstrapButton = screen.getByText(/Bootstrap/i);
|
||||
await user.click(bootstrapButton);
|
||||
const input = screen.getByPlaceholderText(/search query/i);
|
||||
await user.type(input, 'test query');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bootstrap).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
const searchButton = screen.getByText(/Search/i);
|
||||
await user.click(searchButton);
|
||||
|
||||
expect(searchFn).toHaveBeenCalledWith('test query');
|
||||
});
|
||||
|
||||
describe('uninstall flow', () => {
|
||||
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(),
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
it('should show confirmation dialog on uninstall', async () => {
|
||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
|
||||
...mockMutations,
|
||||
uninstall,
|
||||
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 uninstallButton = screen.getByText(/Uninstall/i);
|
||||
await user.click(uninstallButton);
|
||||
const reindexButton = screen.getByText(/Reindex/i);
|
||||
await user.click(reindexButton);
|
||||
|
||||
expect(global.confirm).toHaveBeenCalledWith(expect.stringContaining('uninstall'));
|
||||
expect(reindexFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should call uninstall when confirmed', async () => {
|
||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
|
||||
...mockMutations,
|
||||
uninstall,
|
||||
it('should show loading skeleton when status is loading', () => {
|
||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
status: null,
|
||||
isLoadingStatus: true,
|
||||
});
|
||||
|
||||
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 show loading skeleton when loading', () => {
|
||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
||||
installed: false,
|
||||
status: undefined,
|
||||
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();
|
||||
// Should have pulse animation elements
|
||||
const pulseElements = document.querySelectorAll('.animate-pulse');
|
||||
expect(pulseElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should disable refresh button when fetching', () => {
|
||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
it('should show error alert when status fetch fails', () => {
|
||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
status: null,
|
||||
statusError: new Error('Network error'),
|
||||
});
|
||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
|
||||
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const refreshButton = screen.getByText(/Refresh/i);
|
||||
expect(refreshButton).toBeDisabled();
|
||||
// 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', () => {
|
||||
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', () => {
|
||||
render(<CodexLensManagerPage />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/CodexLens/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 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();
|
||||
// Page title from zh codexlens.json
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,85 +1,67 @@
|
||||
// ========================================
|
||||
// CodexLens Manager Page
|
||||
// CodexLens Manager Page (v2)
|
||||
// ========================================
|
||||
// Manage CodexLens semantic code search with tabbed interface
|
||||
// Supports Overview, Settings, Models, and Advanced tabs
|
||||
// V2 search management interface with index status, search test, and configuration
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Sparkles,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Trash2,
|
||||
Database,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { TabsNavigation } from '@/components/ui/TabsNavigation';
|
||||
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 { useV2SearchManager } from '@/hooks';
|
||||
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() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [isUninstallDialogOpen, setIsUninstallDialogOpen] = useState(false);
|
||||
const [isSemanticInstallOpen, setIsSemanticInstallOpen] = useState(false);
|
||||
const [isInstallOverlayOpen, setIsInstallOverlayOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const {
|
||||
installed,
|
||||
status,
|
||||
config,
|
||||
semantic,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useCodexLensDashboard();
|
||||
isLoadingStatus,
|
||||
statusError,
|
||||
refetchStatus,
|
||||
search,
|
||||
isSearching,
|
||||
searchResult,
|
||||
reindex,
|
||||
isReindexing,
|
||||
} = useV2SearchManager();
|
||||
|
||||
const {
|
||||
bootstrap,
|
||||
isBootstrapping,
|
||||
uninstall,
|
||||
isUninstalling,
|
||||
} = useCodexLensMutations();
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch();
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
await search(searchQuery.trim());
|
||||
};
|
||||
|
||||
const handleBootstrap = () => {
|
||||
setIsInstallOverlayOpen(true);
|
||||
};
|
||||
|
||||
const handleBootstrapInstall = async () => {
|
||||
const result = await bootstrap();
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleUninstall = async () => {
|
||||
const result = await uninstall();
|
||||
if (result.success) {
|
||||
refetch();
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
setIsUninstallDialogOpen(false);
|
||||
};
|
||||
|
||||
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>
|
||||
<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' })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
@@ -98,150 +80,196 @@ export function CodexLensManagerPage() {
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
onClick={refetchStatus}
|
||||
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' })}
|
||||
</Button>
|
||||
{!installed ? (
|
||||
<Button
|
||||
onClick={handleBootstrap}
|
||||
disabled={isBootstrapping}
|
||||
onClick={() => reindex()}
|
||||
disabled={isReindexing}
|
||||
>
|
||||
<Download className={cn('w-4 h-4 mr-2', isBootstrapping && 'animate-spin')} />
|
||||
{isBootstrapping
|
||||
? formatMessage({ id: 'codexlens.bootstrapping' })
|
||||
: formatMessage({ id: 'codexlens.bootstrap' })
|
||||
<Zap className={cn('w-4 h-4 mr-2', isReindexing && 'animate-spin')} />
|
||||
{isReindexing
|
||||
? formatMessage({ id: 'codexlens.reindexing' })
|
||||
: formatMessage({ id: 'codexlens.reindex' })
|
||||
}
|
||||
</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>
|
||||
|
||||
{/* Installation Status Alert */}
|
||||
{!installed && !isLoading && (
|
||||
<Card className="p-4 bg-warning/10 border-warning/20">
|
||||
<p className="text-sm text-warning-foreground">
|
||||
{formatMessage({ id: 'codexlens.notInstalled' })}
|
||||
{/* Error Alert */}
|
||||
{statusError && (
|
||||
<Card className="p-4 bg-destructive/10 border-destructive/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
{formatMessage({ id: 'codexlens.statusError' })}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabbed Interface */}
|
||||
<TabsNavigation
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
tabs={[
|
||||
{ value: 'overview', label: formatMessage({ id: 'codexlens.tabs.overview' }) },
|
||||
{ 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' }) },
|
||||
]}
|
||||
{/* Index Status Section */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
{formatMessage({ id: 'codexlens.indexStatus.title' })}
|
||||
</h2>
|
||||
|
||||
{isLoadingStatus ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-16 bg-muted/50 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<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>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="mt-4">
|
||||
<OverviewTab
|
||||
installed={installed}
|
||||
status={status}
|
||||
config={config}
|
||||
isLoading={isLoading}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
{searchResult && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<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>
|
||||
|
||||
{searchResult.results.length > 0 ? (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{searchResult.results.map((result, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3 rounded-lg border border-border bg-muted/20 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-mono text-primary truncate">
|
||||
{result.file}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-2 shrink-0">
|
||||
{(result.score * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-3">
|
||||
{result.snippet}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{formatMessage({ id: 'codexlens.searchTest.noResults' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="mt-4">
|
||||
<SettingsTab enabled={installed} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'models' && (
|
||||
<div className="mt-4">
|
||||
<ModelsTab installed={installed} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'search' && (
|
||||
<div className="mt-4">
|
||||
<SearchTab enabled={installed} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'advanced' && (
|
||||
<div className="mt-4">
|
||||
<AdvancedTab enabled={installed} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Semantic Install Dialog */}
|
||||
<SemanticInstallDialog
|
||||
open={isSemanticInstallOpen}
|
||||
onOpenChange={setIsSemanticInstallOpen}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
|
||||
{/* Install Progress Overlay */}
|
||||
<InstallProgressOverlay
|
||||
open={isInstallOverlayOpen}
|
||||
onOpenChange={setIsInstallOverlayOpen}
|
||||
onInstall={handleBootstrapInstall}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,173 +118,29 @@ const mockMessages: Record<Locale, Record<string, string>> = {
|
||||
'issues.discovery.status.failed': 'Failed',
|
||||
'issues.discovery.progress': 'Progress',
|
||||
'issues.discovery.findings': 'Findings',
|
||||
// CodexLens
|
||||
'codexlens.title': 'CodexLens',
|
||||
'codexlens.description': 'Semantic code search engine',
|
||||
'codexlens.bootstrap': 'Bootstrap',
|
||||
'codexlens.bootstrapping': 'Bootstrapping...',
|
||||
'codexlens.uninstall': 'Uninstall',
|
||||
'codexlens.uninstalling': 'Uninstalling...',
|
||||
'codexlens.confirmUninstall': 'Are you sure you want to uninstall CodexLens?',
|
||||
'codexlens.notInstalled': 'CodexLens is not installed',
|
||||
'codexlens.comingSoon': 'Coming Soon',
|
||||
'codexlens.tabs.overview': 'Overview',
|
||||
'codexlens.tabs.settings': 'Settings',
|
||||
'codexlens.tabs.models': 'Models',
|
||||
'codexlens.tabs.advanced': 'Advanced',
|
||||
'codexlens.overview.status.installation': 'Installation Status',
|
||||
'codexlens.overview.status.ready': 'Ready',
|
||||
'codexlens.overview.status.notReady': 'Not Ready',
|
||||
'codexlens.overview.status.version': 'Version',
|
||||
'codexlens.overview.status.indexPath': 'Index Path',
|
||||
'codexlens.overview.status.indexCount': 'Index Count',
|
||||
'codexlens.overview.notInstalled.title': 'CodexLens Not Installed',
|
||||
'codexlens.overview.notInstalled.message': 'Please install CodexLens to use semantic code search features.',
|
||||
'codexlens.overview.actions.title': 'Quick Actions',
|
||||
'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...',
|
||||
// CodexLens (v2)
|
||||
'codexlens.title': 'Search Manager',
|
||||
'codexlens.description': 'V2 semantic search index management',
|
||||
'codexlens.reindex': 'Reindex',
|
||||
'codexlens.reindexing': 'Reindexing...',
|
||||
'codexlens.statusError': 'Failed to load search index status',
|
||||
'codexlens.indexStatus.title': 'Index Status',
|
||||
'codexlens.indexStatus.status': 'Status',
|
||||
'codexlens.indexStatus.ready': 'Ready',
|
||||
'codexlens.indexStatus.notIndexed': 'Not Indexed',
|
||||
'codexlens.indexStatus.files': 'Files',
|
||||
'codexlens.indexStatus.dbSize': 'DB Size',
|
||||
'codexlens.indexStatus.lastIndexed': 'Last Indexed',
|
||||
'codexlens.indexStatus.chunks': 'Chunks',
|
||||
'codexlens.indexStatus.vectorDim': 'Vector Dim',
|
||||
'codexlens.indexStatus.enabled': 'Enabled',
|
||||
'codexlens.indexStatus.disabled': 'Disabled',
|
||||
'codexlens.indexStatus.unavailable': 'Index status unavailable',
|
||||
'codexlens.searchTest.title': 'Search Test',
|
||||
'codexlens.searchTest.placeholder': 'Enter search query...',
|
||||
'codexlens.searchTest.button': 'Search',
|
||||
'codexlens.searchTest.results': 'results',
|
||||
'codexlens.searchTest.noResults': 'No results found',
|
||||
// MCP - CCW Tools
|
||||
'mcp.ccw.title': 'CCW MCP Server',
|
||||
'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.progress': '进度',
|
||||
'issues.discovery.findings': '发现',
|
||||
// CodexLens
|
||||
'codexlens.title': 'CodexLens',
|
||||
'codexlens.description': '语义代码搜索引擎',
|
||||
'codexlens.bootstrap': '引导安装',
|
||||
'codexlens.bootstrapping': '安装中...',
|
||||
'codexlens.uninstall': '卸载',
|
||||
'codexlens.uninstalling': '卸载中...',
|
||||
'codexlens.confirmUninstall': '确定要卸载 CodexLens 吗?',
|
||||
'codexlens.notInstalled': 'CodexLens 尚未安装',
|
||||
'codexlens.comingSoon': '即将推出',
|
||||
'codexlens.tabs.overview': '概览',
|
||||
'codexlens.tabs.settings': '设置',
|
||||
'codexlens.tabs.models': '模型',
|
||||
'codexlens.tabs.advanced': '高级',
|
||||
'codexlens.overview.status.installation': '安装状态',
|
||||
'codexlens.overview.status.ready': '就绪',
|
||||
'codexlens.overview.status.notReady': '未就绪',
|
||||
'codexlens.overview.status.version': '版本',
|
||||
'codexlens.overview.status.indexPath': '索引路径',
|
||||
'codexlens.overview.status.indexCount': '索引数量',
|
||||
'codexlens.overview.notInstalled.title': 'CodexLens 未安装',
|
||||
'codexlens.overview.notInstalled.message': '请先安装 CodexLens 以使用语义代码搜索功能。',
|
||||
'codexlens.overview.actions.title': '快速操作',
|
||||
'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': '选择提供商...',
|
||||
// CodexLens (v2)
|
||||
'codexlens.title': '搜索管理',
|
||||
'codexlens.description': 'V2 语义搜索索引管理',
|
||||
'codexlens.reindex': '重建索引',
|
||||
'codexlens.reindexing': '重建中...',
|
||||
'codexlens.statusError': '加载搜索索引状态失败',
|
||||
'codexlens.indexStatus.title': '索引状态',
|
||||
'codexlens.indexStatus.status': '状态',
|
||||
'codexlens.indexStatus.ready': '就绪',
|
||||
'codexlens.indexStatus.notIndexed': '未索引',
|
||||
'codexlens.indexStatus.files': '文件数',
|
||||
'codexlens.indexStatus.dbSize': '数据库大小',
|
||||
'codexlens.indexStatus.lastIndexed': '上次索引',
|
||||
'codexlens.indexStatus.chunks': '分块数',
|
||||
'codexlens.indexStatus.vectorDim': '向量维度',
|
||||
'codexlens.indexStatus.enabled': '已启用',
|
||||
'codexlens.indexStatus.disabled': '已禁用',
|
||||
'codexlens.indexStatus.unavailable': '索引状态不可用',
|
||||
'codexlens.searchTest.title': '搜索测试',
|
||||
'codexlens.searchTest.placeholder': '输入搜索查询...',
|
||||
'codexlens.searchTest.button': '搜索',
|
||||
'codexlens.searchTest.results': '个结果',
|
||||
'codexlens.searchTest.noResults': '未找到结果',
|
||||
// MCP - CCW Tools
|
||||
'mcp.ccw.title': 'CCW MCP 服务器',
|
||||
'mcp.ccw.description': '配置 CCW MCP 工具与路径',
|
||||
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
* which generates and searches embeddings for memory chunks using CodexLens's embedder.
|
||||
*
|
||||
* Features:
|
||||
* - Reuses CodexLens venv at ~/.codexlens/venv
|
||||
* - JSON protocol communication
|
||||
* - Three commands: embed, search, status
|
||||
* - Automatic availability checking
|
||||
* - Stage1 output embedding for V2 pipeline
|
||||
* The Python memory_embedder.py bridge has been removed. This module provides
|
||||
* no-op stubs so that existing consumers compile without errors.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
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';
|
||||
const V1_REMOVED = 'Memory embedder Python bridge has been removed (v1 cleanup).';
|
||||
|
||||
// Get directory of this module
|
||||
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
|
||||
// Types (kept for backward compatibility)
|
||||
export interface EmbedResult {
|
||||
success: boolean;
|
||||
chunks_processed: number;
|
||||
@@ -78,197 +54,6 @@ export interface SearchOptions {
|
||||
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 {
|
||||
success: boolean;
|
||||
chunksCreated: number;
|
||||
@@ -276,98 +61,54 @@ export interface Stage1EmbedResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk and embed stage1_outputs (raw_memory + rollout_summary) for semantic search.
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
export function isEmbedderAvailable(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function generateEmbeddings(
|
||||
_dbPath: string,
|
||||
_options: EmbedOptions = {}
|
||||
): Promise<EmbedResult> {
|
||||
return {
|
||||
success: true,
|
||||
chunksCreated: totalChunksCreated,
|
||||
chunksEmbedded,
|
||||
success: false,
|
||||
chunks_processed: 0,
|
||||
chunks_failed: 0,
|
||||
elapsed_time: 0,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
} catch (err) {
|
||||
}
|
||||
|
||||
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: (err as Error).message,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,17 +3,6 @@
|
||||
* Handles LiteLLM provider management, endpoint configuration, and cache management
|
||||
*/
|
||||
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';
|
||||
|
||||
// ========== Input Validation Schemas ==========
|
||||
@@ -81,106 +70,13 @@ import {
|
||||
type EmbeddingPoolConfig,
|
||||
} from '../../config/litellm-api-config-manager.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';
|
||||
|
||||
interface CcwLitellmEnvCheck {
|
||||
python: string;
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
const V1_REMOVED = 'Python bridge has been removed (v1 cleanup).';
|
||||
|
||||
interface CcwLitellmStatusResponse {
|
||||
/**
|
||||
* 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)
|
||||
// Clear cache (no-op stub, kept for backward compatibility)
|
||||
export function clearCcwLitellmStatusCache() {
|
||||
ccwLitellmStatusCache.data = null;
|
||||
ccwLitellmStatusCache.timestamp = 0;
|
||||
// no-op: Python bridge removed
|
||||
}
|
||||
|
||||
function sanitizeProviderForResponse(provider: any): any {
|
||||
@@ -922,57 +818,10 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
|
||||
// CCW-LiteLLM Package Management
|
||||
// ===========================
|
||||
|
||||
// GET /api/litellm-api/ccw-litellm/status - Check ccw-litellm installation status
|
||||
// Supports ?refresh=true to bypass cache
|
||||
// GET /api/litellm-api/ccw-litellm/status - Stub (v1 Python bridge removed)
|
||||
if (pathname === '/api/litellm-api/ccw-litellm/status' && req.method === 'GET') {
|
||||
const forceRefresh = url.searchParams.get('refresh') === 'true';
|
||||
|
||||
// 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));
|
||||
}
|
||||
res.end(JSON.stringify({ installed: false, error: V1_REMOVED }));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1367,96 +1216,18 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
|
||||
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') {
|
||||
handlePostRequest(req, res, async () => {
|
||||
try {
|
||||
// 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 { success: false, error: V1_REMOVED };
|
||||
});
|
||||
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') {
|
||||
handlePostRequest(req, res, async () => {
|
||||
try {
|
||||
// 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 { success: false, error: V1_REMOVED };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getCliToolsStatus } from '../../tools/cli-executor.js';
|
||||
import { checkVenvStatus, checkSemanticStatus } from '../../tools/codex-lens.js';
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
// Performance logging helper
|
||||
@@ -80,36 +79,14 @@ export async function handleStatusRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const ccwInstallStatus = checkCcwInstallStatus();
|
||||
perfLog('checkCcwInstallStatus', ccwStart);
|
||||
|
||||
// Execute all status checks in parallel with individual timing
|
||||
// Execute async status checks
|
||||
const cliStart = Date.now();
|
||||
const codexStart = Date.now();
|
||||
const semanticStart = Date.now();
|
||||
|
||||
const [cliStatus, codexLensStatus, semanticStatus] = await Promise.all([
|
||||
getCliToolsStatus().then(result => {
|
||||
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 cliStatus = await getCliToolsStatus();
|
||||
perfLog('getCliToolsStatus', cliStart, { toolCount: Object.keys(cliStatus).length });
|
||||
|
||||
const response = {
|
||||
cli: cliStatus,
|
||||
codexLens: codexLensStatus,
|
||||
semantic: semanticStatus,
|
||||
ccwInstall: ccwInstallStatus,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@ import { handleUnifiedMemoryRoutes } from './routes/unified-memory-routes.js';
|
||||
import { handleMcpRoutes } from './routes/mcp-routes.js';
|
||||
import { handleHooksRoutes } from './routes/hooks-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 { handleSystemRoutes } from './routes/system-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 status check functions for warmup
|
||||
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
|
||||
import { getCliToolsStatus } from '../tools/cli-executor.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
|
||||
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
|
||||
(async () => {
|
||||
const taskStart = Date.now();
|
||||
@@ -598,11 +574,6 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleUnsplashRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// CodexLens routes (/api/codexlens/*)
|
||||
if (pathname.startsWith('/api/codexlens/')) {
|
||||
if (await handleCodexLensRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// LiteLLM routes (/api/litellm/*)
|
||||
if (pathname.startsWith('/api/litellm/')) {
|
||||
if (await handleLiteLLMRoutes(routeContext)) return;
|
||||
|
||||
@@ -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
|
||||
* (core_memory, cli_history, workflow, entity, pattern) via CodexLens VectorStore.
|
||||
*
|
||||
* 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
|
||||
* The Python unified_memory_embedder.py bridge has been removed. This module
|
||||
* provides no-op stubs so that existing consumers compile without errors.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
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';
|
||||
const V1_REMOVED = 'Unified vector index Python bridge has been removed (v1 cleanup).';
|
||||
|
||||
// Get directory of this module
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (kept for backward compatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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';
|
||||
|
||||
/** Valid category values for vector filtering */
|
||||
export type VectorCategory = 'core_memory' | 'cli_history' | 'workflow' | 'entity' | 'pattern';
|
||||
|
||||
/** Metadata attached to each chunk in the vector store */
|
||||
export interface ChunkMetadata {
|
||||
/** Source identifier (e.g., memory ID, session ID) */
|
||||
source_id: string;
|
||||
/** Source type */
|
||||
source_type: SourceType;
|
||||
/** Category for filtering */
|
||||
category: VectorCategory;
|
||||
/** Chunk index within the source */
|
||||
chunk_index?: number;
|
||||
/** Additional metadata */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** A chunk to be embedded and indexed */
|
||||
export interface VectorChunk {
|
||||
/** Text content */
|
||||
content: string;
|
||||
/** Source identifier */
|
||||
source_id: string;
|
||||
/** Source type */
|
||||
source_type: SourceType;
|
||||
/** Category for filtering */
|
||||
category: VectorCategory;
|
||||
/** Chunk index */
|
||||
chunk_index: number;
|
||||
/** Additional metadata */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Result of an embed operation */
|
||||
export interface EmbedResult {
|
||||
success: boolean;
|
||||
chunks_processed: number;
|
||||
@@ -82,7 +40,6 @@ export interface EmbedResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A single search match */
|
||||
export interface VectorSearchMatch {
|
||||
content: string;
|
||||
score: number;
|
||||
@@ -93,7 +50,6 @@ export interface VectorSearchMatch {
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Result of a search operation */
|
||||
export interface VectorSearchResult {
|
||||
success: boolean;
|
||||
matches: VectorSearchMatch[];
|
||||
@@ -102,14 +58,12 @@ export interface VectorSearchResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Search options */
|
||||
export interface VectorSearchOptions {
|
||||
topK?: number;
|
||||
minScore?: number;
|
||||
category?: VectorCategory;
|
||||
}
|
||||
|
||||
/** Index status information */
|
||||
export interface VectorIndexStatus {
|
||||
success: boolean;
|
||||
total_chunks: number;
|
||||
@@ -126,7 +80,6 @@ export interface VectorIndexStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Reindex result */
|
||||
export interface ReindexResult {
|
||||
success: boolean;
|
||||
hnsw_count?: number;
|
||||
@@ -134,344 +87,73 @@ export interface ReindexResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Python Bridge
|
||||
// =============================================================================
|
||||
// ---------------------------------------------------------------------------
|
||||
// No-op implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if the unified embedder is available (venv and script exist)
|
||||
*/
|
||||
export function isUnifiedEmbedderAvailable(): boolean {
|
||||
if (!existsSync(VENV_PYTHON)) {
|
||||
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[] {
|
||||
const chunks: string[] = [];
|
||||
|
||||
// Split by paragraph boundaries first
|
||||
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;
|
||||
}
|
||||
// Minimal chunking for backward compat - just return the content as-is
|
||||
if (!content.trim()) return [];
|
||||
return [content];
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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(
|
||||
content: string,
|
||||
metadata: ChunkMetadata
|
||||
_content: string,
|
||||
_metadata: ChunkMetadata
|
||||
): Promise<EmbedResult> {
|
||||
if (!content.trim()) {
|
||||
return {
|
||||
success: true,
|
||||
success: false,
|
||||
chunks_processed: 0,
|
||||
chunks_failed: 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(
|
||||
query: string,
|
||||
options: VectorSearchOptions = {}
|
||||
_query: string,
|
||||
_options: VectorSearchOptions = {}
|
||||
): Promise<VectorSearchResult> {
|
||||
const { topK = 10, minScore = 0.3, category } = options;
|
||||
|
||||
try {
|
||||
const result = await runPython<VectorSearchResult>({
|
||||
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,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
vector: number[],
|
||||
options: VectorSearchOptions = {}
|
||||
_vector: number[],
|
||||
_options: VectorSearchOptions = {}
|
||||
): Promise<VectorSearchResult> {
|
||||
const { topK = 10, minScore = 0.3, category } = options;
|
||||
|
||||
try {
|
||||
const result = await runPython<VectorSearchResult>({
|
||||
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,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the HNSW index from scratch.
|
||||
*
|
||||
* @returns Reindex result
|
||||
*/
|
||||
async reindexAll(): Promise<ReindexResult> {
|
||||
try {
|
||||
const result = await runPython<ReindexResult>({
|
||||
operation: 'reindex',
|
||||
store_path: this.storePath,
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: (err as Error).message,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status of the vector index.
|
||||
*
|
||||
* @returns Index status including chunk counts, HNSW availability, dimension
|
||||
*/
|
||||
async getStatus(): Promise<VectorIndexStatus> {
|
||||
try {
|
||||
const result = await runPython<VectorIndexStatus>({
|
||||
operation: 'status',
|
||||
store_path: this.storePath,
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
total_chunks: 0,
|
||||
hnsw_available: false,
|
||||
hnsw_count: 0,
|
||||
dimension: 0,
|
||||
error: (err as Error).message,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -21,7 +21,7 @@ import * as cliExecutorMod from './cli-executor.js';
|
||||
import * as smartSearchMod from './smart-search.js';
|
||||
import { executeInitWithProgress } from './smart-search.js';
|
||||
// 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 readManyFilesMod from './read-many-files.js';
|
||||
import * as readOutlineMod from './read-outline.js';
|
||||
@@ -365,7 +365,7 @@ registerTool(toLegacyTool(sessionManagerMod));
|
||||
registerTool(toLegacyTool(cliExecutorMod));
|
||||
registerTool(toLegacyTool(smartSearchMod));
|
||||
// codex_lens removed - functionality integrated into smart_search
|
||||
registerTool(toLegacyTool(codexLensLspMod));
|
||||
// codex_lens_lsp removed - v1 LSP bridge removed
|
||||
registerTool(toLegacyTool(readFileMod));
|
||||
registerTool(toLegacyTool(readManyFilesMod));
|
||||
registerTool(toLegacyTool(readOutlineMod));
|
||||
|
||||
@@ -1,64 +1,23 @@
|
||||
/**
|
||||
* LiteLLM Client - Bridge between CCW and ccw-litellm Python package
|
||||
* Provides LLM chat and embedding capabilities via spawned Python process
|
||||
* LiteLLM Client - STUB (v1 Python bridge removed)
|
||||
*
|
||||
* Features:
|
||||
* - Chat completions with multiple models
|
||||
* - Text embeddings generation
|
||||
* - Configuration management
|
||||
* - JSON protocol communication
|
||||
* The Python ccw-litellm bridge has been removed. This module provides
|
||||
* no-op stubs so that existing consumers compile without errors.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { getCodexLensPython, getCodexLensHiddenPython, getCodexLensVenvDir } from '../utils/codexlens-path.js';
|
||||
const V1_REMOVED = 'LiteLLM Python bridge has been removed (v1 cleanup).';
|
||||
|
||||
export interface LiteLLMConfig {
|
||||
pythonPath?: string; // Default: CodexLens venv Python
|
||||
configPath?: string; // Configuration file path
|
||||
timeout?: number; // Default 60000ms
|
||||
pythonPath?: string;
|
||||
configPath?: string;
|
||||
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 {
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -90,179 +49,35 @@ export interface LiteLLMStatus {
|
||||
}
|
||||
|
||||
export class LiteLLMClient {
|
||||
private pythonPath: string;
|
||||
private configPath?: string;
|
||||
private timeout: number;
|
||||
constructor(_config: LiteLLMConfig = {}) {}
|
||||
|
||||
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> {
|
||||
try {
|
||||
// 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> {
|
||||
try {
|
||||
// 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
|
||||
};
|
||||
return { available: false, error: V1_REMOVED };
|
||||
}
|
||||
|
||||
async getConfig(): Promise<unknown> {
|
||||
return { error: V1_REMOVED };
|
||||
}
|
||||
|
||||
async embed(_texts: string[], _model?: string): Promise<EmbedResponse> {
|
||||
throw new Error(V1_REMOVED);
|
||||
}
|
||||
|
||||
async chat(_message: string, _model?: string): Promise<string> {
|
||||
throw new Error(V1_REMOVED);
|
||||
}
|
||||
|
||||
async chatMessages(_messages: ChatMessage[], _model?: string): Promise<ChatResponse> {
|
||||
throw new Error(V1_REMOVED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
async getConfig(): Promise<any> {
|
||||
// config command outputs JSON by default, no --json flag needed
|
||||
const output = await this.executePython(['config']);
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for texts
|
||||
*/
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat with LLM
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-turn chat with messages array
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Get or create singleton LiteLLM client
|
||||
*/
|
||||
export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
|
||||
if (!_client) {
|
||||
_client = new LiteLLMClient(config);
|
||||
@@ -270,29 +85,10 @@ export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
|
||||
return _client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if LiteLLM is available
|
||||
*/
|
||||
export async function checkLiteLLMAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const client = getLiteLLMClient();
|
||||
return await client.isAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LiteLLM status
|
||||
*/
|
||||
export async function getLiteLLMStatus(): Promise<LiteLLMStatus> {
|
||||
try {
|
||||
const client = getLiteLLMClient();
|
||||
return await client.getStatus();
|
||||
} catch (error: any) {
|
||||
return {
|
||||
available: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
return { available: false, error: V1_REMOVED };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* 2. Default: ~/.codexlens
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
@@ -26,56 +25,3 @@ export function getCodexLensDataDir(): string {
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
32
codex-lens-v2/.gitignore
vendored
32
codex-lens-v2/.gitignore
vendored
@@ -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/
|
||||
|
||||
# Workflow (internal)
|
||||
.workflow/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -1,143 +1,221 @@
|
||||
# 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
|
||||
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
|
||||
Add to your project `.mcp.json`:
|
||||
|
||||
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
|
||||
# Core only (FTS search, no vector search)
|
||||
# Standard install (includes vector search + API clients)
|
||||
pip install codexlens-search
|
||||
|
||||
# With semantic search (recommended)
|
||||
pip install codexlens-search[semantic]
|
||||
|
||||
# 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]
|
||||
# With MCP server for Claude Code
|
||||
pip install codexlens-search[mcp]
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
Optional extras for advanced use:
|
||||
|
||||
```python
|
||||
from codexlens_search import Config, IndexingPipeline, SearchPipeline
|
||||
from codexlens_search.core import create_ann_index, create_binary_index
|
||||
from codexlens_search.embed.local import FastEmbedEmbedder
|
||||
from codexlens_search.rerank.local import LocalReranker
|
||||
from codexlens_search.search.fts import FTSEngine
|
||||
| Extra | Description |
|
||||
|-------|-------------|
|
||||
| `mcp` | MCP server (`codexlens-mcp` command) |
|
||||
| `gpu` | GPU-accelerated embedding (onnxruntime-gpu) |
|
||||
| `faiss-cpu` | FAISS ANN backend |
|
||||
| `watcher` | File watcher for auto-indexing |
|
||||
|
||||
# 1. Configure
|
||||
config = Config(embed_model="BAAI/bge-small-en-v1.5", embed_dim=384)
|
||||
## MCP Tools
|
||||
|
||||
# 2. Create components
|
||||
embedder = FastEmbedEmbedder(config)
|
||||
binary_store = create_binary_index(config, db_path="index/binary.db")
|
||||
ann_index = create_ann_index(config, index_path="index/ann.bin")
|
||||
fts = FTSEngine("index/fts.db")
|
||||
reranker = LocalReranker()
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search_code` | Semantic search with hybrid fusion + reranking |
|
||||
| `index_project` | Build or rebuild the search index |
|
||||
| `index_status` | Show index statistics |
|
||||
| `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
|
||||
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")
|
||||
## MCP Configuration Examples
|
||||
|
||||
# 4. Search
|
||||
pipeline = SearchPipeline(embedder, binary_store, ann_index, reranker, fts, config)
|
||||
results = pipeline.search("authentication handler", top_k=10)
|
||||
for r in results:
|
||||
print(f" {r.path} (score={r.score:.3f})")
|
||||
### API Embedding Only (simplest)
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Extras
|
||||
### API Embedding + API Reranker (best quality)
|
||||
|
||||
| Extra | Dependencies | Description |
|
||||
|-------|-------------|-------------|
|
||||
| `semantic` | hnswlib, numpy, fastembed | Vector search with local embeddings |
|
||||
| `gpu` | onnxruntime-gpu | GPU-accelerated embedding inference |
|
||||
| `semantic-gpu` | semantic + gpu combined | Vector search with GPU acceleration |
|
||||
| `faiss-cpu` | faiss-cpu | FAISS ANN backend (CPU) |
|
||||
| `faiss-gpu` | faiss-gpu | FAISS ANN backend (GPU) |
|
||||
| `reranker-api` | httpx | Remote reranker API client |
|
||||
| `dev` | pytest, pytest-cov | Development and testing |
|
||||
```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",
|
||||
"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
|
||||
|
||||
```
|
||||
Query
|
||||
|
|
||||
v
|
||||
[Embedder] --> query vector
|
||||
|
|
||||
+---> [BinaryStore.coarse_search] --> candidate IDs (Hamming distance)
|
||||
| |
|
||||
| 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'
|
||||
)
|
||||
Query → [Embedder] → query vector
|
||||
├→ [BinaryStore] → candidates (Hamming)
|
||||
│ └→ [ANNIndex] → ranked IDs (cosine)
|
||||
├→ [FTS exact] → exact matches
|
||||
└→ [FTS fuzzy] → fuzzy matches
|
||||
└→ [RRF Fusion] → merged ranking
|
||||
└→ [Reranker] → final top-k
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nicepkg/codexlens-search.git
|
||||
git clone https://github.com/catlog22/codexlens-search.git
|
||||
cd codexlens-search
|
||||
pip install -e ".[dev,semantic]"
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
BIN
codex-lens-v2/dist/codexlens_search-0.2.0.tar.gz
vendored
BIN
codex-lens-v2/dist/codexlens_search-0.2.0.tar.gz
vendored
Binary file not shown.
@@ -4,10 +4,15 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "codexlens-search"
|
||||
version = "0.2.0"
|
||||
description = "Lightweight semantic code search engine — 2-stage vector + FTS + RRF fusion"
|
||||
version = "0.3.0"
|
||||
description = "Lightweight semantic code search engine — 2-stage vector + FTS + RRF fusion + MCP server"
|
||||
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"}
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
@@ -26,14 +31,12 @@ classifiers = [
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/nicepkg/codexlens-search"
|
||||
Repository = "https://github.com/nicepkg/codexlens-search"
|
||||
Homepage = "https://github.com/catlog22/codexlens-search"
|
||||
Repository = "https://github.com/catlog22/codexlens-search"
|
||||
|
||||
[project.optional-dependencies]
|
||||
semantic = [
|
||||
"hnswlib>=0.8.0",
|
||||
"numpy>=1.26",
|
||||
"fastembed>=0.4.0,<2.0",
|
||||
mcp = [
|
||||
"mcp[cli]>=1.0.0",
|
||||
]
|
||||
gpu = [
|
||||
"onnxruntime-gpu>=1.16",
|
||||
@@ -44,21 +47,9 @@ faiss-cpu = [
|
||||
faiss-gpu = [
|
||||
"faiss-gpu>=1.7.4",
|
||||
]
|
||||
embed-api = [
|
||||
"httpx>=0.25",
|
||||
]
|
||||
reranker-api = [
|
||||
"httpx>=0.25",
|
||||
]
|
||||
watcher = [
|
||||
"watchdog>=3.0",
|
||||
]
|
||||
semantic-gpu = [
|
||||
"hnswlib>=0.8.0",
|
||||
"numpy>=1.26",
|
||||
"fastembed>=0.4.0,<2.0",
|
||||
"onnxruntime-gpu>=1.16",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov",
|
||||
@@ -66,6 +57,7 @@ dev = [
|
||||
|
||||
[project.scripts]
|
||||
codexlens-search = "codexlens_search.bridge:main"
|
||||
codexlens-mcp = "codexlens_search.mcp_server:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/codexlens_search"]
|
||||
|
||||
@@ -50,21 +50,19 @@ def _resolve_db_path(args: argparse.Namespace) -> Path:
|
||||
return db_path
|
||||
|
||||
|
||||
def _create_config(args: argparse.Namespace) -> "Config":
|
||||
"""Build Config from CLI args."""
|
||||
def create_config_from_env(db_path: str | Path, **overrides: object) -> "Config":
|
||||
"""Build Config from environment variables and optional overrides.
|
||||
|
||||
Used by both CLI bridge and MCP server.
|
||||
"""
|
||||
from codexlens_search.config import Config
|
||||
|
||||
kwargs: dict = {}
|
||||
if hasattr(args, "embed_model") and args.embed_model:
|
||||
kwargs["embed_model"] = args.embed_model
|
||||
# API embedding overrides
|
||||
if hasattr(args, "embed_api_url") and args.embed_api_url:
|
||||
kwargs["embed_api_url"] = args.embed_api_url
|
||||
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
|
||||
# Apply explicit overrides first
|
||||
for key in ("embed_model", "embed_api_url", "embed_api_key", "embed_api_model"):
|
||||
if overrides.get(key):
|
||||
kwargs[key] = overrides[key]
|
||||
# Env vars as fallback
|
||||
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"]
|
||||
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"])
|
||||
if os.environ.get("CODEXLENS_HNSW_M"):
|
||||
kwargs["hnsw_M"] = int(os.environ["CODEXLENS_HNSW_M"])
|
||||
db_path = Path(args.db_path).resolve()
|
||||
kwargs["metadata_db_path"] = str(db_path / "metadata.db")
|
||||
resolved = Path(db_path).resolve()
|
||||
kwargs["metadata_db_path"] = str(resolved / "metadata.db")
|
||||
return Config(**kwargs)
|
||||
|
||||
|
||||
def _create_pipeline(
|
||||
args: argparse.Namespace,
|
||||
def _create_config(args: argparse.Namespace) -> "Config":
|
||||
"""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:
|
||||
"""Lazily construct pipeline components from CLI args.
|
||||
"""Construct pipeline components from db_path and 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.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.pipeline import SearchPipeline
|
||||
|
||||
config = _create_config(args)
|
||||
db_path = _resolve_db_path(args)
|
||||
if config is None:
|
||||
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
|
||||
if config.embed_api_url:
|
||||
@@ -163,10 +178,10 @@ def _create_pipeline(
|
||||
from codexlens_search.embed.local import FastEmbedEmbedder
|
||||
embedder = FastEmbedEmbedder(config)
|
||||
|
||||
binary_store = create_binary_index(db_path, config.embed_dim, config)
|
||||
ann_index = create_ann_index(db_path, config.embed_dim, config)
|
||||
fts = FTSEngine(db_path / "fts.db")
|
||||
metadata = MetadataStore(db_path / "metadata.db")
|
||||
binary_store = create_binary_index(resolved, config.embed_dim, config)
|
||||
ann_index = create_ann_index(resolved, config.embed_dim, config)
|
||||
fts = FTSEngine(resolved / "fts.db")
|
||||
metadata = MetadataStore(resolved / "metadata.db")
|
||||
|
||||
# Select reranker: API if configured, otherwise local fastembed
|
||||
if config.reranker_api_url:
|
||||
@@ -199,6 +214,15 @@ def _create_pipeline(
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -269,14 +293,14 @@ def cmd_remove_file(args: argparse.Namespace) -> None:
|
||||
})
|
||||
|
||||
|
||||
_DEFAULT_EXCLUDES = frozenset({
|
||||
DEFAULT_EXCLUDES = frozenset({
|
||||
"node_modules", ".git", "__pycache__", "dist", "build",
|
||||
".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
|
||||
".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."""
|
||||
parts = path.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():
|
||||
_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 "**/*"
|
||||
file_paths = [
|
||||
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)
|
||||
|
||||
367
codex-lens-v2/src/codexlens_search/mcp_server.py
Normal file
367
codex-lens-v2/src/codexlens_search/mcp_server.py
Normal 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()
|
||||
Reference in New Issue
Block a user