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,
|
WorkspaceQueryKeys,
|
||||||
} from './useWorkspaceQueryKeys';
|
} from './useWorkspaceQueryKeys';
|
||||||
|
|
||||||
// ========== CodexLens ==========
|
// ========== CodexLens (v2) ==========
|
||||||
export {
|
export {
|
||||||
useCodexLensDashboard,
|
useV2SearchManager,
|
||||||
useCodexLensStatus,
|
} from './useV2SearchManager';
|
||||||
useCodexLensWorkspaceStatus,
|
|
||||||
useCodexLensConfig,
|
|
||||||
useCodexLensModels,
|
|
||||||
useCodexLensModelInfo,
|
|
||||||
useCodexLensEnv,
|
|
||||||
useCodexLensGpu,
|
|
||||||
useCodexLensIgnorePatterns,
|
|
||||||
useUpdateCodexLensConfig,
|
|
||||||
useBootstrapCodexLens,
|
|
||||||
useUninstallCodexLens,
|
|
||||||
useDownloadModel,
|
|
||||||
useDeleteModel,
|
|
||||||
useUpdateCodexLensEnv,
|
|
||||||
useSelectGpu,
|
|
||||||
useUpdateIgnorePatterns,
|
|
||||||
useCodexLensMutations,
|
|
||||||
codexLensKeys,
|
|
||||||
useCodexLensIndexes,
|
|
||||||
useCodexLensIndexingStatus,
|
|
||||||
useRebuildIndex,
|
|
||||||
useUpdateIndex,
|
|
||||||
useCancelIndexing,
|
|
||||||
useCodexLensWatcher,
|
|
||||||
useCodexLensWatcherMutations,
|
|
||||||
useCodexLensLspStatus,
|
|
||||||
useCodexLensLspMutations,
|
|
||||||
useCodexLensRerankerConfig,
|
|
||||||
useUpdateRerankerConfig,
|
|
||||||
useCcwToolsList,
|
|
||||||
} from './useCodexLens';
|
|
||||||
export type {
|
export type {
|
||||||
UseCodexLensDashboardOptions,
|
V2IndexStatus,
|
||||||
UseCodexLensDashboardReturn,
|
V2SearchTestResult,
|
||||||
UseCodexLensStatusOptions,
|
UseV2SearchManagerReturn,
|
||||||
UseCodexLensStatusReturn,
|
} from './useV2SearchManager';
|
||||||
UseCodexLensWorkspaceStatusOptions,
|
|
||||||
UseCodexLensWorkspaceStatusReturn,
|
|
||||||
UseCodexLensConfigOptions,
|
|
||||||
UseCodexLensConfigReturn,
|
|
||||||
UseCodexLensModelsOptions,
|
|
||||||
UseCodexLensModelsReturn,
|
|
||||||
UseCodexLensModelInfoOptions,
|
|
||||||
UseCodexLensModelInfoReturn,
|
|
||||||
UseCodexLensEnvOptions,
|
|
||||||
UseCodexLensEnvReturn,
|
|
||||||
UseCodexLensGpuOptions,
|
|
||||||
UseCodexLensGpuReturn,
|
|
||||||
UseCodexLensIgnorePatternsOptions,
|
|
||||||
UseCodexLensIgnorePatternsReturn,
|
|
||||||
UseUpdateCodexLensConfigReturn,
|
|
||||||
UseBootstrapCodexLensReturn,
|
|
||||||
UseUninstallCodexLensReturn,
|
|
||||||
UseDownloadModelReturn,
|
|
||||||
UseDeleteModelReturn,
|
|
||||||
UseUpdateCodexLensEnvReturn,
|
|
||||||
UseSelectGpuReturn,
|
|
||||||
UseUpdateIgnorePatternsReturn,
|
|
||||||
UseCodexLensIndexesOptions,
|
|
||||||
UseCodexLensIndexesReturn,
|
|
||||||
UseCodexLensIndexingStatusReturn,
|
|
||||||
UseRebuildIndexReturn,
|
|
||||||
UseUpdateIndexReturn,
|
|
||||||
UseCancelIndexingReturn,
|
|
||||||
UseCodexLensWatcherOptions,
|
|
||||||
UseCodexLensWatcherReturn,
|
|
||||||
UseCodexLensWatcherMutationsReturn,
|
|
||||||
UseCodexLensLspStatusOptions,
|
|
||||||
UseCodexLensLspStatusReturn,
|
|
||||||
UseCodexLensLspMutationsReturn,
|
|
||||||
UseCodexLensRerankerConfigOptions,
|
|
||||||
UseCodexLensRerankerConfigReturn,
|
|
||||||
UseUpdateRerankerConfigReturn,
|
|
||||||
UseCcwToolsListReturn,
|
|
||||||
} from './useCodexLens';
|
|
||||||
|
|
||||||
// ========== Skill Hub ==========
|
// ========== Skill Hub ==========
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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",
|
"title": "Search Manager",
|
||||||
"description": "Semantic code search engine",
|
"description": "V2 semantic search index management",
|
||||||
"bootstrap": "Bootstrap",
|
"reindex": "Reindex",
|
||||||
"bootstrapping": "Bootstrapping...",
|
"reindexing": "Reindexing...",
|
||||||
"uninstall": "Uninstall",
|
"statusError": "Failed to load search index status",
|
||||||
"uninstalling": "Uninstalling...",
|
"indexStatus": {
|
||||||
"confirmUninstall": "Are you sure you want to uninstall CodexLens? This action cannot be undone.",
|
"title": "Index Status",
|
||||||
"confirmUninstallTitle": "Confirm Uninstall",
|
"status": "Status",
|
||||||
"notInstalled": "CodexLens is not installed",
|
"ready": "Ready",
|
||||||
"comingSoon": "Coming Soon",
|
"notIndexed": "Not Indexed",
|
||||||
"tabs": {
|
"files": "Files",
|
||||||
"overview": "Overview",
|
"dbSize": "DB Size",
|
||||||
"settings": "Settings",
|
"lastIndexed": "Last Indexed",
|
||||||
"models": "Models",
|
"chunks": "Chunks",
|
||||||
"search": "Search",
|
"vectorDim": "Vector Dim",
|
||||||
"advanced": "Advanced"
|
|
||||||
},
|
|
||||||
"overview": {
|
|
||||||
"status": {
|
|
||||||
"installation": "Installation Status",
|
|
||||||
"ready": "Ready",
|
|
||||||
"notReady": "Not Ready",
|
|
||||||
"version": "Version",
|
|
||||||
"indexPath": "Index Path",
|
|
||||||
"indexCount": "Index Count"
|
|
||||||
},
|
|
||||||
"notInstalled": {
|
|
||||||
"title": "CodexLens Not Installed",
|
|
||||||
"message": "Please install CodexLens to use semantic code search features."
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"title": "Quick Actions",
|
|
||||||
"ftsFull": "FTS Full",
|
|
||||||
"ftsFullDesc": "Rebuild full-text index",
|
|
||||||
"ftsIncremental": "FTS Incremental",
|
|
||||||
"ftsIncrementalDesc": "Incremental update full-text index",
|
|
||||||
"vectorFull": "Vector Full",
|
|
||||||
"vectorFullDesc": "Rebuild vector index",
|
|
||||||
"vectorIncremental": "Vector Incremental",
|
|
||||||
"vectorIncrementalDesc": "Incremental update vector index"
|
|
||||||
},
|
|
||||||
"venv": {
|
|
||||||
"title": "Python Virtual Environment Details",
|
|
||||||
"pythonVersion": "Python Version",
|
|
||||||
"venvPath": "Virtual Environment Path",
|
|
||||||
"lastCheck": "Last Check Time"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"index": {
|
|
||||||
"operationComplete": "Index Operation Complete",
|
|
||||||
"operationFailed": "Index Operation Failed",
|
|
||||||
"noProject": "No Project Selected",
|
|
||||||
"noProjectDesc": "Please open a project to perform index operations.",
|
|
||||||
"starting": "Starting index operation...",
|
|
||||||
"cancelFailed": "Failed to cancel operation",
|
|
||||||
"unknownError": "An unknown error occurred",
|
|
||||||
"complete": "Complete",
|
|
||||||
"failed": "Failed",
|
|
||||||
"cancelled": "Cancelled",
|
|
||||||
"inProgress": "In Progress"
|
|
||||||
},
|
|
||||||
"semantic": {
|
|
||||||
"installTitle": "Install Semantic Search",
|
|
||||||
"installDescription": "Install FastEmbed and semantic search dependencies with GPU acceleration support.",
|
|
||||||
"installInfo": "GPU acceleration requires compatible hardware. CPU mode works on all systems but is slower.",
|
|
||||||
"gpu": {
|
|
||||||
"cpu": "CPU Mode",
|
|
||||||
"cpuDesc": "Universal compatibility, slower processing. Works on all systems.",
|
|
||||||
"directml": "DirectML (Windows GPU)",
|
|
||||||
"directmlDesc": "Best for Windows with AMD/Intel GPUs. Recommended for most users.",
|
|
||||||
"cuda": "CUDA (NVIDIA GPU)",
|
|
||||||
"cudaDesc": "Best performance with NVIDIA GPUs. Requires CUDA toolkit."
|
|
||||||
},
|
|
||||||
"recommended": "Recommended",
|
|
||||||
"install": "Install",
|
|
||||||
"installing": "Installing...",
|
|
||||||
"installSuccess": "Installation Complete",
|
|
||||||
"installSuccessDesc": "Semantic search installed successfully with {mode} mode",
|
|
||||||
"installFailed": "Installation Failed",
|
|
||||||
"unknownError": "An unknown error occurred"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"currentCount": "Current Index Count",
|
|
||||||
"currentWorkers": "Current Workers",
|
|
||||||
"currentBatchSize": "Current Batch Size",
|
|
||||||
"configTitle": "Basic Configuration",
|
|
||||||
"indexDir": {
|
|
||||||
"label": "Index Directory",
|
|
||||||
"placeholder": "~/.codexlens/indexes",
|
|
||||||
"hint": "Directory path for storing code indexes"
|
|
||||||
},
|
|
||||||
"maxWorkers": {
|
|
||||||
"label": "Max Workers",
|
|
||||||
"hint": "API concurrent processing threads (1-32)"
|
|
||||||
},
|
|
||||||
"batchSize": {
|
|
||||||
"label": "Batch Size",
|
|
||||||
"hint": "Number of files processed per batch (1-64)"
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"indexDirRequired": "Index directory is required",
|
|
||||||
"maxWorkersRange": "Workers must be between 1 and 32",
|
|
||||||
"batchSizeRange": "Batch size must be between 1 and 64"
|
|
||||||
},
|
|
||||||
"save": "Save",
|
|
||||||
"saving": "Saving...",
|
|
||||||
"reset": "Reset",
|
|
||||||
"saveSuccess": "Configuration saved",
|
|
||||||
"saveFailed": "Save failed",
|
|
||||||
"configUpdated": "Configuration updated successfully",
|
|
||||||
"saveError": "Error saving configuration",
|
|
||||||
"unknownError": "An unknown error occurred"
|
|
||||||
},
|
|
||||||
"gpu": {
|
|
||||||
"title": "GPU Settings",
|
|
||||||
"status": "GPU Status",
|
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"available": "Available",
|
"disabled": "Disabled",
|
||||||
"unavailable": "Unavailable",
|
"unavailable": "Index status unavailable"
|
||||||
"supported": "Your system supports GPU acceleration",
|
|
||||||
"notSupported": "Your system does not support GPU acceleration",
|
|
||||||
"detect": "Detect",
|
|
||||||
"detectSuccess": "GPU detection completed",
|
|
||||||
"detectFailed": "GPU detection failed",
|
|
||||||
"detectComplete": "Detected {count} GPU devices",
|
|
||||||
"detectError": "Error detecting GPU",
|
|
||||||
"select": "Select",
|
|
||||||
"selected": "Selected",
|
|
||||||
"active": "Current",
|
|
||||||
"selectSuccess": "GPU selected",
|
|
||||||
"selectFailed": "GPU selection failed",
|
|
||||||
"gpuSelected": "GPU device enabled",
|
|
||||||
"selectError": "Error selecting GPU",
|
|
||||||
"reset": "Reset",
|
|
||||||
"resetSuccess": "GPU reset",
|
|
||||||
"resetFailed": "GPU reset failed",
|
|
||||||
"gpuReset": "GPU disabled, will use CPU",
|
|
||||||
"resetError": "Error resetting GPU",
|
|
||||||
"unknownError": "An unknown error occurred",
|
|
||||||
"noDevices": "No GPU devices detected",
|
|
||||||
"notAvailable": "GPU functionality not available",
|
|
||||||
"unknownDevice": "Unknown device",
|
|
||||||
"type": "Type",
|
|
||||||
"discrete": "Discrete GPU",
|
|
||||||
"integrated": "Integrated GPU",
|
|
||||||
"driver": "Driver Version",
|
|
||||||
"memory": "Memory"
|
|
||||||
},
|
},
|
||||||
"advanced": {
|
"searchTest": {
|
||||||
"warningTitle": "Sensitive Operations Warning",
|
"title": "Search Test",
|
||||||
"warningMessage": "Modifying environment variables may affect CodexLens operation. Ensure you understand each variable's purpose.",
|
"placeholder": "Enter search query...",
|
||||||
"loadError": "Failed to load environment variables",
|
|
||||||
"loadErrorDesc": "Unable to fetch environment configuration. Please check if CodexLens is properly installed.",
|
|
||||||
"currentVars": "Current Environment Variables",
|
|
||||||
"settingsVars": "Settings Variables",
|
|
||||||
"customVars": "Custom Variables",
|
|
||||||
"envEditor": "Environment Variable Editor",
|
|
||||||
"envFile": "File",
|
|
||||||
"envContent": "Environment Variable Content",
|
|
||||||
"envPlaceholder": "# Comment lines start with #\nKEY=value\nANOTHER_KEY=\"another value\"",
|
|
||||||
"envHint": "One variable per line, format: KEY=value. Comment lines start with #",
|
|
||||||
"save": "Save",
|
|
||||||
"saving": "Saving...",
|
|
||||||
"reset": "Reset",
|
|
||||||
"saveSuccess": "Environment variables saved",
|
|
||||||
"saveFailed": "Save failed",
|
|
||||||
"envUpdated": "Environment variables updated, restart service to take effect",
|
|
||||||
"saveError": "Error saving environment variables",
|
|
||||||
"unknownError": "An unknown error occurred",
|
|
||||||
"validation": {
|
|
||||||
"invalidKeys": "Invalid variable names: {keys}"
|
|
||||||
},
|
|
||||||
"helpTitle": "Format Help",
|
|
||||||
"helpComment": "Comment lines start with #",
|
|
||||||
"helpFormat": "Variable format: KEY=value",
|
|
||||||
"helpQuotes": "Values with spaces should use quotes",
|
|
||||||
"helpRestart": "Restart service after changes to take effect"
|
|
||||||
},
|
|
||||||
"downloadedModels": "Downloaded Models",
|
|
||||||
"noConfiguredModels": "No models configured",
|
|
||||||
"noLocalModels": "No models downloaded",
|
|
||||||
"models": {
|
|
||||||
"title": "Model Management",
|
|
||||||
"searchPlaceholder": "Search models...",
|
|
||||||
"downloading": "Downloading...",
|
|
||||||
"status": {
|
|
||||||
"downloaded": "Downloaded",
|
|
||||||
"available": "Available"
|
|
||||||
},
|
|
||||||
"types": {
|
|
||||||
"embedding": "Embedding Models",
|
|
||||||
"reranker": "Reranker Models"
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"label": "Filter",
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"download": "Download",
|
|
||||||
"delete": "Delete",
|
|
||||||
"cancel": "Cancel"
|
|
||||||
},
|
|
||||||
"custom": {
|
|
||||||
"title": "Custom Model",
|
|
||||||
"placeholder": "HuggingFace model name (e.g., BAAI/bge-small-zh-v1.5)",
|
|
||||||
"description": "Download custom models from HuggingFace. Ensure the model name is correct."
|
|
||||||
},
|
|
||||||
"deleteConfirm": "Are you sure you want to delete model {modelName}?",
|
|
||||||
"notInstalled": {
|
|
||||||
"title": "CodexLens Not Installed",
|
|
||||||
"description": "Please install CodexLens to use model management features."
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"title": "Failed to load models",
|
|
||||||
"description": "Unable to fetch model list. Please check if CodexLens is properly installed."
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"title": "No models found",
|
|
||||||
"description": "No models are available. Try downloading models from the list.",
|
|
||||||
"filtered": "No models match your filter",
|
|
||||||
"filteredDesc": "Try adjusting your search or filter criteria"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"search": {
|
|
||||||
"type": "Search Type",
|
|
||||||
"content": "Content Search",
|
|
||||||
"files": "File Search",
|
|
||||||
"symbol": "Symbol Search",
|
|
||||||
"semantic": "Semantic Search (LSP)",
|
|
||||||
"mode": "Mode",
|
|
||||||
"mode.semantic": "Semantic (default)",
|
|
||||||
"mode.exact": "Exact (FTS)",
|
|
||||||
"mode.fuzzy": "Fuzzy",
|
|
||||||
"semanticMode": "Search Mode",
|
|
||||||
"semanticMode.fusion": "Fusion Search",
|
|
||||||
"semanticMode.vector": "Vector Search",
|
|
||||||
"semanticMode.structural": "Structural Search",
|
|
||||||
"fusionStrategy": "Fusion Strategy",
|
|
||||||
"fusionStrategy.rrf": "RRF (default)",
|
|
||||||
"fusionStrategy.dense_rerank": "Dense Rerank",
|
|
||||||
"fusionStrategy.binary": "Binary",
|
|
||||||
"fusionStrategy.hybrid": "Hybrid",
|
|
||||||
"fusionStrategy.staged": "Staged",
|
|
||||||
"stagedStage2Mode": "Staged Stage 2",
|
|
||||||
"stagedStage2Mode.precomputed": "Precomputed (graph_neighbors)",
|
|
||||||
"stagedStage2Mode.realtime": "Realtime (LSP)",
|
|
||||||
"stagedStage2Mode.static_global_graph": "Static Global Graph",
|
|
||||||
"lspStatus": "LSP Status",
|
|
||||||
"lspAvailable": "Semantic search available",
|
|
||||||
"lspUnavailable": "Semantic search unavailable",
|
|
||||||
"lspNoVector": "Vector index required",
|
|
||||||
"lspNoSemantic": "Semantic dependencies required",
|
|
||||||
"query": "Query",
|
|
||||||
"queryPlaceholder": "Enter search query...",
|
|
||||||
"button": "Search",
|
"button": "Search",
|
||||||
"searching": "Searching...",
|
"results": "results",
|
||||||
"results": "Results",
|
"noResults": "No results found"
|
||||||
"resultsCount": "results",
|
|
||||||
"notInstalled": {
|
|
||||||
"title": "CodexLens Not Installed",
|
|
||||||
"description": "Please install CodexLens to use semantic code search features."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"reranker": {
|
|
||||||
"title": "Reranker Configuration",
|
|
||||||
"description": "Configure the reranker backend, model, and provider for search result ranking.",
|
|
||||||
"backend": "Backend",
|
|
||||||
"backendHint": "Inference backend for reranking",
|
|
||||||
"model": "Model",
|
|
||||||
"modelHint": "Reranker model name or LiteLLM endpoint",
|
|
||||||
"provider": "API Provider",
|
|
||||||
"providerHint": "API provider for reranker service",
|
|
||||||
"apiKeyStatus": "API Key",
|
|
||||||
"apiKeySet": "Configured",
|
|
||||||
"apiKeyNotSet": "Not configured",
|
|
||||||
"configSource": "Config Source",
|
|
||||||
"save": "Save Reranker Config",
|
|
||||||
"saving": "Saving...",
|
|
||||||
"saveSuccess": "Reranker configuration saved",
|
|
||||||
"saveFailed": "Failed to save reranker configuration",
|
|
||||||
"noBackends": "No backends available",
|
|
||||||
"noModels": "No models available",
|
|
||||||
"noProviders": "No providers available",
|
|
||||||
"litellmModels": "LiteLLM Models",
|
|
||||||
"selectBackend": "Select backend...",
|
|
||||||
"selectModel": "Select model...",
|
|
||||||
"selectProvider": "Select provider..."
|
|
||||||
},
|
|
||||||
"envGroup": {
|
|
||||||
"embedding": "Embedding",
|
|
||||||
"reranker": "Reranker",
|
|
||||||
"search": "Search Pipeline",
|
|
||||||
"indexing": "Indexing"
|
|
||||||
},
|
|
||||||
"envField": {
|
|
||||||
"backend": "Backend",
|
|
||||||
"model": "Model",
|
|
||||||
"localModel": "Local Model",
|
|
||||||
"apiUrl": "API URL",
|
|
||||||
"apiKey": "API Key",
|
|
||||||
"multiEndpoints": "Multi-Endpoint",
|
|
||||||
"embedDim": "Embed Dimension",
|
|
||||||
"apiConcurrency": "Concurrency",
|
|
||||||
"maxTokensPerBatch": "Max Tokens/Batch",
|
|
||||||
"useGpu": "Device",
|
|
||||||
"topKResults": "Top K Results",
|
|
||||||
"batchSize": "Batch Size",
|
|
||||||
"binaryTopK": "Binary Top K",
|
|
||||||
"annTopK": "ANN Top K",
|
|
||||||
"ftsTopK": "FTS Top K",
|
|
||||||
"fusionK": "Fusion K",
|
|
||||||
"codeAwareChunking": "Code-Aware Chunking",
|
|
||||||
"indexWorkers": "Index Workers",
|
|
||||||
"maxFileSize": "Max File Size (bytes)",
|
|
||||||
"hnswEf": "HNSW ef",
|
|
||||||
"hnswM": "HNSW M"
|
|
||||||
},
|
|
||||||
"install": {
|
|
||||||
"title": "Install CodexLens",
|
|
||||||
"description": "Set up Python virtual environment and install CodexLens package.",
|
|
||||||
"checklist": "What will be installed",
|
|
||||||
"pythonVenv": "Python Virtual Environment",
|
|
||||||
"pythonVenvDesc": "Isolated Python environment for CodexLens",
|
|
||||||
"codexlensPackage": "CodexLens Package",
|
|
||||||
"codexlensPackageDesc": "Core semantic code search engine",
|
|
||||||
"sqliteFts": "SQLite FTS5",
|
|
||||||
"sqliteFtsDesc": "Full-text search extension for fast code lookup",
|
|
||||||
"location": "Install Location",
|
|
||||||
"locationPath": "~/.codexlens/venv",
|
|
||||||
"timeEstimate": "Installation may take 1-3 minutes depending on network speed.",
|
|
||||||
"stage": {
|
|
||||||
"creatingVenv": "Creating Python virtual environment...",
|
|
||||||
"installingPip": "Installing pip dependencies...",
|
|
||||||
"installingPackage": "Installing CodexLens package...",
|
|
||||||
"settingUpDeps": "Setting up dependencies...",
|
|
||||||
"finalizing": "Finalizing installation...",
|
|
||||||
"complete": "Installation complete!"
|
|
||||||
},
|
|
||||||
"installNow": "Install Now",
|
|
||||||
"installing": "Installing..."
|
|
||||||
},
|
|
||||||
"mcp": {
|
|
||||||
"title": "CCW Tools Registry",
|
|
||||||
"loading": "Loading tools...",
|
|
||||||
"error": "Failed to load tools",
|
|
||||||
"errorDesc": "Unable to fetch CCW tools list. Please check if the server is running.",
|
|
||||||
"emptyDesc": "No tools are currently registered.",
|
|
||||||
"totalCount": "{count} tools",
|
|
||||||
"codexLensSection": "CodexLens Tools",
|
|
||||||
"otherSection": "Other Tools"
|
|
||||||
},
|
|
||||||
"watcher": {
|
|
||||||
"title": "File Watcher",
|
|
||||||
"status": {
|
|
||||||
"running": "Running",
|
|
||||||
"stopped": "Stopped"
|
|
||||||
},
|
|
||||||
"eventsProcessed": "Events Processed",
|
|
||||||
"uptime": "Uptime",
|
|
||||||
"start": "Start Watcher",
|
|
||||||
"starting": "Starting...",
|
|
||||||
"stop": "Stop Watcher",
|
|
||||||
"stopping": "Stopping...",
|
|
||||||
"started": "File watcher started",
|
|
||||||
"stopped": "File watcher stopped"
|
|
||||||
},
|
|
||||||
"lsp": {
|
|
||||||
"title": "LSP Server",
|
|
||||||
"status": {
|
|
||||||
"running": "Running",
|
|
||||||
"stopped": "Stopped"
|
|
||||||
},
|
|
||||||
"projects": "Projects",
|
|
||||||
"embeddings": "Embeddings",
|
|
||||||
"modes": "Modes",
|
|
||||||
"semanticAvailable": "Semantic",
|
|
||||||
"available": "Available",
|
|
||||||
"unavailable": "Unavailable",
|
|
||||||
"start": "Start Server",
|
|
||||||
"starting": "Starting...",
|
|
||||||
"stop": "Stop Server",
|
|
||||||
"stopping": "Stopping...",
|
|
||||||
"restart": "Restart",
|
|
||||||
"restarting": "Restarting...",
|
|
||||||
"started": "LSP server started",
|
|
||||||
"stopped": "LSP server stopped",
|
|
||||||
"restarted": "LSP server restarted"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"prompts": "Prompt History",
|
"prompts": "Prompt History",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"mcp": "MCP Servers",
|
"mcp": "MCP Servers",
|
||||||
"codexlens": "CodexLens",
|
"codexlens": "Search Manager",
|
||||||
"apiSettings": "API Settings",
|
"apiSettings": "API Settings",
|
||||||
"endpoints": "CLI Endpoints",
|
"endpoints": "CLI Endpoints",
|
||||||
"installations": "Installations",
|
"installations": "Installations",
|
||||||
|
|||||||
@@ -1,390 +1,28 @@
|
|||||||
{
|
{
|
||||||
"title": "CodexLens",
|
"title": "搜索管理",
|
||||||
"description": "语义代码搜索引擎",
|
"description": "V2 语义搜索索引管理",
|
||||||
"bootstrap": "引导安装",
|
"reindex": "重建索引",
|
||||||
"bootstrapping": "安装中...",
|
"reindexing": "重建中...",
|
||||||
"uninstall": "卸载",
|
"statusError": "加载搜索索引状态失败",
|
||||||
"uninstalling": "卸载中...",
|
"indexStatus": {
|
||||||
"confirmUninstall": "确定要卸载 CodexLens 吗?此操作无法撤销。",
|
"title": "索引状态",
|
||||||
"confirmUninstallTitle": "确认卸载",
|
"status": "状态",
|
||||||
"notInstalled": "CodexLens 尚未安装",
|
"ready": "就绪",
|
||||||
"comingSoon": "即将推出",
|
"notIndexed": "未索引",
|
||||||
"tabs": {
|
"files": "文件数",
|
||||||
"overview": "概览",
|
"dbSize": "数据库大小",
|
||||||
"settings": "设置",
|
"lastIndexed": "上次索引",
|
||||||
"models": "模型",
|
"chunks": "分块数",
|
||||||
"search": "搜索",
|
"vectorDim": "向量维度",
|
||||||
"advanced": "高级"
|
|
||||||
},
|
|
||||||
"overview": {
|
|
||||||
"status": {
|
|
||||||
"installation": "安装状态",
|
|
||||||
"ready": "就绪",
|
|
||||||
"notReady": "未就绪",
|
|
||||||
"version": "版本",
|
|
||||||
"indexPath": "索引路径",
|
|
||||||
"indexCount": "索引数量"
|
|
||||||
},
|
|
||||||
"notInstalled": {
|
|
||||||
"title": "CodexLens 未安装",
|
|
||||||
"message": "请先安装 CodexLens 以使用语义代码搜索功能。"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"title": "快速操作",
|
|
||||||
"ftsFull": "FTS 全量",
|
|
||||||
"ftsFullDesc": "重建全文索引",
|
|
||||||
"ftsIncremental": "FTS 增量",
|
|
||||||
"ftsIncrementalDesc": "增量更新全文索引",
|
|
||||||
"vectorFull": "向量全量",
|
|
||||||
"vectorFullDesc": "重建向量索引",
|
|
||||||
"vectorIncremental": "向量增量",
|
|
||||||
"vectorIncrementalDesc": "增量更新向量索引"
|
|
||||||
},
|
|
||||||
"venv": {
|
|
||||||
"title": "Python 虚拟环境详情",
|
|
||||||
"pythonVersion": "Python 版本",
|
|
||||||
"venvPath": "虚拟环境路径",
|
|
||||||
"lastCheck": "最后检查时间"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"index": {
|
|
||||||
"operationComplete": "索引操作完成",
|
|
||||||
"operationFailed": "索引操作失败",
|
|
||||||
"noProject": "未选择项目",
|
|
||||||
"noProjectDesc": "请打开一个项目以执行索引操作。",
|
|
||||||
"starting": "正在启动索引操作...",
|
|
||||||
"cancelFailed": "取消操作失败",
|
|
||||||
"unknownError": "发生未知错误",
|
|
||||||
"complete": "完成",
|
|
||||||
"failed": "失败",
|
|
||||||
"cancelled": "已取消",
|
|
||||||
"inProgress": "进行中"
|
|
||||||
},
|
|
||||||
"semantic": {
|
|
||||||
"installTitle": "安装语义搜索",
|
|
||||||
"installDescription": "安装 FastEmbed 和语义搜索依赖,支持 GPU 加速。",
|
|
||||||
"installInfo": "GPU 加速需要兼容的硬件。CPU 模式在所有系统上都可用,但速度较慢。",
|
|
||||||
"gpu": {
|
|
||||||
"cpu": "CPU 模式",
|
|
||||||
"cpuDesc": "通用兼容,处理较慢。适用于所有系统。",
|
|
||||||
"directml": "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 状态",
|
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"available": "可用",
|
"disabled": "已禁用",
|
||||||
"unavailable": "不可用",
|
"unavailable": "索引状态不可用"
|
||||||
"supported": "您的系统支持 GPU 加速",
|
|
||||||
"notSupported": "您的系统不支持 GPU 加速",
|
|
||||||
"detect": "检测",
|
|
||||||
"detectSuccess": "GPU 检测完成",
|
|
||||||
"detectFailed": "GPU 检测失败",
|
|
||||||
"detectComplete": "检测到 {count} 个 GPU 设备",
|
|
||||||
"detectError": "检测 GPU 时出错",
|
|
||||||
"select": "选择",
|
|
||||||
"selected": "已选择",
|
|
||||||
"active": "当前",
|
|
||||||
"selectSuccess": "GPU 已选择",
|
|
||||||
"selectFailed": "GPU 选择失败",
|
|
||||||
"gpuSelected": "GPU 设备已启用",
|
|
||||||
"selectError": "选择 GPU 时出错",
|
|
||||||
"reset": "重置",
|
|
||||||
"resetSuccess": "GPU 已重置",
|
|
||||||
"resetFailed": "GPU 重置失败",
|
|
||||||
"gpuReset": "GPU 已禁用,将使用 CPU",
|
|
||||||
"resetError": "重置 GPU 时出错",
|
|
||||||
"unknownError": "发生未知错误",
|
|
||||||
"noDevices": "未检测到 GPU 设备",
|
|
||||||
"notAvailable": "GPU 功能不可用",
|
|
||||||
"unknownDevice": "未知设备",
|
|
||||||
"type": "类型",
|
|
||||||
"discrete": "独立显卡",
|
|
||||||
"integrated": "集成显卡",
|
|
||||||
"driver": "驱动版本",
|
|
||||||
"memory": "显存"
|
|
||||||
},
|
},
|
||||||
"advanced": {
|
"searchTest": {
|
||||||
"warningTitle": "敏感操作警告",
|
"title": "搜索测试",
|
||||||
"warningMessage": "修改环境变量可能影响 CodexLens 的正常运行。请确保您了解每个变量的作用。",
|
"placeholder": "输入搜索查询...",
|
||||||
"loadError": "加载环境变量失败",
|
|
||||||
"loadErrorDesc": "无法获取环境配置。请检查 CodexLens 是否正确安装。",
|
|
||||||
"currentVars": "当前环境变量",
|
|
||||||
"settingsVars": "设置变量",
|
|
||||||
"customVars": "自定义变量",
|
|
||||||
"envEditor": "环境变量编辑器",
|
|
||||||
"envFile": "文件",
|
|
||||||
"envContent": "环境变量内容",
|
|
||||||
"envPlaceholder": "# 注释行以 # 开头\nKEY=value\nANOTHER_KEY=\"another value\"",
|
|
||||||
"envHint": "每行一个变量,格式:KEY=value。注释行以 # 开头",
|
|
||||||
"save": "保存",
|
|
||||||
"saving": "保存中...",
|
|
||||||
"reset": "重置",
|
|
||||||
"saveSuccess": "环境变量已保存",
|
|
||||||
"saveFailed": "保存失败",
|
|
||||||
"envUpdated": "环境变量更新成功,重启服务后生效",
|
|
||||||
"saveError": "保存环境变量时出错",
|
|
||||||
"unknownError": "发生未知错误",
|
|
||||||
"validation": {
|
|
||||||
"invalidKeys": "无效的变量名: {keys}"
|
|
||||||
},
|
|
||||||
"helpTitle": "格式说明",
|
|
||||||
"helpComment": "注释行以 # 开头",
|
|
||||||
"helpFormat": "变量格式:KEY=value",
|
|
||||||
"helpQuotes": "包含空格的值建议使用引号",
|
|
||||||
"helpRestart": "修改后需要重启服务才能生效"
|
|
||||||
},
|
|
||||||
"downloadedModels": "已下载模型",
|
|
||||||
"noConfiguredModels": "无已配置模型",
|
|
||||||
"noLocalModels": "无已下载模型",
|
|
||||||
"models": {
|
|
||||||
"title": "模型管理",
|
|
||||||
"searchPlaceholder": "搜索模型...",
|
|
||||||
"downloading": "下载中...",
|
|
||||||
"status": {
|
|
||||||
"downloaded": "已下载",
|
|
||||||
"available": "可用"
|
|
||||||
},
|
|
||||||
"types": {
|
|
||||||
"embedding": "嵌入模型",
|
|
||||||
"reranker": "重排序模型"
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"label": "筛选",
|
|
||||||
"all": "全部"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"download": "下载",
|
|
||||||
"delete": "删除",
|
|
||||||
"cancel": "取消"
|
|
||||||
},
|
|
||||||
"custom": {
|
|
||||||
"title": "自定义模型",
|
|
||||||
"placeholder": "HuggingFace 模型名称 (如: BAAI/bge-small-zh-v1.5)",
|
|
||||||
"description": "从 HuggingFace 下载自定义模型。请确保模型名称正确。"
|
|
||||||
},
|
|
||||||
"deleteConfirm": "确定要删除模型 {modelName} 吗?",
|
|
||||||
"notInstalled": {
|
|
||||||
"title": "CodexLens 未安装",
|
|
||||||
"description": "请先安装 CodexLens 以使用模型管理功能。"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"title": "加载模型失败",
|
|
||||||
"description": "无法获取模型列表。请检查 CodexLens 是否正确安装。"
|
|
||||||
},
|
|
||||||
"empty": {
|
|
||||||
"title": "没有找到模型",
|
|
||||||
"description": "当前没有可用模型。请从列表中下载模型。",
|
|
||||||
"filtered": "没有匹配的模型",
|
|
||||||
"filteredDesc": "尝试调整搜索或筛选条件"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"search": {
|
|
||||||
"type": "搜索类型",
|
|
||||||
"content": "内容搜索",
|
|
||||||
"files": "文件搜索",
|
|
||||||
"symbol": "符号搜索",
|
|
||||||
"semantic": "语义搜索 (LSP)",
|
|
||||||
"mode": "模式",
|
|
||||||
"mode.semantic": "语义(默认)",
|
|
||||||
"mode.exact": "精确(FTS)",
|
|
||||||
"mode.fuzzy": "模糊",
|
|
||||||
"semanticMode": "搜索模式",
|
|
||||||
"semanticMode.fusion": "融合搜索",
|
|
||||||
"semanticMode.vector": "向量搜索",
|
|
||||||
"semanticMode.structural": "结构搜索",
|
|
||||||
"fusionStrategy": "融合策略",
|
|
||||||
"fusionStrategy.rrf": "RRF(默认)",
|
|
||||||
"fusionStrategy.dense_rerank": "Dense Rerank",
|
|
||||||
"fusionStrategy.binary": "Binary",
|
|
||||||
"fusionStrategy.hybrid": "Hybrid",
|
|
||||||
"fusionStrategy.staged": "Staged",
|
|
||||||
"stagedStage2Mode": "Stage 2 扩展",
|
|
||||||
"stagedStage2Mode.precomputed": "预计算 (graph_neighbors)",
|
|
||||||
"stagedStage2Mode.realtime": "实时 (LSP)",
|
|
||||||
"stagedStage2Mode.static_global_graph": "静态全局图",
|
|
||||||
"lspStatus": "LSP 状态",
|
|
||||||
"lspAvailable": "语义搜索可用",
|
|
||||||
"lspUnavailable": "语义搜索不可用",
|
|
||||||
"lspNoVector": "需要先建立向量索引",
|
|
||||||
"lspNoSemantic": "需要先安装语义依赖",
|
|
||||||
"query": "查询",
|
|
||||||
"queryPlaceholder": "输入搜索查询...",
|
|
||||||
"button": "搜索",
|
"button": "搜索",
|
||||||
"searching": "搜索中...",
|
"results": "个结果",
|
||||||
"results": "结果",
|
"noResults": "未找到结果"
|
||||||
"resultsCount": "个结果",
|
|
||||||
"notInstalled": {
|
|
||||||
"title": "CodexLens 未安装",
|
|
||||||
"description": "请先安装 CodexLens 以使用语义代码搜索功能。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"reranker": {
|
|
||||||
"title": "重排序配置",
|
|
||||||
"description": "配置重排序后端、模型和提供商,用于搜索结果排序。",
|
|
||||||
"backend": "后端",
|
|
||||||
"backendHint": "重排序推理后端",
|
|
||||||
"model": "模型",
|
|
||||||
"modelHint": "重排序模型名称或 LiteLLM 端点",
|
|
||||||
"provider": "API 提供商",
|
|
||||||
"providerHint": "重排序服务的 API 提供商",
|
|
||||||
"apiKeyStatus": "API 密钥",
|
|
||||||
"apiKeySet": "已配置",
|
|
||||||
"apiKeyNotSet": "未配置",
|
|
||||||
"configSource": "配置来源",
|
|
||||||
"save": "保存重排序配置",
|
|
||||||
"saving": "保存中...",
|
|
||||||
"saveSuccess": "重排序配置已保存",
|
|
||||||
"saveFailed": "保存重排序配置失败",
|
|
||||||
"noBackends": "无可用后端",
|
|
||||||
"noModels": "无可用模型",
|
|
||||||
"noProviders": "无可用提供商",
|
|
||||||
"litellmModels": "LiteLLM 模型",
|
|
||||||
"selectBackend": "选择后端...",
|
|
||||||
"selectModel": "选择模型...",
|
|
||||||
"selectProvider": "选择提供商..."
|
|
||||||
},
|
|
||||||
"envGroup": {
|
|
||||||
"embedding": "嵌入模型",
|
|
||||||
"reranker": "重排序",
|
|
||||||
"search": "搜索流水线",
|
|
||||||
"indexing": "索引"
|
|
||||||
},
|
|
||||||
"envField": {
|
|
||||||
"backend": "后端",
|
|
||||||
"model": "模型",
|
|
||||||
"localModel": "本地模型",
|
|
||||||
"apiUrl": "API 地址",
|
|
||||||
"apiKey": "API 密钥",
|
|
||||||
"multiEndpoints": "多端点",
|
|
||||||
"embedDim": "向量维度",
|
|
||||||
"apiConcurrency": "并发数",
|
|
||||||
"maxTokensPerBatch": "每批最大Token数",
|
|
||||||
"useGpu": "设备",
|
|
||||||
"topKResults": "Top K 结果数",
|
|
||||||
"batchSize": "批次大小",
|
|
||||||
"binaryTopK": "二值粗筛 K",
|
|
||||||
"annTopK": "ANN 精筛 K",
|
|
||||||
"ftsTopK": "全文搜索 K",
|
|
||||||
"fusionK": "融合 K",
|
|
||||||
"codeAwareChunking": "代码感知分块",
|
|
||||||
"indexWorkers": "索引线程数",
|
|
||||||
"maxFileSize": "最大文件大小(字节)",
|
|
||||||
"hnswEf": "HNSW ef",
|
|
||||||
"hnswM": "HNSW M"
|
|
||||||
},
|
|
||||||
"install": {
|
|
||||||
"title": "安装 CodexLens",
|
|
||||||
"description": "设置 Python 虚拟环境并安装 CodexLens 包。",
|
|
||||||
"checklist": "将要安装的内容",
|
|
||||||
"pythonVenv": "Python 虚拟环境",
|
|
||||||
"pythonVenvDesc": "CodexLens 的隔离 Python 环境",
|
|
||||||
"codexlensPackage": "CodexLens 包",
|
|
||||||
"codexlensPackageDesc": "核心语义代码搜索引擎",
|
|
||||||
"sqliteFts": "SQLite FTS5",
|
|
||||||
"sqliteFtsDesc": "用于快速代码查找的全文搜索扩展",
|
|
||||||
"location": "安装位置",
|
|
||||||
"locationPath": "~/.codexlens/venv",
|
|
||||||
"timeEstimate": "安装可能需要 1-3 分钟,取决于网络速度。",
|
|
||||||
"stage": {
|
|
||||||
"creatingVenv": "正在创建 Python 虚拟环境...",
|
|
||||||
"installingPip": "正在安装 pip 依赖...",
|
|
||||||
"installingPackage": "正在安装 CodexLens 包...",
|
|
||||||
"settingUpDeps": "正在设置依赖项...",
|
|
||||||
"finalizing": "正在完成安装...",
|
|
||||||
"complete": "安装完成!"
|
|
||||||
},
|
|
||||||
"installNow": "立即安装",
|
|
||||||
"installing": "安装中..."
|
|
||||||
},
|
|
||||||
"mcp": {
|
|
||||||
"title": "CCW 工具注册表",
|
|
||||||
"loading": "加载工具中...",
|
|
||||||
"error": "加载工具失败",
|
|
||||||
"errorDesc": "无法获取 CCW 工具列表。请检查服务器是否正在运行。",
|
|
||||||
"emptyDesc": "当前没有已注册的工具。",
|
|
||||||
"totalCount": "{count} 个工具",
|
|
||||||
"codexLensSection": "CodexLens 工具",
|
|
||||||
"otherSection": "其他工具"
|
|
||||||
},
|
|
||||||
"watcher": {
|
|
||||||
"title": "文件监听器",
|
|
||||||
"status": {
|
|
||||||
"running": "运行中",
|
|
||||||
"stopped": "已停止"
|
|
||||||
},
|
|
||||||
"eventsProcessed": "已处理事件",
|
|
||||||
"uptime": "运行时间",
|
|
||||||
"start": "启动监听",
|
|
||||||
"starting": "启动中...",
|
|
||||||
"stop": "停止监听",
|
|
||||||
"stopping": "停止中...",
|
|
||||||
"started": "文件监听器已启动",
|
|
||||||
"stopped": "文件监听器已停止"
|
|
||||||
},
|
|
||||||
"lsp": {
|
|
||||||
"title": "LSP 服务器",
|
|
||||||
"status": {
|
|
||||||
"running": "运行中",
|
|
||||||
"stopped": "已停止"
|
|
||||||
},
|
|
||||||
"projects": "项目数",
|
|
||||||
"embeddings": "嵌入模型",
|
|
||||||
"modes": "模式",
|
|
||||||
"semanticAvailable": "语义搜索",
|
|
||||||
"available": "可用",
|
|
||||||
"unavailable": "不可用",
|
|
||||||
"start": "启动服务",
|
|
||||||
"starting": "启动中...",
|
|
||||||
"stop": "停止服务",
|
|
||||||
"stopping": "停止中...",
|
|
||||||
"restart": "重启",
|
|
||||||
"restarting": "重启中...",
|
|
||||||
"started": "LSP 服务器已启动",
|
|
||||||
"stopped": "LSP 服务器已停止",
|
|
||||||
"restarted": "LSP 服务器已重启"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"prompts": "提示历史",
|
"prompts": "提示历史",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"mcp": "MCP 服务器",
|
"mcp": "MCP 服务器",
|
||||||
"codexlens": "CodexLens",
|
"codexlens": "搜索管理",
|
||||||
"apiSettings": "API 设置",
|
"apiSettings": "API 设置",
|
||||||
"endpoints": "CLI 端点",
|
"endpoints": "CLI 端点",
|
||||||
"installations": "安装",
|
"installations": "安装",
|
||||||
|
|||||||
@@ -1,361 +1,196 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// CodexLens Manager Page Tests
|
// CodexLens Manager Page Tests (v2)
|
||||||
// ========================================
|
// ========================================
|
||||||
// Integration tests for CodexLens manager page with tabs
|
// Tests for v2 search management page
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { render, screen, waitFor } from '@/test/i18n';
|
import { render, screen } from '@/test/i18n';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { CodexLensManagerPage } from './CodexLensManagerPage';
|
import { CodexLensManagerPage } from './CodexLensManagerPage';
|
||||||
|
|
||||||
// Mock api module
|
// Mock the v2 search manager hook
|
||||||
vi.mock('@/lib/api', () => ({
|
vi.mock('@/hooks/useV2SearchManager', () => ({
|
||||||
fetchCodexLensDashboardInit: vi.fn(),
|
useV2SearchManager: vi.fn(),
|
||||||
bootstrapCodexLens: vi.fn(),
|
|
||||||
uninstallCodexLens: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock hooks
|
import { useV2SearchManager } from '@/hooks/useV2SearchManager';
|
||||||
vi.mock('@/hooks/useCodexLens', () => ({
|
|
||||||
useCodexLensDashboard: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/hooks/useCodexLens', () => ({
|
const mockStatus = {
|
||||||
useCodexLensDashboard: vi.fn(),
|
indexed: true,
|
||||||
}));
|
totalFiles: 150,
|
||||||
|
totalChunks: 1200,
|
||||||
vi.mock('@/hooks/useNotifications', () => ({
|
lastIndexedAt: '2026-03-17T10:00:00Z',
|
||||||
useNotifications: vi.fn(() => ({
|
dbSizeBytes: 5242880,
|
||||||
success: vi.fn(),
|
vectorDimension: 384,
|
||||||
error: vi.fn(),
|
ftsEnabled: true,
|
||||||
toasts: [],
|
|
||||||
wsStatus: 'disconnected' as const,
|
|
||||||
wsLastMessage: null,
|
|
||||||
isWsConnected: false,
|
|
||||||
addToast: vi.fn(),
|
|
||||||
removeToast: vi.fn(),
|
|
||||||
clearAllToasts: vi.fn(),
|
|
||||||
connectWebSocket: vi.fn(),
|
|
||||||
disconnectWebSocket: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the mutations hook separately
|
|
||||||
vi.mock('@/hooks/useCodexLens', async () => {
|
|
||||||
return {
|
|
||||||
useCodexLensDashboard: (await import('@/hooks/useCodexLens')).useCodexLensDashboard,
|
|
||||||
useCodexLensMutations: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock window.confirm
|
|
||||||
global.confirm = vi.fn(() => true);
|
|
||||||
|
|
||||||
const mockDashboardData = {
|
|
||||||
installed: true,
|
|
||||||
status: {
|
|
||||||
ready: true,
|
|
||||||
installed: true,
|
|
||||||
version: '1.0.0',
|
|
||||||
pythonVersion: '3.11.0',
|
|
||||||
venvPath: '/path/to/venv',
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
index_dir: '~/.codexlens/indexes',
|
|
||||||
index_count: 100,
|
|
||||||
},
|
|
||||||
semantic: { available: true },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockMutations = {
|
const defaultHookReturn = {
|
||||||
bootstrap: vi.fn().mockResolvedValue({ success: true }),
|
status: mockStatus,
|
||||||
uninstall: vi.fn().mockResolvedValue({ success: true }),
|
isLoadingStatus: false,
|
||||||
isBootstrapping: false,
|
statusError: null,
|
||||||
isUninstalling: false,
|
refetchStatus: vi.fn(),
|
||||||
|
search: vi.fn().mockResolvedValue({
|
||||||
|
query: 'test',
|
||||||
|
results: [],
|
||||||
|
timingMs: 12.5,
|
||||||
|
totalResults: 0,
|
||||||
|
}),
|
||||||
|
isSearching: false,
|
||||||
|
searchResult: null,
|
||||||
|
reindex: vi.fn().mockResolvedValue(undefined),
|
||||||
|
isReindexing: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
|
describe('CodexLensManagerPage (v2)', () => {
|
||||||
|
|
||||||
describe('CodexLensManagerPage', () => {
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
(vi.mocked(useV2SearchManager) as any).mockReturnValue(defaultHookReturn);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when installed', () => {
|
it('should render page title', () => {
|
||||||
beforeEach(() => {
|
render(<CodexLensManagerPage />);
|
||||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
// The title comes from i18n codexlens.title
|
||||||
installed: true,
|
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||||
status: mockDashboardData.status,
|
|
||||||
config: mockDashboardData.config,
|
|
||||||
semantic: mockDashboardData.semantic,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render page title and description', () => {
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Semantic code search engine/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render all tabs', () => {
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Overview/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Settings/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Models/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Advanced/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show uninstall button when installed', () => {
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Uninstall/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should switch between tabs', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
const settingsTab = screen.getByText(/Settings/i);
|
|
||||||
await user.click(settingsTab);
|
|
||||||
|
|
||||||
expect(settingsTab).toHaveAttribute('data-state', 'active');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call refresh on button click', async () => {
|
|
||||||
const refetch = vi.fn();
|
|
||||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
|
||||||
installed: true,
|
|
||||||
status: mockDashboardData.status,
|
|
||||||
config: mockDashboardData.config,
|
|
||||||
semantic: mockDashboardData.semantic,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
refetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
const refreshButton = screen.getByText(/Refresh/i);
|
|
||||||
await user.click(refreshButton);
|
|
||||||
|
|
||||||
expect(refetch).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when not installed', () => {
|
it('should render index status section', () => {
|
||||||
beforeEach(() => {
|
render(<CodexLensManagerPage />);
|
||||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
// Check for file count display
|
||||||
installed: false,
|
expect(screen.getByText('150')).toBeInTheDocument();
|
||||||
status: undefined,
|
|
||||||
config: undefined,
|
|
||||||
semantic: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show bootstrap button', () => {
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Bootstrap/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show not installed alert', () => {
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/CodexLens is not installed/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call bootstrap on button click', async () => {
|
|
||||||
const bootstrap = vi.fn().mockResolvedValue({ success: true });
|
|
||||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
|
|
||||||
...mockMutations,
|
|
||||||
bootstrap,
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
const bootstrapButton = screen.getByText(/Bootstrap/i);
|
|
||||||
await user.click(bootstrapButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(bootstrap).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('uninstall flow', () => {
|
it('should render search input', () => {
|
||||||
beforeEach(() => {
|
render(<CodexLensManagerPage />);
|
||||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
const input = screen.getByPlaceholderText(/search query/i);
|
||||||
installed: true,
|
expect(input).toBeInTheDocument();
|
||||||
status: mockDashboardData.status,
|
|
||||||
config: mockDashboardData.config,
|
|
||||||
semantic: mockDashboardData.semantic,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show confirmation dialog on uninstall', async () => {
|
|
||||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
|
||||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
|
|
||||||
...mockMutations,
|
|
||||||
uninstall,
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
const uninstallButton = screen.getByText(/Uninstall/i);
|
|
||||||
await user.click(uninstallButton);
|
|
||||||
|
|
||||||
expect(global.confirm).toHaveBeenCalledWith(expect.stringContaining('uninstall'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call uninstall when confirmed', async () => {
|
|
||||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
|
||||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
|
|
||||||
...mockMutations,
|
|
||||||
uninstall,
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
const uninstallButton = screen.getByText(/Uninstall/i);
|
|
||||||
await user.click(uninstallButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(uninstall).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not call uninstall when cancelled', async () => {
|
|
||||||
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
|
||||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
|
||||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
|
|
||||||
...mockMutations,
|
|
||||||
uninstall,
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
const uninstallButton = screen.getByText(/Uninstall/i);
|
|
||||||
await user.click(uninstallButton);
|
|
||||||
|
|
||||||
expect(uninstall).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loading states', () => {
|
it('should call refetchStatus on refresh click', async () => {
|
||||||
it('should show loading skeleton when loading', () => {
|
const refetchStatus = vi.fn();
|
||||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||||
installed: false,
|
...defaultHookReturn,
|
||||||
status: undefined,
|
refetchStatus,
|
||||||
config: undefined,
|
|
||||||
semantic: undefined,
|
|
||||||
isLoading: true,
|
|
||||||
isFetching: true,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
|
|
||||||
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
// Check for skeleton or loading indicator
|
|
||||||
const refreshButton = screen.getByText(/Refresh/i);
|
|
||||||
expect(refreshButton).toBeDisabled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should disable refresh button when fetching', () => {
|
const user = userEvent.setup();
|
||||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
render(<CodexLensManagerPage />);
|
||||||
installed: true,
|
|
||||||
status: mockDashboardData.status,
|
|
||||||
config: mockDashboardData.config,
|
|
||||||
semantic: mockDashboardData.semantic,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: true,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
|
|
||||||
|
|
||||||
render(<CodexLensManagerPage />);
|
const refreshButton = screen.getByText(/Refresh/i);
|
||||||
|
await user.click(refreshButton);
|
||||||
|
|
||||||
const refreshButton = screen.getByText(/Refresh/i);
|
expect(refetchStatus).toHaveBeenCalledOnce();
|
||||||
expect(refreshButton).toBeDisabled();
|
});
|
||||||
|
|
||||||
|
it('should call search when clicking search button', async () => {
|
||||||
|
const searchFn = vi.fn().mockResolvedValue({
|
||||||
|
query: 'test query',
|
||||||
|
results: [],
|
||||||
|
timingMs: 5,
|
||||||
|
totalResults: 0,
|
||||||
});
|
});
|
||||||
|
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||||
|
...defaultHookReturn,
|
||||||
|
search: searchFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CodexLensManagerPage />);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/search query/i);
|
||||||
|
await user.type(input, 'test query');
|
||||||
|
|
||||||
|
const searchButton = screen.getByText(/Search/i);
|
||||||
|
await user.click(searchButton);
|
||||||
|
|
||||||
|
expect(searchFn).toHaveBeenCalledWith('test query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display search results', () => {
|
||||||
|
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||||
|
...defaultHookReturn,
|
||||||
|
searchResult: {
|
||||||
|
query: 'auth',
|
||||||
|
results: [
|
||||||
|
{ file: 'src/auth.ts', score: 0.95, snippet: 'export function authenticate()' },
|
||||||
|
],
|
||||||
|
timingMs: 8.2,
|
||||||
|
totalResults: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CodexLensManagerPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('src/auth.ts')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('95.0%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('export function authenticate()')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call reindex on button click', async () => {
|
||||||
|
const reindexFn = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||||
|
...defaultHookReturn,
|
||||||
|
reindex: reindexFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CodexLensManagerPage />);
|
||||||
|
|
||||||
|
const reindexButton = screen.getByText(/Reindex/i);
|
||||||
|
await user.click(reindexButton);
|
||||||
|
|
||||||
|
expect(reindexFn).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show loading skeleton when status is loading', () => {
|
||||||
|
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||||
|
...defaultHookReturn,
|
||||||
|
status: null,
|
||||||
|
isLoadingStatus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CodexLensManagerPage />);
|
||||||
|
|
||||||
|
// Should have pulse animation elements
|
||||||
|
const pulseElements = document.querySelectorAll('.animate-pulse');
|
||||||
|
expect(pulseElements.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error alert when status fetch fails', () => {
|
||||||
|
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||||
|
...defaultHookReturn,
|
||||||
|
status: null,
|
||||||
|
statusError: new Error('Network error'),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CodexLensManagerPage />);
|
||||||
|
|
||||||
|
// Error message should be visible
|
||||||
|
expect(screen.getByText(/Failed to load/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show not indexed state', () => {
|
||||||
|
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
||||||
|
...defaultHookReturn,
|
||||||
|
status: {
|
||||||
|
...mockStatus,
|
||||||
|
indexed: false,
|
||||||
|
totalFiles: 0,
|
||||||
|
totalChunks: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CodexLensManagerPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Not Indexed/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('i18n - Chinese locale', () => {
|
describe('i18n - Chinese locale', () => {
|
||||||
beforeEach(() => {
|
|
||||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
|
||||||
installed: true,
|
|
||||||
status: mockDashboardData.status,
|
|
||||||
config: mockDashboardData.config,
|
|
||||||
semantic: mockDashboardData.semantic,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: null,
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display translated text in Chinese', () => {
|
it('should display translated text in Chinese', () => {
|
||||||
render(<CodexLensManagerPage />, { locale: 'zh' });
|
render(<CodexLensManagerPage />, { locale: 'zh' });
|
||||||
|
|
||||||
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
|
// Page title from zh codexlens.json
|
||||||
expect(screen.getByText(/语义代码搜索引擎/i)).toBeInTheDocument();
|
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/概览/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/设置/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/模型/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/高级/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display translated uninstall button', () => {
|
|
||||||
render(<CodexLensManagerPage />, { locale: 'zh' });
|
|
||||||
|
|
||||||
expect(screen.getByText(/卸载/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('error states', () => {
|
|
||||||
it('should handle API errors gracefully', () => {
|
|
||||||
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
|
|
||||||
installed: false,
|
|
||||||
status: undefined,
|
|
||||||
config: undefined,
|
|
||||||
semantic: undefined,
|
|
||||||
isLoading: false,
|
|
||||||
isFetching: false,
|
|
||||||
error: new Error('API Error'),
|
|
||||||
refetch: vi.fn(),
|
|
||||||
});
|
|
||||||
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
|
|
||||||
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
// Page should still render even with error
|
|
||||||
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,85 +1,67 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// CodexLens Manager Page
|
// CodexLens Manager Page (v2)
|
||||||
// ========================================
|
// ========================================
|
||||||
// Manage CodexLens semantic code search with tabbed interface
|
// V2 search management interface with index status, search test, and configuration
|
||||||
// Supports Overview, Settings, Models, and Advanced tabs
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import {
|
import {
|
||||||
Sparkles,
|
Search,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Download,
|
Database,
|
||||||
Trash2,
|
|
||||||
Zap,
|
Zap,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
HardDrive,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { TabsNavigation } from '@/components/ui/TabsNavigation';
|
import { useV2SearchManager } from '@/hooks';
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
} from '@/components/ui/AlertDialog';
|
|
||||||
import { OverviewTab } from '@/components/codexlens/OverviewTab';
|
|
||||||
import { SettingsTab } from '@/components/codexlens/SettingsTab';
|
|
||||||
import { AdvancedTab } from '@/components/codexlens/AdvancedTab';
|
|
||||||
import { ModelsTab } from '@/components/codexlens/ModelsTab';
|
|
||||||
import { SearchTab } from '@/components/codexlens/SearchTab';
|
|
||||||
import { SemanticInstallDialog } from '@/components/codexlens/SemanticInstallDialog';
|
|
||||||
import { InstallProgressOverlay } from '@/components/codexlens/InstallProgressOverlay';
|
|
||||||
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function CodexLensManagerPage() {
|
export function CodexLensManagerPage() {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const [activeTab, setActiveTab] = useState('overview');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isUninstallDialogOpen, setIsUninstallDialogOpen] = useState(false);
|
|
||||||
const [isSemanticInstallOpen, setIsSemanticInstallOpen] = useState(false);
|
|
||||||
const [isInstallOverlayOpen, setIsInstallOverlayOpen] = useState(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
installed,
|
|
||||||
status,
|
status,
|
||||||
config,
|
isLoadingStatus,
|
||||||
semantic,
|
statusError,
|
||||||
isLoading,
|
refetchStatus,
|
||||||
isFetching,
|
search,
|
||||||
refetch,
|
isSearching,
|
||||||
} = useCodexLensDashboard();
|
searchResult,
|
||||||
|
reindex,
|
||||||
|
isReindexing,
|
||||||
|
} = useV2SearchManager();
|
||||||
|
|
||||||
const {
|
const handleSearch = async () => {
|
||||||
bootstrap,
|
if (!searchQuery.trim()) return;
|
||||||
isBootstrapping,
|
await search(searchQuery.trim());
|
||||||
uninstall,
|
|
||||||
isUninstalling,
|
|
||||||
} = useCodexLensMutations();
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
refetch();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBootstrap = () => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
setIsInstallOverlayOpen(true);
|
if (e.key === 'Enter') {
|
||||||
};
|
handleSearch();
|
||||||
|
|
||||||
const handleBootstrapInstall = async () => {
|
|
||||||
const result = await bootstrap();
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUninstall = async () => {
|
|
||||||
const result = await uninstall();
|
|
||||||
if (result.success) {
|
|
||||||
refetch();
|
|
||||||
}
|
}
|
||||||
setIsUninstallDialogOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,7 +70,7 @@ export function CodexLensManagerPage() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
<Sparkles className="w-6 h-6 text-primary" />
|
<Search className="w-6 h-6 text-primary" />
|
||||||
{formatMessage({ id: 'codexlens.title' })}
|
{formatMessage({ id: 'codexlens.title' })}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
@@ -98,150 +80,196 @@ export function CodexLensManagerPage() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleRefresh}
|
onClick={refetchStatus}
|
||||||
disabled={isFetching}
|
disabled={isLoadingStatus}
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
<RefreshCw className={cn('w-4 h-4 mr-2', isLoadingStatus && 'animate-spin')} />
|
||||||
{formatMessage({ id: 'common.actions.refresh' })}
|
{formatMessage({ id: 'common.actions.refresh' })}
|
||||||
</Button>
|
</Button>
|
||||||
{!installed ? (
|
<Button
|
||||||
<Button
|
onClick={() => reindex()}
|
||||||
onClick={handleBootstrap}
|
disabled={isReindexing}
|
||||||
disabled={isBootstrapping}
|
>
|
||||||
>
|
<Zap className={cn('w-4 h-4 mr-2', isReindexing && 'animate-spin')} />
|
||||||
<Download className={cn('w-4 h-4 mr-2', isBootstrapping && 'animate-spin')} />
|
{isReindexing
|
||||||
{isBootstrapping
|
? formatMessage({ id: 'codexlens.reindexing' })
|
||||||
? formatMessage({ id: 'codexlens.bootstrapping' })
|
: formatMessage({ id: 'codexlens.reindex' })
|
||||||
: formatMessage({ id: 'codexlens.bootstrap' })
|
}
|
||||||
}
|
</Button>
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsSemanticInstallOpen(true)}
|
|
||||||
disabled={!semantic?.available}
|
|
||||||
>
|
|
||||||
<Zap className="w-4 h-4 mr-2" />
|
|
||||||
{formatMessage({ id: 'codexlens.semantic.install' })}
|
|
||||||
</Button>
|
|
||||||
<AlertDialog open={isUninstallDialogOpen} onOpenChange={setIsUninstallDialogOpen}>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={isUninstalling}
|
|
||||||
>
|
|
||||||
<Trash2 className={cn('w-4 h-4 mr-2', isUninstalling && 'animate-spin')} />
|
|
||||||
{isUninstalling
|
|
||||||
? formatMessage({ id: 'codexlens.uninstalling' })
|
|
||||||
: formatMessage({ id: 'codexlens.uninstall' })
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{formatMessage({ id: 'codexlens.confirmUninstallTitle' })}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{formatMessage({ id: 'codexlens.confirmUninstall' })}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={isUninstalling}>
|
|
||||||
{formatMessage({ id: 'common.actions.cancel' })}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleUninstall}
|
|
||||||
disabled={isUninstalling}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{isUninstalling
|
|
||||||
? formatMessage({ id: 'codexlens.uninstalling' })
|
|
||||||
: formatMessage({ id: 'common.actions.confirm' })
|
|
||||||
}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Installation Status Alert */}
|
{/* Error Alert */}
|
||||||
{!installed && !isLoading && (
|
{statusError && (
|
||||||
<Card className="p-4 bg-warning/10 border-warning/20">
|
<Card className="p-4 bg-destructive/10 border-destructive/20">
|
||||||
<p className="text-sm text-warning-foreground">
|
<div className="flex items-center gap-2">
|
||||||
{formatMessage({ id: 'codexlens.notInstalled' })}
|
<AlertCircle className="w-4 h-4 text-destructive" />
|
||||||
</p>
|
<p className="text-sm text-destructive">
|
||||||
|
{formatMessage({ id: 'codexlens.statusError' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabbed Interface */}
|
{/* Index Status Section */}
|
||||||
<TabsNavigation
|
<Card className="p-6">
|
||||||
value={activeTab}
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
onValueChange={setActiveTab}
|
<Database className="w-5 h-5 text-primary" />
|
||||||
tabs={[
|
{formatMessage({ id: 'codexlens.indexStatus.title' })}
|
||||||
{ value: 'overview', label: formatMessage({ id: 'codexlens.tabs.overview' }) },
|
</h2>
|
||||||
{ value: 'settings', label: formatMessage({ id: 'codexlens.tabs.settings' }) },
|
|
||||||
{ value: 'models', label: formatMessage({ id: 'codexlens.tabs.models' }) },
|
|
||||||
{ value: 'search', label: formatMessage({ id: 'codexlens.tabs.search' }) },
|
|
||||||
{ value: 'advanced', label: formatMessage({ id: 'codexlens.tabs.advanced' }) },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
{isLoadingStatus ? (
|
||||||
{activeTab === 'overview' && (
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="mt-4">
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<OverviewTab
|
<div key={i} className="h-16 bg-muted/50 rounded-lg animate-pulse" />
|
||||||
installed={installed}
|
))}
|
||||||
status={status}
|
</div>
|
||||||
config={config}
|
) : status ? (
|
||||||
isLoading={isLoading}
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
onRefresh={handleRefresh}
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
|
||||||
|
{status.indexed ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'codexlens.indexStatus.status' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{status.indexed
|
||||||
|
? formatMessage({ id: 'codexlens.indexStatus.ready' })
|
||||||
|
: formatMessage({ id: 'codexlens.indexStatus.notIndexed' })
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
|
||||||
|
<FileText className="w-5 h-5 text-blue-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'codexlens.indexStatus.files' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium">{status.totalFiles.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
|
||||||
|
<HardDrive className="w-5 h-5 text-purple-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'codexlens.indexStatus.dbSize' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium">{formatBytes(status.dbSizeBytes)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
|
||||||
|
<Clock className="w-5 h-5 text-orange-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'codexlens.indexStatus.lastIndexed' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium">{formatDate(status.lastIndexedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'codexlens.indexStatus.unavailable' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<div className="mt-4 flex gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{formatMessage({ id: 'codexlens.indexStatus.chunks' })}: {status.totalChunks.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{status.vectorDimension && (
|
||||||
|
<span>
|
||||||
|
{formatMessage({ id: 'codexlens.indexStatus.vectorDim' })}: {status.vectorDimension}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
FTS: {status.ftsEnabled
|
||||||
|
? formatMessage({ id: 'codexlens.indexStatus.enabled' })
|
||||||
|
: formatMessage({ id: 'codexlens.indexStatus.disabled' })
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Search Test Section */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<Search className="w-5 h-5 text-primary" />
|
||||||
|
{formatMessage({ id: 'codexlens.searchTest.title' })}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={formatMessage({ id: 'codexlens.searchTest.placeholder' })}
|
||||||
|
className="flex-1 px-3 py-2 border border-input rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={isSearching || !searchQuery.trim()}
|
||||||
|
>
|
||||||
|
{isSearching ? (
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{formatMessage({ id: 'codexlens.searchTest.button' })}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'settings' && (
|
{searchResult && (
|
||||||
<div className="mt-4">
|
<div>
|
||||||
<SettingsTab enabled={installed} />
|
<div className="flex items-center justify-between mb-2">
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">
|
||||||
)}
|
{searchResult.totalResults} {formatMessage({ id: 'codexlens.searchTest.results' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{searchResult.timingMs.toFixed(1)}ms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{activeTab === 'models' && (
|
{searchResult.results.length > 0 ? (
|
||||||
<div className="mt-4">
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
<ModelsTab installed={installed} />
|
{searchResult.results.map((result, idx) => (
|
||||||
</div>
|
<div
|
||||||
)}
|
key={idx}
|
||||||
|
className="p-3 rounded-lg border border-border bg-muted/20 hover:bg-muted/40 transition-colors"
|
||||||
{activeTab === 'search' && (
|
>
|
||||||
<div className="mt-4">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<SearchTab enabled={installed} />
|
<span className="text-sm font-mono text-primary truncate">
|
||||||
</div>
|
{result.file}
|
||||||
)}
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2 shrink-0">
|
||||||
{activeTab === 'advanced' && (
|
{(result.score * 100).toFixed(1)}%
|
||||||
<div className="mt-4">
|
</span>
|
||||||
<AdvancedTab enabled={installed} />
|
</div>
|
||||||
</div>
|
<pre className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-3">
|
||||||
)}
|
{result.snippet}
|
||||||
|
</pre>
|
||||||
{/* Semantic Install Dialog */}
|
</div>
|
||||||
<SemanticInstallDialog
|
))}
|
||||||
open={isSemanticInstallOpen}
|
</div>
|
||||||
onOpenChange={setIsSemanticInstallOpen}
|
) : (
|
||||||
onSuccess={() => refetch()}
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
/>
|
{formatMessage({ id: 'codexlens.searchTest.noResults' })}
|
||||||
|
</p>
|
||||||
{/* Install Progress Overlay */}
|
)}
|
||||||
<InstallProgressOverlay
|
</div>
|
||||||
open={isInstallOverlayOpen}
|
)}
|
||||||
onOpenChange={setIsInstallOverlayOpen}
|
</Card>
|
||||||
onInstall={handleBootstrapInstall}
|
|
||||||
onSuccess={() => refetch()}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,173 +118,29 @@ const mockMessages: Record<Locale, Record<string, string>> = {
|
|||||||
'issues.discovery.status.failed': 'Failed',
|
'issues.discovery.status.failed': 'Failed',
|
||||||
'issues.discovery.progress': 'Progress',
|
'issues.discovery.progress': 'Progress',
|
||||||
'issues.discovery.findings': 'Findings',
|
'issues.discovery.findings': 'Findings',
|
||||||
// CodexLens
|
// CodexLens (v2)
|
||||||
'codexlens.title': 'CodexLens',
|
'codexlens.title': 'Search Manager',
|
||||||
'codexlens.description': 'Semantic code search engine',
|
'codexlens.description': 'V2 semantic search index management',
|
||||||
'codexlens.bootstrap': 'Bootstrap',
|
'codexlens.reindex': 'Reindex',
|
||||||
'codexlens.bootstrapping': 'Bootstrapping...',
|
'codexlens.reindexing': 'Reindexing...',
|
||||||
'codexlens.uninstall': 'Uninstall',
|
'codexlens.statusError': 'Failed to load search index status',
|
||||||
'codexlens.uninstalling': 'Uninstalling...',
|
'codexlens.indexStatus.title': 'Index Status',
|
||||||
'codexlens.confirmUninstall': 'Are you sure you want to uninstall CodexLens?',
|
'codexlens.indexStatus.status': 'Status',
|
||||||
'codexlens.notInstalled': 'CodexLens is not installed',
|
'codexlens.indexStatus.ready': 'Ready',
|
||||||
'codexlens.comingSoon': 'Coming Soon',
|
'codexlens.indexStatus.notIndexed': 'Not Indexed',
|
||||||
'codexlens.tabs.overview': 'Overview',
|
'codexlens.indexStatus.files': 'Files',
|
||||||
'codexlens.tabs.settings': 'Settings',
|
'codexlens.indexStatus.dbSize': 'DB Size',
|
||||||
'codexlens.tabs.models': 'Models',
|
'codexlens.indexStatus.lastIndexed': 'Last Indexed',
|
||||||
'codexlens.tabs.advanced': 'Advanced',
|
'codexlens.indexStatus.chunks': 'Chunks',
|
||||||
'codexlens.overview.status.installation': 'Installation Status',
|
'codexlens.indexStatus.vectorDim': 'Vector Dim',
|
||||||
'codexlens.overview.status.ready': 'Ready',
|
'codexlens.indexStatus.enabled': 'Enabled',
|
||||||
'codexlens.overview.status.notReady': 'Not Ready',
|
'codexlens.indexStatus.disabled': 'Disabled',
|
||||||
'codexlens.overview.status.version': 'Version',
|
'codexlens.indexStatus.unavailable': 'Index status unavailable',
|
||||||
'codexlens.overview.status.indexPath': 'Index Path',
|
'codexlens.searchTest.title': 'Search Test',
|
||||||
'codexlens.overview.status.indexCount': 'Index Count',
|
'codexlens.searchTest.placeholder': 'Enter search query...',
|
||||||
'codexlens.overview.notInstalled.title': 'CodexLens Not Installed',
|
'codexlens.searchTest.button': 'Search',
|
||||||
'codexlens.overview.notInstalled.message': 'Please install CodexLens to use semantic code search features.',
|
'codexlens.searchTest.results': 'results',
|
||||||
'codexlens.overview.actions.title': 'Quick Actions',
|
'codexlens.searchTest.noResults': 'No results found',
|
||||||
'codexlens.overview.actions.ftsFull': 'FTS Full',
|
|
||||||
'codexlens.overview.actions.ftsFullDesc': 'Rebuild full-text index',
|
|
||||||
'codexlens.overview.actions.ftsIncremental': 'FTS Incremental',
|
|
||||||
'codexlens.overview.actions.ftsIncrementalDesc': 'Incremental update full-text index',
|
|
||||||
'codexlens.overview.actions.vectorFull': 'Vector Full',
|
|
||||||
'codexlens.overview.actions.vectorFullDesc': 'Rebuild vector index',
|
|
||||||
'codexlens.overview.actions.vectorIncremental': 'Vector Incremental',
|
|
||||||
'codexlens.overview.actions.vectorIncrementalDesc': 'Incremental update vector index',
|
|
||||||
'codexlens.overview.venv.title': 'Python Virtual Environment Details',
|
|
||||||
'codexlens.overview.venv.pythonVersion': 'Python Version',
|
|
||||||
'codexlens.overview.venv.venvPath': 'Virtual Environment Path',
|
|
||||||
'codexlens.overview.venv.lastCheck': 'Last Check Time',
|
|
||||||
'codexlens.install.title': 'Install CodexLens',
|
|
||||||
// Env Groups & Fields
|
|
||||||
'codexlens.envGroup.embedding': 'Embedding',
|
|
||||||
'codexlens.envGroup.reranker': 'Reranker',
|
|
||||||
'codexlens.envGroup.concurrency': 'Concurrency',
|
|
||||||
'codexlens.envGroup.cascade': 'Cascade Search',
|
|
||||||
'codexlens.envGroup.indexing': 'Indexing',
|
|
||||||
'codexlens.envGroup.chunking': 'Chunking',
|
|
||||||
'codexlens.envField.backend': 'Backend',
|
|
||||||
'codexlens.envField.model': 'Model',
|
|
||||||
'codexlens.envField.useGpu': 'Use GPU',
|
|
||||||
'codexlens.envField.highAvailability': 'High Availability',
|
|
||||||
'codexlens.envField.loadBalanceStrategy': 'Load Balance Strategy',
|
|
||||||
'codexlens.envField.rateLimitCooldown': 'Rate Limit Cooldown',
|
|
||||||
'codexlens.envField.enabled': 'Enabled',
|
|
||||||
'codexlens.envField.topKResults': 'Top K Results',
|
|
||||||
'codexlens.envField.maxWorkers': 'Max Workers',
|
|
||||||
'codexlens.envField.batchSize': 'Batch Size',
|
|
||||||
'codexlens.envField.dynamicBatchSize': 'Dynamic Batch Size',
|
|
||||||
'codexlens.envField.batchSizeUtilization': 'Utilization Factor',
|
|
||||||
'codexlens.envField.batchSizeMax': 'Max Batch Size',
|
|
||||||
'codexlens.envField.charsPerToken': 'Chars Per Token',
|
|
||||||
'codexlens.envField.searchStrategy': 'Search Strategy',
|
|
||||||
'codexlens.envField.coarseK': 'Coarse K',
|
|
||||||
'codexlens.envField.fineK': 'Fine K',
|
|
||||||
'codexlens.envField.stagedStage2Mode': 'Stage-2 Mode',
|
|
||||||
'codexlens.envField.stagedClusteringStrategy': 'Clustering Strategy',
|
|
||||||
'codexlens.envField.stagedClusteringMinSize': 'Cluster Min Size',
|
|
||||||
'codexlens.envField.enableStagedRerank': 'Enable Rerank',
|
|
||||||
'codexlens.envField.useAstGrep': 'Use ast-grep',
|
|
||||||
'codexlens.envField.staticGraphEnabled': 'Static Graph',
|
|
||||||
'codexlens.envField.staticGraphRelationshipTypes': 'Relationship Types',
|
|
||||||
'codexlens.envField.stripComments': 'Strip Comments',
|
|
||||||
'codexlens.envField.stripDocstrings': 'Strip Docstrings',
|
|
||||||
'codexlens.envField.testFilePenalty': 'Test File Penalty',
|
|
||||||
'codexlens.envField.docstringWeight': 'Docstring Weight',
|
|
||||||
'codexlens.install.description': 'Set up Python virtual environment and install CodexLens package.',
|
|
||||||
'codexlens.install.checklist': 'What will be installed',
|
|
||||||
'codexlens.install.pythonVenv': 'Python Virtual Environment',
|
|
||||||
'codexlens.install.pythonVenvDesc': 'Isolated Python environment for CodexLens',
|
|
||||||
'codexlens.install.codexlensPackage': 'CodexLens Package',
|
|
||||||
'codexlens.install.codexlensPackageDesc': 'Core semantic code search engine',
|
|
||||||
'codexlens.install.sqliteFts': 'SQLite FTS5',
|
|
||||||
'codexlens.install.sqliteFtsDesc': 'Full-text search extension for fast code lookup',
|
|
||||||
'codexlens.install.location': 'Install Location',
|
|
||||||
'codexlens.install.locationPath': '~/.codexlens/venv',
|
|
||||||
'codexlens.install.timeEstimate': 'Installation may take 1-3 minutes depending on network speed.',
|
|
||||||
'codexlens.install.stage.creatingVenv': 'Creating Python virtual environment...',
|
|
||||||
'codexlens.install.stage.installingPip': 'Installing pip dependencies...',
|
|
||||||
'codexlens.install.stage.installingPackage': 'Installing CodexLens package...',
|
|
||||||
'codexlens.install.stage.settingUpDeps': 'Setting up dependencies...',
|
|
||||||
'codexlens.install.stage.finalizing': 'Finalizing installation...',
|
|
||||||
'codexlens.install.stage.complete': 'Installation complete!',
|
|
||||||
'codexlens.install.installNow': 'Install Now',
|
|
||||||
'codexlens.install.installing': 'Installing...',
|
|
||||||
'codexlens.watcher.title': 'File Watcher',
|
|
||||||
'codexlens.watcher.status.running': 'Running',
|
|
||||||
'codexlens.watcher.status.stopped': 'Stopped',
|
|
||||||
'codexlens.watcher.eventsProcessed': 'Events Processed',
|
|
||||||
'codexlens.watcher.uptime': 'Uptime',
|
|
||||||
'codexlens.watcher.start': 'Start Watcher',
|
|
||||||
'codexlens.watcher.starting': 'Starting...',
|
|
||||||
'codexlens.watcher.stop': 'Stop Watcher',
|
|
||||||
'codexlens.watcher.stopping': 'Stopping...',
|
|
||||||
'codexlens.watcher.started': 'File watcher started',
|
|
||||||
'codexlens.watcher.stopped': 'File watcher stopped',
|
|
||||||
'codexlens.settings.currentCount': 'Current Index Count',
|
|
||||||
'codexlens.settings.currentWorkers': 'Current Workers',
|
|
||||||
'codexlens.settings.currentBatchSize': 'Current Batch Size',
|
|
||||||
'codexlens.settings.configTitle': 'Basic Configuration',
|
|
||||||
'codexlens.settings.indexDir.label': 'Index Directory',
|
|
||||||
'codexlens.settings.indexDir.placeholder': '~/.codexlens/indexes',
|
|
||||||
'codexlens.settings.indexDir.hint': 'Directory path for storing code indexes',
|
|
||||||
'codexlens.settings.maxWorkers.label': 'Max Workers',
|
|
||||||
'codexlens.settings.maxWorkers.hint': 'API concurrent processing threads (1-32)',
|
|
||||||
'codexlens.settings.batchSize.label': 'Batch Size',
|
|
||||||
'codexlens.settings.batchSize.hint': 'Number of files processed per batch (1-64)',
|
|
||||||
'codexlens.settings.validation.indexDirRequired': 'Index directory is required',
|
|
||||||
'codexlens.settings.validation.maxWorkersRange': 'Workers must be between 1 and 32',
|
|
||||||
'codexlens.settings.validation.batchSizeRange': 'Batch size must be between 1 and 64',
|
|
||||||
'codexlens.settings.save': 'Save',
|
|
||||||
'codexlens.settings.saving': 'Saving...',
|
|
||||||
'codexlens.settings.reset': 'Reset',
|
|
||||||
'codexlens.settings.saveSuccess': 'Configuration saved',
|
|
||||||
'codexlens.settings.saveFailed': 'Save failed',
|
|
||||||
'codexlens.settings.configUpdated': 'Configuration updated successfully',
|
|
||||||
'codexlens.settings.saveError': 'Error saving configuration',
|
|
||||||
'codexlens.settings.unknownError': 'An unknown error occurred',
|
|
||||||
'codexlens.models.title': 'Model Management',
|
|
||||||
'codexlens.models.searchPlaceholder': 'Search models...',
|
|
||||||
'codexlens.models.downloading': 'Downloading...',
|
|
||||||
'codexlens.models.status.downloaded': 'Downloaded',
|
|
||||||
'codexlens.models.status.available': 'Available',
|
|
||||||
'codexlens.models.types.embedding': 'Embedding Models',
|
|
||||||
'codexlens.models.types.reranker': 'Reranker Models',
|
|
||||||
'codexlens.models.filters.label': 'Filter',
|
|
||||||
'codexlens.models.filters.all': 'All',
|
|
||||||
'codexlens.models.actions.download': 'Download',
|
|
||||||
'codexlens.models.actions.delete': 'Delete',
|
|
||||||
'codexlens.models.actions.cancel': 'Cancel',
|
|
||||||
'codexlens.models.custom.title': 'Custom Model',
|
|
||||||
'codexlens.models.custom.placeholder': 'HuggingFace model name (e.g., BAAI/bge-small-zh-v1.5)',
|
|
||||||
'codexlens.models.custom.description': 'Download custom models from HuggingFace. Ensure the model name is correct.',
|
|
||||||
'codexlens.models.deleteConfirm': 'Are you sure you want to delete model {modelName}?',
|
|
||||||
'codexlens.models.notInstalled.title': 'CodexLens Not Installed',
|
|
||||||
'codexlens.models.notInstalled.description': 'Please install CodexLens to use model management features.',
|
|
||||||
'codexlens.models.empty.title': 'No models found',
|
|
||||||
'codexlens.models.empty.description': 'Try adjusting your search or filter criteria',
|
|
||||||
// Reranker
|
|
||||||
'codexlens.reranker.title': 'Reranker Configuration',
|
|
||||||
'codexlens.reranker.description': 'Configure the reranker backend, model, and provider for search result ranking.',
|
|
||||||
'codexlens.reranker.backend': 'Backend',
|
|
||||||
'codexlens.reranker.backendHint': 'Inference backend for reranking',
|
|
||||||
'codexlens.reranker.model': 'Model',
|
|
||||||
'codexlens.reranker.modelHint': 'Reranker model name or LiteLLM endpoint',
|
|
||||||
'codexlens.reranker.provider': 'API Provider',
|
|
||||||
'codexlens.reranker.providerHint': 'API provider for reranker service',
|
|
||||||
'codexlens.reranker.apiKeyStatus': 'API Key',
|
|
||||||
'codexlens.reranker.apiKeySet': 'Configured',
|
|
||||||
'codexlens.reranker.apiKeyNotSet': 'Not configured',
|
|
||||||
'codexlens.reranker.configSource': 'Config Source',
|
|
||||||
'codexlens.reranker.save': 'Save Reranker Config',
|
|
||||||
'codexlens.reranker.saving': 'Saving...',
|
|
||||||
'codexlens.reranker.saveSuccess': 'Reranker configuration saved',
|
|
||||||
'codexlens.reranker.saveFailed': 'Failed to save reranker configuration',
|
|
||||||
'codexlens.reranker.noBackends': 'No backends available',
|
|
||||||
'codexlens.reranker.noModels': 'No models available',
|
|
||||||
'codexlens.reranker.noProviders': 'No providers available',
|
|
||||||
'codexlens.reranker.litellmModels': 'LiteLLM Models',
|
|
||||||
'codexlens.reranker.selectBackend': 'Select backend...',
|
|
||||||
'codexlens.reranker.selectModel': 'Select model...',
|
|
||||||
'codexlens.reranker.selectProvider': 'Select provider...',
|
|
||||||
// MCP - CCW Tools
|
// MCP - CCW Tools
|
||||||
'mcp.ccw.title': 'CCW MCP Server',
|
'mcp.ccw.title': 'CCW MCP Server',
|
||||||
'mcp.ccw.description': 'Configure CCW MCP tools and paths',
|
'mcp.ccw.description': 'Configure CCW MCP tools and paths',
|
||||||
@@ -438,173 +294,29 @@ const mockMessages: Record<Locale, Record<string, string>> = {
|
|||||||
'issues.discovery.status.failed': '失败',
|
'issues.discovery.status.failed': '失败',
|
||||||
'issues.discovery.progress': '进度',
|
'issues.discovery.progress': '进度',
|
||||||
'issues.discovery.findings': '发现',
|
'issues.discovery.findings': '发现',
|
||||||
// CodexLens
|
// CodexLens (v2)
|
||||||
'codexlens.title': 'CodexLens',
|
'codexlens.title': '搜索管理',
|
||||||
'codexlens.description': '语义代码搜索引擎',
|
'codexlens.description': 'V2 语义搜索索引管理',
|
||||||
'codexlens.bootstrap': '引导安装',
|
'codexlens.reindex': '重建索引',
|
||||||
'codexlens.bootstrapping': '安装中...',
|
'codexlens.reindexing': '重建中...',
|
||||||
'codexlens.uninstall': '卸载',
|
'codexlens.statusError': '加载搜索索引状态失败',
|
||||||
'codexlens.uninstalling': '卸载中...',
|
'codexlens.indexStatus.title': '索引状态',
|
||||||
'codexlens.confirmUninstall': '确定要卸载 CodexLens 吗?',
|
'codexlens.indexStatus.status': '状态',
|
||||||
'codexlens.notInstalled': 'CodexLens 尚未安装',
|
'codexlens.indexStatus.ready': '就绪',
|
||||||
'codexlens.comingSoon': '即将推出',
|
'codexlens.indexStatus.notIndexed': '未索引',
|
||||||
'codexlens.tabs.overview': '概览',
|
'codexlens.indexStatus.files': '文件数',
|
||||||
'codexlens.tabs.settings': '设置',
|
'codexlens.indexStatus.dbSize': '数据库大小',
|
||||||
'codexlens.tabs.models': '模型',
|
'codexlens.indexStatus.lastIndexed': '上次索引',
|
||||||
'codexlens.tabs.advanced': '高级',
|
'codexlens.indexStatus.chunks': '分块数',
|
||||||
'codexlens.overview.status.installation': '安装状态',
|
'codexlens.indexStatus.vectorDim': '向量维度',
|
||||||
'codexlens.overview.status.ready': '就绪',
|
'codexlens.indexStatus.enabled': '已启用',
|
||||||
'codexlens.overview.status.notReady': '未就绪',
|
'codexlens.indexStatus.disabled': '已禁用',
|
||||||
'codexlens.overview.status.version': '版本',
|
'codexlens.indexStatus.unavailable': '索引状态不可用',
|
||||||
'codexlens.overview.status.indexPath': '索引路径',
|
'codexlens.searchTest.title': '搜索测试',
|
||||||
'codexlens.overview.status.indexCount': '索引数量',
|
'codexlens.searchTest.placeholder': '输入搜索查询...',
|
||||||
'codexlens.overview.notInstalled.title': 'CodexLens 未安装',
|
'codexlens.searchTest.button': '搜索',
|
||||||
'codexlens.overview.notInstalled.message': '请先安装 CodexLens 以使用语义代码搜索功能。',
|
'codexlens.searchTest.results': '个结果',
|
||||||
'codexlens.overview.actions.title': '快速操作',
|
'codexlens.searchTest.noResults': '未找到结果',
|
||||||
'codexlens.overview.actions.ftsFull': 'FTS 全量',
|
|
||||||
'codexlens.overview.actions.ftsFullDesc': '重建全文索引',
|
|
||||||
'codexlens.overview.actions.ftsIncremental': 'FTS 增量',
|
|
||||||
'codexlens.overview.actions.ftsIncrementalDesc': '增量更新全文索引',
|
|
||||||
'codexlens.overview.actions.vectorFull': '向量全量',
|
|
||||||
'codexlens.overview.actions.vectorFullDesc': '重建向量索引',
|
|
||||||
'codexlens.overview.actions.vectorIncremental': '向量增量',
|
|
||||||
'codexlens.overview.actions.vectorIncrementalDesc': '增量更新向量索引',
|
|
||||||
'codexlens.overview.venv.title': 'Python 虚拟环境详情',
|
|
||||||
'codexlens.overview.venv.pythonVersion': 'Python 版本',
|
|
||||||
'codexlens.overview.venv.venvPath': '虚拟环境路径',
|
|
||||||
'codexlens.overview.venv.lastCheck': '最后检查时间',
|
|
||||||
'codexlens.install.title': '安装 CodexLens',
|
|
||||||
// Env Groups & Fields
|
|
||||||
'codexlens.envGroup.embedding': '嵌入模型',
|
|
||||||
'codexlens.envGroup.reranker': '重排序',
|
|
||||||
'codexlens.envGroup.concurrency': '并发',
|
|
||||||
'codexlens.envGroup.cascade': '级联搜索',
|
|
||||||
'codexlens.envGroup.indexing': '索引与解析',
|
|
||||||
'codexlens.envGroup.chunking': '分块',
|
|
||||||
'codexlens.envField.backend': '后端',
|
|
||||||
'codexlens.envField.model': '模型',
|
|
||||||
'codexlens.envField.useGpu': '使用 GPU',
|
|
||||||
'codexlens.envField.highAvailability': '高可用',
|
|
||||||
'codexlens.envField.loadBalanceStrategy': '负载均衡策略',
|
|
||||||
'codexlens.envField.rateLimitCooldown': '限流冷却时间',
|
|
||||||
'codexlens.envField.enabled': '启用',
|
|
||||||
'codexlens.envField.topKResults': 'Top K 结果数',
|
|
||||||
'codexlens.envField.maxWorkers': '最大工作线程',
|
|
||||||
'codexlens.envField.batchSize': '批次大小',
|
|
||||||
'codexlens.envField.dynamicBatchSize': '动态批次大小',
|
|
||||||
'codexlens.envField.batchSizeUtilization': '利用率因子',
|
|
||||||
'codexlens.envField.batchSizeMax': '最大批次大小',
|
|
||||||
'codexlens.envField.charsPerToken': '每 Token 字符数',
|
|
||||||
'codexlens.envField.searchStrategy': '搜索策略',
|
|
||||||
'codexlens.envField.coarseK': '粗筛 K 值',
|
|
||||||
'codexlens.envField.fineK': '精筛 K 值',
|
|
||||||
'codexlens.envField.stagedStage2Mode': 'Stage-2 模式',
|
|
||||||
'codexlens.envField.stagedClusteringStrategy': '聚类策略',
|
|
||||||
'codexlens.envField.stagedClusteringMinSize': '最小聚类大小',
|
|
||||||
'codexlens.envField.enableStagedRerank': '启用重排序',
|
|
||||||
'codexlens.envField.useAstGrep': '使用 ast-grep',
|
|
||||||
'codexlens.envField.staticGraphEnabled': '启用静态图',
|
|
||||||
'codexlens.envField.staticGraphRelationshipTypes': '关系类型',
|
|
||||||
'codexlens.envField.stripComments': '去除注释',
|
|
||||||
'codexlens.envField.stripDocstrings': '去除文档字符串',
|
|
||||||
'codexlens.envField.testFilePenalty': '测试文件惩罚',
|
|
||||||
'codexlens.envField.docstringWeight': '文档字符串权重',
|
|
||||||
'codexlens.install.description': '设置 Python 虚拟环境并安装 CodexLens 包。',
|
|
||||||
'codexlens.install.checklist': '将要安装的内容',
|
|
||||||
'codexlens.install.pythonVenv': 'Python 虚拟环境',
|
|
||||||
'codexlens.install.pythonVenvDesc': 'CodexLens 的隔离 Python 环境',
|
|
||||||
'codexlens.install.codexlensPackage': 'CodexLens 包',
|
|
||||||
'codexlens.install.codexlensPackageDesc': '核心语义代码搜索引擎',
|
|
||||||
'codexlens.install.sqliteFts': 'SQLite FTS5',
|
|
||||||
'codexlens.install.sqliteFtsDesc': '用于快速代码查找的全文搜索扩展',
|
|
||||||
'codexlens.install.location': '安装位置',
|
|
||||||
'codexlens.install.locationPath': '~/.codexlens/venv',
|
|
||||||
'codexlens.install.timeEstimate': '安装可能需要 1-3 分钟,取决于网络速度。',
|
|
||||||
'codexlens.install.stage.creatingVenv': '正在创建 Python 虚拟环境...',
|
|
||||||
'codexlens.install.stage.installingPip': '正在安装 pip 依赖...',
|
|
||||||
'codexlens.install.stage.installingPackage': '正在安装 CodexLens 包...',
|
|
||||||
'codexlens.install.stage.settingUpDeps': '正在设置依赖项...',
|
|
||||||
'codexlens.install.stage.finalizing': '正在完成安装...',
|
|
||||||
'codexlens.install.stage.complete': '安装完成!',
|
|
||||||
'codexlens.install.installNow': '立即安装',
|
|
||||||
'codexlens.install.installing': '安装中...',
|
|
||||||
'codexlens.watcher.title': '文件监听器',
|
|
||||||
'codexlens.watcher.status.running': '运行中',
|
|
||||||
'codexlens.watcher.status.stopped': '已停止',
|
|
||||||
'codexlens.watcher.eventsProcessed': '已处理事件',
|
|
||||||
'codexlens.watcher.uptime': '运行时间',
|
|
||||||
'codexlens.watcher.start': '启动监听',
|
|
||||||
'codexlens.watcher.starting': '启动中...',
|
|
||||||
'codexlens.watcher.stop': '停止监听',
|
|
||||||
'codexlens.watcher.stopping': '停止中...',
|
|
||||||
'codexlens.watcher.started': '文件监听器已启动',
|
|
||||||
'codexlens.watcher.stopped': '文件监听器已停止',
|
|
||||||
'codexlens.settings.currentCount': '当前索引数量',
|
|
||||||
'codexlens.settings.currentWorkers': '当前工作线程',
|
|
||||||
'codexlens.settings.currentBatchSize': '当前批次大小',
|
|
||||||
'codexlens.settings.configTitle': '基本配置',
|
|
||||||
'codexlens.settings.indexDir.label': '索引目录',
|
|
||||||
'codexlens.settings.indexDir.placeholder': '~/.codexlens/indexes',
|
|
||||||
'codexlens.settings.indexDir.hint': '存储代码索引的目录路径',
|
|
||||||
'codexlens.settings.maxWorkers.label': '最大工作线程',
|
|
||||||
'codexlens.settings.maxWorkers.hint': 'API 并发处理线程数 (1-32)',
|
|
||||||
'codexlens.settings.batchSize.label': '批次大小',
|
|
||||||
'codexlens.settings.batchSize.hint': '每次批量处理的文件数量 (1-64)',
|
|
||||||
'codexlens.settings.validation.indexDirRequired': '索引目录不能为空',
|
|
||||||
'codexlens.settings.validation.maxWorkersRange': '工作线程数必须在 1-32 之间',
|
|
||||||
'codexlens.settings.validation.batchSizeRange': '批次大小必须在 1-64 之间',
|
|
||||||
'codexlens.settings.save': '保存',
|
|
||||||
'codexlens.settings.saving': '保存中...',
|
|
||||||
'codexlens.settings.reset': '重置',
|
|
||||||
'codexlens.settings.saveSuccess': '配置已保存',
|
|
||||||
'codexlens.settings.saveFailed': '保存失败',
|
|
||||||
'codexlens.settings.configUpdated': '配置更新成功',
|
|
||||||
'codexlens.settings.saveError': '保存配置时出错',
|
|
||||||
'codexlens.settings.unknownError': '发生未知错误',
|
|
||||||
'codexlens.models.title': '模型管理',
|
|
||||||
'codexlens.models.searchPlaceholder': '搜索模型...',
|
|
||||||
'codexlens.models.downloading': '下载中...',
|
|
||||||
'codexlens.models.status.downloaded': '已下载',
|
|
||||||
'codexlens.models.status.available': '可用',
|
|
||||||
'codexlens.models.types.embedding': '嵌入模型',
|
|
||||||
'codexlens.models.types.reranker': '重排序模型',
|
|
||||||
'codexlens.models.filters.label': '筛选',
|
|
||||||
'codexlens.models.filters.all': '全部',
|
|
||||||
'codexlens.models.actions.download': '下载',
|
|
||||||
'codexlens.models.actions.delete': '删除',
|
|
||||||
'codexlens.models.actions.cancel': '取消',
|
|
||||||
'codexlens.models.custom.title': '自定义模型',
|
|
||||||
'codexlens.models.custom.placeholder': 'HuggingFace 模型名称 (如: BAAI/bge-small-zh-v1.5)',
|
|
||||||
'codexlens.models.custom.description': '从 HuggingFace 下载自定义模型。请确保模型名称正确。',
|
|
||||||
'codexlens.models.deleteConfirm': '确定要删除模型 {modelName} 吗?',
|
|
||||||
'codexlens.models.notInstalled.title': 'CodexLens 未安装',
|
|
||||||
'codexlens.models.notInstalled.description': '请先安装 CodexLens 以使用模型管理功能。',
|
|
||||||
'codexlens.models.empty.title': '没有找到模型',
|
|
||||||
'codexlens.models.empty.description': '尝试调整搜索或筛选条件',
|
|
||||||
// Reranker
|
|
||||||
'codexlens.reranker.title': '重排序配置',
|
|
||||||
'codexlens.reranker.description': '配置重排序后端、模型和提供商,用于搜索结果排序。',
|
|
||||||
'codexlens.reranker.backend': '后端',
|
|
||||||
'codexlens.reranker.backendHint': '重排序推理后端',
|
|
||||||
'codexlens.reranker.model': '模型',
|
|
||||||
'codexlens.reranker.modelHint': '重排序模型名称或 LiteLLM 端点',
|
|
||||||
'codexlens.reranker.provider': 'API 提供商',
|
|
||||||
'codexlens.reranker.providerHint': '重排序服务的 API 提供商',
|
|
||||||
'codexlens.reranker.apiKeyStatus': 'API 密钥',
|
|
||||||
'codexlens.reranker.apiKeySet': '已配置',
|
|
||||||
'codexlens.reranker.apiKeyNotSet': '未配置',
|
|
||||||
'codexlens.reranker.configSource': '配置来源',
|
|
||||||
'codexlens.reranker.save': '保存重排序配置',
|
|
||||||
'codexlens.reranker.saving': '保存中...',
|
|
||||||
'codexlens.reranker.saveSuccess': '重排序配置已保存',
|
|
||||||
'codexlens.reranker.saveFailed': '保存重排序配置失败',
|
|
||||||
'codexlens.reranker.noBackends': '无可用后端',
|
|
||||||
'codexlens.reranker.noModels': '无可用模型',
|
|
||||||
'codexlens.reranker.noProviders': '无可用提供商',
|
|
||||||
'codexlens.reranker.litellmModels': 'LiteLLM 模型',
|
|
||||||
'codexlens.reranker.selectBackend': '选择后端...',
|
|
||||||
'codexlens.reranker.selectModel': '选择模型...',
|
|
||||||
'codexlens.reranker.selectProvider': '选择提供商...',
|
|
||||||
// MCP - CCW Tools
|
// MCP - CCW Tools
|
||||||
'mcp.ccw.title': 'CCW MCP 服务器',
|
'mcp.ccw.title': 'CCW MCP 服务器',
|
||||||
'mcp.ccw.description': '配置 CCW MCP 工具与路径',
|
'mcp.ccw.description': '配置 CCW MCP 工具与路径',
|
||||||
|
|||||||
@@ -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,
|
* The Python memory_embedder.py bridge has been removed. This module provides
|
||||||
* which generates and searches embeddings for memory chunks using CodexLens's embedder.
|
* no-op stubs so that existing consumers compile without errors.
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Reuses CodexLens venv at ~/.codexlens/venv
|
|
||||||
* - JSON protocol communication
|
|
||||||
* - Three commands: embed, search, status
|
|
||||||
* - Automatic availability checking
|
|
||||||
* - Stage1 output embedding for V2 pipeline
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
const V1_REMOVED = 'Memory embedder Python bridge has been removed (v1 cleanup).';
|
||||||
import { join, dirname } from 'path';
|
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
|
|
||||||
import { getCoreMemoryStore } from './core-memory-store.js';
|
|
||||||
import type { Stage1Output } from './core-memory-store.js';
|
|
||||||
import { StoragePaths } from '../config/storage-paths.js';
|
|
||||||
|
|
||||||
// Get directory of this module
|
// Types (kept for backward compatibility)
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Venv paths (reuse CodexLens venv)
|
|
||||||
const VENV_PYTHON = getCodexLensHiddenPython();
|
|
||||||
|
|
||||||
// Script path
|
|
||||||
const EMBEDDER_SCRIPT = join(__dirname, '..', '..', 'scripts', 'memory_embedder.py');
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export interface EmbedResult {
|
export interface EmbedResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
chunks_processed: number;
|
chunks_processed: number;
|
||||||
@@ -78,197 +54,6 @@ export interface SearchOptions {
|
|||||||
sourceType?: 'core_memory' | 'workflow' | 'cli_history';
|
sourceType?: 'core_memory' | 'workflow' | 'cli_history';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if embedder is available (venv and script exist)
|
|
||||||
* @returns True if embedder is available
|
|
||||||
*/
|
|
||||||
export function isEmbedderAvailable(): boolean {
|
|
||||||
// Check venv python exists
|
|
||||||
if (!existsSync(VENV_PYTHON)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check script exists
|
|
||||||
if (!existsSync(EMBEDDER_SCRIPT)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Python script with arguments
|
|
||||||
* @param args - Command line arguments
|
|
||||||
* @param timeout - Timeout in milliseconds
|
|
||||||
* @returns JSON output from script
|
|
||||||
*/
|
|
||||||
function runPython(args: string[], timeout: number = 300000): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Check availability
|
|
||||||
if (!isEmbedderAvailable()) {
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
'Memory embedder not available. Ensure CodexLens venv exists at ~/.codexlens/venv'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spawn Python process
|
|
||||||
const child = spawn(VENV_PYTHON, [EMBEDDER_SCRIPT, ...args], {
|
|
||||||
shell: false,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout,
|
|
||||||
windowsHide: true,
|
|
||||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
|
|
||||||
child.stdout.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolve(stdout.trim());
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Python script failed (exit code ${code}): ${stderr || stdout}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', (err) => {
|
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
|
||||||
reject(new Error('Python script timed out'));
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Failed to spawn Python: ${err.message}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate embeddings for memory chunks
|
|
||||||
* @param dbPath - Path to SQLite database
|
|
||||||
* @param options - Embedding options
|
|
||||||
* @returns Embedding result
|
|
||||||
*/
|
|
||||||
export async function generateEmbeddings(
|
|
||||||
dbPath: string,
|
|
||||||
options: EmbedOptions = {}
|
|
||||||
): Promise<EmbedResult> {
|
|
||||||
const { sourceId, batchSize = 8, force = false } = options;
|
|
||||||
|
|
||||||
// Build arguments
|
|
||||||
const args = ['embed', dbPath];
|
|
||||||
|
|
||||||
if (sourceId) {
|
|
||||||
args.push('--source-id', sourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batchSize !== 8) {
|
|
||||||
args.push('--batch-size', batchSize.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (force) {
|
|
||||||
args.push('--force');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Default timeout: 5 minutes
|
|
||||||
const output = await runPython(args, 300000);
|
|
||||||
const result = JSON.parse(output) as EmbedResult;
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
chunks_processed: 0,
|
|
||||||
chunks_failed: 0,
|
|
||||||
elapsed_time: 0,
|
|
||||||
error: (err as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search memory chunks using semantic search
|
|
||||||
* @param dbPath - Path to SQLite database
|
|
||||||
* @param query - Search query text
|
|
||||||
* @param options - Search options
|
|
||||||
* @returns Search results
|
|
||||||
*/
|
|
||||||
export async function searchMemories(
|
|
||||||
dbPath: string,
|
|
||||||
query: string,
|
|
||||||
options: SearchOptions = {}
|
|
||||||
): Promise<SearchResult> {
|
|
||||||
const { topK = 10, minScore = 0.3, sourceType } = options;
|
|
||||||
|
|
||||||
// Build arguments
|
|
||||||
const args = ['search', dbPath, query];
|
|
||||||
|
|
||||||
if (topK !== 10) {
|
|
||||||
args.push('--top-k', topK.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minScore !== 0.3) {
|
|
||||||
args.push('--min-score', minScore.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceType) {
|
|
||||||
args.push('--type', sourceType);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Default timeout: 30 seconds
|
|
||||||
const output = await runPython(args, 30000);
|
|
||||||
const result = JSON.parse(output) as SearchResult;
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
matches: [],
|
|
||||||
error: (err as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get embedding status statistics
|
|
||||||
* @param dbPath - Path to SQLite database
|
|
||||||
* @returns Embedding status
|
|
||||||
*/
|
|
||||||
export async function getEmbeddingStatus(dbPath: string): Promise<EmbeddingStatus> {
|
|
||||||
// Build arguments
|
|
||||||
const args = ['status', dbPath];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Default timeout: 30 seconds
|
|
||||||
const output = await runPython(args, 30000);
|
|
||||||
const result = JSON.parse(output) as EmbeddingStatus;
|
|
||||||
return { ...result, success: true };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
total_chunks: 0,
|
|
||||||
embedded_chunks: 0,
|
|
||||||
pending_chunks: 0,
|
|
||||||
by_type: {},
|
|
||||||
error: (err as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Memory V2: Stage1 Output Embedding
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/** Result of stage1 embedding operation */
|
|
||||||
export interface Stage1EmbedResult {
|
export interface Stage1EmbedResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
chunksCreated: number;
|
chunksCreated: number;
|
||||||
@@ -276,98 +61,54 @@ export interface Stage1EmbedResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function isEmbedderAvailable(): boolean {
|
||||||
* Chunk and embed stage1_outputs (raw_memory + rollout_summary) for semantic search.
|
return false;
|
||||||
*
|
|
||||||
* Reads all stage1_outputs from the DB, chunks their raw_memory and rollout_summary
|
|
||||||
* content, inserts chunks into memory_chunks with source_type='cli_history' and
|
|
||||||
* metadata indicating the V2 origin, then triggers embedding generation.
|
|
||||||
*
|
|
||||||
* Uses source_id format: "s1:{thread_id}" to differentiate from regular cli_history chunks.
|
|
||||||
*
|
|
||||||
* @param projectPath - Project root path
|
|
||||||
* @param force - Force re-chunking even if chunks exist
|
|
||||||
* @returns Embedding result
|
|
||||||
*/
|
|
||||||
export async function embedStage1Outputs(
|
|
||||||
projectPath: string,
|
|
||||||
force: boolean = false
|
|
||||||
): Promise<Stage1EmbedResult> {
|
|
||||||
try {
|
|
||||||
const store = getCoreMemoryStore(projectPath);
|
|
||||||
const stage1Outputs = store.listStage1Outputs();
|
|
||||||
|
|
||||||
if (stage1Outputs.length === 0) {
|
|
||||||
return { success: true, chunksCreated: 0, chunksEmbedded: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalChunksCreated = 0;
|
|
||||||
|
|
||||||
for (const output of stage1Outputs) {
|
|
||||||
const sourceId = `s1:${output.thread_id}`;
|
|
||||||
|
|
||||||
// Check if already chunked
|
|
||||||
const existingChunks = store.getChunks(sourceId);
|
|
||||||
if (existingChunks.length > 0 && !force) continue;
|
|
||||||
|
|
||||||
// Delete old chunks if force
|
|
||||||
if (force && existingChunks.length > 0) {
|
|
||||||
store.deleteChunks(sourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine raw_memory and rollout_summary for richer semantic content
|
|
||||||
const combinedContent = [
|
|
||||||
output.rollout_summary ? `## Summary\n${output.rollout_summary}` : '',
|
|
||||||
output.raw_memory ? `## Raw Memory\n${output.raw_memory}` : '',
|
|
||||||
].filter(Boolean).join('\n\n');
|
|
||||||
|
|
||||||
if (!combinedContent.trim()) continue;
|
|
||||||
|
|
||||||
// Chunk using the store's built-in chunking
|
|
||||||
const chunks = store.chunkContent(combinedContent, sourceId, 'cli_history');
|
|
||||||
|
|
||||||
// Insert chunks with V2 metadata
|
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
|
||||||
store.insertChunk({
|
|
||||||
source_id: sourceId,
|
|
||||||
source_type: 'cli_history',
|
|
||||||
chunk_index: i,
|
|
||||||
content: chunks[i],
|
|
||||||
metadata: JSON.stringify({
|
|
||||||
v2_source: 'stage1_output',
|
|
||||||
thread_id: output.thread_id,
|
|
||||||
generated_at: output.generated_at,
|
|
||||||
}),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
totalChunksCreated++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we created chunks, generate embeddings
|
|
||||||
let chunksEmbedded = 0;
|
|
||||||
if (totalChunksCreated > 0) {
|
|
||||||
const paths = StoragePaths.project(projectPath);
|
|
||||||
const dbPath = join(paths.root, 'core-memory', 'core_memory.db');
|
|
||||||
|
|
||||||
const embedResult = await generateEmbeddings(dbPath, { force: false });
|
|
||||||
if (embedResult.success) {
|
|
||||||
chunksEmbedded = embedResult.chunks_processed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
chunksCreated: totalChunksCreated,
|
|
||||||
chunksEmbedded,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
chunksCreated: 0,
|
|
||||||
chunksEmbedded: 0,
|
|
||||||
error: (err as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateEmbeddings(
|
||||||
|
_dbPath: string,
|
||||||
|
_options: EmbedOptions = {}
|
||||||
|
): Promise<EmbedResult> {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
chunks_processed: 0,
|
||||||
|
chunks_failed: 0,
|
||||||
|
elapsed_time: 0,
|
||||||
|
error: V1_REMOVED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchMemories(
|
||||||
|
_dbPath: string,
|
||||||
|
_query: string,
|
||||||
|
_options: SearchOptions = {}
|
||||||
|
): Promise<SearchResult> {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
matches: [],
|
||||||
|
error: V1_REMOVED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmbeddingStatus(_dbPath: string): Promise<EmbeddingStatus> {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
total_chunks: 0,
|
||||||
|
embedded_chunks: 0,
|
||||||
|
pending_chunks: 0,
|
||||||
|
by_type: {},
|
||||||
|
error: V1_REMOVED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function embedStage1Outputs(
|
||||||
|
_projectPath: string,
|
||||||
|
_force: boolean = false
|
||||||
|
): Promise<Stage1EmbedResult> {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
chunksCreated: 0,
|
||||||
|
chunksEmbedded: 0,
|
||||||
|
error: V1_REMOVED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
* Handles LiteLLM provider management, endpoint configuration, and cache management
|
||||||
*/
|
*/
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import {
|
|
||||||
getSystemPythonCommand,
|
|
||||||
parsePythonCommandSpec,
|
|
||||||
type PythonCommandSpec,
|
|
||||||
} from '../../utils/python-utils.js';
|
|
||||||
import {
|
|
||||||
isUvAvailable,
|
|
||||||
createCodexLensUvManager
|
|
||||||
} from '../../utils/uv-manager.js';
|
|
||||||
import { ensureLiteLLMEmbedderReady } from '../../tools/codex-lens.js';
|
|
||||||
import type { RouteContext } from './types.js';
|
import type { RouteContext } from './types.js';
|
||||||
|
|
||||||
// ========== Input Validation Schemas ==========
|
// ========== Input Validation Schemas ==========
|
||||||
@@ -81,106 +70,13 @@ import {
|
|||||||
type EmbeddingPoolConfig,
|
type EmbeddingPoolConfig,
|
||||||
} from '../../config/litellm-api-config-manager.js';
|
} from '../../config/litellm-api-config-manager.js';
|
||||||
import { getContextCacheStore } from '../../tools/context-cache-store.js';
|
import { getContextCacheStore } from '../../tools/context-cache-store.js';
|
||||||
import { getLiteLLMClient } from '../../tools/litellm-client.js';
|
|
||||||
import { testApiKeyConnection, getDefaultApiBase } from '../services/api-key-tester.js';
|
import { testApiKeyConnection, getDefaultApiBase } from '../services/api-key-tester.js';
|
||||||
|
|
||||||
interface CcwLitellmEnvCheck {
|
const V1_REMOVED = 'Python bridge has been removed (v1 cleanup).';
|
||||||
python: string;
|
|
||||||
installed: boolean;
|
|
||||||
version?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CcwLitellmStatusResponse {
|
// Clear cache (no-op stub, kept for backward compatibility)
|
||||||
/**
|
|
||||||
* Whether ccw-litellm is installed in the CodexLens venv.
|
|
||||||
* This is the environment used for the LiteLLM embedding backend.
|
|
||||||
*/
|
|
||||||
installed: boolean;
|
|
||||||
version?: string;
|
|
||||||
error?: string;
|
|
||||||
checks?: {
|
|
||||||
codexLensVenv: CcwLitellmEnvCheck;
|
|
||||||
systemPython?: CcwLitellmEnvCheck;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkCcwLitellmImport(
|
|
||||||
pythonCmd: string | PythonCommandSpec,
|
|
||||||
options: { timeout: number }
|
|
||||||
): Promise<CcwLitellmEnvCheck> {
|
|
||||||
const { timeout } = options;
|
|
||||||
const pythonSpec = typeof pythonCmd === 'string' ? parsePythonCommandSpec(pythonCmd) : pythonCmd;
|
|
||||||
|
|
||||||
const sanitizePythonError = (stderrText: string): string | undefined => {
|
|
||||||
const trimmed = stderrText.trim();
|
|
||||||
if (!trimmed) return undefined;
|
|
||||||
const lines = trimmed
|
|
||||||
.split(/\r?\n/g)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
// Prefer the final exception line (avoids leaking full traceback + file paths)
|
|
||||||
return lines[lines.length - 1] || undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const child = spawn(pythonSpec.command, [...pythonSpec.args, '-c', 'import ccw_litellm; print(ccw_litellm.__version__)'], {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout,
|
|
||||||
windowsHide: true,
|
|
||||||
shell: false,
|
|
||||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
|
|
||||||
child.stdout?.on('data', (data: Buffer) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr?.on('data', (data: Buffer) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code: number | null) => {
|
|
||||||
const version = stdout.trim();
|
|
||||||
const error = sanitizePythonError(stderr);
|
|
||||||
|
|
||||||
if (code === 0 && version) {
|
|
||||||
resolve({ python: pythonSpec.display, installed: true, version });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code === null) {
|
|
||||||
resolve({ python: pythonSpec.display, installed: false, error: `Timed out after ${timeout}ms` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({ python: pythonSpec.display, installed: false, error: error || undefined });
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', (err) => {
|
|
||||||
resolve({ python: pythonSpec.display, installed: false, error: err.message });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for ccw-litellm status check
|
|
||||||
let ccwLitellmStatusCache: {
|
|
||||||
data: CcwLitellmStatusResponse | null;
|
|
||||||
timestamp: number;
|
|
||||||
ttl: number;
|
|
||||||
} = {
|
|
||||||
data: null,
|
|
||||||
timestamp: 0,
|
|
||||||
ttl: 5 * 60 * 1000, // 5 minutes
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clear cache (call after install)
|
|
||||||
export function clearCcwLitellmStatusCache() {
|
export function clearCcwLitellmStatusCache() {
|
||||||
ccwLitellmStatusCache.data = null;
|
// no-op: Python bridge removed
|
||||||
ccwLitellmStatusCache.timestamp = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeProviderForResponse(provider: any): any {
|
function sanitizeProviderForResponse(provider: any): any {
|
||||||
@@ -922,57 +818,10 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
|
|||||||
// CCW-LiteLLM Package Management
|
// CCW-LiteLLM Package Management
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
// GET /api/litellm-api/ccw-litellm/status - Check ccw-litellm installation status
|
// GET /api/litellm-api/ccw-litellm/status - Stub (v1 Python bridge removed)
|
||||||
// Supports ?refresh=true to bypass cache
|
|
||||||
if (pathname === '/api/litellm-api/ccw-litellm/status' && req.method === 'GET') {
|
if (pathname === '/api/litellm-api/ccw-litellm/status' && req.method === 'GET') {
|
||||||
const forceRefresh = url.searchParams.get('refresh') === 'true';
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ installed: false, error: V1_REMOVED }));
|
||||||
// Check cache first (unless force refresh)
|
|
||||||
if (!forceRefresh && ccwLitellmStatusCache.data &&
|
|
||||||
Date.now() - ccwLitellmStatusCache.timestamp < ccwLitellmStatusCache.ttl) {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(ccwLitellmStatusCache.data));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const uv = createCodexLensUvManager();
|
|
||||||
const venvPython = uv.getVenvPython();
|
|
||||||
const statusTimeout = process.platform === 'win32' ? 15000 : 10000;
|
|
||||||
const codexLensVenv = uv.isVenvValid()
|
|
||||||
? await checkCcwLitellmImport(venvPython, { timeout: statusTimeout })
|
|
||||||
: { python: venvPython, installed: false, error: 'CodexLens venv not valid' };
|
|
||||||
|
|
||||||
// Diagnostics only: if not installed in venv, also check system python so users understand mismatches.
|
|
||||||
// NOTE: `installed` flag remains the CodexLens venv status (we want isolated venv dependencies).
|
|
||||||
const systemPython = !codexLensVenv.installed
|
|
||||||
? await checkCcwLitellmImport(getSystemPythonCommand(), { timeout: statusTimeout })
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const result: CcwLitellmStatusResponse = {
|
|
||||||
installed: codexLensVenv.installed,
|
|
||||||
version: codexLensVenv.version,
|
|
||||||
error: codexLensVenv.error,
|
|
||||||
checks: {
|
|
||||||
codexLensVenv,
|
|
||||||
...(systemPython ? { systemPython } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update cache
|
|
||||||
ccwLitellmStatusCache = {
|
|
||||||
data: result,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
ttl: 5 * 60 * 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(result));
|
|
||||||
} catch (err) {
|
|
||||||
const errorResult = { installed: false, error: (err as Error).message };
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(errorResult));
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1367,96 +1216,18 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/litellm-api/ccw-litellm/install - Install ccw-litellm package
|
// POST /api/litellm-api/ccw-litellm/install - Stub (v1 Python bridge removed)
|
||||||
if (pathname === '/api/litellm-api/ccw-litellm/install' && req.method === 'POST') {
|
if (pathname === '/api/litellm-api/ccw-litellm/install' && req.method === 'POST') {
|
||||||
handlePostRequest(req, res, async () => {
|
handlePostRequest(req, res, async () => {
|
||||||
try {
|
return { success: false, error: V1_REMOVED };
|
||||||
// Delegate entirely to ensureLiteLLMEmbedderReady for consistent installation
|
|
||||||
// This uses unified package discovery and handles UV → pip fallback
|
|
||||||
const result = await ensureLiteLLMEmbedderReady();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
clearCcwLitellmStatusCache();
|
|
||||||
broadcastToClients({
|
|
||||||
type: 'CCW_LITELLM_INSTALLED',
|
|
||||||
payload: { timestamp: new Date().toISOString(), method: 'unified' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/litellm-api/ccw-litellm/uninstall - Uninstall ccw-litellm package
|
// POST /api/litellm-api/ccw-litellm/uninstall - Stub (v1 Python bridge removed)
|
||||||
if (pathname === '/api/litellm-api/ccw-litellm/uninstall' && req.method === 'POST') {
|
if (pathname === '/api/litellm-api/ccw-litellm/uninstall' && req.method === 'POST') {
|
||||||
handlePostRequest(req, res, async () => {
|
handlePostRequest(req, res, async () => {
|
||||||
try {
|
return { success: false, error: V1_REMOVED };
|
||||||
// Priority 1: Use UV to uninstall from CodexLens venv
|
|
||||||
if (await isUvAvailable()) {
|
|
||||||
const uv = createCodexLensUvManager();
|
|
||||||
if (uv.isVenvValid()) {
|
|
||||||
console.log('[ccw-litellm uninstall] Using UV to uninstall from CodexLens venv...');
|
|
||||||
const uvResult = await uv.uninstall(['ccw-litellm']);
|
|
||||||
clearCcwLitellmStatusCache();
|
|
||||||
|
|
||||||
if (uvResult.success) {
|
|
||||||
broadcastToClients({
|
|
||||||
type: 'CCW_LITELLM_UNINSTALLED',
|
|
||||||
payload: { timestamp: new Date().toISOString() }
|
|
||||||
});
|
|
||||||
return { success: true, message: 'ccw-litellm uninstalled successfully via UV' };
|
|
||||||
}
|
|
||||||
console.log('[ccw-litellm uninstall] UV uninstall failed, falling back to pip:', uvResult.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: Fallback to system pip uninstall
|
|
||||||
console.log('[ccw-litellm uninstall] Using pip fallback...');
|
|
||||||
const pythonCmd = getSystemPythonCommand();
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const proc = spawn(
|
|
||||||
pythonCmd.command,
|
|
||||||
[...pythonCmd.args, '-m', 'pip', 'uninstall', '-y', 'ccw-litellm'],
|
|
||||||
{
|
|
||||||
shell: false,
|
|
||||||
timeout: 120000,
|
|
||||||
windowsHide: true,
|
|
||||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let output = '';
|
|
||||||
let error = '';
|
|
||||||
proc.stdout?.on('data', (data) => { output += data.toString(); });
|
|
||||||
proc.stderr?.on('data', (data) => { error += data.toString(); });
|
|
||||||
proc.on('close', (code) => {
|
|
||||||
// Clear status cache after uninstallation attempt
|
|
||||||
clearCcwLitellmStatusCache();
|
|
||||||
|
|
||||||
if (code === 0) {
|
|
||||||
broadcastToClients({
|
|
||||||
type: 'CCW_LITELLM_UNINSTALLED',
|
|
||||||
payload: { timestamp: new Date().toISOString() }
|
|
||||||
});
|
|
||||||
resolve({ success: true, message: 'ccw-litellm uninstalled successfully' });
|
|
||||||
} else {
|
|
||||||
// Check if package was not installed
|
|
||||||
if (error.includes('not installed') || output.includes('not installed')) {
|
|
||||||
resolve({ success: true, message: 'ccw-litellm was not installed' });
|
|
||||||
} else {
|
|
||||||
resolve({ success: false, error: error || output || 'Uninstallation failed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
proc.on('error', (err) => resolve({ success: false, error: err.message }));
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: (err as Error).message };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { existsSync } from 'fs';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { getCliToolsStatus } from '../../tools/cli-executor.js';
|
import { getCliToolsStatus } from '../../tools/cli-executor.js';
|
||||||
import { checkVenvStatus, checkSemanticStatus } from '../../tools/codex-lens.js';
|
|
||||||
import type { RouteContext } from './types.js';
|
import type { RouteContext } from './types.js';
|
||||||
|
|
||||||
// Performance logging helper
|
// Performance logging helper
|
||||||
@@ -80,36 +79,14 @@ export async function handleStatusRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
const ccwInstallStatus = checkCcwInstallStatus();
|
const ccwInstallStatus = checkCcwInstallStatus();
|
||||||
perfLog('checkCcwInstallStatus', ccwStart);
|
perfLog('checkCcwInstallStatus', ccwStart);
|
||||||
|
|
||||||
// Execute all status checks in parallel with individual timing
|
// Execute async status checks
|
||||||
const cliStart = Date.now();
|
const cliStart = Date.now();
|
||||||
const codexStart = Date.now();
|
|
||||||
const semanticStart = Date.now();
|
|
||||||
|
|
||||||
const [cliStatus, codexLensStatus, semanticStatus] = await Promise.all([
|
const cliStatus = await getCliToolsStatus();
|
||||||
getCliToolsStatus().then(result => {
|
perfLog('getCliToolsStatus', cliStart, { toolCount: Object.keys(cliStatus).length });
|
||||||
perfLog('getCliToolsStatus', cliStart, { toolCount: Object.keys(result).length });
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
checkVenvStatus().then(result => {
|
|
||||||
perfLog('checkVenvStatus', codexStart, { ready: result.ready });
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
// Always check semantic status (will return available: false if CodexLens not ready)
|
|
||||||
checkSemanticStatus()
|
|
||||||
.then(result => {
|
|
||||||
perfLog('checkSemanticStatus', semanticStart, { available: result.available });
|
|
||||||
return result;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
perfLog('checkSemanticStatus (error)', semanticStart);
|
|
||||||
return { available: false, backend: null };
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
cli: cliStatus,
|
cli: cliStatus,
|
||||||
codexLens: codexLensStatus,
|
|
||||||
semantic: semanticStatus,
|
|
||||||
ccwInstall: ccwInstallStatus,
|
ccwInstall: ccwInstallStatus,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { handleUnifiedMemoryRoutes } from './routes/unified-memory-routes.js';
|
|||||||
import { handleMcpRoutes } from './routes/mcp-routes.js';
|
import { handleMcpRoutes } from './routes/mcp-routes.js';
|
||||||
import { handleHooksRoutes } from './routes/hooks-routes.js';
|
import { handleHooksRoutes } from './routes/hooks-routes.js';
|
||||||
import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js';
|
import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js';
|
||||||
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
|
|
||||||
import { handleGraphRoutes } from './routes/graph-routes.js';
|
import { handleGraphRoutes } from './routes/graph-routes.js';
|
||||||
import { handleSystemRoutes } from './routes/system-routes.js';
|
import { handleSystemRoutes } from './routes/system-routes.js';
|
||||||
import { handleFilesRoutes } from './routes/files-routes.js';
|
import { handleFilesRoutes } from './routes/files-routes.js';
|
||||||
@@ -66,7 +65,6 @@ import { getCliSessionManager } from './services/cli-session-manager.js';
|
|||||||
import { QueueSchedulerService } from './services/queue-scheduler-service.js';
|
import { QueueSchedulerService } from './services/queue-scheduler-service.js';
|
||||||
|
|
||||||
// Import status check functions for warmup
|
// Import status check functions for warmup
|
||||||
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
|
|
||||||
import { getCliToolsStatus } from '../tools/cli-executor.js';
|
import { getCliToolsStatus } from '../tools/cli-executor.js';
|
||||||
|
|
||||||
import type { ServerConfig } from '../types/config.js';
|
import type { ServerConfig } from '../types/config.js';
|
||||||
@@ -302,28 +300,6 @@ async function warmupCaches(initialPath: string): Promise<void> {
|
|||||||
|
|
||||||
// Run all warmup tasks in parallel for faster startup
|
// Run all warmup tasks in parallel for faster startup
|
||||||
const warmupTasks = [
|
const warmupTasks = [
|
||||||
// Warmup semantic status cache (Python process startup - can be slow first time)
|
|
||||||
(async () => {
|
|
||||||
const taskStart = Date.now();
|
|
||||||
try {
|
|
||||||
const semanticStatus = await checkSemanticStatus();
|
|
||||||
console.log(`[WARMUP] Semantic status: ${semanticStatus.available ? 'available' : 'not available'} (${Date.now() - taskStart}ms)`);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[WARMUP] Semantic status check failed: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
|
|
||||||
// Warmup venv status cache
|
|
||||||
(async () => {
|
|
||||||
const taskStart = Date.now();
|
|
||||||
try {
|
|
||||||
const venvStatus = await checkVenvStatus();
|
|
||||||
console.log(`[WARMUP] Venv status: ${venvStatus.ready ? 'ready' : 'not ready'} (${Date.now() - taskStart}ms)`);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[WARMUP] Venv status check failed: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
|
|
||||||
// Warmup CLI tools status cache
|
// Warmup CLI tools status cache
|
||||||
(async () => {
|
(async () => {
|
||||||
const taskStart = Date.now();
|
const taskStart = Date.now();
|
||||||
@@ -598,11 +574,6 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
|||||||
if (await handleUnsplashRoutes(routeContext)) return;
|
if (await handleUnsplashRoutes(routeContext)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CodexLens routes (/api/codexlens/*)
|
|
||||||
if (pathname.startsWith('/api/codexlens/')) {
|
|
||||||
if (await handleCodexLensRoutes(routeContext)) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LiteLLM routes (/api/litellm/*)
|
// LiteLLM routes (/api/litellm/*)
|
||||||
if (pathname.startsWith('/api/litellm/')) {
|
if (pathname.startsWith('/api/litellm/')) {
|
||||||
if (await handleLiteLLMRoutes(routeContext)) return;
|
if (await handleLiteLLMRoutes(routeContext)) return;
|
||||||
|
|||||||
@@ -1,79 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* Unified Vector Index - TypeScript bridge to unified_memory_embedder.py
|
* Unified Vector Index - STUB (v1 Python bridge removed)
|
||||||
*
|
*
|
||||||
* Provides HNSW-backed vector indexing and search for all memory content
|
* The Python unified_memory_embedder.py bridge has been removed. This module
|
||||||
* (core_memory, cli_history, workflow, entity, pattern) via CodexLens VectorStore.
|
* provides no-op stubs so that existing consumers compile without errors.
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - JSON stdin/stdout protocol to Python embedder
|
|
||||||
* - Content chunking (paragraph -> sentence splitting, CHUNK_SIZE=1500, OVERLAP=200)
|
|
||||||
* - Batch embedding via CodexLens EmbedderFactory
|
|
||||||
* - HNSW approximate nearest neighbor search (sub-10ms for 1000 chunks)
|
|
||||||
* - Category-based filtering
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
const V1_REMOVED = 'Unified vector index Python bridge has been removed (v1 cleanup).';
|
||||||
import { join, dirname } from 'path';
|
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
|
|
||||||
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
|
|
||||||
|
|
||||||
// Get directory of this module
|
// ---------------------------------------------------------------------------
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
// Types (kept for backward compatibility)
|
||||||
const __dirname = dirname(__filename);
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Venv python path (reuse CodexLens venv)
|
|
||||||
const VENV_PYTHON = getCodexLensHiddenPython();
|
|
||||||
|
|
||||||
// Script path
|
|
||||||
const EMBEDDER_SCRIPT = join(__dirname, '..', '..', 'scripts', 'unified_memory_embedder.py');
|
|
||||||
|
|
||||||
// Chunking constants (match existing core-memory-store.ts)
|
|
||||||
const CHUNK_SIZE = 1500;
|
|
||||||
const OVERLAP = 200;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Types
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/** Valid source types for vector content */
|
|
||||||
export type SourceType = 'core_memory' | 'workflow' | 'cli_history';
|
export type SourceType = 'core_memory' | 'workflow' | 'cli_history';
|
||||||
|
|
||||||
/** Valid category values for vector filtering */
|
|
||||||
export type VectorCategory = 'core_memory' | 'cli_history' | 'workflow' | 'entity' | 'pattern';
|
export type VectorCategory = 'core_memory' | 'cli_history' | 'workflow' | 'entity' | 'pattern';
|
||||||
|
|
||||||
/** Metadata attached to each chunk in the vector store */
|
|
||||||
export interface ChunkMetadata {
|
export interface ChunkMetadata {
|
||||||
/** Source identifier (e.g., memory ID, session ID) */
|
|
||||||
source_id: string;
|
source_id: string;
|
||||||
/** Source type */
|
|
||||||
source_type: SourceType;
|
source_type: SourceType;
|
||||||
/** Category for filtering */
|
|
||||||
category: VectorCategory;
|
category: VectorCategory;
|
||||||
/** Chunk index within the source */
|
|
||||||
chunk_index?: number;
|
chunk_index?: number;
|
||||||
/** Additional metadata */
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A chunk to be embedded and indexed */
|
|
||||||
export interface VectorChunk {
|
export interface VectorChunk {
|
||||||
/** Text content */
|
|
||||||
content: string;
|
content: string;
|
||||||
/** Source identifier */
|
|
||||||
source_id: string;
|
source_id: string;
|
||||||
/** Source type */
|
|
||||||
source_type: SourceType;
|
source_type: SourceType;
|
||||||
/** Category for filtering */
|
|
||||||
category: VectorCategory;
|
category: VectorCategory;
|
||||||
/** Chunk index */
|
|
||||||
chunk_index: number;
|
chunk_index: number;
|
||||||
/** Additional metadata */
|
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of an embed operation */
|
|
||||||
export interface EmbedResult {
|
export interface EmbedResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
chunks_processed: number;
|
chunks_processed: number;
|
||||||
@@ -82,7 +40,6 @@ export interface EmbedResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single search match */
|
|
||||||
export interface VectorSearchMatch {
|
export interface VectorSearchMatch {
|
||||||
content: string;
|
content: string;
|
||||||
score: number;
|
score: number;
|
||||||
@@ -93,7 +50,6 @@ export interface VectorSearchMatch {
|
|||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of a search operation */
|
|
||||||
export interface VectorSearchResult {
|
export interface VectorSearchResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
matches: VectorSearchMatch[];
|
matches: VectorSearchMatch[];
|
||||||
@@ -102,14 +58,12 @@ export interface VectorSearchResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Search options */
|
|
||||||
export interface VectorSearchOptions {
|
export interface VectorSearchOptions {
|
||||||
topK?: number;
|
topK?: number;
|
||||||
minScore?: number;
|
minScore?: number;
|
||||||
category?: VectorCategory;
|
category?: VectorCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index status information */
|
|
||||||
export interface VectorIndexStatus {
|
export interface VectorIndexStatus {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
total_chunks: number;
|
total_chunks: number;
|
||||||
@@ -126,7 +80,6 @@ export interface VectorIndexStatus {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reindex result */
|
|
||||||
export interface ReindexResult {
|
export interface ReindexResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
hnsw_count?: number;
|
hnsw_count?: number;
|
||||||
@@ -134,344 +87,73 @@ export interface ReindexResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// ---------------------------------------------------------------------------
|
||||||
// Python Bridge
|
// No-op implementations
|
||||||
// =============================================================================
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the unified embedder is available (venv and script exist)
|
|
||||||
*/
|
|
||||||
export function isUnifiedEmbedderAvailable(): boolean {
|
export function isUnifiedEmbedderAvailable(): boolean {
|
||||||
if (!existsSync(VENV_PYTHON)) {
|
return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!existsSync(EMBEDDER_SCRIPT)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Python script with JSON stdin/stdout protocol.
|
|
||||||
*
|
|
||||||
* @param request - JSON request object to send via stdin
|
|
||||||
* @param timeout - Timeout in milliseconds (default: 5 minutes)
|
|
||||||
* @returns Parsed JSON response
|
|
||||||
*/
|
|
||||||
function runPython<T>(request: Record<string, unknown>, timeout: number = 300000): Promise<T> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!isUnifiedEmbedderAvailable()) {
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
'Unified embedder not available. Ensure CodexLens venv exists at ~/.codexlens/venv'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = spawn(VENV_PYTHON, [EMBEDDER_SCRIPT], {
|
|
||||||
shell: false,
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
timeout,
|
|
||||||
windowsHide: true,
|
|
||||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
|
|
||||||
child.stdout.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
if (code === 0 && stdout.trim()) {
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(stdout.trim()) as T);
|
|
||||||
} catch {
|
|
||||||
reject(new Error(`Failed to parse Python output: ${stdout.substring(0, 500)}`));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Python script failed (exit code ${code}): ${stderr || stdout}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', (err) => {
|
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
|
||||||
reject(new Error('Python script timed out'));
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Failed to spawn Python: ${err.message}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write JSON request to stdin and close
|
|
||||||
const jsonInput = JSON.stringify(request);
|
|
||||||
child.stdin.write(jsonInput);
|
|
||||||
child.stdin.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Content Chunking
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chunk content into smaller pieces for embedding.
|
|
||||||
* Uses paragraph-first, sentence-fallback strategy with overlap.
|
|
||||||
*
|
|
||||||
* Matches the chunking logic in core-memory-store.ts:
|
|
||||||
* - CHUNK_SIZE = 1500 characters
|
|
||||||
* - OVERLAP = 200 characters
|
|
||||||
* - Split by paragraph boundaries (\n\n) first
|
|
||||||
* - Fall back to sentence boundaries (. ) for oversized paragraphs
|
|
||||||
*
|
|
||||||
* @param content - Text content to chunk
|
|
||||||
* @returns Array of chunk strings
|
|
||||||
*/
|
|
||||||
export function chunkContent(content: string): string[] {
|
export function chunkContent(content: string): string[] {
|
||||||
const chunks: string[] = [];
|
// Minimal chunking for backward compat - just return the content as-is
|
||||||
|
if (!content.trim()) return [];
|
||||||
// Split by paragraph boundaries first
|
return [content];
|
||||||
const paragraphs = content.split(/\n\n+/);
|
|
||||||
let currentChunk = '';
|
|
||||||
|
|
||||||
for (const paragraph of paragraphs) {
|
|
||||||
// If adding this paragraph would exceed chunk size
|
|
||||||
if (currentChunk.length + paragraph.length > CHUNK_SIZE && currentChunk.length > 0) {
|
|
||||||
chunks.push(currentChunk.trim());
|
|
||||||
|
|
||||||
// Start new chunk with overlap
|
|
||||||
const overlapText = currentChunk.slice(-OVERLAP);
|
|
||||||
currentChunk = overlapText + '\n\n' + paragraph;
|
|
||||||
} else {
|
|
||||||
currentChunk += (currentChunk ? '\n\n' : '') + paragraph;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining chunk
|
|
||||||
if (currentChunk.trim()) {
|
|
||||||
chunks.push(currentChunk.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
// If chunks are still too large, split by sentences
|
|
||||||
const finalChunks: string[] = [];
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
if (chunk.length <= CHUNK_SIZE) {
|
|
||||||
finalChunks.push(chunk);
|
|
||||||
} else {
|
|
||||||
// Split by sentence boundaries
|
|
||||||
const sentences = chunk.split(/\. +/);
|
|
||||||
let sentenceChunk = '';
|
|
||||||
|
|
||||||
for (const sentence of sentences) {
|
|
||||||
const sentenceWithPeriod = sentence + '. ';
|
|
||||||
if (
|
|
||||||
sentenceChunk.length + sentenceWithPeriod.length > CHUNK_SIZE &&
|
|
||||||
sentenceChunk.length > 0
|
|
||||||
) {
|
|
||||||
finalChunks.push(sentenceChunk.trim());
|
|
||||||
const overlapText = sentenceChunk.slice(-OVERLAP);
|
|
||||||
sentenceChunk = overlapText + sentenceWithPeriod;
|
|
||||||
} else {
|
|
||||||
sentenceChunk += sentenceWithPeriod;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sentenceChunk.trim()) {
|
|
||||||
finalChunks.push(sentenceChunk.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalChunks.length > 0 ? finalChunks : [content];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// UnifiedVectorIndex Class
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified vector index backed by CodexLens VectorStore (HNSW).
|
|
||||||
*
|
|
||||||
* Provides content chunking, embedding, storage, and search for all
|
|
||||||
* memory content types through a single interface.
|
|
||||||
*/
|
|
||||||
export class UnifiedVectorIndex {
|
export class UnifiedVectorIndex {
|
||||||
private storePath: string;
|
constructor(_projectPath: string) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a UnifiedVectorIndex for a project.
|
|
||||||
*
|
|
||||||
* @param projectPath - Project root path (used to resolve storage location)
|
|
||||||
*/
|
|
||||||
constructor(projectPath: string) {
|
|
||||||
const paths = StoragePaths.project(projectPath);
|
|
||||||
this.storePath = paths.unifiedVectors.root;
|
|
||||||
ensureStorageDir(this.storePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Index content by chunking, embedding, and storing in VectorStore.
|
|
||||||
*
|
|
||||||
* @param content - Text content to index
|
|
||||||
* @param metadata - Metadata for all chunks (source_id, source_type, category)
|
|
||||||
* @returns Embed result
|
|
||||||
*/
|
|
||||||
async indexContent(
|
async indexContent(
|
||||||
content: string,
|
_content: string,
|
||||||
metadata: ChunkMetadata
|
_metadata: ChunkMetadata
|
||||||
): Promise<EmbedResult> {
|
): Promise<EmbedResult> {
|
||||||
if (!content.trim()) {
|
return {
|
||||||
return {
|
success: false,
|
||||||
success: true,
|
chunks_processed: 0,
|
||||||
chunks_processed: 0,
|
chunks_failed: 0,
|
||||||
chunks_failed: 0,
|
elapsed_time: 0,
|
||||||
elapsed_time: 0,
|
error: V1_REMOVED,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Chunk content
|
|
||||||
const textChunks = chunkContent(content);
|
|
||||||
|
|
||||||
// Build chunk objects for Python
|
|
||||||
const chunks: VectorChunk[] = textChunks.map((text, index) => ({
|
|
||||||
content: text,
|
|
||||||
source_id: metadata.source_id,
|
|
||||||
source_type: metadata.source_type,
|
|
||||||
category: metadata.category,
|
|
||||||
chunk_index: metadata.chunk_index != null ? metadata.chunk_index + index : index,
|
|
||||||
metadata: { ...metadata },
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await runPython<EmbedResult>({
|
|
||||||
operation: 'embed',
|
|
||||||
store_path: this.storePath,
|
|
||||||
chunks,
|
|
||||||
batch_size: 8,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
chunks_processed: 0,
|
|
||||||
chunks_failed: textChunks.length,
|
|
||||||
elapsed_time: 0,
|
|
||||||
error: (err as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Search the vector index using semantic similarity.
|
|
||||||
*
|
|
||||||
* @param query - Natural language search query
|
|
||||||
* @param options - Search options (topK, minScore, category)
|
|
||||||
* @returns Search results sorted by relevance
|
|
||||||
*/
|
|
||||||
async search(
|
async search(
|
||||||
query: string,
|
_query: string,
|
||||||
options: VectorSearchOptions = {}
|
_options: VectorSearchOptions = {}
|
||||||
): Promise<VectorSearchResult> {
|
): Promise<VectorSearchResult> {
|
||||||
const { topK = 10, minScore = 0.3, category } = options;
|
return {
|
||||||
|
success: false,
|
||||||
try {
|
matches: [],
|
||||||
const result = await runPython<VectorSearchResult>({
|
error: V1_REMOVED,
|
||||||
operation: 'search',
|
};
|
||||||
store_path: this.storePath,
|
|
||||||
query,
|
|
||||||
top_k: topK,
|
|
||||||
min_score: minScore,
|
|
||||||
category: category || null,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
matches: [],
|
|
||||||
error: (err as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Search the vector index using a pre-computed embedding vector.
|
|
||||||
* Bypasses text embedding, directly querying HNSW with a raw vector.
|
|
||||||
*
|
|
||||||
* @param vector - Pre-computed embedding vector (array of floats)
|
|
||||||
* @param options - Search options (topK, minScore, category)
|
|
||||||
* @returns Search results sorted by relevance
|
|
||||||
*/
|
|
||||||
async searchByVector(
|
async searchByVector(
|
||||||
vector: number[],
|
_vector: number[],
|
||||||
options: VectorSearchOptions = {}
|
_options: VectorSearchOptions = {}
|
||||||
): Promise<VectorSearchResult> {
|
): Promise<VectorSearchResult> {
|
||||||
const { topK = 10, minScore = 0.3, category } = options;
|
return {
|
||||||
|
success: false,
|
||||||
try {
|
matches: [],
|
||||||
const result = await runPython<VectorSearchResult>({
|
error: V1_REMOVED,
|
||||||
operation: 'search_by_vector',
|
};
|
||||||
store_path: this.storePath,
|
|
||||||
vector,
|
|
||||||
top_k: topK,
|
|
||||||
min_score: minScore,
|
|
||||||
category: category || null,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
matches: [],
|
|
||||||
error: (err as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild the HNSW index from scratch.
|
|
||||||
*
|
|
||||||
* @returns Reindex result
|
|
||||||
*/
|
|
||||||
async reindexAll(): Promise<ReindexResult> {
|
async reindexAll(): Promise<ReindexResult> {
|
||||||
try {
|
return {
|
||||||
const result = await runPython<ReindexResult>({
|
success: false,
|
||||||
operation: 'reindex',
|
error: V1_REMOVED,
|
||||||
store_path: this.storePath,
|
};
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: (err as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current status of the vector index.
|
|
||||||
*
|
|
||||||
* @returns Index status including chunk counts, HNSW availability, dimension
|
|
||||||
*/
|
|
||||||
async getStatus(): Promise<VectorIndexStatus> {
|
async getStatus(): Promise<VectorIndexStatus> {
|
||||||
try {
|
return {
|
||||||
const result = await runPython<VectorIndexStatus>({
|
success: false,
|
||||||
operation: 'status',
|
total_chunks: 0,
|
||||||
store_path: this.storePath,
|
hnsw_available: false,
|
||||||
});
|
hnsw_count: 0,
|
||||||
return result;
|
dimension: 0,
|
||||||
} catch (err) {
|
error: V1_REMOVED,
|
||||||
return {
|
};
|
||||||
success: false,
|
|
||||||
total_chunks: 0,
|
|
||||||
hnsw_available: false,
|
|
||||||
hnsw_count: 0,
|
|
||||||
dimension: 0,
|
|
||||||
error: (err as Error).message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 * as smartSearchMod from './smart-search.js';
|
||||||
import { executeInitWithProgress } from './smart-search.js';
|
import { executeInitWithProgress } from './smart-search.js';
|
||||||
// codex_lens removed - functionality integrated into smart_search
|
// codex_lens removed - functionality integrated into smart_search
|
||||||
import * as codexLensLspMod from './codex-lens-lsp.js';
|
// codex_lens_lsp removed - v1 LSP bridge removed
|
||||||
import * as readFileMod from './read-file.js';
|
import * as readFileMod from './read-file.js';
|
||||||
import * as readManyFilesMod from './read-many-files.js';
|
import * as readManyFilesMod from './read-many-files.js';
|
||||||
import * as readOutlineMod from './read-outline.js';
|
import * as readOutlineMod from './read-outline.js';
|
||||||
@@ -365,7 +365,7 @@ registerTool(toLegacyTool(sessionManagerMod));
|
|||||||
registerTool(toLegacyTool(cliExecutorMod));
|
registerTool(toLegacyTool(cliExecutorMod));
|
||||||
registerTool(toLegacyTool(smartSearchMod));
|
registerTool(toLegacyTool(smartSearchMod));
|
||||||
// codex_lens removed - functionality integrated into smart_search
|
// codex_lens removed - functionality integrated into smart_search
|
||||||
registerTool(toLegacyTool(codexLensLspMod));
|
// codex_lens_lsp removed - v1 LSP bridge removed
|
||||||
registerTool(toLegacyTool(readFileMod));
|
registerTool(toLegacyTool(readFileMod));
|
||||||
registerTool(toLegacyTool(readManyFilesMod));
|
registerTool(toLegacyTool(readManyFilesMod));
|
||||||
registerTool(toLegacyTool(readOutlineMod));
|
registerTool(toLegacyTool(readOutlineMod));
|
||||||
|
|||||||
@@ -1,64 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* LiteLLM Client - Bridge between CCW and ccw-litellm Python package
|
* LiteLLM Client - STUB (v1 Python bridge removed)
|
||||||
* Provides LLM chat and embedding capabilities via spawned Python process
|
|
||||||
*
|
*
|
||||||
* Features:
|
* The Python ccw-litellm bridge has been removed. This module provides
|
||||||
* - Chat completions with multiple models
|
* no-op stubs so that existing consumers compile without errors.
|
||||||
* - Text embeddings generation
|
|
||||||
* - Configuration management
|
|
||||||
* - JSON protocol communication
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
const V1_REMOVED = 'LiteLLM Python bridge has been removed (v1 cleanup).';
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { getCodexLensPython, getCodexLensHiddenPython, getCodexLensVenvDir } from '../utils/codexlens-path.js';
|
|
||||||
|
|
||||||
export interface LiteLLMConfig {
|
export interface LiteLLMConfig {
|
||||||
pythonPath?: string; // Default: CodexLens venv Python
|
pythonPath?: string;
|
||||||
configPath?: string; // Configuration file path
|
configPath?: string;
|
||||||
timeout?: number; // Default 60000ms
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform-specific constants for CodexLens venv
|
|
||||||
const IS_WINDOWS = process.platform === 'win32';
|
|
||||||
const CODEXLENS_VENV = getCodexLensVenvDir();
|
|
||||||
const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin';
|
|
||||||
const PYTHON_EXECUTABLE = IS_WINDOWS ? 'pythonw.exe' : 'python';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Python path from CodexLens venv
|
|
||||||
* Falls back to system 'python' if venv doesn't exist
|
|
||||||
* @returns Path to Python executable
|
|
||||||
*/
|
|
||||||
export function getCodexLensVenvPython(): string {
|
export function getCodexLensVenvPython(): string {
|
||||||
const venvPython = join(CODEXLENS_VENV, VENV_BIN_DIR, PYTHON_EXECUTABLE);
|
|
||||||
if (existsSync(venvPython)) {
|
|
||||||
return venvPython;
|
|
||||||
}
|
|
||||||
const hiddenPython = getCodexLensHiddenPython();
|
|
||||||
if (existsSync(hiddenPython)) {
|
|
||||||
return hiddenPython;
|
|
||||||
}
|
|
||||||
// Fallback to system Python if venv not available
|
|
||||||
return 'python';
|
return 'python';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Python path from CodexLens venv using centralized path utility
|
|
||||||
* Falls back to system 'python' if venv doesn't exist
|
|
||||||
* @returns Path to Python executable
|
|
||||||
*/
|
|
||||||
export function getCodexLensPythonPath(): string {
|
export function getCodexLensPythonPath(): string {
|
||||||
const codexLensPython = getCodexLensHiddenPython();
|
|
||||||
if (existsSync(codexLensPython)) {
|
|
||||||
return codexLensPython;
|
|
||||||
}
|
|
||||||
const fallbackPython = getCodexLensPython();
|
|
||||||
if (existsSync(fallbackPython)) {
|
|
||||||
return fallbackPython;
|
|
||||||
}
|
|
||||||
// Fallback to system Python if venv not available
|
|
||||||
return 'python';
|
return 'python';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,179 +49,35 @@ export interface LiteLLMStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LiteLLMClient {
|
export class LiteLLMClient {
|
||||||
private pythonPath: string;
|
constructor(_config: LiteLLMConfig = {}) {}
|
||||||
private configPath?: string;
|
|
||||||
private timeout: number;
|
|
||||||
|
|
||||||
constructor(config: LiteLLMConfig = {}) {
|
|
||||||
this.pythonPath = config.pythonPath || getCodexLensVenvPython();
|
|
||||||
this.configPath = config.configPath;
|
|
||||||
this.timeout = config.timeout || 60000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute Python ccw-litellm command
|
|
||||||
*/
|
|
||||||
private async executePython(args: string[], options: { timeout?: number } = {}): Promise<string> {
|
|
||||||
const timeout = options.timeout || this.timeout;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const proc = spawn(this.pythonPath, ['-m', 'ccw_litellm.cli', ...args], {
|
|
||||||
shell: false,
|
|
||||||
windowsHide: true,
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' }
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
let timedOut = false;
|
|
||||||
|
|
||||||
// Set up timeout
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
proc.kill('SIGTERM');
|
|
||||||
reject(new Error(`Command timed out after ${timeout}ms`));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
proc.stdout.on('data', (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.stderr.on('data', (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('error', (error) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
reject(new Error(`Failed to spawn Python process: ${error.message}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (timedOut) {
|
|
||||||
return; // Already rejected
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code === 0) {
|
|
||||||
resolve(stdout.trim());
|
|
||||||
} else {
|
|
||||||
const errorMsg = stderr.trim() || `Process exited with code ${code}`;
|
|
||||||
reject(new Error(errorMsg));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if ccw-litellm is available
|
|
||||||
*/
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
try {
|
return false;
|
||||||
// Increased timeout to 15s for Python cold start
|
|
||||||
await this.executePython(['version'], { timeout: 15000 });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get status information
|
|
||||||
*/
|
|
||||||
async getStatus(): Promise<LiteLLMStatus> {
|
async getStatus(): Promise<LiteLLMStatus> {
|
||||||
try {
|
return { available: false, error: V1_REMOVED };
|
||||||
// Increased timeout to 15s for Python cold start
|
|
||||||
const output = await this.executePython(['version'], { timeout: 15000 });
|
|
||||||
// Parse "ccw-litellm 0.1.0" format
|
|
||||||
const versionMatch = output.trim().match(/ccw-litellm\s+([\d.]+)/);
|
|
||||||
const version = versionMatch ? versionMatch[1] : output.trim();
|
|
||||||
return {
|
|
||||||
available: true,
|
|
||||||
version
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getConfig(): Promise<unknown> {
|
||||||
* Get current configuration
|
return { error: V1_REMOVED };
|
||||||
*/
|
|
||||||
async getConfig(): Promise<any> {
|
|
||||||
// config command outputs JSON by default, no --json flag needed
|
|
||||||
const output = await this.executePython(['config']);
|
|
||||||
return JSON.parse(output);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async embed(_texts: string[], _model?: string): Promise<EmbedResponse> {
|
||||||
* Generate embeddings for texts
|
throw new Error(V1_REMOVED);
|
||||||
*/
|
|
||||||
async embed(texts: string[], model: string = 'default'): Promise<EmbedResponse> {
|
|
||||||
if (!texts || texts.length === 0) {
|
|
||||||
throw new Error('texts array cannot be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = ['embed', '--model', model, '--output', 'json'];
|
|
||||||
|
|
||||||
// Add texts as arguments
|
|
||||||
for (const text of texts) {
|
|
||||||
args.push(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = await this.executePython(args, { timeout: this.timeout * 2 });
|
|
||||||
const vectors = JSON.parse(output);
|
|
||||||
|
|
||||||
return {
|
|
||||||
vectors,
|
|
||||||
dimensions: vectors[0]?.length || 0,
|
|
||||||
model
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async chat(_message: string, _model?: string): Promise<string> {
|
||||||
* Chat with LLM
|
throw new Error(V1_REMOVED);
|
||||||
*/
|
|
||||||
async chat(message: string, model: string = 'default'): Promise<string> {
|
|
||||||
if (!message) {
|
|
||||||
throw new Error('message cannot be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = ['chat', '--model', model, message];
|
|
||||||
return this.executePython(args, { timeout: this.timeout * 2 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async chatMessages(_messages: ChatMessage[], _model?: string): Promise<ChatResponse> {
|
||||||
* Multi-turn chat with messages array
|
throw new Error(V1_REMOVED);
|
||||||
*/
|
|
||||||
async chatMessages(messages: ChatMessage[], model: string = 'default'): Promise<ChatResponse> {
|
|
||||||
if (!messages || messages.length === 0) {
|
|
||||||
throw new Error('messages array cannot be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now, just use the last user message
|
|
||||||
// TODO: Implement full message history support in ccw-litellm
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
const content = await this.chat(lastMessage.content, model);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content,
|
|
||||||
model,
|
|
||||||
usage: undefined // TODO: Add usage tracking
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
let _client: LiteLLMClient | null = null;
|
let _client: LiteLLMClient | null = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create singleton LiteLLM client
|
|
||||||
*/
|
|
||||||
export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
|
export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
|
||||||
if (!_client) {
|
if (!_client) {
|
||||||
_client = new LiteLLMClient(config);
|
_client = new LiteLLMClient(config);
|
||||||
@@ -270,29 +85,10 @@ export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
|
|||||||
return _client;
|
return _client;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if LiteLLM is available
|
|
||||||
*/
|
|
||||||
export async function checkLiteLLMAvailable(): Promise<boolean> {
|
export async function checkLiteLLMAvailable(): Promise<boolean> {
|
||||||
try {
|
return false;
|
||||||
const client = getLiteLLMClient();
|
|
||||||
return await client.isAvailable();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get LiteLLM status
|
|
||||||
*/
|
|
||||||
export async function getLiteLLMStatus(): Promise<LiteLLMStatus> {
|
export async function getLiteLLMStatus(): Promise<LiteLLMStatus> {
|
||||||
try {
|
return { available: false, error: V1_REMOVED };
|
||||||
const client = getLiteLLMClient();
|
|
||||||
return await client.getStatus();
|
|
||||||
} catch (error: any) {
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
* 2. Default: ~/.codexlens
|
* 2. Default: ~/.codexlens
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
|
|
||||||
@@ -26,56 +25,3 @@ export function getCodexLensDataDir(): string {
|
|||||||
}
|
}
|
||||||
return join(homedir(), '.codexlens');
|
return join(homedir(), '.codexlens');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the CodexLens virtual environment path.
|
|
||||||
*
|
|
||||||
* @returns Path to CodexLens venv directory
|
|
||||||
*/
|
|
||||||
export function getCodexLensVenvDir(): string {
|
|
||||||
return join(getCodexLensDataDir(), 'venv');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Python executable path in the CodexLens venv.
|
|
||||||
*
|
|
||||||
* @returns Path to python executable
|
|
||||||
*/
|
|
||||||
export function getCodexLensPython(): string {
|
|
||||||
const venvDir = getCodexLensVenvDir();
|
|
||||||
return process.platform === 'win32'
|
|
||||||
? join(venvDir, 'Scripts', 'python.exe')
|
|
||||||
: join(venvDir, 'bin', 'python');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the preferred Python executable for hidden/windowless CodexLens subprocesses.
|
|
||||||
* On Windows this prefers pythonw.exe when available to avoid transient console windows.
|
|
||||||
*
|
|
||||||
* @returns Path to the preferred hidden-subprocess Python executable
|
|
||||||
*/
|
|
||||||
export function getCodexLensHiddenPython(): string {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
return getCodexLensPython();
|
|
||||||
}
|
|
||||||
|
|
||||||
const venvDir = getCodexLensVenvDir();
|
|
||||||
const pythonwPath = join(venvDir, 'Scripts', 'pythonw.exe');
|
|
||||||
if (existsSync(pythonwPath)) {
|
|
||||||
return pythonwPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getCodexLensPython();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the pip executable path in the CodexLens venv.
|
|
||||||
*
|
|
||||||
* @returns Path to pip executable
|
|
||||||
*/
|
|
||||||
export function getCodexLensPip(): string {
|
|
||||||
const venvDir = getCodexLensVenvDir();
|
|
||||||
return process.platform === 'win32'
|
|
||||||
? join(venvDir, 'Scripts', 'pip.exe')
|
|
||||||
: join(venvDir, 'bin', 'pip');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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/
|
.ace-tool/
|
||||||
|
|
||||||
|
# Workflow (internal)
|
||||||
|
.workflow/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|||||||
@@ -1,143 +1,221 @@
|
|||||||
# codexlens-search
|
# codexlens-search
|
||||||
|
|
||||||
Lightweight semantic code search engine with 2-stage vector search, full-text search, and Reciprocal Rank Fusion.
|
Semantic code search engine with MCP server for Claude Code.
|
||||||
|
|
||||||
## Overview
|
2-stage vector search + FTS + RRF fusion + reranking — install once, configure API keys, ready to use.
|
||||||
|
|
||||||
codexlens-search provides fast, accurate code search through a multi-stage retrieval pipeline:
|
## Quick Start (Claude Code MCP)
|
||||||
|
|
||||||
1. **Binary coarse search** - Hamming-distance filtering narrows candidates quickly
|
Add to your project `.mcp.json`:
|
||||||
2. **ANN fine search** - HNSW or FAISS refines the candidate set with float vectors
|
|
||||||
3. **Full-text search** - SQLite FTS5 handles exact and fuzzy keyword matching
|
|
||||||
4. **RRF fusion** - Reciprocal Rank Fusion merges vector and text results
|
|
||||||
5. **Reranking** - Optional cross-encoder or API-based reranker for final ordering
|
|
||||||
|
|
||||||
The core library has **zero required dependencies**. Install optional extras to enable semantic search, GPU acceleration, or FAISS backends.
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"codexlens": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["--from", "codexlens-search[mcp]", "codexlens-mcp"],
|
||||||
|
"env": {
|
||||||
|
"CODEXLENS_EMBED_API_URL": "https://api.openai.com/v1",
|
||||||
|
"CODEXLENS_EMBED_API_KEY": "${OPENAI_API_KEY}",
|
||||||
|
"CODEXLENS_EMBED_API_MODEL": "text-embedding-3-small",
|
||||||
|
"CODEXLENS_EMBED_DIM": "1536"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Installation
|
That's it. Claude Code will auto-discover the tools: `index_project` → `search_code`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Core only (FTS search, no vector search)
|
# Standard install (includes vector search + API clients)
|
||||||
pip install codexlens-search
|
pip install codexlens-search
|
||||||
|
|
||||||
# With semantic search (recommended)
|
# With MCP server for Claude Code
|
||||||
pip install codexlens-search[semantic]
|
pip install codexlens-search[mcp]
|
||||||
|
|
||||||
# Semantic search + GPU acceleration
|
|
||||||
pip install codexlens-search[semantic-gpu]
|
|
||||||
|
|
||||||
# With FAISS backend (CPU)
|
|
||||||
pip install codexlens-search[faiss-cpu]
|
|
||||||
|
|
||||||
# With API-based reranker
|
|
||||||
pip install codexlens-search[reranker-api]
|
|
||||||
|
|
||||||
# Everything (semantic + GPU + FAISS + reranker)
|
|
||||||
pip install codexlens-search[semantic-gpu,faiss-gpu,reranker-api]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
Optional extras for advanced use:
|
||||||
|
|
||||||
```python
|
| Extra | Description |
|
||||||
from codexlens_search import Config, IndexingPipeline, SearchPipeline
|
|-------|-------------|
|
||||||
from codexlens_search.core import create_ann_index, create_binary_index
|
| `mcp` | MCP server (`codexlens-mcp` command) |
|
||||||
from codexlens_search.embed.local import FastEmbedEmbedder
|
| `gpu` | GPU-accelerated embedding (onnxruntime-gpu) |
|
||||||
from codexlens_search.rerank.local import LocalReranker
|
| `faiss-cpu` | FAISS ANN backend |
|
||||||
from codexlens_search.search.fts import FTSEngine
|
| `watcher` | File watcher for auto-indexing |
|
||||||
|
|
||||||
# 1. Configure
|
## MCP Tools
|
||||||
config = Config(embed_model="BAAI/bge-small-en-v1.5", embed_dim=384)
|
|
||||||
|
|
||||||
# 2. Create components
|
| Tool | Description |
|
||||||
embedder = FastEmbedEmbedder(config)
|
|------|-------------|
|
||||||
binary_store = create_binary_index(config, db_path="index/binary.db")
|
| `search_code` | Semantic search with hybrid fusion + reranking |
|
||||||
ann_index = create_ann_index(config, index_path="index/ann.bin")
|
| `index_project` | Build or rebuild the search index |
|
||||||
fts = FTSEngine("index/fts.db")
|
| `index_status` | Show index statistics |
|
||||||
reranker = LocalReranker()
|
| `index_update` | Incremental sync (only changed files) |
|
||||||
|
| `find_files` | Glob file discovery |
|
||||||
|
| `list_models` | List models with cache status |
|
||||||
|
| `download_models` | Download local fastembed models |
|
||||||
|
|
||||||
# 3. Index files
|
## MCP Configuration Examples
|
||||||
indexer = IndexingPipeline(embedder, binary_store, ann_index, fts, config)
|
|
||||||
stats = indexer.index_directory("./src")
|
|
||||||
print(f"Indexed {stats.files_processed} files, {stats.chunks_created} chunks")
|
|
||||||
|
|
||||||
# 4. Search
|
### API Embedding Only (simplest)
|
||||||
pipeline = SearchPipeline(embedder, binary_store, ann_index, reranker, fts, config)
|
|
||||||
results = pipeline.search("authentication handler", top_k=10)
|
```json
|
||||||
for r in results:
|
{
|
||||||
print(f" {r.path} (score={r.score:.3f})")
|
"mcpServers": {
|
||||||
|
"codexlens": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["--from", "codexlens-search[mcp]", "codexlens-mcp"],
|
||||||
|
"env": {
|
||||||
|
"CODEXLENS_EMBED_API_URL": "https://api.openai.com/v1",
|
||||||
|
"CODEXLENS_EMBED_API_KEY": "${OPENAI_API_KEY}",
|
||||||
|
"CODEXLENS_EMBED_API_MODEL": "text-embedding-3-small",
|
||||||
|
"CODEXLENS_EMBED_DIM": "1536"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extras
|
### API Embedding + API Reranker (best quality)
|
||||||
|
|
||||||
| Extra | Dependencies | Description |
|
```json
|
||||||
|-------|-------------|-------------|
|
{
|
||||||
| `semantic` | hnswlib, numpy, fastembed | Vector search with local embeddings |
|
"mcpServers": {
|
||||||
| `gpu` | onnxruntime-gpu | GPU-accelerated embedding inference |
|
"codexlens": {
|
||||||
| `semantic-gpu` | semantic + gpu combined | Vector search with GPU acceleration |
|
"command": "uvx",
|
||||||
| `faiss-cpu` | faiss-cpu | FAISS ANN backend (CPU) |
|
"args": ["--from", "codexlens-search[mcp]", "codexlens-mcp"],
|
||||||
| `faiss-gpu` | faiss-gpu | FAISS ANN backend (GPU) |
|
"env": {
|
||||||
| `reranker-api` | httpx | Remote reranker API client |
|
"CODEXLENS_EMBED_API_URL": "https://api.openai.com/v1",
|
||||||
| `dev` | pytest, pytest-cov | Development and testing |
|
"CODEXLENS_EMBED_API_KEY": "${OPENAI_API_KEY}",
|
||||||
|
"CODEXLENS_EMBED_API_MODEL": "text-embedding-3-small",
|
||||||
|
"CODEXLENS_EMBED_DIM": "1536",
|
||||||
|
"CODEXLENS_RERANKER_API_URL": "https://api.jina.ai/v1",
|
||||||
|
"CODEXLENS_RERANKER_API_KEY": "${JINA_API_KEY}",
|
||||||
|
"CODEXLENS_RERANKER_API_MODEL": "jina-reranker-v2-base-multilingual"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Endpoint Load Balancing
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"codexlens": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["--from", "codexlens-search[mcp]", "codexlens-mcp"],
|
||||||
|
"env": {
|
||||||
|
"CODEXLENS_EMBED_API_ENDPOINTS": "https://api1.example.com/v1|sk-key1|model,https://api2.example.com/v1|sk-key2|model",
|
||||||
|
"CODEXLENS_EMBED_DIM": "1536"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Format: `url|key|model,url|key|model,...`
|
||||||
|
|
||||||
|
### Local Models (Offline, No API)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install codexlens-search[mcp]
|
||||||
|
codexlens-search download-models
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"codexlens": {
|
||||||
|
"command": "codexlens-mcp",
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-installed (no uvx)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"codexlens": {
|
||||||
|
"command": "codexlens-mcp",
|
||||||
|
"env": {
|
||||||
|
"CODEXLENS_EMBED_API_URL": "https://api.openai.com/v1",
|
||||||
|
"CODEXLENS_EMBED_API_KEY": "${OPENAI_API_KEY}",
|
||||||
|
"CODEXLENS_EMBED_API_MODEL": "text-embedding-3-small",
|
||||||
|
"CODEXLENS_EMBED_DIM": "1536"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codexlens-search --db-path .codexlens sync --root ./src
|
||||||
|
codexlens-search --db-path .codexlens search -q "auth handler" -k 10
|
||||||
|
codexlens-search --db-path .codexlens status
|
||||||
|
codexlens-search list-models
|
||||||
|
codexlens-search download-models
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Embedding
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `CODEXLENS_EMBED_API_URL` | Embedding API base URL | `https://api.openai.com/v1` |
|
||||||
|
| `CODEXLENS_EMBED_API_KEY` | API key | `sk-xxx` |
|
||||||
|
| `CODEXLENS_EMBED_API_MODEL` | Model name | `text-embedding-3-small` |
|
||||||
|
| `CODEXLENS_EMBED_API_ENDPOINTS` | Multi-endpoint: `url\|key\|model,...` | See above |
|
||||||
|
| `CODEXLENS_EMBED_DIM` | Vector dimension | `1536` |
|
||||||
|
|
||||||
|
### Reranker
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `CODEXLENS_RERANKER_API_URL` | Reranker API base URL | `https://api.jina.ai/v1` |
|
||||||
|
| `CODEXLENS_RERANKER_API_KEY` | API key | `jina-xxx` |
|
||||||
|
| `CODEXLENS_RERANKER_API_MODEL` | Model name | `jina-reranker-v2-base-multilingual` |
|
||||||
|
|
||||||
|
### Tuning
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CODEXLENS_BINARY_TOP_K` | `200` | Binary coarse search candidates |
|
||||||
|
| `CODEXLENS_ANN_TOP_K` | `50` | ANN fine search candidates |
|
||||||
|
| `CODEXLENS_FTS_TOP_K` | `50` | FTS results per method |
|
||||||
|
| `CODEXLENS_FUSION_K` | `60` | RRF fusion k parameter |
|
||||||
|
| `CODEXLENS_RERANKER_TOP_K` | `20` | Results to rerank |
|
||||||
|
| `CODEXLENS_INDEX_WORKERS` | `2` | Parallel indexing workers |
|
||||||
|
| `CODEXLENS_MAX_FILE_SIZE` | `1000000` | Max file size in bytes |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Query
|
Query → [Embedder] → query vector
|
||||||
|
|
├→ [BinaryStore] → candidates (Hamming)
|
||||||
v
|
│ └→ [ANNIndex] → ranked IDs (cosine)
|
||||||
[Embedder] --> query vector
|
├→ [FTS exact] → exact matches
|
||||||
|
|
└→ [FTS fuzzy] → fuzzy matches
|
||||||
+---> [BinaryStore.coarse_search] --> candidate IDs (Hamming distance)
|
└→ [RRF Fusion] → merged ranking
|
||||||
| |
|
└→ [Reranker] → final top-k
|
||||||
| v
|
|
||||||
+---> [ANNIndex.fine_search] ------> ranked IDs (cosine/L2)
|
|
||||||
| |
|
|
||||||
| v (intersect)
|
|
||||||
| vector_results
|
|
||||||
|
|
|
||||||
+---> [FTSEngine.exact_search] ----> exact text matches
|
|
||||||
+---> [FTSEngine.fuzzy_search] ----> fuzzy text matches
|
|
||||||
|
|
|
||||||
v
|
|
||||||
[RRF Fusion] --> merged ranking (adaptive weights by query intent)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
[Reranker] --> final top-k results
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Design Decisions
|
|
||||||
|
|
||||||
- **2-stage vector search**: Binary coarse search (fast Hamming distance on binarized vectors) filters candidates before the more expensive ANN search. This keeps memory usage low and search fast even on large corpora.
|
|
||||||
- **Parallel retrieval**: Vector search and FTS run concurrently via ThreadPoolExecutor.
|
|
||||||
- **Adaptive fusion weights**: Query intent detection adjusts RRF weights between vector and text signals.
|
|
||||||
- **Backend abstraction**: ANN index supports both hnswlib and FAISS backends via a factory function.
|
|
||||||
- **Zero core dependencies**: The base package requires only Python 3.10+. All heavy dependencies are optional.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
The `Config` dataclass controls all pipeline parameters:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from codexlens_search import Config
|
|
||||||
|
|
||||||
config = Config(
|
|
||||||
embed_model="BAAI/bge-small-en-v1.5", # embedding model name
|
|
||||||
embed_dim=384, # embedding dimension
|
|
||||||
embed_batch_size=64, # batch size for embedding
|
|
||||||
ann_backend="auto", # 'auto', 'faiss', 'hnswlib'
|
|
||||||
binary_top_k=200, # binary coarse search candidates
|
|
||||||
ann_top_k=50, # ANN fine search candidates
|
|
||||||
fts_top_k=50, # FTS results per method
|
|
||||||
device="auto", # 'auto', 'cuda', 'cpu'
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/nicepkg/codexlens-search.git
|
git clone https://github.com/catlog22/codexlens-search.git
|
||||||
cd codexlens-search
|
cd codexlens-search
|
||||||
pip install -e ".[dev,semantic]"
|
pip install -e ".[dev]"
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
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]
|
[project]
|
||||||
name = "codexlens-search"
|
name = "codexlens-search"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "Lightweight semantic code search engine — 2-stage vector + FTS + RRF fusion"
|
description = "Lightweight semantic code search engine — 2-stage vector + FTS + RRF fusion + MCP server"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
"hnswlib>=0.8.0",
|
||||||
|
"numpy>=1.26",
|
||||||
|
"fastembed>=0.4.0,<2.0",
|
||||||
|
"httpx>=0.25",
|
||||||
|
]
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [
|
authors = [
|
||||||
@@ -26,14 +31,12 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/nicepkg/codexlens-search"
|
Homepage = "https://github.com/catlog22/codexlens-search"
|
||||||
Repository = "https://github.com/nicepkg/codexlens-search"
|
Repository = "https://github.com/catlog22/codexlens-search"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
semantic = [
|
mcp = [
|
||||||
"hnswlib>=0.8.0",
|
"mcp[cli]>=1.0.0",
|
||||||
"numpy>=1.26",
|
|
||||||
"fastembed>=0.4.0,<2.0",
|
|
||||||
]
|
]
|
||||||
gpu = [
|
gpu = [
|
||||||
"onnxruntime-gpu>=1.16",
|
"onnxruntime-gpu>=1.16",
|
||||||
@@ -44,21 +47,9 @@ faiss-cpu = [
|
|||||||
faiss-gpu = [
|
faiss-gpu = [
|
||||||
"faiss-gpu>=1.7.4",
|
"faiss-gpu>=1.7.4",
|
||||||
]
|
]
|
||||||
embed-api = [
|
|
||||||
"httpx>=0.25",
|
|
||||||
]
|
|
||||||
reranker-api = [
|
|
||||||
"httpx>=0.25",
|
|
||||||
]
|
|
||||||
watcher = [
|
watcher = [
|
||||||
"watchdog>=3.0",
|
"watchdog>=3.0",
|
||||||
]
|
]
|
||||||
semantic-gpu = [
|
|
||||||
"hnswlib>=0.8.0",
|
|
||||||
"numpy>=1.26",
|
|
||||||
"fastembed>=0.4.0,<2.0",
|
|
||||||
"onnxruntime-gpu>=1.16",
|
|
||||||
]
|
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
@@ -66,6 +57,7 @@ dev = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
codexlens-search = "codexlens_search.bridge:main"
|
codexlens-search = "codexlens_search.bridge:main"
|
||||||
|
codexlens-mcp = "codexlens_search.mcp_server:main"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/codexlens_search"]
|
packages = ["src/codexlens_search"]
|
||||||
|
|||||||
@@ -50,21 +50,19 @@ def _resolve_db_path(args: argparse.Namespace) -> Path:
|
|||||||
return db_path
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
def _create_config(args: argparse.Namespace) -> "Config":
|
def create_config_from_env(db_path: str | Path, **overrides: object) -> "Config":
|
||||||
"""Build Config from CLI args."""
|
"""Build Config from environment variables and optional overrides.
|
||||||
|
|
||||||
|
Used by both CLI bridge and MCP server.
|
||||||
|
"""
|
||||||
from codexlens_search.config import Config
|
from codexlens_search.config import Config
|
||||||
|
|
||||||
kwargs: dict = {}
|
kwargs: dict = {}
|
||||||
if hasattr(args, "embed_model") and args.embed_model:
|
# Apply explicit overrides first
|
||||||
kwargs["embed_model"] = args.embed_model
|
for key in ("embed_model", "embed_api_url", "embed_api_key", "embed_api_model"):
|
||||||
# API embedding overrides
|
if overrides.get(key):
|
||||||
if hasattr(args, "embed_api_url") and args.embed_api_url:
|
kwargs[key] = overrides[key]
|
||||||
kwargs["embed_api_url"] = args.embed_api_url
|
# Env vars as fallback
|
||||||
if hasattr(args, "embed_api_key") and args.embed_api_key:
|
|
||||||
kwargs["embed_api_key"] = args.embed_api_key
|
|
||||||
if hasattr(args, "embed_api_model") and args.embed_api_model:
|
|
||||||
kwargs["embed_api_model"] = args.embed_api_model
|
|
||||||
# Also check env vars as fallback
|
|
||||||
if "embed_api_url" not in kwargs and os.environ.get("CODEXLENS_EMBED_API_URL"):
|
if "embed_api_url" not in kwargs and os.environ.get("CODEXLENS_EMBED_API_URL"):
|
||||||
kwargs["embed_api_url"] = os.environ["CODEXLENS_EMBED_API_URL"]
|
kwargs["embed_api_url"] = os.environ["CODEXLENS_EMBED_API_URL"]
|
||||||
if "embed_api_key" not in kwargs and os.environ.get("CODEXLENS_EMBED_API_KEY"):
|
if "embed_api_key" not in kwargs and os.environ.get("CODEXLENS_EMBED_API_KEY"):
|
||||||
@@ -124,18 +122,33 @@ def _create_config(args: argparse.Namespace) -> "Config":
|
|||||||
kwargs["hnsw_ef"] = int(os.environ["CODEXLENS_HNSW_EF"])
|
kwargs["hnsw_ef"] = int(os.environ["CODEXLENS_HNSW_EF"])
|
||||||
if os.environ.get("CODEXLENS_HNSW_M"):
|
if os.environ.get("CODEXLENS_HNSW_M"):
|
||||||
kwargs["hnsw_M"] = int(os.environ["CODEXLENS_HNSW_M"])
|
kwargs["hnsw_M"] = int(os.environ["CODEXLENS_HNSW_M"])
|
||||||
db_path = Path(args.db_path).resolve()
|
resolved = Path(db_path).resolve()
|
||||||
kwargs["metadata_db_path"] = str(db_path / "metadata.db")
|
kwargs["metadata_db_path"] = str(resolved / "metadata.db")
|
||||||
return Config(**kwargs)
|
return Config(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _create_pipeline(
|
def _create_config(args: argparse.Namespace) -> "Config":
|
||||||
args: argparse.Namespace,
|
"""Build Config from CLI args (delegates to create_config_from_env)."""
|
||||||
|
overrides: dict = {}
|
||||||
|
if hasattr(args, "embed_model") and args.embed_model:
|
||||||
|
overrides["embed_model"] = args.embed_model
|
||||||
|
if hasattr(args, "embed_api_url") and args.embed_api_url:
|
||||||
|
overrides["embed_api_url"] = args.embed_api_url
|
||||||
|
if hasattr(args, "embed_api_key") and args.embed_api_key:
|
||||||
|
overrides["embed_api_key"] = args.embed_api_key
|
||||||
|
if hasattr(args, "embed_api_model") and args.embed_api_model:
|
||||||
|
overrides["embed_api_model"] = args.embed_api_model
|
||||||
|
return create_config_from_env(args.db_path, **overrides)
|
||||||
|
|
||||||
|
|
||||||
|
def create_pipeline(
|
||||||
|
db_path: str | Path,
|
||||||
|
config: "Config | None" = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Lazily construct pipeline components from CLI args.
|
"""Construct pipeline components from db_path and config.
|
||||||
|
|
||||||
Returns (indexing_pipeline, search_pipeline, config).
|
Returns (indexing_pipeline, search_pipeline, config).
|
||||||
Only loads embedder/reranker models when needed.
|
Used by both CLI bridge and MCP server.
|
||||||
"""
|
"""
|
||||||
from codexlens_search.config import Config
|
from codexlens_search.config import Config
|
||||||
from codexlens_search.core.factory import create_ann_index, create_binary_index
|
from codexlens_search.core.factory import create_ann_index, create_binary_index
|
||||||
@@ -144,8 +157,10 @@ def _create_pipeline(
|
|||||||
from codexlens_search.search.fts import FTSEngine
|
from codexlens_search.search.fts import FTSEngine
|
||||||
from codexlens_search.search.pipeline import SearchPipeline
|
from codexlens_search.search.pipeline import SearchPipeline
|
||||||
|
|
||||||
config = _create_config(args)
|
if config is None:
|
||||||
db_path = _resolve_db_path(args)
|
config = create_config_from_env(db_path)
|
||||||
|
resolved = Path(db_path).resolve()
|
||||||
|
resolved.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Select embedder: API if configured, otherwise local fastembed
|
# Select embedder: API if configured, otherwise local fastembed
|
||||||
if config.embed_api_url:
|
if config.embed_api_url:
|
||||||
@@ -163,10 +178,10 @@ def _create_pipeline(
|
|||||||
from codexlens_search.embed.local import FastEmbedEmbedder
|
from codexlens_search.embed.local import FastEmbedEmbedder
|
||||||
embedder = FastEmbedEmbedder(config)
|
embedder = FastEmbedEmbedder(config)
|
||||||
|
|
||||||
binary_store = create_binary_index(db_path, config.embed_dim, config)
|
binary_store = create_binary_index(resolved, config.embed_dim, config)
|
||||||
ann_index = create_ann_index(db_path, config.embed_dim, config)
|
ann_index = create_ann_index(resolved, config.embed_dim, config)
|
||||||
fts = FTSEngine(db_path / "fts.db")
|
fts = FTSEngine(resolved / "fts.db")
|
||||||
metadata = MetadataStore(db_path / "metadata.db")
|
metadata = MetadataStore(resolved / "metadata.db")
|
||||||
|
|
||||||
# Select reranker: API if configured, otherwise local fastembed
|
# Select reranker: API if configured, otherwise local fastembed
|
||||||
if config.reranker_api_url:
|
if config.reranker_api_url:
|
||||||
@@ -199,6 +214,15 @@ def _create_pipeline(
|
|||||||
return indexing, search, config
|
return indexing, search, config
|
||||||
|
|
||||||
|
|
||||||
|
def _create_pipeline(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
) -> tuple:
|
||||||
|
"""CLI wrapper: construct pipeline from argparse args."""
|
||||||
|
config = _create_config(args)
|
||||||
|
db_path = _resolve_db_path(args)
|
||||||
|
return create_pipeline(db_path, config)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Subcommand handlers
|
# Subcommand handlers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -269,14 +293,14 @@ def cmd_remove_file(args: argparse.Namespace) -> None:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_EXCLUDES = frozenset({
|
DEFAULT_EXCLUDES = frozenset({
|
||||||
"node_modules", ".git", "__pycache__", "dist", "build",
|
"node_modules", ".git", "__pycache__", "dist", "build",
|
||||||
".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
|
".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
|
||||||
".next", ".nuxt", "coverage", ".eggs", "*.egg-info",
|
".next", ".nuxt", "coverage", ".eggs", "*.egg-info",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def _should_exclude(path: Path, exclude_dirs: frozenset[str]) -> bool:
|
def should_exclude(path: Path, exclude_dirs: frozenset[str]) -> bool:
|
||||||
"""Check if any path component matches an exclude pattern."""
|
"""Check if any path component matches an exclude pattern."""
|
||||||
parts = path.parts
|
parts = path.parts
|
||||||
return any(part in exclude_dirs for part in parts)
|
return any(part in exclude_dirs for part in parts)
|
||||||
@@ -290,11 +314,11 @@ def cmd_sync(args: argparse.Namespace) -> None:
|
|||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
_error_exit(f"Root directory not found: {root}")
|
_error_exit(f"Root directory not found: {root}")
|
||||||
|
|
||||||
exclude_dirs = frozenset(args.exclude) if args.exclude else _DEFAULT_EXCLUDES
|
exclude_dirs = frozenset(args.exclude) if args.exclude else DEFAULT_EXCLUDES
|
||||||
pattern = args.glob or "**/*"
|
pattern = args.glob or "**/*"
|
||||||
file_paths = [
|
file_paths = [
|
||||||
p for p in root.glob(pattern)
|
p for p in root.glob(pattern)
|
||||||
if p.is_file() and not _should_exclude(p.relative_to(root), exclude_dirs)
|
if p.is_file() and not should_exclude(p.relative_to(root), exclude_dirs)
|
||||||
]
|
]
|
||||||
|
|
||||||
log.debug("Sync: %d files after exclusion (root=%s, pattern=%s)", len(file_paths), root, pattern)
|
log.debug("Sync: %d files after exclusion (root=%s, pattern=%s)", len(file_paths), root, pattern)
|
||||||
|
|||||||
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