mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: add experimental support for AST parsing and static graph indexing
- Introduced CLI options for using AST grep parsers and enabling static graph relationships during indexing. - Updated configuration management to load new settings for AST parsing and static graph types. - Enhanced AST grep processor to handle imports with aliases and improve relationship tracking. - Modified TreeSitter parsers to support synthetic module scopes for better static graph persistence. - Implemented global relationship updates in the incremental indexer for static graph expansion. - Added new ArtifactTag and FloatingFileBrowser components to the frontend for improved terminal dashboard functionality. - Created utility functions for detecting CCW artifacts in terminal output with associated tests.
This commit is contained in:
@@ -11,42 +11,39 @@ Main process orchestrator: intent analysis → workflow selection → command ch
|
|||||||
|
|
||||||
## Skill 映射
|
## Skill 映射
|
||||||
|
|
||||||
命令链中的 workflow 操作通过 `Skill()` 调用。
|
命令链中的 workflow 操作通过 `Skill()` 调用。每个 Skill 是自包含的执行单元,内部处理完整流水线。
|
||||||
|
|
||||||
| Skill | 包含操作 |
|
| Skill | 内部流水线 |
|
||||||
|-------|---------|
|
|-------|-----------|
|
||||||
| `workflow-lite-plan` | lite-plan, lite-execute |
|
| `workflow-lite-plan` | explore → plan → confirm → execute |
|
||||||
| `workflow-plan` | plan, plan-verify, replan |
|
| `workflow-plan` | session → context → convention → gen → verify/replan |
|
||||||
| `workflow-execute` | execute |
|
| `workflow-execute` | session discovery → task processing → commit |
|
||||||
| `workflow-multi-cli-plan` | multi-cli-plan |
|
| `workflow-tdd` | 6-phase TDD plan → verify |
|
||||||
| `workflow-test-fix` | test-fix-gen, test-cycle-execute |
|
| `workflow-test-fix` | session → context → analysis → gen → cycle |
|
||||||
| `workflow-tdd` | tdd-plan, tdd-verify |
|
| `workflow-multi-cli-plan` | ACE context → CLI discussion → plan → execute |
|
||||||
| `review-cycle` | review-session-cycle, review-module-cycle, review-cycle-fix |
|
| `review-cycle` | session/module review → fix orchestration |
|
||||||
| `brainstorm` | auto-parallel, artifacts, role-analysis, synthesis |
|
| `brainstorm` | auto/single-role → artifacts → analysis → synthesis |
|
||||||
|
|
||||||
独立命令:workflow:brainstorm-with-file, workflow:debug-with-file, workflow:analyze-with-file, issue:*
|
独立命令(仍使用 colon 格式):workflow:brainstorm-with-file, workflow:debug-with-file, workflow:analyze-with-file, workflow:ui-design:*, issue:*, workflow:session:*
|
||||||
|
|
||||||
## Core Concept: Minimum Execution Units (最小执行单元)
|
## Core Concept: Self-Contained Skills (自包含 Skill)
|
||||||
|
|
||||||
**Definition**: A set of commands that must execute together as an atomic group to achieve a meaningful workflow milestone.
|
**Definition**: 每个 Skill 内部处理完整流水线,是天然的最小执行单元。单次 Skill 调用即完成一个有意义的工作里程碑。
|
||||||
|
|
||||||
**Why This Matters**:
|
**Why This Matters**:
|
||||||
- **Prevents Incomplete States**: Avoid stopping after task generation without execution
|
- **Prevents Incomplete States**: 每个 Skill 内部保证端到端完整性
|
||||||
- **User Experience**: User gets complete results, not intermediate artifacts requiring manual follow-up
|
- **User Experience**: User gets complete results, not intermediate artifacts requiring manual follow-up
|
||||||
- **Workflow Integrity**: Maintains logical coherence of multi-step operations
|
- **Simplified Orchestration**: 命令链只需组合独立 Skill,无需关注内部步骤
|
||||||
|
|
||||||
**Key Units in CCW**:
|
**Key Units in CCW**:
|
||||||
|
|
||||||
| Unit Type | Pattern | Example |
|
| 单元类型 | Skill | 说明 |
|
||||||
|-----------|---------|---------|
|
|---------|-------|------|
|
||||||
| **Planning + Execution** | plan-cmd → execute-cmd | lite-plan → lite-execute |
|
| 轻量 Plan+Execute | `workflow-lite-plan` | 内部完成 plan→execute |
|
||||||
| **Testing** | test-gen-cmd → test-exec-cmd | test-fix-gen → test-cycle-execute |
|
| 标准 Planning | `workflow-plan` → `workflow-execute` | plan 和 execute 是独立 Skill |
|
||||||
| **Review** | review-cmd → fix-cmd | review-session-cycle → review-cycle-fix |
|
| TDD Planning | `workflow-tdd` → `workflow-execute` | tdd-plan 和 execute 是独立 Skill |
|
||||||
|
| 测试流水线 | `workflow-test-fix` | 内部完成 gen→cycle |
|
||||||
**Atomic Rules**:
|
| 代码审查 | `review-cycle` | 内部完成 review→fix |
|
||||||
1. CCW automatically groups commands into minimum units - never splits them
|
|
||||||
2. Pipeline visualization shows units with `【 】` markers
|
|
||||||
3. Error handling preserves unit boundaries (retry/skip affects whole unit)
|
|
||||||
|
|
||||||
## Execution Model
|
## Execution Model
|
||||||
|
|
||||||
@@ -157,153 +154,119 @@ function selectWorkflow(analysis) {
|
|||||||
return buildCommandChain(selected, analysis);
|
return buildCommandChain(selected, analysis);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build command chain (port-based matching with Minimum Execution Units)
|
// Build command chain (Skill-based composition)
|
||||||
function buildCommandChain(workflow, analysis) {
|
function buildCommandChain(workflow, analysis) {
|
||||||
const chains = {
|
const chains = {
|
||||||
// Level 2 - Lightweight
|
// Level 2 - Lightweight
|
||||||
'rapid': [
|
'rapid': [
|
||||||
// Unit: Quick Implementation【lite-plan → lite-execute】
|
{ cmd: 'workflow-lite-plan', args: `"${analysis.goal}"` },
|
||||||
{ cmd: '/workflow:lite-plan', args: `"${analysis.goal}"`, unit: 'quick-impl' },
|
|
||||||
{ cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'quick-impl' },
|
|
||||||
// Unit: Test Validation【test-fix-gen → test-cycle-execute】
|
|
||||||
...(analysis.constraints?.includes('skip-tests') ? [] : [
|
...(analysis.constraints?.includes('skip-tests') ? [] : [
|
||||||
{ cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
|
{ cmd: 'workflow-test-fix', args: '' }
|
||||||
{ cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
|
|
||||||
])
|
])
|
||||||
],
|
],
|
||||||
|
|
||||||
// Level 2 Bridge - Lightweight to Issue Workflow
|
// Level 2 Bridge - Lightweight to Issue Workflow
|
||||||
'rapid-to-issue': [
|
'rapid-to-issue': [
|
||||||
// Unit: Quick Implementation【lite-plan → convert-to-plan】
|
{ cmd: 'workflow-lite-plan', args: `"${analysis.goal}" --plan-only` },
|
||||||
{ cmd: '/workflow:lite-plan', args: `"${analysis.goal}"`, unit: 'quick-impl-to-issue' },
|
{ cmd: 'issue:convert-to-plan', args: '--latest-lite-plan -y' },
|
||||||
{ cmd: '/issue:convert-to-plan', args: '--latest-lite-plan -y', unit: 'quick-impl-to-issue' },
|
{ cmd: 'issue:queue', args: '' },
|
||||||
// Auto-continue to issue workflow
|
{ cmd: 'issue:execute', args: '--queue auto' }
|
||||||
{ cmd: '/issue:queue', args: '' },
|
|
||||||
{ cmd: '/issue:execute', args: '--queue auto' }
|
|
||||||
],
|
],
|
||||||
|
|
||||||
'bugfix.standard': [
|
'bugfix.standard': [
|
||||||
// Unit: Bug Fix【lite-plan → lite-execute】
|
{ cmd: 'workflow-lite-plan', args: `--bugfix "${analysis.goal}"` },
|
||||||
{ cmd: '/workflow:lite-plan', args: `--bugfix "${analysis.goal}"`, unit: 'bug-fix' },
|
|
||||||
{ cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'bug-fix' },
|
|
||||||
// Unit: Test Validation【test-fix-gen → test-cycle-execute】
|
|
||||||
...(analysis.constraints?.includes('skip-tests') ? [] : [
|
...(analysis.constraints?.includes('skip-tests') ? [] : [
|
||||||
{ cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
|
{ cmd: 'workflow-test-fix', args: '' }
|
||||||
{ cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
|
|
||||||
])
|
])
|
||||||
],
|
],
|
||||||
|
|
||||||
'bugfix.hotfix': [
|
'bugfix.hotfix': [
|
||||||
{ cmd: '/workflow:lite-plan', args: `--hotfix "${analysis.goal}"` }
|
{ cmd: 'workflow-lite-plan', args: `--hotfix "${analysis.goal}"` }
|
||||||
],
|
],
|
||||||
|
|
||||||
'multi-cli-plan': [
|
'multi-cli-plan': [
|
||||||
// Unit: Multi-CLI Planning【multi-cli-plan → lite-execute】
|
{ cmd: 'workflow-multi-cli-plan', args: `"${analysis.goal}"` },
|
||||||
{ cmd: '/workflow:multi-cli-plan', args: `"${analysis.goal}"`, unit: 'multi-cli' },
|
|
||||||
{ cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'multi-cli' },
|
|
||||||
// Unit: Test Validation【test-fix-gen → test-cycle-execute】
|
|
||||||
...(analysis.constraints?.includes('skip-tests') ? [] : [
|
...(analysis.constraints?.includes('skip-tests') ? [] : [
|
||||||
{ cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
|
{ cmd: 'workflow-test-fix', args: '' }
|
||||||
{ cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
|
|
||||||
])
|
])
|
||||||
],
|
],
|
||||||
|
|
||||||
'docs': [
|
'docs': [
|
||||||
// Unit: Quick Implementation【lite-plan → lite-execute】
|
{ cmd: 'workflow-lite-plan', args: `"${analysis.goal}"` }
|
||||||
{ cmd: '/workflow:lite-plan', args: `"${analysis.goal}"`, unit: 'quick-impl' },
|
|
||||||
{ cmd: '/workflow:lite-execute', args: '--in-memory', unit: 'quick-impl' }
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// With-File workflows (documented exploration with multi-CLI collaboration)
|
// With-File workflows (documented exploration with multi-CLI collaboration)
|
||||||
'brainstorm-with-file': [
|
'brainstorm-with-file': [
|
||||||
{ cmd: '/workflow:brainstorm-with-file', args: `"${analysis.goal}"` }
|
{ cmd: 'workflow:brainstorm-with-file', args: `"${analysis.goal}"` }
|
||||||
// Note: Has built-in post-completion options (create plan, create issue, deep analysis)
|
// Note: Has built-in post-completion options (create plan, create issue, deep analysis)
|
||||||
],
|
],
|
||||||
|
|
||||||
// Brainstorm-to-Issue workflow (bridge from brainstorm to issue execution)
|
// Brainstorm-to-Issue workflow (bridge from brainstorm to issue execution)
|
||||||
'brainstorm-to-issue': [
|
'brainstorm-to-issue': [
|
||||||
// Note: Assumes brainstorm session already exists, or run brainstorm first
|
// Note: Assumes brainstorm session already exists, or run brainstorm first
|
||||||
{ cmd: '/issue:from-brainstorm', args: `SESSION="${extractBrainstormSession(analysis)}" --auto` },
|
{ cmd: 'issue:from-brainstorm', args: `SESSION="${extractBrainstormSession(analysis)}" --auto` },
|
||||||
{ cmd: '/issue:queue', args: '' },
|
{ cmd: 'issue:queue', args: '' },
|
||||||
{ cmd: '/issue:execute', args: '--queue auto' }
|
{ cmd: 'issue:execute', args: '--queue auto' }
|
||||||
],
|
],
|
||||||
|
|
||||||
'debug-with-file': [
|
'debug-with-file': [
|
||||||
{ cmd: '/workflow:debug-with-file', args: `"${analysis.goal}"` }
|
{ cmd: 'workflow:debug-with-file', args: `"${analysis.goal}"` }
|
||||||
// Note: Self-contained with hypothesis-driven iteration and Gemini validation
|
// Note: Self-contained with hypothesis-driven iteration and Gemini validation
|
||||||
],
|
],
|
||||||
|
|
||||||
'analyze-with-file': [
|
'analyze-with-file': [
|
||||||
{ cmd: '/workflow:analyze-with-file', args: `"${analysis.goal}"` }
|
{ cmd: 'workflow:analyze-with-file', args: `"${analysis.goal}"` }
|
||||||
// Note: Self-contained with multi-round discussion and CLI exploration
|
// Note: Self-contained with multi-round discussion and CLI exploration
|
||||||
],
|
],
|
||||||
|
|
||||||
// Level 3 - Standard
|
// Level 3 - Standard
|
||||||
'coupled': [
|
'coupled': [
|
||||||
// Unit: Verified Planning【plan → plan-verify】
|
{ cmd: 'workflow-plan', args: `"${analysis.goal}"` },
|
||||||
{ cmd: '/workflow:plan', args: `"${analysis.goal}"`, unit: 'verified-planning' },
|
{ cmd: 'workflow-execute', args: '' },
|
||||||
{ cmd: '/workflow:plan-verify', args: '', unit: 'verified-planning' },
|
{ cmd: 'review-cycle', args: '' },
|
||||||
// Execution
|
|
||||||
{ cmd: '/workflow:execute', args: '' },
|
|
||||||
// Unit: Code Review【review-session-cycle → review-cycle-fix】
|
|
||||||
{ cmd: '/workflow:review-session-cycle', args: '', unit: 'code-review' },
|
|
||||||
{ cmd: '/workflow:review-cycle-fix', args: '', unit: 'code-review' },
|
|
||||||
// Unit: Test Validation【test-fix-gen → test-cycle-execute】
|
|
||||||
...(analysis.constraints?.includes('skip-tests') ? [] : [
|
...(analysis.constraints?.includes('skip-tests') ? [] : [
|
||||||
{ cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
|
{ cmd: 'workflow-test-fix', args: '' }
|
||||||
{ cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
|
|
||||||
])
|
])
|
||||||
],
|
],
|
||||||
|
|
||||||
'tdd': [
|
'tdd': [
|
||||||
// Unit: TDD Planning + Execution【tdd-plan → execute】
|
{ cmd: 'workflow-tdd', args: `"${analysis.goal}"` },
|
||||||
{ cmd: '/workflow:tdd-plan', args: `"${analysis.goal}"`, unit: 'tdd-planning' },
|
{ cmd: 'workflow-execute', args: '' }
|
||||||
{ cmd: '/workflow:execute', args: '', unit: 'tdd-planning' },
|
|
||||||
// TDD Verification
|
|
||||||
{ cmd: '/workflow:tdd-verify', args: '' }
|
|
||||||
],
|
],
|
||||||
|
|
||||||
'test-fix-gen': [
|
'test-fix-gen': [
|
||||||
// Unit: Test Validation【test-fix-gen → test-cycle-execute】
|
{ cmd: 'workflow-test-fix', args: `"${analysis.goal}"` }
|
||||||
{ cmd: '/workflow:test-fix-gen', args: `"${analysis.goal}"`, unit: 'test-validation' },
|
|
||||||
{ cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
|
|
||||||
],
|
],
|
||||||
|
|
||||||
'review-cycle-fix': [
|
'review-cycle-fix': [
|
||||||
// Unit: Code Review【review-session-cycle → review-cycle-fix】
|
{ cmd: 'review-cycle', args: '' },
|
||||||
{ cmd: '/workflow:review-session-cycle', args: '', unit: 'code-review' },
|
...(analysis.constraints?.includes('skip-tests') ? [] : [
|
||||||
{ cmd: '/workflow:review-cycle-fix', args: '', unit: 'code-review' },
|
{ cmd: 'workflow-test-fix', args: '' }
|
||||||
// Unit: Test Validation【test-fix-gen → test-cycle-execute】
|
])
|
||||||
{ cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
|
|
||||||
{ cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
|
|
||||||
],
|
],
|
||||||
|
|
||||||
'ui': [
|
'ui': [
|
||||||
{ cmd: '/workflow:ui-design:explore-auto', args: `"${analysis.goal}"` },
|
{ cmd: 'workflow:ui-design:explore-auto', args: `"${analysis.goal}"` },
|
||||||
// Unit: Planning + Execution【plan → execute】
|
{ cmd: 'workflow-plan', args: '' },
|
||||||
{ cmd: '/workflow:plan', args: '', unit: 'plan-execute' },
|
{ cmd: 'workflow-execute', args: '' }
|
||||||
{ cmd: '/workflow:execute', args: '', unit: 'plan-execute' }
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// Level 4 - Brainstorm
|
// Level 4 - Full
|
||||||
'full': [
|
'full': [
|
||||||
{ cmd: '/brainstorm', args: `"${analysis.goal}"` },
|
{ cmd: 'brainstorm', args: `"${analysis.goal}"` },
|
||||||
// Unit: Verified Planning【plan → plan-verify】
|
{ cmd: 'workflow-plan', args: '' },
|
||||||
{ cmd: '/workflow:plan', args: '', unit: 'verified-planning' },
|
{ cmd: 'workflow-execute', args: '' },
|
||||||
{ cmd: '/workflow:plan-verify', args: '', unit: 'verified-planning' },
|
...(analysis.constraints?.includes('skip-tests') ? [] : [
|
||||||
// Execution
|
{ cmd: 'workflow-test-fix', args: '' }
|
||||||
{ cmd: '/workflow:execute', args: '' },
|
])
|
||||||
// Unit: Test Validation【test-fix-gen → test-cycle-execute】
|
|
||||||
{ cmd: '/workflow:test-fix-gen', args: '', unit: 'test-validation' },
|
|
||||||
{ cmd: '/workflow:test-cycle-execute', args: '', unit: 'test-validation' }
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// Issue Workflow
|
// Issue Workflow
|
||||||
'issue': [
|
'issue': [
|
||||||
{ cmd: '/issue:discover', args: '' },
|
{ cmd: 'issue:discover', args: '' },
|
||||||
{ cmd: '/issue:plan', args: '--all-pending' },
|
{ cmd: 'issue:plan', args: '--all-pending' },
|
||||||
{ cmd: '/issue:queue', args: '' },
|
{ cmd: 'issue:queue', args: '' },
|
||||||
{ cmd: '/issue:execute', args: '' }
|
{ cmd: 'issue:execute', args: '' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -311,7 +274,7 @@ function buildCommandChain(workflow, analysis) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Output**: `Level [X] - [flow] | Pipeline: [...] | Commands: [1. /cmd1 2. /cmd2 ...]`
|
**Output**: `Level [X] - [flow] | Pipeline: [...] | Commands: [1. cmd1 2. cmd2 ...]`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -377,7 +340,7 @@ function setupTodoTracking(chain, workflow, analysis) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Output**:
|
**Output**:
|
||||||
- TODO: `-> CCW:rapid: [1/3] /workflow:lite-plan | CCW:rapid: [2/3] /workflow:lite-execute | ...`
|
- TODO: `-> CCW:rapid: [1/2] workflow-lite-plan | CCW:rapid: [2/2] workflow-test-fix | ...`
|
||||||
- Status File: `.workflow/.ccw/{session_id}/status.json`
|
- Status File: `.workflow/.ccw/{session_id}/status.json`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -397,8 +360,8 @@ async function executeCommandChain(chain, workflow, trackingState) {
|
|||||||
state.updated_at = new Date().toISOString();
|
state.updated_at = new Date().toISOString();
|
||||||
Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2));
|
Write(`${stateDir}/status.json`, JSON.stringify(state, null, 2));
|
||||||
|
|
||||||
const fullCommand = assembleCommand(chain[i], previousResult);
|
const assembled = assembleCommand(chain[i], previousResult);
|
||||||
const result = await Skill({ skill: fullCommand });
|
const result = await Skill(assembled);
|
||||||
|
|
||||||
previousResult = { ...result, success: true };
|
previousResult = { ...result, success: true };
|
||||||
|
|
||||||
@@ -442,15 +405,13 @@ async function executeCommandChain(chain, workflow, trackingState) {
|
|||||||
return { success: true, completed: chain.length, sessionId };
|
return { success: true, completed: chain.length, sessionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assemble full command with session/plan parameters
|
// Assemble Skill call with session/plan parameters
|
||||||
function assembleCommand(step, previousResult) {
|
function assembleCommand(step, previousResult) {
|
||||||
let command = step.cmd;
|
let args = step.args || '';
|
||||||
if (step.args) {
|
if (!args && previousResult?.session_id) {
|
||||||
command += ` ${step.args}`;
|
args = `--session="${previousResult.session_id}"`;
|
||||||
} else if (previousResult?.session_id) {
|
|
||||||
command += ` --session="${previousResult.session_id}"`;
|
|
||||||
}
|
}
|
||||||
return command;
|
return { skill: step.cmd, args };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update TODO: mark current as complete, next as in-progress
|
// Update TODO: mark current as complete, next as in-progress
|
||||||
@@ -498,7 +459,7 @@ Phase 1: Analyze Intent
|
|||||||
Phase 2: Select Workflow & Build Chain
|
Phase 2: Select Workflow & Build Chain
|
||||||
|-- Map task_type -> Level (1/2/3/4/Issue)
|
|-- Map task_type -> Level (1/2/3/4/Issue)
|
||||||
|-- Select flow based on complexity
|
|-- Select flow based on complexity
|
||||||
+-- Build command chain (port-based)
|
+-- Build command chain (Skill-based)
|
||||||
|
|
|
|
||||||
Phase 3: User Confirmation (optional)
|
Phase 3: User Confirmation (optional)
|
||||||
|-- Show pipeline visualization
|
|-- Show pipeline visualization
|
||||||
@@ -511,7 +472,7 @@ Phase 4: Setup TODO Tracking & Status File
|
|||||||
Phase 5: Execute Command Chain
|
Phase 5: Execute Command Chain
|
||||||
|-- For each command:
|
|-- For each command:
|
||||||
| |-- Update status.json (current=running)
|
| |-- Update status.json (current=running)
|
||||||
| |-- Assemble full command
|
| |-- Assemble Skill call
|
||||||
| |-- Execute via Skill
|
| |-- Execute via Skill
|
||||||
| |-- Update status.json (current=completed, next=running)
|
| |-- Update status.json (current=completed, next=running)
|
||||||
| |-- Update TODO status
|
| |-- Update TODO status
|
||||||
@@ -521,22 +482,20 @@ Phase 5: Execute Command Chain
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pipeline Examples (with Minimum Execution Units)
|
## Pipeline Examples
|
||||||
|
|
||||||
**Note**: `【 】` marks Minimum Execution Units - commands execute together as atomic groups.
|
| Input | Type | Level | Pipeline |
|
||||||
|
|-------|------|-------|----------|
|
||||||
| Input | Type | Level | Pipeline (with Units) |
|
| "Add API endpoint" | feature (low) | 2 | workflow-lite-plan → workflow-test-fix |
|
||||||
|-------|------|-------|-----------------------|
|
| "Fix login timeout" | bugfix | 2 | workflow-lite-plan → workflow-test-fix |
|
||||||
| "Add API endpoint" | feature (low) | 2 |【lite-plan → lite-execute】→【test-fix-gen → test-cycle-execute】|
|
| "Use issue workflow" | issue-transition | 2.5 | workflow-lite-plan(plan-only) → convert-to-plan → queue → execute |
|
||||||
| "Fix login timeout" | bugfix | 2 |【lite-plan → lite-execute】→【test-fix-gen → test-cycle-execute】|
|
| "头脑风暴: 通知系统重构" | brainstorm | 4 | workflow:brainstorm-with-file |
|
||||||
| "Use issue workflow" | issue-transition | 2.5 |【lite-plan → convert-to-plan】→ queue → execute |
|
| "从头脑风暴创建 issue" | brainstorm-to-issue | 4 | issue:from-brainstorm → issue:queue → issue:execute |
|
||||||
| "头脑风暴: 通知系统重构" | brainstorm | 4 | brainstorm-with-file → (built-in post-completion) |
|
| "深度调试 WebSocket" | debug-file | 3 | workflow:debug-with-file |
|
||||||
| "从头脑风暴创建 issue" | brainstorm-to-issue | 4 | from-brainstorm → queue → execute |
|
| "协作分析: 认证架构优化" | analyze-file | 3 | workflow:analyze-with-file |
|
||||||
| "深度调试 WebSocket 连接断开" | debug-file | 3 | debug-with-file → (hypothesis iteration) |
|
| "OAuth2 system" | feature (high) | 3 | workflow-plan → workflow-execute → review-cycle → workflow-test-fix |
|
||||||
| "协作分析: 认证架构优化" | analyze-file | 3 | analyze-with-file → (multi-round discussion) |
|
| "Implement with TDD" | tdd | 3 | workflow-tdd → workflow-execute |
|
||||||
| "OAuth2 system" | feature (high) | 3 |【plan → plan-verify】→ execute →【review-session-cycle → review-cycle-fix】→【test-fix-gen → test-cycle-execute】|
|
| "Uncertain: real-time" | exploration | 4 | brainstorm → workflow-plan → workflow-execute → workflow-test-fix |
|
||||||
| "Implement with TDD" | tdd | 3 |【tdd-plan → execute】→ tdd-verify |
|
|
||||||
| "Uncertain: real-time arch" | exploration | 4 | brainstorm →【plan → plan-verify】→ execute →【test-fix-gen → test-cycle-execute】|
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -544,11 +503,11 @@ Phase 5: Execute Command Chain
|
|||||||
|
|
||||||
1. **Main Process Execution** - Use Skill in main process, no external CLI
|
1. **Main Process Execution** - Use Skill in main process, no external CLI
|
||||||
2. **Intent-Driven** - Auto-select workflow based on task intent
|
2. **Intent-Driven** - Auto-select workflow based on task intent
|
||||||
3. **Port-Based Chaining** - Build command chain using port matching
|
3. **Skill-Based Chaining** - Build command chain by composing independent Skills
|
||||||
4. **Minimum Execution Units** - Commands grouped into atomic units, never split (e.g., lite-plan → lite-execute)
|
4. **Self-Contained Skills** - 每个 Skill 内部处理完整流水线,是天然的最小执行单元
|
||||||
5. **Progressive Clarification** - Low clarity triggers clarification phase
|
5. **Progressive Clarification** - Low clarity triggers clarification phase
|
||||||
6. **TODO Tracking** - Use CCW prefix to isolate workflow todos
|
6. **TODO Tracking** - Use CCW prefix to isolate workflow todos
|
||||||
7. **Unit-Aware Error Handling** - Retry/skip/abort affects whole unit, not individual commands
|
7. **Error Handling** - Retry/skip/abort at Skill level
|
||||||
8. **User Control** - Optional user confirmation at each phase
|
8. **User Control** - Optional user confirmation at each phase
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -560,18 +519,16 @@ Phase 5: Execute Command Chain
|
|||||||
**1. TodoWrite-Based Tracking** (UI Display): All execution state tracked via TodoWrite with `CCW:` prefix.
|
**1. TodoWrite-Based Tracking** (UI Display): All execution state tracked via TodoWrite with `CCW:` prefix.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Initial state
|
// Initial state (rapid workflow: 2 steps)
|
||||||
todos = [
|
todos = [
|
||||||
{ content: "CCW:rapid: [1/3] /workflow:lite-plan", status: "in_progress" },
|
{ content: "CCW:rapid: [1/2] workflow-lite-plan", status: "in_progress" },
|
||||||
{ content: "CCW:rapid: [2/3] /workflow:lite-execute", status: "pending" },
|
{ content: "CCW:rapid: [2/2] workflow-test-fix", status: "pending" }
|
||||||
{ content: "CCW:rapid: [3/3] /workflow:test-cycle-execute", status: "pending" }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// After command 1 completes
|
// After step 1 completes
|
||||||
todos = [
|
todos = [
|
||||||
{ content: "CCW:rapid: [1/3] /workflow:lite-plan", status: "completed" },
|
{ content: "CCW:rapid: [1/2] workflow-lite-plan", status: "completed" },
|
||||||
{ content: "CCW:rapid: [2/3] /workflow:lite-execute", status: "in_progress" },
|
{ content: "CCW:rapid: [2/2] workflow-test-fix", status: "in_progress" }
|
||||||
{ content: "CCW:rapid: [3/3] /workflow:test-cycle-execute", status: "pending" }
|
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -597,18 +554,13 @@ todos = [
|
|||||||
"command_chain": [
|
"command_chain": [
|
||||||
{
|
{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"command": "/workflow:lite-plan",
|
"command": "workflow-lite-plan",
|
||||||
"status": "completed"
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"index": 1,
|
"index": 1,
|
||||||
"command": "/workflow:lite-execute",
|
"command": "workflow-test-fix",
|
||||||
"status": "running"
|
"status": "running"
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"command": "/workflow:test-cycle-execute",
|
|
||||||
"status": "pending"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"current_index": 1
|
"current_index": 1
|
||||||
|
|||||||
@@ -704,7 +704,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
|
|||||||
const isLastOdd = currentSession.tasks!.length % 2 === 1 && index === currentSession.tasks!.length - 1;
|
const isLastOdd = currentSession.tasks!.length % 2 === 1 && index === currentSession.tasks!.length - 1;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${currentSession.session_id}-${task.task_id}`}
|
key={`${currentSession.session_id}-${task.task_id}-${index}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 p-2 rounded hover:bg-background/50 transition-colors',
|
'flex items-center gap-2 p-2 rounded hover:bg-background/50 transition-colors',
|
||||||
isLastOdd && 'col-span-2'
|
isLastOdd && 'col-span-2'
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Globe,
|
Globe,
|
||||||
Folder,
|
Folder,
|
||||||
|
AlertTriangle,
|
||||||
} 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';
|
||||||
@@ -84,6 +85,12 @@ export interface CcwToolsMcpCardProps {
|
|||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
/** Installation target: Claude or Codex */
|
/** Installation target: Claude or Codex */
|
||||||
target?: 'claude' | 'codex';
|
target?: 'claude' | 'codex';
|
||||||
|
/** Scopes where CCW MCP is currently installed */
|
||||||
|
installedScopes?: ('global' | 'project')[];
|
||||||
|
/** Callback to uninstall from a specific scope */
|
||||||
|
onUninstallScope?: (scope: 'global' | 'project') => void;
|
||||||
|
/** Callback to install to an additional scope */
|
||||||
|
onInstallToScope?: (scope: 'global' | 'project') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Constants ==========
|
// ========== Constants ==========
|
||||||
@@ -115,6 +122,9 @@ export function CcwToolsMcpCard({
|
|||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
onInstall,
|
onInstall,
|
||||||
target = 'claude',
|
target = 'claude',
|
||||||
|
installedScopes = [],
|
||||||
|
onUninstallScope,
|
||||||
|
onInstallToScope,
|
||||||
}: CcwToolsMcpCardProps) {
|
}: CcwToolsMcpCardProps) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -242,9 +252,26 @@ export function CcwToolsMcpCard({
|
|||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{formatMessage({ id: 'mcp.ccw.title' })}
|
{formatMessage({ id: 'mcp.ccw.title' })}
|
||||||
</span>
|
</span>
|
||||||
|
{isInstalled && installedScopes.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{installedScopes.map((s) => (
|
||||||
|
<Badge key={s} variant="default" className="text-xs">
|
||||||
|
{s === 'global' ? <Globe className="w-3 h-3 mr-1" /> : <Folder className="w-3 h-3 mr-1" />}
|
||||||
|
{formatMessage({ id: `mcp.ccw.scope.${s}` })}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{installedScopes.length >= 2 && (
|
||||||
|
<Badge variant="outline" className="text-xs text-orange-500 border-orange-300">
|
||||||
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||||
|
{formatMessage({ id: 'mcp.conflict.badge' })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<Badge variant={isInstalled ? 'default' : 'secondary'} className="text-xs">
|
<Badge variant={isInstalled ? 'default' : 'secondary'} className="text-xs">
|
||||||
{isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })}
|
{isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
{isCodex && (
|
{isCodex && (
|
||||||
<Badge variant="outline" className="text-xs text-blue-500">
|
<Badge variant="outline" className="text-xs text-blue-500">
|
||||||
Codex
|
Codex
|
||||||
@@ -425,7 +452,7 @@ export function CcwToolsMcpCard({
|
|||||||
|
|
||||||
{/* Install/Uninstall Button */}
|
{/* Install/Uninstall Button */}
|
||||||
<div className="pt-3 border-t border-border space-y-3">
|
<div className="pt-3 border-t border-border space-y-3">
|
||||||
{/* Scope Selection - Claude only (Codex is always global) */}
|
{/* Scope Selection - Claude only, only when not installed */}
|
||||||
{!isInstalled && !isCodex && (
|
{!isInstalled && !isCodex && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase">
|
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||||
@@ -465,6 +492,20 @@ export function CcwToolsMcpCard({
|
|||||||
{formatMessage({ id: 'mcp.ccw.codexNote' })}
|
{formatMessage({ id: 'mcp.ccw.codexNote' })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dual-scope conflict warning */}
|
||||||
|
{isInstalled && !isCodex && installedScopes.length >= 2 && (
|
||||||
|
<div className="p-3 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-lg space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">{formatMessage({ id: 'mcp.conflict.title' })}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-orange-600 dark:text-orange-400/80">
|
||||||
|
{formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: 'mcp.scope.global' }) })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isInstalled ? (
|
{!isInstalled ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleInstallClick}
|
onClick={handleInstallClick}
|
||||||
@@ -476,7 +517,8 @@ export function CcwToolsMcpCard({
|
|||||||
: formatMessage({ id: isCodex ? 'mcp.ccw.actions.installCodex' : 'mcp.ccw.actions.install' })
|
: formatMessage({ id: isCodex ? 'mcp.ccw.actions.installCodex' : 'mcp.ccw.actions.install' })
|
||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : isCodex ? (
|
||||||
|
/* Codex: single uninstall button */
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleUninstallClick}
|
onClick={handleUninstallClick}
|
||||||
@@ -488,6 +530,63 @@ export function CcwToolsMcpCard({
|
|||||||
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
|
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
|
||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
/* Claude: per-scope install/uninstall */
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Install to missing scope */}
|
||||||
|
{installedScopes.length === 1 && onInstallToScope && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const missingScope = installedScopes.includes('global') ? 'project' : 'global';
|
||||||
|
onInstallToScope(missingScope);
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{installedScopes.includes('global')
|
||||||
|
? formatMessage({ id: 'mcp.ccw.scope.installToProject' })
|
||||||
|
: formatMessage({ id: 'mcp.ccw.scope.installToGlobal' })
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-scope uninstall buttons */}
|
||||||
|
{onUninstallScope && installedScopes.map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s}
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(formatMessage({ id: 'mcp.ccw.actions.uninstallScopeConfirm' }, { scope: formatMessage({ id: `mcp.ccw.scope.${s}` }) }))) {
|
||||||
|
onUninstallScope(s);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{s === 'global'
|
||||||
|
? formatMessage({ id: 'mcp.ccw.scope.uninstallGlobal' })
|
||||||
|
: formatMessage({ id: 'mcp.ccw.scope.uninstallProject' })
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fallback: full uninstall if no scope info */}
|
||||||
|
{(!onUninstallScope || installedScopes.length === 0) && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleUninstallClick}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isPending
|
||||||
|
? formatMessage({ id: 'mcp.ccw.actions.uninstalling' })
|
||||||
|
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
TestTube,
|
TestTube,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
MessageSquare,
|
||||||
|
Bell,
|
||||||
|
Users,
|
||||||
|
Mail,
|
||||||
} 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';
|
||||||
@@ -28,6 +32,10 @@ import type {
|
|||||||
DiscordConfig,
|
DiscordConfig,
|
||||||
TelegramConfig,
|
TelegramConfig,
|
||||||
WebhookConfig,
|
WebhookConfig,
|
||||||
|
FeishuConfig,
|
||||||
|
DingTalkConfig,
|
||||||
|
WeComConfig,
|
||||||
|
EmailConfig,
|
||||||
} from '@/types/remote-notification';
|
} from '@/types/remote-notification';
|
||||||
import { PLATFORM_INFO } from '@/types/remote-notification';
|
import { PLATFORM_INFO } from '@/types/remote-notification';
|
||||||
|
|
||||||
@@ -38,11 +46,11 @@ interface PlatformConfigCardsProps {
|
|||||||
onToggleExpand: (platform: NotificationPlatform | null) => void;
|
onToggleExpand: (platform: NotificationPlatform | null) => void;
|
||||||
onUpdateConfig: (
|
onUpdateConfig: (
|
||||||
platform: NotificationPlatform,
|
platform: NotificationPlatform,
|
||||||
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig>
|
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig>
|
||||||
) => void;
|
) => void;
|
||||||
onTest: (
|
onTest: (
|
||||||
platform: NotificationPlatform,
|
platform: NotificationPlatform,
|
||||||
config: DiscordConfig | TelegramConfig | WebhookConfig
|
config: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig
|
||||||
) => void;
|
) => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
@@ -60,7 +68,7 @@ export function PlatformConfigCards({
|
|||||||
}: PlatformConfigCardsProps) {
|
}: PlatformConfigCardsProps) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
const platforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook'];
|
const platforms: NotificationPlatform[] = ['discord', 'telegram', 'feishu', 'dingtalk', 'wecom', 'email', 'webhook'];
|
||||||
|
|
||||||
const getPlatformIcon = (platform: NotificationPlatform) => {
|
const getPlatformIcon = (platform: NotificationPlatform) => {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
@@ -68,6 +76,14 @@ export function PlatformConfigCards({
|
|||||||
return <MessageCircle className="w-4 h-4" />;
|
return <MessageCircle className="w-4 h-4" />;
|
||||||
case 'telegram':
|
case 'telegram':
|
||||||
return <Send className="w-4 h-4" />;
|
return <Send className="w-4 h-4" />;
|
||||||
|
case 'feishu':
|
||||||
|
return <MessageSquare className="w-4 h-4" />;
|
||||||
|
case 'dingtalk':
|
||||||
|
return <Bell className="w-4 h-4" />;
|
||||||
|
case 'wecom':
|
||||||
|
return <Users className="w-4 h-4" />;
|
||||||
|
case 'email':
|
||||||
|
return <Mail className="w-4 h-4" />;
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
return <Link className="w-4 h-4" />;
|
return <Link className="w-4 h-4" />;
|
||||||
}
|
}
|
||||||
@@ -75,12 +91,20 @@ export function PlatformConfigCards({
|
|||||||
|
|
||||||
const getPlatformConfig = (
|
const getPlatformConfig = (
|
||||||
platform: NotificationPlatform
|
platform: NotificationPlatform
|
||||||
): DiscordConfig | TelegramConfig | WebhookConfig => {
|
): DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig => {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case 'discord':
|
case 'discord':
|
||||||
return config.platforms.discord || { enabled: false, webhookUrl: '' };
|
return config.platforms.discord || { enabled: false, webhookUrl: '' };
|
||||||
case 'telegram':
|
case 'telegram':
|
||||||
return config.platforms.telegram || { enabled: false, botToken: '', chatId: '' };
|
return config.platforms.telegram || { enabled: false, botToken: '', chatId: '' };
|
||||||
|
case 'feishu':
|
||||||
|
return config.platforms.feishu || { enabled: false, webhookUrl: '' };
|
||||||
|
case 'dingtalk':
|
||||||
|
return config.platforms.dingtalk || { enabled: false, webhookUrl: '' };
|
||||||
|
case 'wecom':
|
||||||
|
return config.platforms.wecom || { enabled: false, webhookUrl: '' };
|
||||||
|
case 'email':
|
||||||
|
return config.platforms.email || { enabled: false, host: '', port: 587, username: '', password: '', from: '', to: [] };
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
return config.platforms.webhook || { enabled: false, url: '', method: 'POST' };
|
return config.platforms.webhook || { enabled: false, url: '', method: 'POST' };
|
||||||
}
|
}
|
||||||
@@ -93,6 +117,15 @@ export function PlatformConfigCards({
|
|||||||
return !!(platformConfig as DiscordConfig).webhookUrl;
|
return !!(platformConfig as DiscordConfig).webhookUrl;
|
||||||
case 'telegram':
|
case 'telegram':
|
||||||
return !!(platformConfig as TelegramConfig).botToken && !!(platformConfig as TelegramConfig).chatId;
|
return !!(platformConfig as TelegramConfig).botToken && !!(platformConfig as TelegramConfig).chatId;
|
||||||
|
case 'feishu':
|
||||||
|
return !!(platformConfig as FeishuConfig).webhookUrl;
|
||||||
|
case 'dingtalk':
|
||||||
|
return !!(platformConfig as DingTalkConfig).webhookUrl;
|
||||||
|
case 'wecom':
|
||||||
|
return !!(platformConfig as WeComConfig).webhookUrl;
|
||||||
|
case 'email':
|
||||||
|
const emailConfig = platformConfig as EmailConfig;
|
||||||
|
return !!(emailConfig.host && emailConfig.username && emailConfig.password && emailConfig.from && emailConfig.to?.length > 0);
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
return !!(platformConfig as WebhookConfig).url;
|
return !!(platformConfig as WebhookConfig).url;
|
||||||
}
|
}
|
||||||
@@ -176,6 +209,30 @@ export function PlatformConfigCards({
|
|||||||
onUpdate={(updates) => onUpdateConfig('telegram', updates)}
|
onUpdate={(updates) => onUpdateConfig('telegram', updates)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{platform === 'feishu' && (
|
||||||
|
<FeishuConfigForm
|
||||||
|
config={platformConfig as FeishuConfig}
|
||||||
|
onUpdate={(updates) => onUpdateConfig('feishu', updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{platform === 'dingtalk' && (
|
||||||
|
<DingTalkConfigForm
|
||||||
|
config={platformConfig as DingTalkConfig}
|
||||||
|
onUpdate={(updates) => onUpdateConfig('dingtalk', updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{platform === 'wecom' && (
|
||||||
|
<WeComConfigForm
|
||||||
|
config={platformConfig as WeComConfig}
|
||||||
|
onUpdate={(updates) => onUpdateConfig('wecom', updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{platform === 'email' && (
|
||||||
|
<EmailConfigForm
|
||||||
|
config={platformConfig as EmailConfig}
|
||||||
|
onUpdate={(updates) => onUpdateConfig('email', updates)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{platform === 'webhook' && (
|
{platform === 'webhook' && (
|
||||||
<WebhookConfigForm
|
<WebhookConfigForm
|
||||||
config={platformConfig as WebhookConfig}
|
config={platformConfig as WebhookConfig}
|
||||||
@@ -393,4 +450,303 @@ function WebhookConfigForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Feishu Config Form ==========
|
||||||
|
|
||||||
|
function FeishuConfigForm({
|
||||||
|
config,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
config: FeishuConfig;
|
||||||
|
onUpdate: (updates: Partial<FeishuConfig>) => void;
|
||||||
|
}) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const [showUrl, setShowUrl] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.feishu.webhookUrl' })}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
type={showUrl ? 'text' : 'password'}
|
||||||
|
value={config.webhookUrl || ''}
|
||||||
|
onChange={(e) => onUpdate({ webhookUrl: e.target.value })}
|
||||||
|
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => setShowUrl(!showUrl)}
|
||||||
|
>
|
||||||
|
{showUrl ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.feishu.webhookUrlHint' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="feishu-useCard"
|
||||||
|
checked={config.useCard || false}
|
||||||
|
onChange={(e) => onUpdate({ useCard: e.target.checked })}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
<label htmlFor="feishu-useCard" className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.feishu.useCard' })}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.feishu.useCardHint' })}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.feishu.title' })}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={config.title || ''}
|
||||||
|
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||||
|
placeholder="CCW Notification"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DingTalk Config Form ==========
|
||||||
|
|
||||||
|
function DingTalkConfigForm({
|
||||||
|
config,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
config: DingTalkConfig;
|
||||||
|
onUpdate: (updates: Partial<DingTalkConfig>) => void;
|
||||||
|
}) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const [showUrl, setShowUrl] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.dingtalk.webhookUrl' })}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
type={showUrl ? 'text' : 'password'}
|
||||||
|
value={config.webhookUrl || ''}
|
||||||
|
onChange={(e) => onUpdate({ webhookUrl: e.target.value })}
|
||||||
|
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => setShowUrl(!showUrl)}
|
||||||
|
>
|
||||||
|
{showUrl ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.dingtalk.webhookUrlHint' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.dingtalk.keywords' })}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={config.keywords?.join(', ') || ''}
|
||||||
|
onChange={(e) => onUpdate({ keywords: e.target.value.split(',').map(k => k.trim()).filter(Boolean) })}
|
||||||
|
placeholder="keyword1, keyword2"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.dingtalk.keywordsHint' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== WeCom Config Form ==========
|
||||||
|
|
||||||
|
function WeComConfigForm({
|
||||||
|
config,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
config: WeComConfig;
|
||||||
|
onUpdate: (updates: Partial<WeComConfig>) => void;
|
||||||
|
}) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const [showUrl, setShowUrl] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.wecom.webhookUrl' })}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
type={showUrl ? 'text' : 'password'}
|
||||||
|
value={config.webhookUrl || ''}
|
||||||
|
onChange={(e) => onUpdate({ webhookUrl: e.target.value })}
|
||||||
|
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => setShowUrl(!showUrl)}
|
||||||
|
>
|
||||||
|
{showUrl ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.wecom.webhookUrlHint' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.wecom.mentionedList' })}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={config.mentionedList?.join(', ') || ''}
|
||||||
|
onChange={(e) => onUpdate({ mentionedList: e.target.value.split(',').map(m => m.trim()).filter(Boolean) })}
|
||||||
|
placeholder="userid1, userid2, @all"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.wecom.mentionedListHint' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Email Config Form ==========
|
||||||
|
|
||||||
|
function EmailConfigForm({
|
||||||
|
config,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
config: EmailConfig;
|
||||||
|
onUpdate: (updates: Partial<EmailConfig>) => void;
|
||||||
|
}) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.email.host' })}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={config.host || ''}
|
||||||
|
onChange={(e) => onUpdate({ host: e.target.value })}
|
||||||
|
placeholder="smtp.gmail.com"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.email.hostHint' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.email.port' })}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.port || 587}
|
||||||
|
onChange={(e) => onUpdate({ port: parseInt(e.target.value, 10) || 587 })}
|
||||||
|
placeholder="587"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="email-secure"
|
||||||
|
checked={config.secure || false}
|
||||||
|
onChange={(e) => onUpdate({ secure: e.target.checked })}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
<label htmlFor="email-secure" className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.email.secure' })}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.email.username' })}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={config.username || ''}
|
||||||
|
onChange={(e) => onUpdate({ username: e.target.value })}
|
||||||
|
placeholder="your-email@gmail.com"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.email.password' })}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={config.password || ''}
|
||||||
|
onChange={(e) => onUpdate({ password: e.target.value })}
|
||||||
|
placeholder="********"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.email.from' })}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={config.from || ''}
|
||||||
|
onChange={(e) => onUpdate({ from: e.target.value })}
|
||||||
|
placeholder="noreply@example.com"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.email.to' })}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={config.to?.join(', ') || ''}
|
||||||
|
onChange={(e) => onUpdate({ to: e.target.value.split(',').map(t => t.trim()).filter(Boolean) })}
|
||||||
|
placeholder="user1@example.com, user2@example.com"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.email.toHint' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default PlatformConfigCards;
|
export default PlatformConfigCards;
|
||||||
|
|||||||
@@ -11,15 +11,13 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
|
Save,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
TestTube,
|
Plus,
|
||||||
Save,
|
|
||||||
AlertTriangle,
|
|
||||||
} 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 { Input } from '@/components/ui/Input';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -30,6 +28,10 @@ import type {
|
|||||||
DiscordConfig,
|
DiscordConfig,
|
||||||
TelegramConfig,
|
TelegramConfig,
|
||||||
WebhookConfig,
|
WebhookConfig,
|
||||||
|
FeishuConfig,
|
||||||
|
DingTalkConfig,
|
||||||
|
WeComConfig,
|
||||||
|
EmailConfig,
|
||||||
} from '@/types/remote-notification';
|
} from '@/types/remote-notification';
|
||||||
import { PLATFORM_INFO, EVENT_INFO, getDefaultConfig } from '@/types/remote-notification';
|
import { PLATFORM_INFO, EVENT_INFO, getDefaultConfig } from '@/types/remote-notification';
|
||||||
import { PlatformConfigCards } from './PlatformConfigCards';
|
import { PlatformConfigCards } from './PlatformConfigCards';
|
||||||
@@ -45,6 +47,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testing, setTesting] = useState<NotificationPlatform | null>(null);
|
const [testing, setTesting] = useState<NotificationPlatform | null>(null);
|
||||||
const [expandedPlatform, setExpandedPlatform] = useState<NotificationPlatform | null>(null);
|
const [expandedPlatform, setExpandedPlatform] = useState<NotificationPlatform | null>(null);
|
||||||
|
const [expandedEvent, setExpandedEvent] = useState<number | null>(null);
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
const loadConfig = useCallback(async () => {
|
const loadConfig = useCallback(async () => {
|
||||||
@@ -97,7 +100,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
|
|||||||
// Test platform
|
// Test platform
|
||||||
const testPlatform = useCallback(async (
|
const testPlatform = useCallback(async (
|
||||||
platform: NotificationPlatform,
|
platform: NotificationPlatform,
|
||||||
platformConfig: DiscordConfig | TelegramConfig | WebhookConfig
|
platformConfig: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig
|
||||||
) => {
|
) => {
|
||||||
setTesting(platform);
|
setTesting(platform);
|
||||||
try {
|
try {
|
||||||
@@ -136,7 +139,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
|
|||||||
// Update platform config
|
// Update platform config
|
||||||
const updatePlatformConfig = (
|
const updatePlatformConfig = (
|
||||||
platform: NotificationPlatform,
|
platform: NotificationPlatform,
|
||||||
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig>
|
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig>
|
||||||
) => {
|
) => {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
@@ -160,6 +163,19 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
|
|||||||
setConfig({ ...config, events: newEvents });
|
setConfig({ ...config, events: newEvents });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Toggle platform for event
|
||||||
|
const toggleEventPlatform = (eventIndex: number, platform: NotificationPlatform) => {
|
||||||
|
if (!config) return;
|
||||||
|
const eventConfig = config.events[eventIndex];
|
||||||
|
const platforms = eventConfig.platforms.includes(platform)
|
||||||
|
? eventConfig.platforms.filter((p) => p !== platform)
|
||||||
|
: [...eventConfig.platforms, platform];
|
||||||
|
updateEventConfig(eventIndex, { platforms });
|
||||||
|
};
|
||||||
|
|
||||||
|
// All available platforms
|
||||||
|
const allPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'feishu', 'dingtalk', 'wecom', 'email', 'webhook'];
|
||||||
|
|
||||||
// Reset to defaults
|
// Reset to defaults
|
||||||
const resetConfig = async () => {
|
const resetConfig = async () => {
|
||||||
if (!confirm(formatMessage({ id: 'settings.remoteNotifications.resetConfirm' }))) {
|
if (!confirm(formatMessage({ id: 'settings.remoteNotifications.resetConfirm' }))) {
|
||||||
@@ -266,10 +282,16 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
|
|||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{config.events.map((eventConfig, index) => {
|
{config.events.map((eventConfig, index) => {
|
||||||
const info = EVENT_INFO[eventConfig.event];
|
const info = EVENT_INFO[eventConfig.event];
|
||||||
|
const isExpanded = expandedEvent === index;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={eventConfig.event}
|
key={eventConfig.event}
|
||||||
className="flex items-center justify-between p-3 rounded-lg border border-border bg-muted/30"
|
className="rounded-lg border border-border bg-muted/30 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Event Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-3 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => setExpandedEvent(isExpanded ? null : index)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -285,12 +307,17 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Platform badges */}
|
{/* Platform badges */}
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1 flex-wrap max-w-xs">
|
||||||
{eventConfig.platforms.map((platform) => (
|
{eventConfig.platforms.slice(0, 3).map((platform) => (
|
||||||
<Badge key={platform} variant="secondary" className="text-xs">
|
<Badge key={platform} variant="secondary" className="text-xs">
|
||||||
{PLATFORM_INFO[platform].name}
|
{PLATFORM_INFO[platform].name}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
{eventConfig.platforms.length > 3 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
+{eventConfig.platforms.length - 3}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{eventConfig.platforms.length === 0 && (
|
{eventConfig.platforms.length === 0 && (
|
||||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||||
{formatMessage({ id: 'settings.remoteNotifications.noPlatforms' })}
|
{formatMessage({ id: 'settings.remoteNotifications.noPlatforms' })}
|
||||||
@@ -302,7 +329,10 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
|
|||||||
variant={eventConfig.enabled ? 'default' : 'outline'}
|
variant={eventConfig.enabled ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7"
|
className="h-7"
|
||||||
onClick={() => updateEventConfig(index, { enabled: !eventConfig.enabled })}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateEventConfig(index, { enabled: !eventConfig.enabled });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{eventConfig.enabled ? (
|
{eventConfig.enabled ? (
|
||||||
<Check className="w-3.5 h-3.5" />
|
<Check className="w-3.5 h-3.5" />
|
||||||
@@ -310,8 +340,50 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
|
|||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Expand icon */}
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content - Platform Selection */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-border p-4 space-y-3 bg-muted/20">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatMessage({ id: 'settings.remoteNotifications.selectPlatforms' })}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allPlatforms.map((platform) => {
|
||||||
|
const isSelected = eventConfig.platforms.includes(platform);
|
||||||
|
const platformInfo = PLATFORM_INFO[platform];
|
||||||
|
const platformConfig = config.platforms[platform];
|
||||||
|
const isConfigured = platformConfig?.enabled;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={platform}
|
||||||
|
variant={isSelected ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8',
|
||||||
|
!isConfigured && !isSelected && 'opacity-50'
|
||||||
|
)}
|
||||||
|
onClick={() => toggleEventPlatform(index, platform)}
|
||||||
|
>
|
||||||
|
{isSelected && <Check className="w-3 h-3 mr-1" />}
|
||||||
|
{platformInfo.name}
|
||||||
|
{!isConfigured && !isSelected && (
|
||||||
|
<Plus className="w-3 h-3 ml-1 opacity-50" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// ========================================
|
||||||
|
// ArtifactTag Component
|
||||||
|
// ========================================
|
||||||
|
// Colored, clickable tag for detected CCW artifacts in terminal output.
|
||||||
|
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { badgeVariants } from '@/components/ui/Badge';
|
||||||
|
import type { ArtifactType } from '@/lib/ccw-artifacts';
|
||||||
|
|
||||||
|
export interface ArtifactTagProps {
|
||||||
|
type: ArtifactType;
|
||||||
|
path: string;
|
||||||
|
onClick?: (path: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariant(type: ArtifactType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'workflow-session':
|
||||||
|
return 'info';
|
||||||
|
case 'lite-session':
|
||||||
|
return 'success';
|
||||||
|
case 'claude-md':
|
||||||
|
return 'review';
|
||||||
|
case 'ccw-config':
|
||||||
|
return 'warning';
|
||||||
|
case 'issue':
|
||||||
|
return 'destructive';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLabelId(type: ArtifactType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'workflow-session':
|
||||||
|
return 'terminalDashboard.artifacts.types.workflowSession';
|
||||||
|
case 'lite-session':
|
||||||
|
return 'terminalDashboard.artifacts.types.liteSession';
|
||||||
|
case 'claude-md':
|
||||||
|
return 'terminalDashboard.artifacts.types.claudeMd';
|
||||||
|
case 'ccw-config':
|
||||||
|
return 'terminalDashboard.artifacts.types.ccwConfig';
|
||||||
|
case 'issue':
|
||||||
|
return 'terminalDashboard.artifacts.types.issue';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(p: string): string {
|
||||||
|
const parts = p.split(/[\\/]/g);
|
||||||
|
return parts[parts.length - 1] || p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtifactTag({ type, path, onClick, className }: ArtifactTagProps) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
const label = formatMessage({ id: getLabelId(type) });
|
||||||
|
const display = basename(path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
badgeVariants({ variant: getVariant(type) as any }),
|
||||||
|
'gap-1 cursor-pointer hover:opacity-90 active:opacity-100',
|
||||||
|
'px-2 py-0.5 text-[11px] font-semibold',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => onClick?.(path)}
|
||||||
|
title={path}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="opacity-80 font-mono">{display}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArtifactTag;
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// ========================================
|
||||||
|
// FloatingFileBrowser Component
|
||||||
|
// ========================================
|
||||||
|
// Floating file browser panel for Terminal Dashboard.
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { Copy, ArrowRightToLine, Loader2, RefreshCw } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { FloatingPanel } from './FloatingPanel';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { TreeView } from '@/components/shared/TreeView';
|
||||||
|
import { FilePreview } from '@/components/shared/FilePreview';
|
||||||
|
import { useFileExplorer, useFileContent } from '@/hooks/useFileExplorer';
|
||||||
|
import type { FileSystemNode } from '@/types/file-explorer';
|
||||||
|
|
||||||
|
export interface FloatingFileBrowserProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
rootPath: string;
|
||||||
|
onInsertPath?: (path: string) => void;
|
||||||
|
initialSelectedPath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingFileBrowser({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
rootPath,
|
||||||
|
onInsertPath,
|
||||||
|
initialSelectedPath = null,
|
||||||
|
}: FloatingFileBrowserProps) {
|
||||||
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
rootNodes,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
setSelectedFile,
|
||||||
|
toggleExpanded,
|
||||||
|
} = useFileExplorer({
|
||||||
|
rootPath,
|
||||||
|
maxDepth: 6,
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPath = state.selectedFile;
|
||||||
|
const { content, isLoading: isContentLoading, error: contentError } = useFileContent(selectedPath, {
|
||||||
|
enabled: isOpen && !!selectedPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (initialSelectedPath) {
|
||||||
|
setSelectedFile(initialSelectedPath);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen, initialSelectedPath]);
|
||||||
|
|
||||||
|
const handleNodeClick = (node: FileSystemNode) => {
|
||||||
|
if (node.type === 'file') {
|
||||||
|
setSelectedFile(node.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyPath = async () => {
|
||||||
|
if (!selectedPath) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(selectedPath);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1200);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[FloatingFileBrowser] copy path failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsert = () => {
|
||||||
|
if (!selectedPath) return;
|
||||||
|
onInsertPath?.(selectedPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FloatingPanel
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={formatMessage({ id: 'terminalDashboard.fileBrowser.title' })}
|
||||||
|
side="right"
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{selectedPath
|
||||||
|
? formatMessage({ id: 'terminalDashboard.fileBrowser.selected' })
|
||||||
|
: formatMessage({ id: 'terminalDashboard.fileBrowser.noSelection' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono truncate" title={selectedPath ?? undefined}>
|
||||||
|
{selectedPath ?? rootPath}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
disabled={!isOpen || isFetching}
|
||||||
|
title={formatMessage({ id: 'common.actions.refresh' })}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleCopyPath}
|
||||||
|
disabled={!selectedPath}
|
||||||
|
title={copied
|
||||||
|
? formatMessage({ id: 'terminalDashboard.fileBrowser.copied' })
|
||||||
|
: formatMessage({ id: 'terminalDashboard.fileBrowser.copyPath' })}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleInsert}
|
||||||
|
disabled={!selectedPath || !onInsertPath}
|
||||||
|
title={formatMessage({ id: 'terminalDashboard.fileBrowser.insertPath' })}
|
||||||
|
>
|
||||||
|
<ArrowRightToLine className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 min-h-0 flex overflow-hidden">
|
||||||
|
{/* Tree */}
|
||||||
|
<div className="w-[180px] shrink-0 border-r border-border overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
<span className="ml-2 text-xs">
|
||||||
|
{formatMessage({ id: 'terminalDashboard.fileBrowser.loading' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-3 text-xs text-destructive">
|
||||||
|
{formatMessage({ id: 'terminalDashboard.fileBrowser.loadFailed' })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TreeView
|
||||||
|
nodes={rootNodes}
|
||||||
|
expandedPaths={state.expandedPaths}
|
||||||
|
selectedPath={state.selectedFile}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onToggle={toggleExpanded}
|
||||||
|
maxDepth={0}
|
||||||
|
className={cn('py-1')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<FilePreview
|
||||||
|
fileContent={content}
|
||||||
|
isLoading={isContentLoading}
|
||||||
|
error={contentError ? String((contentError as any).message ?? contentError) : null}
|
||||||
|
className="h-full overflow-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FloatingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FloatingFileBrowser;
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
// XTerm instance in ref, FitAddon, ResizeObserver, batched PTY input (30ms),
|
// XTerm instance in ref, FitAddon, ResizeObserver, batched PTY input (30ms),
|
||||||
// output chunk streaming from cliSessionStore.
|
// output chunk streaming from cliSessionStore.
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { Terminal as XTerm } from 'xterm';
|
import { Terminal as XTerm } from 'xterm';
|
||||||
import { FitAddon } from 'xterm-addon-fit';
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
resizeCliSession,
|
resizeCliSession,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { detectCcArtifacts, type CcArtifact } from '@/lib/ccw-artifacts';
|
||||||
|
import { ArtifactTag } from './ArtifactTag';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -26,11 +28,54 @@ interface TerminalInstanceProps {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
/** Additional CSS classes */
|
/** Additional CSS classes */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Optional callback to reveal a detected artifact path (e.g. open file browser) */
|
||||||
|
onRevealPath?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Component ==========
|
// ========== Component ==========
|
||||||
|
|
||||||
export function TerminalInstance({ sessionId, className }: TerminalInstanceProps) {
|
const ARTIFACT_DEBOUNCE_MS = 250;
|
||||||
|
const MAX_ARTIFACT_TAGS = 12;
|
||||||
|
|
||||||
|
function isAbsolutePath(p: string): boolean {
|
||||||
|
if (!p) return false;
|
||||||
|
if (p.startsWith('/') || p.startsWith('\\')) return true;
|
||||||
|
if (/^[A-Za-z]:[\\/]/.test(p)) return true;
|
||||||
|
if (p.startsWith('~')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(base: string, relative: string): string {
|
||||||
|
const sep = base.includes('\\') ? '\\' : '/';
|
||||||
|
const b = base.replace(/[\\/]+$/, '');
|
||||||
|
const r = relative.replace(/^[\\/]+/, '');
|
||||||
|
return `${b}${sep}${r}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveArtifactPath(path: string, projectPath: string | null): string {
|
||||||
|
if (!path) return path;
|
||||||
|
if (isAbsolutePath(path)) return path;
|
||||||
|
if (!projectPath) return path;
|
||||||
|
return joinPath(projectPath, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeArtifacts(prev: CcArtifact[], next: CcArtifact[]): CcArtifact[] {
|
||||||
|
if (next.length === 0) return prev;
|
||||||
|
const map = new Map<string, CcArtifact>();
|
||||||
|
for (const a of prev) map.set(`${a.type}:${a.path}`, a);
|
||||||
|
let changed = false;
|
||||||
|
for (const a of next) {
|
||||||
|
const key = `${a.type}:${a.path}`;
|
||||||
|
if (map.has(key)) continue;
|
||||||
|
map.set(key, a);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!changed) return prev;
|
||||||
|
const merged = Array.from(map.values());
|
||||||
|
return merged.length > MAX_ARTIFACT_TAGS ? merged.slice(merged.length - MAX_ARTIFACT_TAGS) : merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminalInstance({ sessionId, className, onRevealPath }: TerminalInstanceProps) {
|
||||||
const projectPath = useWorkflowStore(selectProjectPath);
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
// cliSessionStore selectors
|
// cliSessionStore selectors
|
||||||
@@ -38,6 +83,8 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
|
|||||||
const setBuffer = useCliSessionStore((s) => s.setBuffer);
|
const setBuffer = useCliSessionStore((s) => s.setBuffer);
|
||||||
const clearOutput = useCliSessionStore((s) => s.clearOutput);
|
const clearOutput = useCliSessionStore((s) => s.clearOutput);
|
||||||
|
|
||||||
|
const [artifacts, setArtifacts] = useState<CcArtifact[]>([]);
|
||||||
|
|
||||||
// ========== xterm Refs ==========
|
// ========== xterm Refs ==========
|
||||||
|
|
||||||
const terminalHostRef = useRef<HTMLDivElement | null>(null);
|
const terminalHostRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -45,6 +92,10 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
|
|||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const lastChunkIndexRef = useRef<number>(0);
|
const lastChunkIndexRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Debounced artifact detection
|
||||||
|
const pendingArtifactTextRef = useRef<string>('');
|
||||||
|
const artifactTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// PTY input batching (30ms, matching TerminalMainArea)
|
// PTY input batching (30ms, matching TerminalMainArea)
|
||||||
const pendingInputRef = useRef<string>('');
|
const pendingInputRef = useRef<string>('');
|
||||||
const flushTimerRef = useRef<number | null>(null);
|
const flushTimerRef = useRef<number | null>(null);
|
||||||
@@ -56,6 +107,37 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
|
|||||||
const projectPathRef = useRef<string | null>(projectPath);
|
const projectPathRef = useRef<string | null>(projectPath);
|
||||||
projectPathRef.current = projectPath;
|
projectPathRef.current = projectPath;
|
||||||
|
|
||||||
|
const handleArtifactClick = useCallback((path: string) => {
|
||||||
|
const resolved = resolveArtifactPath(path, projectPathRef.current);
|
||||||
|
navigator.clipboard.writeText(resolved).catch((err) => {
|
||||||
|
console.error('[TerminalInstance] copy artifact path failed:', err);
|
||||||
|
});
|
||||||
|
onRevealPath?.(resolved);
|
||||||
|
}, [onRevealPath]);
|
||||||
|
|
||||||
|
const scheduleArtifactParse = useCallback((text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
pendingArtifactTextRef.current += text;
|
||||||
|
if (artifactTimerRef.current !== null) return;
|
||||||
|
artifactTimerRef.current = window.setTimeout(() => {
|
||||||
|
artifactTimerRef.current = null;
|
||||||
|
const pending = pendingArtifactTextRef.current;
|
||||||
|
pendingArtifactTextRef.current = '';
|
||||||
|
const detected = detectCcArtifacts(pending);
|
||||||
|
if (detected.length === 0) return;
|
||||||
|
setArtifacts((prev) => mergeArtifacts(prev, detected));
|
||||||
|
}, ARTIFACT_DEBOUNCE_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (artifactTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(artifactTimerRef.current);
|
||||||
|
artifactTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ========== PTY Input Batching ==========
|
// ========== PTY Input Batching ==========
|
||||||
|
|
||||||
const flushInput = useCallback(async () => {
|
const flushInput = useCallback(async () => {
|
||||||
@@ -139,6 +221,14 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
|
|||||||
term.reset();
|
term.reset();
|
||||||
term.clear();
|
term.clear();
|
||||||
|
|
||||||
|
// Reset artifact detection state per session
|
||||||
|
setArtifacts([]);
|
||||||
|
pendingArtifactTextRef.current = '';
|
||||||
|
if (artifactTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(artifactTimerRef.current);
|
||||||
|
artifactTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
clearOutput(sessionId);
|
clearOutput(sessionId);
|
||||||
|
|
||||||
@@ -164,12 +254,18 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
|
|||||||
if (start >= chunks.length) return;
|
if (start >= chunks.length) return;
|
||||||
|
|
||||||
const { feedMonitor } = useSessionManagerStore.getState();
|
const { feedMonitor } = useSessionManagerStore.getState();
|
||||||
|
const newTextParts: string[] = [];
|
||||||
for (let i = start; i < chunks.length; i++) {
|
for (let i = start; i < chunks.length; i++) {
|
||||||
term.write(chunks[i].data);
|
term.write(chunks[i].data);
|
||||||
feedMonitor(sessionId, chunks[i].data);
|
feedMonitor(sessionId, chunks[i].data);
|
||||||
|
newTextParts.push(chunks[i].data);
|
||||||
}
|
}
|
||||||
lastChunkIndexRef.current = chunks.length;
|
lastChunkIndexRef.current = chunks.length;
|
||||||
}, [outputChunks, sessionId]);
|
|
||||||
|
if (newTextParts.length > 0) {
|
||||||
|
scheduleArtifactParse(newTextParts.join(''));
|
||||||
|
}
|
||||||
|
}, [outputChunks, sessionId, scheduleArtifactParse]);
|
||||||
|
|
||||||
// ResizeObserver -> fit + resize backend
|
// ResizeObserver -> fit + resize backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -203,9 +299,21 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
|
|||||||
// ========== Render ==========
|
// ========== Render ==========
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn('relative h-full w-full', className)}>
|
||||||
ref={terminalHostRef}
|
{artifacts.length > 0 && (
|
||||||
className={cn('h-full w-full bg-black/90', className)}
|
<div className="absolute top-2 left-2 right-2 z-10 flex flex-wrap gap-1 pointer-events-none">
|
||||||
|
{artifacts.map((a) => (
|
||||||
|
<ArtifactTag
|
||||||
|
key={`${a.type}:${a.path}`}
|
||||||
|
type={a.type}
|
||||||
|
path={a.path}
|
||||||
|
onClick={handleArtifactClick}
|
||||||
|
className="pointer-events-auto"
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={terminalHostRef} className="h-full w-full bg-black/90" />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useIntl } from 'react-intl';
|
|||||||
import {
|
import {
|
||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
SplitSquareVertical,
|
SplitSquareVertical,
|
||||||
|
FolderOpen,
|
||||||
Eraser,
|
Eraser,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
X,
|
X,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { TerminalInstance } from './TerminalInstance';
|
import { TerminalInstance } from './TerminalInstance';
|
||||||
|
import { FloatingFileBrowser } from './FloatingFileBrowser';
|
||||||
import {
|
import {
|
||||||
useTerminalGridStore,
|
useTerminalGridStore,
|
||||||
selectTerminalGridPanes,
|
selectTerminalGridPanes,
|
||||||
@@ -37,6 +39,8 @@ import {
|
|||||||
} from '@/stores/issueQueueIntegrationStore';
|
} from '@/stores/issueQueueIntegrationStore';
|
||||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||||
import { getAllPaneIds } from '@/lib/layout-utils';
|
import { getAllPaneIds } from '@/lib/layout-utils';
|
||||||
|
import { sendCliSessionText } from '@/lib/api';
|
||||||
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
import type { PaneId } from '@/stores/viewerStore';
|
import type { PaneId } from '@/stores/viewerStore';
|
||||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||||
|
|
||||||
@@ -75,6 +79,10 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
|||||||
const isFocused = focusedPaneId === paneId;
|
const isFocused = focusedPaneId === paneId;
|
||||||
const canClose = getAllPaneIds(layout).length > 1;
|
const canClose = getAllPaneIds(layout).length > 1;
|
||||||
|
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
const [isFileBrowserOpen, setIsFileBrowserOpen] = useState(false);
|
||||||
|
const [initialFileBrowserPath, setInitialFileBrowserPath] = useState<string | null>(null);
|
||||||
|
|
||||||
// Session data
|
// Session data
|
||||||
const groups = useSessionManagerStore(selectGroups);
|
const groups = useSessionManagerStore(selectGroups);
|
||||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||||
@@ -146,6 +154,25 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
|||||||
}
|
}
|
||||||
}, [paneId, sessionId, assignSession]);
|
}, [paneId, sessionId, assignSession]);
|
||||||
|
|
||||||
|
const handleOpenFileBrowser = useCallback(() => {
|
||||||
|
setInitialFileBrowserPath(null);
|
||||||
|
setIsFileBrowserOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRevealPath = useCallback((path: string) => {
|
||||||
|
setInitialFileBrowserPath(path);
|
||||||
|
setIsFileBrowserOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInsertPath = useCallback((path: string) => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
sendCliSessionText(
|
||||||
|
sessionId,
|
||||||
|
{ text: path, appendNewline: false },
|
||||||
|
projectPath ?? undefined
|
||||||
|
).catch((err) => console.error('[TerminalPane] insert path failed:', err));
|
||||||
|
}, [sessionId, projectPath]);
|
||||||
|
|
||||||
const handleRestart = useCallback(async () => {
|
const handleRestart = useCallback(async () => {
|
||||||
if (!sessionId || isRestarting) return;
|
if (!sessionId || isRestarting) return;
|
||||||
setIsRestarting(true);
|
setIsRestarting(true);
|
||||||
@@ -291,6 +318,19 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleOpenFileBrowser}
|
||||||
|
disabled={!projectPath}
|
||||||
|
className={cn(
|
||||||
|
'p-1 rounded hover:bg-muted transition-colors',
|
||||||
|
projectPath
|
||||||
|
? 'text-muted-foreground hover:text-foreground'
|
||||||
|
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
title={formatMessage({ id: 'terminalDashboard.fileBrowser.open' })}
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
{alertCount > 0 && (
|
{alertCount > 0 && (
|
||||||
<span className="flex items-center gap-0.5 px-1 text-destructive">
|
<span className="flex items-center gap-0.5 px-1 text-destructive">
|
||||||
<AlertTriangle className="w-3 h-3" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
@@ -314,7 +354,7 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
|||||||
{/* Terminal content */}
|
{/* Terminal content */}
|
||||||
{sessionId ? (
|
{sessionId ? (
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<TerminalInstance sessionId={sessionId} />
|
<TerminalInstance sessionId={sessionId} onRevealPath={handleRevealPath} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
@@ -329,6 +369,17 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FloatingFileBrowser
|
||||||
|
isOpen={isFileBrowserOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsFileBrowserOpen(false);
|
||||||
|
setInitialFileBrowserPath(null);
|
||||||
|
}}
|
||||||
|
rootPath={projectPath ?? '/'}
|
||||||
|
onInsertPath={sessionId ? handleInsertPath : undefined}
|
||||||
|
initialSelectedPath={initialFileBrowserPath}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
crossCliCopy,
|
crossCliCopy,
|
||||||
type McpServer,
|
type McpServer,
|
||||||
type McpServersResponse,
|
type McpServersResponse,
|
||||||
|
type McpServerConflict,
|
||||||
type McpProjectConfigType,
|
type McpProjectConfigType,
|
||||||
type McpTemplate,
|
type McpTemplate,
|
||||||
type McpTemplateInstallRequest,
|
type McpTemplateInstallRequest,
|
||||||
@@ -66,6 +67,7 @@ export interface UseMcpServersReturn {
|
|||||||
servers: McpServer[];
|
servers: McpServer[];
|
||||||
projectServers: McpServer[];
|
projectServers: McpServer[];
|
||||||
globalServers: McpServer[];
|
globalServers: McpServer[];
|
||||||
|
conflicts: McpServerConflict[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
enabledCount: number;
|
enabledCount: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -95,6 +97,7 @@ export function useMcpServers(options: UseMcpServersOptions = {}): UseMcpServers
|
|||||||
|
|
||||||
const projectServers = query.data?.project ?? [];
|
const projectServers = query.data?.project ?? [];
|
||||||
const globalServers = query.data?.global ?? [];
|
const globalServers = query.data?.global ?? [];
|
||||||
|
const conflicts = query.data?.conflicts ?? [];
|
||||||
const allServers = scope === 'project' ? projectServers : scope === 'global' ? globalServers : [...projectServers, ...globalServers];
|
const allServers = scope === 'project' ? projectServers : scope === 'global' ? globalServers : [...projectServers, ...globalServers];
|
||||||
|
|
||||||
const enabledServers = allServers.filter((s) => s.enabled);
|
const enabledServers = allServers.filter((s) => s.enabled);
|
||||||
@@ -111,6 +114,7 @@ export function useMcpServers(options: UseMcpServersOptions = {}): UseMcpServers
|
|||||||
servers: allServers,
|
servers: allServers,
|
||||||
projectServers,
|
projectServers,
|
||||||
globalServers,
|
globalServers,
|
||||||
|
conflicts,
|
||||||
totalCount: allServers.length,
|
totalCount: allServers.length,
|
||||||
enabledCount: enabledServers.length,
|
enabledCount: enabledServers.length,
|
||||||
isLoading: query.isLoading,
|
isLoading: query.isLoading,
|
||||||
@@ -224,6 +228,7 @@ export function useToggleMcpServer(): UseToggleMcpServerReturn {
|
|||||||
return {
|
return {
|
||||||
project: updateServer(old.project),
|
project: updateServer(old.project),
|
||||||
global: updateServer(old.global),
|
global: updateServer(old.global),
|
||||||
|
conflicts: old.conflicts ?? [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2507,9 +2507,18 @@ export interface McpServer {
|
|||||||
scope: 'project' | 'global';
|
scope: 'project' | 'global';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface McpServerConflict {
|
||||||
|
name: string;
|
||||||
|
projectServer: McpServer;
|
||||||
|
globalServer: McpServer;
|
||||||
|
/** Runtime effective scope */
|
||||||
|
effectiveScope: 'global' | 'project';
|
||||||
|
}
|
||||||
|
|
||||||
export interface McpServersResponse {
|
export interface McpServersResponse {
|
||||||
project: McpServer[];
|
project: McpServer[];
|
||||||
global: McpServer[];
|
global: McpServer[];
|
||||||
|
conflicts: McpServerConflict[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2618,7 +2627,6 @@ export async function fetchMcpServers(projectPath?: string): Promise<McpServersR
|
|||||||
const disabledSet = new Set(disabledServers);
|
const disabledSet = new Set(disabledServers);
|
||||||
|
|
||||||
const userServers = isUnknownRecord(config.userServers) ? (config.userServers as UnknownRecord) : {};
|
const userServers = isUnknownRecord(config.userServers) ? (config.userServers as UnknownRecord) : {};
|
||||||
const enterpriseServers = isUnknownRecord(config.enterpriseServers) ? (config.enterpriseServers as UnknownRecord) : {};
|
|
||||||
|
|
||||||
const projectServersRecord = projectConfig && isUnknownRecord(projectConfig.mcpServers)
|
const projectServersRecord = projectConfig && isUnknownRecord(projectConfig.mcpServers)
|
||||||
? (projectConfig.mcpServers as UnknownRecord)
|
? (projectConfig.mcpServers as UnknownRecord)
|
||||||
@@ -2635,21 +2643,34 @@ export async function fetchMcpServers(projectPath?: string): Promise<McpServersR
|
|||||||
});
|
});
|
||||||
|
|
||||||
const project: McpServer[] = Object.entries(projectServersRecord)
|
const project: McpServer[] = Object.entries(projectServersRecord)
|
||||||
// Avoid duplicates: if defined globally/enterprise, treat it as global
|
|
||||||
.filter(([name]) => !(name in userServers) && !(name in enterpriseServers))
|
|
||||||
.map(([name, raw]) => {
|
.map(([name, raw]) => {
|
||||||
const normalized = normalizeServerConfig(raw);
|
const normalized = normalizeServerConfig(raw);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
...normalized,
|
...normalized,
|
||||||
enabled: !disabledSet.has(name),
|
enabled: !disabledSet.has(name),
|
||||||
scope: 'project',
|
scope: 'project' as const,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Detect conflicts: same name exists in both project and global
|
||||||
|
const conflicts: McpServerConflict[] = [];
|
||||||
|
for (const ps of project) {
|
||||||
|
const gs = global.find(g => g.name === ps.name);
|
||||||
|
if (gs) {
|
||||||
|
conflicts.push({
|
||||||
|
name: ps.name,
|
||||||
|
projectServer: ps,
|
||||||
|
globalServer: gs,
|
||||||
|
effectiveScope: 'global',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
global,
|
global,
|
||||||
|
conflicts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3549,6 +3570,7 @@ export interface CcwMcpConfig {
|
|||||||
projectRoot?: string;
|
projectRoot?: string;
|
||||||
allowedDirs?: string;
|
allowedDirs?: string;
|
||||||
enableSandbox?: boolean;
|
enableSandbox?: boolean;
|
||||||
|
installedScopes: ('global' | 'project')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3605,22 +3627,24 @@ export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
|
|||||||
try {
|
try {
|
||||||
const config = await fetchMcpConfig();
|
const config = await fetchMcpConfig();
|
||||||
|
|
||||||
// Check if ccw-tools server exists in any config
|
const installedScopes: ('global' | 'project')[] = [];
|
||||||
let ccwServer: any = null;
|
let ccwServer: any = null;
|
||||||
|
|
||||||
// Check global servers
|
// Check global/user servers
|
||||||
if (config.globalServers?.['ccw-tools']) {
|
if (config.globalServers?.['ccw-tools']) {
|
||||||
|
installedScopes.push('global');
|
||||||
ccwServer = config.globalServers['ccw-tools'];
|
ccwServer = config.globalServers['ccw-tools'];
|
||||||
}
|
} else if (config.userServers?.['ccw-tools']) {
|
||||||
// Check user servers
|
installedScopes.push('global');
|
||||||
if (!ccwServer && config.userServers?.['ccw-tools']) {
|
|
||||||
ccwServer = config.userServers['ccw-tools'];
|
ccwServer = config.userServers['ccw-tools'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check project servers
|
// Check project servers
|
||||||
if (!ccwServer && config.projects) {
|
if (config.projects) {
|
||||||
for (const proj of Object.values(config.projects)) {
|
for (const proj of Object.values(config.projects)) {
|
||||||
if (proj.mcpServers?.['ccw-tools']) {
|
if (proj.mcpServers?.['ccw-tools']) {
|
||||||
ccwServer = proj.mcpServers['ccw-tools'];
|
installedScopes.push('project');
|
||||||
|
if (!ccwServer) ccwServer = proj.mcpServers['ccw-tools'];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3630,6 +3654,7 @@ export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
|
|||||||
return {
|
return {
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
enabledTools: [],
|
enabledTools: [],
|
||||||
|
installedScopes: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3646,11 +3671,13 @@ export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
|
|||||||
projectRoot: env.CCW_PROJECT_ROOT,
|
projectRoot: env.CCW_PROJECT_ROOT,
|
||||||
allowedDirs: env.CCW_ALLOWED_DIRS,
|
allowedDirs: env.CCW_ALLOWED_DIRS,
|
||||||
enableSandbox: env.CCW_ENABLE_SANDBOX === '1',
|
enableSandbox: env.CCW_ENABLE_SANDBOX === '1',
|
||||||
|
installedScopes,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
enabledTools: [],
|
enabledTools: [],
|
||||||
|
installedScopes: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3742,6 +3769,27 @@ export async function uninstallCcwMcp(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstall CCW Tools MCP server from a specific scope
|
||||||
|
*/
|
||||||
|
export async function uninstallCcwMcpFromScope(
|
||||||
|
scope: 'global' | 'project',
|
||||||
|
projectPath?: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (scope === 'global') {
|
||||||
|
await fetchApi('/api/mcp-remove-global-server', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ serverName: 'ccw-tools' }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!projectPath) throw new Error('projectPath required for project scope uninstall');
|
||||||
|
await fetchApi('/api/mcp-remove-server', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ projectPath, serverName: 'ccw-tools' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== CCW Tools MCP - Codex API ==========
|
// ========== CCW Tools MCP - Codex API ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3753,7 +3801,7 @@ export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
|
|||||||
const ccwServer = servers.find((s) => s.name === 'ccw-tools');
|
const ccwServer = servers.find((s) => s.name === 'ccw-tools');
|
||||||
|
|
||||||
if (!ccwServer) {
|
if (!ccwServer) {
|
||||||
return { isInstalled: false, enabledTools: [] };
|
return { isInstalled: false, enabledTools: [], installedScopes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = ccwServer.env || {};
|
const env = ccwServer.env || {};
|
||||||
@@ -3768,9 +3816,10 @@ export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
|
|||||||
projectRoot: env.CCW_PROJECT_ROOT,
|
projectRoot: env.CCW_PROJECT_ROOT,
|
||||||
allowedDirs: env.CCW_ALLOWED_DIRS,
|
allowedDirs: env.CCW_ALLOWED_DIRS,
|
||||||
enableSandbox: env.CCW_ENABLE_SANDBOX === '1',
|
enableSandbox: env.CCW_ENABLE_SANDBOX === '1',
|
||||||
|
installedScopes: ['global'],
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { isInstalled: false, enabledTools: [] };
|
return { isInstalled: false, enabledTools: [], installedScopes: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3856,18 +3905,39 @@ export async function updateCcwConfigForCodex(config: {
|
|||||||
* @param projectPath - Optional project path to filter data by workspace
|
* @param projectPath - Optional project path to filter data by workspace
|
||||||
*/
|
*/
|
||||||
export async function fetchIndexStatus(projectPath?: string): Promise<IndexStatus> {
|
export async function fetchIndexStatus(projectPath?: string): Promise<IndexStatus> {
|
||||||
const url = projectPath ? `/api/index/status?path=${encodeURIComponent(projectPath)}` : '/api/index/status';
|
const url = projectPath
|
||||||
return fetchApi<IndexStatus>(url);
|
? `/api/codexlens/workspace-status?path=${encodeURIComponent(projectPath)}`
|
||||||
|
: '/api/codexlens/workspace-status';
|
||||||
|
const resp = await fetchApi<{
|
||||||
|
success: boolean;
|
||||||
|
hasIndex: boolean;
|
||||||
|
fts?: { indexedFiles: number; totalFiles: number };
|
||||||
|
}>(url);
|
||||||
|
return {
|
||||||
|
totalFiles: resp.fts?.totalFiles ?? 0,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
buildTime: 0,
|
||||||
|
status: resp.hasIndex ? 'completed' : 'idle',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild index
|
* Rebuild index
|
||||||
*/
|
*/
|
||||||
export async function rebuildIndex(request: IndexRebuildRequest = {}): Promise<IndexStatus> {
|
export async function rebuildIndex(request: IndexRebuildRequest = {}): Promise<IndexStatus> {
|
||||||
return fetchApi<IndexStatus>('/api/index/rebuild', {
|
await fetchApi<{ success: boolean }>('/api/codexlens/init', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify({
|
||||||
|
path: request.paths?.[0],
|
||||||
|
indexType: 'vector',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
totalFiles: 0,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
buildTime: 0,
|
||||||
|
status: 'building',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Prompt History API ==========
|
// ========== Prompt History API ==========
|
||||||
|
|||||||
73
ccw/frontend/src/lib/ccw-artifacts.test.ts
Normal file
73
ccw/frontend/src/lib/ccw-artifacts.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { detectCcArtifacts } from './ccw-artifacts';
|
||||||
|
|
||||||
|
describe('ccw-artifacts', () => {
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(detectCcArtifacts('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects workflow session artifacts', () => {
|
||||||
|
const text = 'Created: (.workflow/active/WFS-demo/workflow-session.json)';
|
||||||
|
expect(detectCcArtifacts(text)).toEqual([
|
||||||
|
{ type: 'workflow-session', path: '.workflow/active/WFS-demo/workflow-session.json' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects lite session artifacts', () => {
|
||||||
|
const text = 'Plan: .workflow/.lite-plan/terminal-dashboard-enhancement-2026-02-15/plan.json';
|
||||||
|
expect(detectCcArtifacts(text)).toEqual([
|
||||||
|
{ type: 'lite-session', path: '.workflow/.lite-plan/terminal-dashboard-enhancement-2026-02-15/plan.json' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects CLAUDE.md artifacts (case-insensitive)', () => {
|
||||||
|
const text = 'Updated: /repo/docs/claude.md and also CLAUDE.md';
|
||||||
|
const res = detectCcArtifacts(text);
|
||||||
|
expect(res).toEqual([
|
||||||
|
{ type: 'claude-md', path: '/repo/docs/claude.md' },
|
||||||
|
{ type: 'claude-md', path: 'CLAUDE.md' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects CCW config artifacts', () => {
|
||||||
|
const text = 'Config: .ccw/config.toml and ccw.config.yaml';
|
||||||
|
expect(detectCcArtifacts(text)).toEqual([
|
||||||
|
{ type: 'ccw-config', path: '.ccw/config.toml' },
|
||||||
|
{ type: 'ccw-config', path: 'ccw.config.yaml' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects issue artifacts', () => {
|
||||||
|
const text = 'Queue: .workflow/issues/queues/index.json';
|
||||||
|
expect(detectCcArtifacts(text)).toEqual([
|
||||||
|
{ type: 'issue', path: '.workflow/issues/queues/index.json' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates repeated artifacts', () => {
|
||||||
|
const text = '.workflow/issues/issues.jsonl ... .workflow/issues/issues.jsonl';
|
||||||
|
expect(detectCcArtifacts(text)).toEqual([
|
||||||
|
{ type: 'issue', path: '.workflow/issues/issues.jsonl' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves discovery order across types', () => {
|
||||||
|
const text = [
|
||||||
|
'Issue: .workflow/issues/issues.jsonl',
|
||||||
|
'Then plan: .workflow/.lite-plan/abc/plan.json',
|
||||||
|
'Then session: .workflow/active/WFS-x/workflow-session.json',
|
||||||
|
'Then config: .ccw/config.toml',
|
||||||
|
'Then CLAUDE: CLAUDE.md',
|
||||||
|
].join(' | ');
|
||||||
|
|
||||||
|
const res = detectCcArtifacts(text);
|
||||||
|
expect(res.map((a) => a.type)).toEqual([
|
||||||
|
'issue',
|
||||||
|
'lite-session',
|
||||||
|
'workflow-session',
|
||||||
|
'ccw-config',
|
||||||
|
'claude-md',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
91
ccw/frontend/src/lib/ccw-artifacts.ts
Normal file
91
ccw/frontend/src/lib/ccw-artifacts.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// ========================================
|
||||||
|
// CCW Artifacts - Types & Detection
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export type ArtifactType =
|
||||||
|
| 'workflow-session'
|
||||||
|
| 'lite-session'
|
||||||
|
| 'claude-md'
|
||||||
|
| 'ccw-config'
|
||||||
|
| 'issue';
|
||||||
|
|
||||||
|
export interface CcArtifact {
|
||||||
|
type: ArtifactType;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRAILING_PUNCTUATION = /[)\]}>,.;:!?]+$/g;
|
||||||
|
const WRAP_QUOTES = /^['"`]+|['"`]+$/g;
|
||||||
|
|
||||||
|
function normalizePath(raw: string): string {
|
||||||
|
return raw.trim().replace(WRAP_QUOTES, '').replace(TRAILING_PUNCTUATION, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patterns for detecting CCW-related artifacts in terminal output.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Prefer relative paths (e.g., `.workflow/...`) so callers can resolve against a project root.
|
||||||
|
* - Keep patterns conservative to reduce false positives in generic logs.
|
||||||
|
*/
|
||||||
|
export const ARTIFACT_PATTERNS: Record<ArtifactType, RegExp[]> = {
|
||||||
|
'workflow-session': [
|
||||||
|
/(?:^|[^\w.])(\.workflow[\\/](?:active|archives)[\\/][^\s"'`]+[\\/]workflow-session\.json)\b/g,
|
||||||
|
],
|
||||||
|
'lite-session': [
|
||||||
|
/(?:^|[^\w.])(\.workflow[\\/]\.lite-plan[\\/][^\s"'`]+)\b/g,
|
||||||
|
],
|
||||||
|
'claude-md': [
|
||||||
|
/([^\s"'`]*CLAUDE\.md)\b/gi,
|
||||||
|
],
|
||||||
|
'ccw-config': [
|
||||||
|
/(?:^|[^\w.])(\.ccw[\\/][^\s"'`]+)\b/g,
|
||||||
|
/(?:^|[^\w.])(ccw\.config\.(?:json|ya?ml|toml))\b/gi,
|
||||||
|
],
|
||||||
|
issue: [
|
||||||
|
/(?:^|[^\w.])(\.workflow[\\/]issues[\\/][^\s"'`]+)\b/g,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect CCW artifacts from an arbitrary text blob.
|
||||||
|
*
|
||||||
|
* Returns a de-duplicated list of `{ type, path }` in discovery order.
|
||||||
|
*/
|
||||||
|
export function detectCcArtifacts(text: string): CcArtifact[] {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const candidates: Array<CcArtifact & { index: number }> = [];
|
||||||
|
|
||||||
|
for (const type of Object.keys(ARTIFACT_PATTERNS) as ArtifactType[]) {
|
||||||
|
for (const pattern of ARTIFACT_PATTERNS[type]) {
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
const raw = match[1] ?? match[0];
|
||||||
|
const path = normalizePath(raw);
|
||||||
|
if (!path) continue;
|
||||||
|
|
||||||
|
const full = match[0] ?? '';
|
||||||
|
const group = match[1] ?? raw;
|
||||||
|
const rel = full.indexOf(group);
|
||||||
|
const index = (match.index ?? 0) + (rel >= 0 ? rel : 0);
|
||||||
|
|
||||||
|
candidates.push({ type, path, index });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
const results: CcArtifact[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const c of candidates) {
|
||||||
|
const key = `${c.type}:${c.path}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
results.push({ type: c.type, path: c.path });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -164,11 +164,28 @@
|
|||||||
"uninstall": "Uninstall",
|
"uninstall": "Uninstall",
|
||||||
"uninstalling": "Uninstalling...",
|
"uninstalling": "Uninstalling...",
|
||||||
"uninstallConfirm": "Are you sure you want to uninstall CCW MCP?",
|
"uninstallConfirm": "Are you sure you want to uninstall CCW MCP?",
|
||||||
|
"uninstallScopeConfirm": "Are you sure you want to remove CCW MCP from {scope}?",
|
||||||
"saveConfig": "Save Configuration",
|
"saveConfig": "Save Configuration",
|
||||||
"saving": "Saving..."
|
"saving": "Saving..."
|
||||||
},
|
},
|
||||||
|
"scope": {
|
||||||
|
"global": "Global",
|
||||||
|
"project": "Project",
|
||||||
|
"addScope": "Install to other scope",
|
||||||
|
"installToProject": "Also install to project",
|
||||||
|
"installToGlobal": "Also install to global",
|
||||||
|
"uninstallFrom": "Uninstall by scope",
|
||||||
|
"uninstallGlobal": "Remove from global",
|
||||||
|
"uninstallProject": "Remove from project"
|
||||||
|
},
|
||||||
"codexNote": "Requires: npm install -g claude-code-workflow"
|
"codexNote": "Requires: npm install -g claude-code-workflow"
|
||||||
},
|
},
|
||||||
|
"conflict": {
|
||||||
|
"badge": "Conflict",
|
||||||
|
"title": "Scope Conflict",
|
||||||
|
"description": "This server exists in both project and global scopes. The {scope} version is used at runtime.",
|
||||||
|
"resolution": "Consider removing this server from one of the scopes."
|
||||||
|
},
|
||||||
"recommended": {
|
"recommended": {
|
||||||
"title": "Recommended Servers",
|
"title": "Recommended Servers",
|
||||||
"description": "Quickly install popular MCP servers with one click",
|
"description": "Quickly install popular MCP servers with one click",
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"platforms": "Platform Configuration",
|
"platforms": "Platform Configuration",
|
||||||
"events": "Event Triggers",
|
"events": "Event Triggers",
|
||||||
|
"selectPlatforms": "Select which platforms to notify for this event:",
|
||||||
"noPlatforms": "No platforms",
|
"noPlatforms": "No platforms",
|
||||||
"configured": "Configured",
|
"configured": "Configured",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -151,6 +152,36 @@
|
|||||||
"method": "HTTP Method",
|
"method": "HTTP Method",
|
||||||
"headers": "Custom Headers (JSON)",
|
"headers": "Custom Headers (JSON)",
|
||||||
"headersHint": "Optional JSON object with custom headers"
|
"headersHint": "Optional JSON object with custom headers"
|
||||||
|
},
|
||||||
|
"feishu": {
|
||||||
|
"webhookUrl": "Webhook URL",
|
||||||
|
"webhookUrlHint": "Get from Feishu robot settings",
|
||||||
|
"useCard": "Use Card Format",
|
||||||
|
"useCardHint": "Send as rich interactive card",
|
||||||
|
"title": "Card Title (optional)"
|
||||||
|
},
|
||||||
|
"dingtalk": {
|
||||||
|
"webhookUrl": "Webhook URL",
|
||||||
|
"webhookUrlHint": "Get from DingTalk robot settings",
|
||||||
|
"keywords": "Security Keywords",
|
||||||
|
"keywordsHint": "Comma-separated keywords for security check"
|
||||||
|
},
|
||||||
|
"wecom": {
|
||||||
|
"webhookUrl": "Webhook URL",
|
||||||
|
"webhookUrlHint": "Get from WeCom robot settings",
|
||||||
|
"mentionedList": "Mention Users",
|
||||||
|
"mentionedListHint": "User IDs to mention, use '@all' for everyone"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"host": "SMTP Host",
|
||||||
|
"hostHint": "e.g., smtp.gmail.com",
|
||||||
|
"port": "Port",
|
||||||
|
"secure": "Use TLS",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"from": "Sender Email",
|
||||||
|
"to": "Recipients",
|
||||||
|
"toHint": "Comma-separated email addresses"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionCheck": {
|
"versionCheck": {
|
||||||
|
|||||||
@@ -78,9 +78,51 @@
|
|||||||
"layoutSplitV": "Split Vertical",
|
"layoutSplitV": "Split Vertical",
|
||||||
"layoutGrid": "Grid 2x2",
|
"layoutGrid": "Grid 2x2",
|
||||||
"launchCli": "Launch CLI",
|
"launchCli": "Launch CLI",
|
||||||
|
"tool": "Tool",
|
||||||
|
"mode": "Mode",
|
||||||
|
"modeDefault": "Default",
|
||||||
|
"modeYolo": "Yolo",
|
||||||
"quickCreate": "Quick Create",
|
"quickCreate": "Quick Create",
|
||||||
"configure": "Configure..."
|
"configure": "Configure..."
|
||||||
},
|
},
|
||||||
|
"cliConfig": {
|
||||||
|
"title": "Create CLI Session",
|
||||||
|
"description": "Configure tool, model, mode, shell, and working directory.",
|
||||||
|
"tool": "Tool",
|
||||||
|
"model": "Model",
|
||||||
|
"modelAuto": "Auto",
|
||||||
|
"mode": "Mode",
|
||||||
|
"modeDefault": "Default",
|
||||||
|
"modeYolo": "Yolo",
|
||||||
|
"shell": "Shell",
|
||||||
|
"workingDir": "Working Directory",
|
||||||
|
"workingDirPlaceholder": "e.g. /path/to/project",
|
||||||
|
"browse": "Browse",
|
||||||
|
"errors": {
|
||||||
|
"workingDirRequired": "Working directory is required.",
|
||||||
|
"createFailed": "Failed to create session."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fileBrowser": {
|
||||||
|
"title": "File Browser",
|
||||||
|
"open": "Open file browser",
|
||||||
|
"selected": "Selected file",
|
||||||
|
"noSelection": "No file selected",
|
||||||
|
"copyPath": "Copy path",
|
||||||
|
"copied": "Copied",
|
||||||
|
"insertPath": "Insert into terminal",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"loadFailed": "Failed to load file tree"
|
||||||
|
},
|
||||||
|
"artifacts": {
|
||||||
|
"types": {
|
||||||
|
"workflowSession": "Workflow",
|
||||||
|
"liteSession": "Lite",
|
||||||
|
"claudeMd": "CLAUDE.md",
|
||||||
|
"ccwConfig": "CCW Config",
|
||||||
|
"issue": "Issue"
|
||||||
|
}
|
||||||
|
},
|
||||||
"pane": {
|
"pane": {
|
||||||
"selectSession": "Select a session",
|
"selectSession": "Select a session",
|
||||||
"selectSessionHint": "Choose a terminal session from the dropdown",
|
"selectSessionHint": "Choose a terminal session from the dropdown",
|
||||||
|
|||||||
@@ -164,11 +164,28 @@
|
|||||||
"uninstall": "卸载",
|
"uninstall": "卸载",
|
||||||
"uninstalling": "卸载中...",
|
"uninstalling": "卸载中...",
|
||||||
"uninstallConfirm": "确定要卸载 CCW MCP 吗?",
|
"uninstallConfirm": "确定要卸载 CCW MCP 吗?",
|
||||||
|
"uninstallScopeConfirm": "确定要从{scope}移除 CCW MCP 吗?",
|
||||||
"saveConfig": "保存配置",
|
"saveConfig": "保存配置",
|
||||||
"saving": "保存中..."
|
"saving": "保存中..."
|
||||||
},
|
},
|
||||||
|
"scope": {
|
||||||
|
"global": "全局",
|
||||||
|
"project": "项目",
|
||||||
|
"addScope": "安装到其他作用域",
|
||||||
|
"installToProject": "同时安装到项目",
|
||||||
|
"installToGlobal": "同时安装到全局",
|
||||||
|
"uninstallFrom": "按作用域卸载",
|
||||||
|
"uninstallGlobal": "从全局移除",
|
||||||
|
"uninstallProject": "从项目移除"
|
||||||
|
},
|
||||||
"codexNote": "需要全局安装:npm install -g claude-code-workflow"
|
"codexNote": "需要全局安装:npm install -g claude-code-workflow"
|
||||||
},
|
},
|
||||||
|
"conflict": {
|
||||||
|
"badge": "冲突",
|
||||||
|
"title": "作用域冲突",
|
||||||
|
"description": "此服务器同时存在于项目和全局作用域。运行时使用{scope}版本。",
|
||||||
|
"resolution": "建议从其中一个作用域移除该服务器。"
|
||||||
|
},
|
||||||
"recommended": {
|
"recommended": {
|
||||||
"title": "推荐服务器",
|
"title": "推荐服务器",
|
||||||
"description": "一键快速安装热门 MCP 服务器",
|
"description": "一键快速安装热门 MCP 服务器",
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"platforms": "平台配置",
|
"platforms": "平台配置",
|
||||||
"events": "事件触发器",
|
"events": "事件触发器",
|
||||||
|
"selectPlatforms": "选择此事件要通知的平台:",
|
||||||
"noPlatforms": "无平台",
|
"noPlatforms": "无平台",
|
||||||
"configured": "已配置",
|
"configured": "已配置",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
@@ -151,6 +152,36 @@
|
|||||||
"method": "HTTP 方法",
|
"method": "HTTP 方法",
|
||||||
"headers": "自定义请求头(JSON)",
|
"headers": "自定义请求头(JSON)",
|
||||||
"headersHint": "可选的 JSON 对象,包含自定义请求头"
|
"headersHint": "可选的 JSON 对象,包含自定义请求头"
|
||||||
|
},
|
||||||
|
"feishu": {
|
||||||
|
"webhookUrl": "Webhook URL",
|
||||||
|
"webhookUrlHint": "从飞书机器人设置中获取",
|
||||||
|
"useCard": "使用卡片格式",
|
||||||
|
"useCardHint": "以富交互卡片形式发送",
|
||||||
|
"title": "卡片标题(可选)"
|
||||||
|
},
|
||||||
|
"dingtalk": {
|
||||||
|
"webhookUrl": "Webhook URL",
|
||||||
|
"webhookUrlHint": "从钉钉机器人设置中获取",
|
||||||
|
"keywords": "安全关键词",
|
||||||
|
"keywordsHint": "逗号分隔的关键词,用于安全校验"
|
||||||
|
},
|
||||||
|
"wecom": {
|
||||||
|
"webhookUrl": "Webhook URL",
|
||||||
|
"webhookUrlHint": "从企业微信机器人设置中获取",
|
||||||
|
"mentionedList": "提醒用户",
|
||||||
|
"mentionedListHint": "要提醒的用户 ID,使用 '@all' 提醒所有人"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"host": "SMTP 服务器",
|
||||||
|
"hostHint": "例如:smtp.gmail.com",
|
||||||
|
"port": "端口",
|
||||||
|
"secure": "使用 TLS",
|
||||||
|
"username": "用户名",
|
||||||
|
"password": "密码",
|
||||||
|
"from": "发件人邮箱",
|
||||||
|
"to": "收件人",
|
||||||
|
"toHint": "逗号分隔的邮箱地址"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionCheck": {
|
"versionCheck": {
|
||||||
|
|||||||
@@ -78,9 +78,51 @@
|
|||||||
"layoutSplitV": "上下分割",
|
"layoutSplitV": "上下分割",
|
||||||
"layoutGrid": "2x2 网格",
|
"layoutGrid": "2x2 网格",
|
||||||
"launchCli": "启动 CLI",
|
"launchCli": "启动 CLI",
|
||||||
|
"tool": "工具",
|
||||||
|
"mode": "模式",
|
||||||
|
"modeDefault": "默认",
|
||||||
|
"modeYolo": "Yolo",
|
||||||
"quickCreate": "快速创建",
|
"quickCreate": "快速创建",
|
||||||
"configure": "配置..."
|
"configure": "配置..."
|
||||||
},
|
},
|
||||||
|
"cliConfig": {
|
||||||
|
"title": "创建 CLI 会话",
|
||||||
|
"description": "配置工具、模型、模式、Shell 与工作目录。",
|
||||||
|
"tool": "工具",
|
||||||
|
"model": "模型",
|
||||||
|
"modelAuto": "自动",
|
||||||
|
"mode": "模式",
|
||||||
|
"modeDefault": "默认",
|
||||||
|
"modeYolo": "Yolo",
|
||||||
|
"shell": "Shell",
|
||||||
|
"workingDir": "工作目录",
|
||||||
|
"workingDirPlaceholder": "例如:/path/to/project",
|
||||||
|
"browse": "浏览",
|
||||||
|
"errors": {
|
||||||
|
"workingDirRequired": "工作目录不能为空。",
|
||||||
|
"createFailed": "创建会话失败。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fileBrowser": {
|
||||||
|
"title": "文件浏览器",
|
||||||
|
"open": "打开文件浏览器",
|
||||||
|
"selected": "已选文件",
|
||||||
|
"noSelection": "未选择文件",
|
||||||
|
"copyPath": "复制路径",
|
||||||
|
"copied": "已复制",
|
||||||
|
"insertPath": "插入到终端",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"loadFailed": "加载文件树失败"
|
||||||
|
},
|
||||||
|
"artifacts": {
|
||||||
|
"types": {
|
||||||
|
"workflowSession": "工作流",
|
||||||
|
"liteSession": "Lite",
|
||||||
|
"claudeMd": "CLAUDE.md",
|
||||||
|
"ccwConfig": "配置",
|
||||||
|
"issue": "问题"
|
||||||
|
}
|
||||||
|
},
|
||||||
"pane": {
|
"pane": {
|
||||||
"selectSession": "选择会话",
|
"selectSession": "选择会话",
|
||||||
"selectSessionHint": "从下拉菜单中选择终端会话",
|
"selectSessionHint": "从下拉菜单中选择终端会话",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// Manage MCP servers (Model Context Protocol) with tabbed interface
|
// Manage MCP servers (Model Context Protocol) with tabbed interface
|
||||||
// Supports Templates, Servers, and Cross-CLI tabs
|
// Supports Templates, Servers, and Cross-CLI tabs
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
BookmarkPlus,
|
BookmarkPlus,
|
||||||
|
AlertTriangle,
|
||||||
} 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';
|
||||||
@@ -38,16 +39,20 @@ import { AllProjectsTable } from '@/components/mcp/AllProjectsTable';
|
|||||||
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
|
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
|
||||||
import { TabsNavigation } from '@/components/ui/TabsNavigation';
|
import { TabsNavigation } from '@/components/ui/TabsNavigation';
|
||||||
import { useMcpServers, useMcpServerMutations, useNotifications } from '@/hooks';
|
import { useMcpServers, useMcpServerMutations, useNotifications } from '@/hooks';
|
||||||
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
import {
|
import {
|
||||||
fetchCodexMcpServers,
|
fetchCodexMcpServers,
|
||||||
fetchCcwMcpConfig,
|
fetchCcwMcpConfig,
|
||||||
fetchCcwMcpConfigForCodex,
|
fetchCcwMcpConfigForCodex,
|
||||||
updateCcwConfig,
|
updateCcwConfig,
|
||||||
updateCcwConfigForCodex,
|
updateCcwConfigForCodex,
|
||||||
|
installCcwMcp,
|
||||||
|
uninstallCcwMcpFromScope,
|
||||||
codexRemoveServer,
|
codexRemoveServer,
|
||||||
codexToggleServer,
|
codexToggleServer,
|
||||||
saveMcpTemplate,
|
saveMcpTemplate,
|
||||||
type McpServer,
|
type McpServer,
|
||||||
|
type McpServerConflict,
|
||||||
type CcwMcpConfig,
|
type CcwMcpConfig,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -62,9 +67,10 @@ interface McpServerCardProps {
|
|||||||
onEdit: (server: McpServer) => void;
|
onEdit: (server: McpServer) => void;
|
||||||
onDelete: (server: McpServer) => void;
|
onDelete: (server: McpServer) => void;
|
||||||
onSaveAsTemplate: (server: McpServer) => void;
|
onSaveAsTemplate: (server: McpServer) => void;
|
||||||
|
conflictInfo?: McpServerConflict;
|
||||||
}
|
}
|
||||||
|
|
||||||
function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate }: McpServerCardProps) {
|
function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate, conflictInfo }: McpServerCardProps) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,6 +103,12 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
|
|||||||
<><Folder className="w-3 h-3 mr-1" />{formatMessage({ id: 'mcp.scope.project' })}</>
|
<><Folder className="w-3 h-3 mr-1" />{formatMessage({ id: 'mcp.scope.project' })}</>
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{conflictInfo && (
|
||||||
|
<Badge variant="outline" className="text-xs text-orange-500 border-orange-300">
|
||||||
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||||
|
{formatMessage({ id: 'mcp.conflict.badge' })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{server.enabled && (
|
{server.enabled && (
|
||||||
<Badge variant="outline" className="text-xs text-green-600">
|
<Badge variant="outline" className="text-xs text-green-600">
|
||||||
{formatMessage({ id: 'mcp.status.enabled' })}
|
{formatMessage({ id: 'mcp.status.enabled' })}
|
||||||
@@ -205,6 +217,22 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Conflict warning panel */}
|
||||||
|
{conflictInfo && (
|
||||||
|
<div className="p-3 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-lg space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">{formatMessage({ id: 'mcp.conflict.title' })}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-orange-600 dark:text-orange-400/80">
|
||||||
|
{formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: `mcp.scope.${conflictInfo.effectiveScope}` }) })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-orange-600 dark:text-orange-400/80">
|
||||||
|
{formatMessage({ id: 'mcp.conflict.resolution' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -233,6 +261,7 @@ export function McpManagerPage() {
|
|||||||
servers,
|
servers,
|
||||||
projectServers,
|
projectServers,
|
||||||
globalServers,
|
globalServers,
|
||||||
|
conflicts,
|
||||||
totalCount,
|
totalCount,
|
||||||
enabledCount,
|
enabledCount,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -350,6 +379,7 @@ export function McpManagerPage() {
|
|||||||
projectRoot: undefined,
|
projectRoot: undefined,
|
||||||
allowedDirs: undefined,
|
allowedDirs: undefined,
|
||||||
enableSandbox: undefined,
|
enableSandbox: undefined,
|
||||||
|
installedScopes: [] as ('global' | 'project')[],
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleCcwTool = async (tool: string, enabled: boolean) => {
|
const handleToggleCcwTool = async (tool: string, enabled: boolean) => {
|
||||||
@@ -401,13 +431,43 @@ export function McpManagerPage() {
|
|||||||
ccwMcpQuery.refetch();
|
ccwMcpQuery.refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
|
// Build conflict map for quick lookup
|
||||||
|
const conflictMap = useMemo(() => {
|
||||||
|
const map = new Map<string, McpServerConflict>();
|
||||||
|
for (const c of conflicts) map.set(c.name, c);
|
||||||
|
return map;
|
||||||
|
}, [conflicts]);
|
||||||
|
|
||||||
|
// CCW scope-specific handlers
|
||||||
|
const handleCcwInstallToScope = async (scope: 'global' | 'project') => {
|
||||||
|
try {
|
||||||
|
await installCcwMcp(scope, scope === 'project' ? projectPath ?? undefined : undefined);
|
||||||
|
ccwMcpQuery.refetch();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to install CCW MCP to scope:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCcwUninstallFromScope = async (scope: 'global' | 'project') => {
|
||||||
|
try {
|
||||||
|
await uninstallCcwMcpFromScope(scope, scope === 'project' ? projectPath ?? undefined : undefined);
|
||||||
|
ccwMcpQuery.refetch();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcpServers'] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to uninstall CCW MCP from scope:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// CCW MCP handlers for Codex mode
|
// CCW MCP handlers for Codex mode
|
||||||
const ccwCodexConfig = ccwMcpCodexQuery.data ?? {
|
const ccwCodexConfig = ccwMcpCodexQuery.data ?? {
|
||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
enabledTools: [],
|
enabledTools: [] as string[],
|
||||||
projectRoot: undefined,
|
projectRoot: undefined,
|
||||||
allowedDirs: undefined,
|
allowedDirs: undefined,
|
||||||
enableSandbox: undefined,
|
enableSandbox: undefined,
|
||||||
|
installedScopes: [] as ('global' | 'project')[],
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleCcwToolCodex = async (tool: string, enabled: boolean) => {
|
const handleToggleCcwToolCodex = async (tool: string, enabled: boolean) => {
|
||||||
@@ -725,6 +785,9 @@ export function McpManagerPage() {
|
|||||||
onToggleTool={handleToggleCcwTool}
|
onToggleTool={handleToggleCcwTool}
|
||||||
onUpdateConfig={handleUpdateCcwConfig}
|
onUpdateConfig={handleUpdateCcwConfig}
|
||||||
onInstall={handleCcwInstall}
|
onInstall={handleCcwInstall}
|
||||||
|
installedScopes={ccwConfig.installedScopes}
|
||||||
|
onInstallToScope={handleCcwInstallToScope}
|
||||||
|
onUninstallScope={handleCcwUninstallFromScope}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{cliMode === 'codex' && (
|
{cliMode === 'codex' && (
|
||||||
@@ -761,7 +824,7 @@ export function McpManagerPage() {
|
|||||||
{currentServers.map((server) => (
|
{currentServers.map((server) => (
|
||||||
cliMode === 'codex' ? (
|
cliMode === 'codex' ? (
|
||||||
<CodexMcpEditableCard
|
<CodexMcpEditableCard
|
||||||
key={server.name}
|
key={`${server.name}-${server.scope}`}
|
||||||
server={server as McpServer}
|
server={server as McpServer}
|
||||||
enabled={server.enabled}
|
enabled={server.enabled}
|
||||||
isExpanded={currentExpanded.has(server.name)}
|
isExpanded={currentExpanded.has(server.name)}
|
||||||
@@ -772,14 +835,15 @@ export function McpManagerPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<McpServerCard
|
<McpServerCard
|
||||||
key={server.name}
|
key={`${server.name}-${server.scope}`}
|
||||||
server={server}
|
server={server}
|
||||||
isExpanded={currentExpanded.has(server.name)}
|
isExpanded={currentExpanded.has(`${server.name}-${server.scope}`)}
|
||||||
onToggleExpand={() => currentToggleExpand(server.name)}
|
onToggleExpand={() => currentToggleExpand(`${server.name}-${server.scope}`)}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onSaveAsTemplate={handleSaveServerAsTemplate}
|
onSaveAsTemplate={handleSaveServerAsTemplate}
|
||||||
|
conflictInfo={conflictMap.get(server.name)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
/**
|
/**
|
||||||
* Supported notification platforms
|
* Supported notification platforms
|
||||||
*/
|
*/
|
||||||
export type NotificationPlatform = 'discord' | 'telegram' | 'webhook';
|
export type NotificationPlatform = 'discord' | 'telegram' | 'feishu' | 'dingtalk' | 'wecom' | 'email' | 'webhook';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event types that can trigger notifications
|
* Event types that can trigger notifications
|
||||||
@@ -39,6 +39,48 @@ export interface TelegramConfig {
|
|||||||
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2';
|
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feishu (Lark) platform configuration
|
||||||
|
*/
|
||||||
|
export interface FeishuConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
webhookUrl: string;
|
||||||
|
useCard?: boolean;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DingTalk platform configuration
|
||||||
|
*/
|
||||||
|
export interface DingTalkConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
webhookUrl: string;
|
||||||
|
keywords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WeCom (WeChat Work) platform configuration
|
||||||
|
*/
|
||||||
|
export interface WeComConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
webhookUrl: string;
|
||||||
|
mentionedList?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email SMTP platform configuration
|
||||||
|
*/
|
||||||
|
export interface EmailConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure?: boolean;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
from: string;
|
||||||
|
to: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic Webhook platform configuration
|
* Generic Webhook platform configuration
|
||||||
*/
|
*/
|
||||||
@@ -67,6 +109,10 @@ export interface RemoteNotificationConfig {
|
|||||||
platforms: {
|
platforms: {
|
||||||
discord?: DiscordConfig;
|
discord?: DiscordConfig;
|
||||||
telegram?: TelegramConfig;
|
telegram?: TelegramConfig;
|
||||||
|
feishu?: FeishuConfig;
|
||||||
|
dingtalk?: DingTalkConfig;
|
||||||
|
wecom?: WeComConfig;
|
||||||
|
email?: EmailConfig;
|
||||||
webhook?: WebhookConfig;
|
webhook?: WebhookConfig;
|
||||||
};
|
};
|
||||||
events: EventConfig[];
|
events: EventConfig[];
|
||||||
@@ -78,7 +124,7 @@ export interface RemoteNotificationConfig {
|
|||||||
*/
|
*/
|
||||||
export interface TestNotificationRequest {
|
export interface TestNotificationRequest {
|
||||||
platform: NotificationPlatform;
|
platform: NotificationPlatform;
|
||||||
config: DiscordConfig | TelegramConfig | WebhookConfig;
|
config: DiscordConfig | TelegramConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig | WebhookConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,6 +175,34 @@ export const PLATFORM_INFO: Record<NotificationPlatform, PlatformInfo> = {
|
|||||||
description: 'Send notifications to Telegram chats via bot',
|
description: 'Send notifications to Telegram chats via bot',
|
||||||
requiredFields: ['botToken', 'chatId'],
|
requiredFields: ['botToken', 'chatId'],
|
||||||
},
|
},
|
||||||
|
feishu: {
|
||||||
|
id: 'feishu',
|
||||||
|
name: 'Feishu',
|
||||||
|
icon: 'message-square',
|
||||||
|
description: 'Send notifications to Feishu (Lark) via webhook with rich card support',
|
||||||
|
requiredFields: ['webhookUrl'],
|
||||||
|
},
|
||||||
|
dingtalk: {
|
||||||
|
id: 'dingtalk',
|
||||||
|
name: 'DingTalk',
|
||||||
|
icon: 'bell',
|
||||||
|
description: 'Send notifications to DingTalk via webhook',
|
||||||
|
requiredFields: ['webhookUrl'],
|
||||||
|
},
|
||||||
|
wecom: {
|
||||||
|
id: 'wecom',
|
||||||
|
name: 'WeCom',
|
||||||
|
icon: 'users',
|
||||||
|
description: 'Send notifications to WeCom (WeChat Work) via webhook',
|
||||||
|
requiredFields: ['webhookUrl'],
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
id: 'email',
|
||||||
|
name: 'Email',
|
||||||
|
icon: 'mail',
|
||||||
|
description: 'Send notifications via SMTP email',
|
||||||
|
requiredFields: ['host', 'username', 'password', 'from', 'to'],
|
||||||
|
},
|
||||||
webhook: {
|
webhook: {
|
||||||
id: 'webhook',
|
id: 'webhook',
|
||||||
name: 'Custom Webhook',
|
name: 'Custom Webhook',
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
// Ensure a single React instance in Vitest (avoid invalid hook call from nested node_modules)
|
||||||
|
react: path.resolve(__dirname, '../../node_modules/react'),
|
||||||
|
'react-dom': path.resolve(__dirname, '../../node_modules/react-dom'),
|
||||||
|
'react/jsx-runtime': path.resolve(__dirname, '../../node_modules/react/jsx-runtime.js'),
|
||||||
|
'react/jsx-dev-runtime': path.resolve(__dirname, '../../node_modules/react/jsx-dev-runtime.js'),
|
||||||
|
'react-dom/client': path.resolve(__dirname, '../../node_modules/react-dom/client.js'),
|
||||||
},
|
},
|
||||||
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
|
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export type QuestionAnswer = z.infer<typeof QuestionAnswerSchema>;
|
|||||||
export const SimpleOptionSchema = z.object({
|
export const SimpleOptionSchema = z.object({
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SimpleOption = z.infer<typeof SimpleOptionSchema>;
|
export type SimpleOption = z.infer<typeof SimpleOptionSchema>;
|
||||||
@@ -114,6 +115,7 @@ export const AskQuestionResultSchema = z.object({
|
|||||||
answers: z.array(QuestionAnswerSchema),
|
answers: z.array(QuestionAnswerSchema),
|
||||||
timestamp: z.string(),
|
timestamp: z.string(),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
|
autoSelected: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AskQuestionResult = z.infer<typeof AskQuestionResultSchema>;
|
export type AskQuestionResult = z.infer<typeof AskQuestionResultSchema>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { homedir } from 'os';
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
import type { RouteContext } from './types.js';
|
import type { RouteContext } from './types.js';
|
||||||
|
import { a2uiWebSocketHandler } from '../a2ui/A2UIWebSocketHandler.js';
|
||||||
|
|
||||||
interface HooksRouteContext extends RouteContext {
|
interface HooksRouteContext extends RouteContext {
|
||||||
extractSessionIdFromPath: (filePath: string) => string | null;
|
extractSessionIdFromPath: (filePath: string) => string | null;
|
||||||
@@ -313,6 +314,20 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// When an A2UI surface is forwarded from the MCP process, initialize
|
||||||
|
// selection tracking on the Dashboard so that submit actions resolve
|
||||||
|
// to the correct value type (single-select string vs multi-select array).
|
||||||
|
if (type === 'a2ui-surface' && extraData?.initialState) {
|
||||||
|
const initState = extraData.initialState as Record<string, unknown>;
|
||||||
|
const questionId = initState.questionId as string | undefined;
|
||||||
|
const questionType = initState.questionType as string | undefined;
|
||||||
|
if (questionId && questionType === 'select') {
|
||||||
|
a2uiWebSocketHandler.initSingleSelect(questionId);
|
||||||
|
} else if (questionId && questionType === 'multi-select') {
|
||||||
|
a2uiWebSocketHandler.initMultiSelect(questionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
broadcastToClients(notification);
|
broadcastToClients(notification);
|
||||||
|
|
||||||
return { success: true, notification };
|
return { success: true, notification };
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '../../config/remote-notification-config.js';
|
} from '../../config/remote-notification-config.js';
|
||||||
import {
|
import {
|
||||||
remoteNotificationService,
|
remoteNotificationService,
|
||||||
} from '../../services/remote-notification-service.js';
|
} from '../services/remote-notification-service.js';
|
||||||
import {
|
import {
|
||||||
maskSensitiveConfig,
|
maskSensitiveConfig,
|
||||||
type RemoteNotificationConfig,
|
type RemoteNotificationConfig,
|
||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
type DiscordConfig,
|
type DiscordConfig,
|
||||||
type TelegramConfig,
|
type TelegramConfig,
|
||||||
type WebhookConfig,
|
type WebhookConfig,
|
||||||
|
type FeishuConfig,
|
||||||
|
type DingTalkConfig,
|
||||||
|
type WeComConfig,
|
||||||
|
type EmailConfig,
|
||||||
} from '../../types/remote-notification.js';
|
} from '../../types/remote-notification.js';
|
||||||
import { deepMerge } from '../../types/util.js';
|
import { deepMerge } from '../../types/util.js';
|
||||||
|
|
||||||
@@ -110,13 +114,72 @@ function isValidHeaders(headers: unknown): { valid: boolean; error?: string } {
|
|||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Feishu webhook URL format
|
||||||
|
*/
|
||||||
|
function isValidFeishuWebhookUrl(url: string): boolean {
|
||||||
|
if (!isValidUrl(url)) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
// Feishu webhooks are typically: open.feishu.cn/open-apis/bot/v2/hook/{token}
|
||||||
|
// or: open.larksuite.com/open-apis/bot/v2/hook/{token}
|
||||||
|
const validHosts = ['open.feishu.cn', 'open.larksuite.com'];
|
||||||
|
return validHosts.includes(parsed.hostname) && parsed.pathname.includes('/bot/');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate DingTalk webhook URL format
|
||||||
|
*/
|
||||||
|
function isValidDingTalkWebhookUrl(url: string): boolean {
|
||||||
|
if (!isValidUrl(url)) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
// DingTalk webhooks are typically: oapi.dingtalk.com/robot/send?access_token=xxx
|
||||||
|
return parsed.hostname.includes('dingtalk.com') && parsed.pathname.includes('robot');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate WeCom webhook URL format
|
||||||
|
*/
|
||||||
|
function isValidWeComWebhookUrl(url: string): boolean {
|
||||||
|
if (!isValidUrl(url)) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
// WeCom webhooks are typically: qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
|
||||||
|
return parsed.hostname.includes('qyapi.weixin.qq.com') && parsed.pathname.includes('webhook');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email address format
|
||||||
|
*/
|
||||||
|
function isValidEmail(email: string): boolean {
|
||||||
|
// Basic email validation regex
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate SMTP port number
|
||||||
|
*/
|
||||||
|
function isValidSmtpPort(port: number): boolean {
|
||||||
|
return Number.isInteger(port) && port > 0 && port <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate configuration updates
|
* Validate configuration updates
|
||||||
*/
|
*/
|
||||||
function validateConfigUpdates(updates: Partial<RemoteNotificationConfig>): { valid: boolean; error?: string } {
|
function validateConfigUpdates(updates: Partial<RemoteNotificationConfig>): { valid: boolean; error?: string } {
|
||||||
// Validate platforms if present
|
// Validate platforms if present
|
||||||
if (updates.platforms) {
|
if (updates.platforms) {
|
||||||
const { discord, telegram, webhook } = updates.platforms;
|
const { discord, telegram, webhook, feishu, dingtalk, wecom, email } = updates.platforms;
|
||||||
|
|
||||||
// Validate Discord config
|
// Validate Discord config
|
||||||
if (discord) {
|
if (discord) {
|
||||||
@@ -165,6 +228,99 @@ function validateConfigUpdates(updates: Partial<RemoteNotificationConfig>): { va
|
|||||||
return { valid: false, error: 'Webhook timeout must be between 1000ms and 60000ms' };
|
return { valid: false, error: 'Webhook timeout must be between 1000ms and 60000ms' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate Feishu config
|
||||||
|
if (feishu) {
|
||||||
|
if (feishu.webhookUrl !== undefined && feishu.webhookUrl !== '') {
|
||||||
|
if (!isValidUrl(feishu.webhookUrl)) {
|
||||||
|
return { valid: false, error: 'Invalid Feishu webhook URL format' };
|
||||||
|
}
|
||||||
|
if (!isValidFeishuWebhookUrl(feishu.webhookUrl)) {
|
||||||
|
console.warn('[RemoteNotification] Webhook URL does not match Feishu format');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (feishu.title !== undefined && feishu.title.length > 100) {
|
||||||
|
return { valid: false, error: 'Feishu title too long (max 100 chars)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate DingTalk config
|
||||||
|
if (dingtalk) {
|
||||||
|
if (dingtalk.webhookUrl !== undefined && dingtalk.webhookUrl !== '') {
|
||||||
|
if (!isValidUrl(dingtalk.webhookUrl)) {
|
||||||
|
return { valid: false, error: 'Invalid DingTalk webhook URL format' };
|
||||||
|
}
|
||||||
|
if (!isValidDingTalkWebhookUrl(dingtalk.webhookUrl)) {
|
||||||
|
console.warn('[RemoteNotification] Webhook URL does not match DingTalk format');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dingtalk.keywords !== undefined) {
|
||||||
|
if (!Array.isArray(dingtalk.keywords)) {
|
||||||
|
return { valid: false, error: 'DingTalk keywords must be an array' };
|
||||||
|
}
|
||||||
|
if (dingtalk.keywords.length > 10) {
|
||||||
|
return { valid: false, error: 'Too many DingTalk keywords (max 10)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate WeCom config
|
||||||
|
if (wecom) {
|
||||||
|
if (wecom.webhookUrl !== undefined && wecom.webhookUrl !== '') {
|
||||||
|
if (!isValidUrl(wecom.webhookUrl)) {
|
||||||
|
return { valid: false, error: 'Invalid WeCom webhook URL format' };
|
||||||
|
}
|
||||||
|
if (!isValidWeComWebhookUrl(wecom.webhookUrl)) {
|
||||||
|
console.warn('[RemoteNotification] Webhook URL does not match WeCom format');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wecom.mentionedList !== undefined) {
|
||||||
|
if (!Array.isArray(wecom.mentionedList)) {
|
||||||
|
return { valid: false, error: 'WeCom mentionedList must be an array' };
|
||||||
|
}
|
||||||
|
if (wecom.mentionedList.length > 100) {
|
||||||
|
return { valid: false, error: 'Too many mentioned users (max 100)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Email config
|
||||||
|
if (email) {
|
||||||
|
if (email.host !== undefined && email.host !== '') {
|
||||||
|
if (email.host.length > 255) {
|
||||||
|
return { valid: false, error: 'Email host too long (max 255 chars)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email.port !== undefined) {
|
||||||
|
if (!isValidSmtpPort(email.port)) {
|
||||||
|
return { valid: false, error: 'Invalid SMTP port (must be 1-65535)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email.username !== undefined && email.username.length > 255) {
|
||||||
|
return { valid: false, error: 'Email username too long (max 255 chars)' };
|
||||||
|
}
|
||||||
|
if (email.from !== undefined && email.from !== '') {
|
||||||
|
if (!isValidEmail(email.from)) {
|
||||||
|
return { valid: false, error: 'Invalid sender email address' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email.to !== undefined) {
|
||||||
|
if (!Array.isArray(email.to)) {
|
||||||
|
return { valid: false, error: 'Email recipients must be an array' };
|
||||||
|
}
|
||||||
|
if (email.to.length === 0) {
|
||||||
|
return { valid: false, error: 'At least one email recipient is required' };
|
||||||
|
}
|
||||||
|
if (email.to.length > 50) {
|
||||||
|
return { valid: false, error: 'Too many email recipients (max 50)' };
|
||||||
|
}
|
||||||
|
for (const addr of email.to) {
|
||||||
|
if (!isValidEmail(addr)) {
|
||||||
|
return { valid: false, error: `Invalid email address: ${addr}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate timeout
|
// Validate timeout
|
||||||
@@ -183,7 +339,7 @@ function validateTestRequest(request: TestNotificationRequest): { valid: boolean
|
|||||||
return { valid: false, error: 'Missing platform' };
|
return { valid: false, error: 'Missing platform' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook'];
|
const validPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook', 'feishu', 'dingtalk', 'wecom', 'email'];
|
||||||
if (!validPlatforms.includes(request.platform as NotificationPlatform)) {
|
if (!validPlatforms.includes(request.platform as NotificationPlatform)) {
|
||||||
return { valid: false, error: `Invalid platform: ${request.platform}` };
|
return { valid: false, error: `Invalid platform: ${request.platform}` };
|
||||||
}
|
}
|
||||||
@@ -236,6 +392,66 @@ function validateTestRequest(request: TestNotificationRequest): { valid: boolean
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'feishu': {
|
||||||
|
const config = request.config as Partial<FeishuConfig>;
|
||||||
|
if (!config.webhookUrl) {
|
||||||
|
return { valid: false, error: 'Feishu webhook URL is required' };
|
||||||
|
}
|
||||||
|
if (!isValidUrl(config.webhookUrl)) {
|
||||||
|
return { valid: false, error: 'Invalid Feishu webhook URL format' };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'dingtalk': {
|
||||||
|
const config = request.config as Partial<DingTalkConfig>;
|
||||||
|
if (!config.webhookUrl) {
|
||||||
|
return { valid: false, error: 'DingTalk webhook URL is required' };
|
||||||
|
}
|
||||||
|
if (!isValidUrl(config.webhookUrl)) {
|
||||||
|
return { valid: false, error: 'Invalid DingTalk webhook URL format' };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'wecom': {
|
||||||
|
const config = request.config as Partial<WeComConfig>;
|
||||||
|
if (!config.webhookUrl) {
|
||||||
|
return { valid: false, error: 'WeCom webhook URL is required' };
|
||||||
|
}
|
||||||
|
if (!isValidUrl(config.webhookUrl)) {
|
||||||
|
return { valid: false, error: 'Invalid WeCom webhook URL format' };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'email': {
|
||||||
|
const config = request.config as Partial<EmailConfig>;
|
||||||
|
if (!config.host) {
|
||||||
|
return { valid: false, error: 'SMTP host is required' };
|
||||||
|
}
|
||||||
|
if (!config.username) {
|
||||||
|
return { valid: false, error: 'SMTP username is required' };
|
||||||
|
}
|
||||||
|
if (!config.password) {
|
||||||
|
return { valid: false, error: 'SMTP password is required' };
|
||||||
|
}
|
||||||
|
if (!config.from) {
|
||||||
|
return { valid: false, error: 'Sender email address is required' };
|
||||||
|
}
|
||||||
|
if (!isValidEmail(config.from)) {
|
||||||
|
return { valid: false, error: 'Invalid sender email address' };
|
||||||
|
}
|
||||||
|
if (!config.to || config.to.length === 0) {
|
||||||
|
return { valid: false, error: 'At least one recipient email is required' };
|
||||||
|
}
|
||||||
|
for (const addr of config.to) {
|
||||||
|
if (!isValidEmail(addr)) {
|
||||||
|
return { valid: false, error: `Invalid recipient email: ${addr}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.port !== undefined && !isValidSmtpPort(config.port)) {
|
||||||
|
return { valid: false, error: 'Invalid SMTP port' };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import type {
|
|||||||
DiscordConfig,
|
DiscordConfig,
|
||||||
TelegramConfig,
|
TelegramConfig,
|
||||||
WebhookConfig,
|
WebhookConfig,
|
||||||
|
FeishuConfig,
|
||||||
|
DingTalkConfig,
|
||||||
|
WeComConfig,
|
||||||
|
EmailConfig,
|
||||||
} from '../../types/remote-notification.js';
|
} from '../../types/remote-notification.js';
|
||||||
import {
|
import {
|
||||||
loadConfig,
|
loadConfig,
|
||||||
@@ -170,6 +174,14 @@ class RemoteNotificationService {
|
|||||||
return await this.sendTelegram(context, config.platforms.telegram!, config.timeout);
|
return await this.sendTelegram(context, config.platforms.telegram!, config.timeout);
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
return await this.sendWebhook(context, config.platforms.webhook!, config.timeout);
|
return await this.sendWebhook(context, config.platforms.webhook!, config.timeout);
|
||||||
|
case 'feishu':
|
||||||
|
return await this.sendFeishu(context, config.platforms.feishu!, config.timeout);
|
||||||
|
case 'dingtalk':
|
||||||
|
return await this.sendDingTalk(context, config.platforms.dingtalk!, config.timeout);
|
||||||
|
case 'wecom':
|
||||||
|
return await this.sendWeCom(context, config.platforms.wecom!, config.timeout);
|
||||||
|
case 'email':
|
||||||
|
return await this.sendEmail(context, config.platforms.email!, config.timeout);
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
platform,
|
platform,
|
||||||
@@ -408,6 +420,538 @@ class RemoteNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Feishu notification via webhook
|
||||||
|
* Supports both rich card format and simple text format
|
||||||
|
*/
|
||||||
|
private async sendFeishu(
|
||||||
|
context: NotificationContext,
|
||||||
|
config: FeishuConfig,
|
||||||
|
timeout: number
|
||||||
|
): Promise<PlatformNotificationResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
if (!config.webhookUrl) {
|
||||||
|
return { platform: 'feishu', success: false, error: 'Webhook URL not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCard = config.useCard !== false; // Default to true
|
||||||
|
|
||||||
|
try {
|
||||||
|
let body: unknown;
|
||||||
|
|
||||||
|
if (useCard) {
|
||||||
|
// Rich card format
|
||||||
|
const card = this.buildFeishuCard(context, config);
|
||||||
|
body = {
|
||||||
|
msg_type: 'interactive',
|
||||||
|
card,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Simple text format
|
||||||
|
const text = this.buildFeishuText(context);
|
||||||
|
body = {
|
||||||
|
msg_type: 'post',
|
||||||
|
content: {
|
||||||
|
post: {
|
||||||
|
zh_cn: {
|
||||||
|
title: config.title || 'CCW Notification',
|
||||||
|
content: [[{ tag: 'text', text }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.httpRequest(config.webhookUrl, body, timeout);
|
||||||
|
return {
|
||||||
|
platform: 'feishu',
|
||||||
|
success: true,
|
||||||
|
responseTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
platform: 'feishu',
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
responseTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Feishu interactive card from context
|
||||||
|
*/
|
||||||
|
private buildFeishuCard(context: NotificationContext, config: FeishuConfig): Record<string, unknown> {
|
||||||
|
const elements: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
// Add event type as header
|
||||||
|
elements.push({
|
||||||
|
tag: 'markdown',
|
||||||
|
content: `**${this.formatEventName(context.eventType)}**`,
|
||||||
|
text_align: 'left' as const,
|
||||||
|
text_size: 'normal_v2' as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add session info
|
||||||
|
if (context.sessionId) {
|
||||||
|
elements.push({
|
||||||
|
tag: 'markdown',
|
||||||
|
content: `**Session:** ${context.sessionId.slice(0, 16)}...`,
|
||||||
|
text_align: 'left' as const,
|
||||||
|
text_size: 'normal_v2' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add question text
|
||||||
|
if (context.questionText) {
|
||||||
|
const truncated = context.questionText.length > 300
|
||||||
|
? context.questionText.slice(0, 300) + '...'
|
||||||
|
: context.questionText;
|
||||||
|
elements.push({
|
||||||
|
tag: 'markdown',
|
||||||
|
content: `**Question:** ${this.escapeFeishuMarkdown(truncated)}`,
|
||||||
|
text_align: 'left' as const,
|
||||||
|
text_size: 'normal_v2' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add task description
|
||||||
|
if (context.taskDescription) {
|
||||||
|
const truncated = context.taskDescription.length > 300
|
||||||
|
? context.taskDescription.slice(0, 300) + '...'
|
||||||
|
: context.taskDescription;
|
||||||
|
elements.push({
|
||||||
|
tag: 'markdown',
|
||||||
|
content: `**Task:** ${this.escapeFeishuMarkdown(truncated)}`,
|
||||||
|
text_align: 'left' as const,
|
||||||
|
text_size: 'normal_v2' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add error message
|
||||||
|
if (context.errorMessage) {
|
||||||
|
const truncated = context.errorMessage.length > 300
|
||||||
|
? context.errorMessage.slice(0, 300) + '...'
|
||||||
|
: context.errorMessage;
|
||||||
|
elements.push({
|
||||||
|
tag: 'markdown',
|
||||||
|
content: `**Error:** ${this.escapeFeishuMarkdown(truncated)}`,
|
||||||
|
text_align: 'left' as const,
|
||||||
|
text_size: 'normal_v2' as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
elements.push({
|
||||||
|
tag: 'markdown',
|
||||||
|
content: `**Time:** ${new Date(context.timestamp).toLocaleString()}`,
|
||||||
|
text_align: 'left' as const,
|
||||||
|
text_size: 'normal_v2' as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema: '2.0',
|
||||||
|
config: {
|
||||||
|
update_multi: true,
|
||||||
|
style: {
|
||||||
|
text_size: {
|
||||||
|
normal_v2: {
|
||||||
|
default: 'normal',
|
||||||
|
pc: 'normal',
|
||||||
|
mobile: 'heading',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
title: {
|
||||||
|
tag: 'plain_text',
|
||||||
|
content: config.title || 'CCW Notification',
|
||||||
|
},
|
||||||
|
template: 'wathet',
|
||||||
|
padding: '12px 12px 12px 12px',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
direction: 'vertical',
|
||||||
|
horizontal_spacing: '8px',
|
||||||
|
vertical_spacing: '8px',
|
||||||
|
horizontal_align: 'left',
|
||||||
|
vertical_align: 'top',
|
||||||
|
padding: '12px 12px 12px 12px',
|
||||||
|
elements,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Feishu simple text message
|
||||||
|
*/
|
||||||
|
private buildFeishuText(context: NotificationContext): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`Event: ${this.formatEventName(context.eventType)}`);
|
||||||
|
|
||||||
|
if (context.sessionId) {
|
||||||
|
lines.push(`Session: ${context.sessionId.slice(0, 16)}...`);
|
||||||
|
}
|
||||||
|
if (context.questionText) {
|
||||||
|
const truncated = context.questionText.length > 200
|
||||||
|
? context.questionText.slice(0, 200) + '...'
|
||||||
|
: context.questionText;
|
||||||
|
lines.push(`Question: ${truncated}`);
|
||||||
|
}
|
||||||
|
if (context.taskDescription) {
|
||||||
|
const truncated = context.taskDescription.length > 200
|
||||||
|
? context.taskDescription.slice(0, 200) + '...'
|
||||||
|
: context.taskDescription;
|
||||||
|
lines.push(`Task: ${truncated}`);
|
||||||
|
}
|
||||||
|
if (context.errorMessage) {
|
||||||
|
const truncated = context.errorMessage.length > 200
|
||||||
|
? context.errorMessage.slice(0, 200) + '...'
|
||||||
|
: context.errorMessage;
|
||||||
|
lines.push(`Error: ${truncated}`);
|
||||||
|
}
|
||||||
|
lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special characters for Feishu markdown
|
||||||
|
*/
|
||||||
|
private escapeFeishuMarkdown(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send DingTalk notification via webhook
|
||||||
|
*/
|
||||||
|
private async sendDingTalk(
|
||||||
|
context: NotificationContext,
|
||||||
|
config: DingTalkConfig,
|
||||||
|
timeout: number
|
||||||
|
): Promise<PlatformNotificationResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
if (!config.webhookUrl) {
|
||||||
|
return { platform: 'dingtalk', success: false, error: 'Webhook URL not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = this.buildDingTalkText(context, config.keywords);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
msgtype: 'text',
|
||||||
|
text: {
|
||||||
|
content: text,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.httpRequest(config.webhookUrl, body, timeout);
|
||||||
|
return {
|
||||||
|
platform: 'dingtalk',
|
||||||
|
success: true,
|
||||||
|
responseTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
platform: 'dingtalk',
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
responseTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build DingTalk text message
|
||||||
|
*/
|
||||||
|
private buildDingTalkText(context: NotificationContext, keywords?: string[]): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Add keywords at the beginning if configured (for security check)
|
||||||
|
if (keywords && keywords.length > 0) {
|
||||||
|
lines.push(`[${keywords[0]}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`Event: ${this.formatEventName(context.eventType)}`);
|
||||||
|
|
||||||
|
if (context.sessionId) {
|
||||||
|
lines.push(`Session: ${context.sessionId.slice(0, 16)}...`);
|
||||||
|
}
|
||||||
|
if (context.questionText) {
|
||||||
|
const truncated = context.questionText.length > 200
|
||||||
|
? context.questionText.slice(0, 200) + '...'
|
||||||
|
: context.questionText;
|
||||||
|
lines.push(`Question: ${truncated}`);
|
||||||
|
}
|
||||||
|
if (context.taskDescription) {
|
||||||
|
const truncated = context.taskDescription.length > 200
|
||||||
|
? context.taskDescription.slice(0, 200) + '...'
|
||||||
|
: context.taskDescription;
|
||||||
|
lines.push(`Task: ${truncated}`);
|
||||||
|
}
|
||||||
|
if (context.errorMessage) {
|
||||||
|
const truncated = context.errorMessage.length > 200
|
||||||
|
? context.errorMessage.slice(0, 200) + '...'
|
||||||
|
: context.errorMessage;
|
||||||
|
lines.push(`Error: ${truncated}`);
|
||||||
|
}
|
||||||
|
lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send WeCom (WeChat Work) notification via webhook
|
||||||
|
*/
|
||||||
|
private async sendWeCom(
|
||||||
|
context: NotificationContext,
|
||||||
|
config: WeComConfig,
|
||||||
|
timeout: number
|
||||||
|
): Promise<PlatformNotificationResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
if (!config.webhookUrl) {
|
||||||
|
return { platform: 'wecom', success: false, error: 'Webhook URL not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = this.buildWeComMarkdown(context);
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
msgtype: 'markdown',
|
||||||
|
markdown: {
|
||||||
|
content: markdown,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add mentioned list if configured
|
||||||
|
if (config.mentionedList && config.mentionedList.length > 0) {
|
||||||
|
body.text = {
|
||||||
|
content: markdown,
|
||||||
|
mentioned_list: config.mentionedList,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.httpRequest(config.webhookUrl, body, timeout);
|
||||||
|
return {
|
||||||
|
platform: 'wecom',
|
||||||
|
success: true,
|
||||||
|
responseTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
platform: 'wecom',
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
responseTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build WeCom markdown message
|
||||||
|
*/
|
||||||
|
private buildWeComMarkdown(context: NotificationContext): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`### ${this.formatEventName(context.eventType)}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
if (context.sessionId) {
|
||||||
|
lines.push(`> Session: \`${context.sessionId.slice(0, 16)}...\``);
|
||||||
|
}
|
||||||
|
if (context.questionText) {
|
||||||
|
const truncated = context.questionText.length > 200
|
||||||
|
? context.questionText.slice(0, 200) + '...'
|
||||||
|
: context.questionText;
|
||||||
|
lines.push(`**Question:** ${truncated}`);
|
||||||
|
}
|
||||||
|
if (context.taskDescription) {
|
||||||
|
const truncated = context.taskDescription.length > 200
|
||||||
|
? context.taskDescription.slice(0, 200) + '...'
|
||||||
|
: context.taskDescription;
|
||||||
|
lines.push(`**Task:** ${truncated}`);
|
||||||
|
}
|
||||||
|
if (context.errorMessage) {
|
||||||
|
const truncated = context.errorMessage.length > 200
|
||||||
|
? context.errorMessage.slice(0, 200) + '...'
|
||||||
|
: context.errorMessage;
|
||||||
|
lines.push(`**Error:** <font color="warning">${truncated}</font>`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Email notification via SMTP
|
||||||
|
*/
|
||||||
|
private async sendEmail(
|
||||||
|
context: NotificationContext,
|
||||||
|
config: EmailConfig,
|
||||||
|
timeout: number
|
||||||
|
): Promise<PlatformNotificationResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
if (!config.host || !config.username || !config.password || !config.from || !config.to || config.to.length === 0) {
|
||||||
|
return { platform: 'email', success: false, error: 'Email configuration incomplete (host, username, password, from, to required)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamic import for nodemailer (optional dependency)
|
||||||
|
const nodemailer = await this.loadNodemailer();
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port || 465,
|
||||||
|
secure: config.secure !== false, // Default to true for port 465
|
||||||
|
auth: {
|
||||||
|
user: config.username,
|
||||||
|
pass: config.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { subject, html } = this.buildEmailContent(context);
|
||||||
|
|
||||||
|
// Set timeout for email sending
|
||||||
|
await Promise.race([
|
||||||
|
transporter.sendMail({
|
||||||
|
from: config.from,
|
||||||
|
to: config.to.join(', '),
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
}),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Email send timeout')), timeout)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: 'email',
|
||||||
|
success: true,
|
||||||
|
responseTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
platform: 'email',
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
responseTime: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load nodemailer module (optional dependency)
|
||||||
|
*/
|
||||||
|
private async loadNodemailer(): Promise<{
|
||||||
|
createTransport: (options: Record<string, unknown>) => {
|
||||||
|
sendMail: (mailOptions: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
return require('nodemailer');
|
||||||
|
} catch {
|
||||||
|
throw new Error('nodemailer not installed. Run: npm install nodemailer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build email subject and HTML content
|
||||||
|
*/
|
||||||
|
private buildEmailContent(context: NotificationContext): { subject: string; html: string } {
|
||||||
|
const subject = `[CCW] ${this.formatEventName(context.eventType)}`;
|
||||||
|
|
||||||
|
const htmlParts: string[] = [];
|
||||||
|
htmlParts.push('<!DOCTYPE html>');
|
||||||
|
htmlParts.push('<html>');
|
||||||
|
htmlParts.push('<head>');
|
||||||
|
htmlParts.push('<meta charset="utf-8">');
|
||||||
|
htmlParts.push('<style>');
|
||||||
|
htmlParts.push('body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; color: #333; }');
|
||||||
|
htmlParts.push('.container { max-width: 600px; margin: 0 auto; padding: 20px; }');
|
||||||
|
htmlParts.push('.header { background: #4a90d9; color: white; padding: 20px; border-radius: 8px 8px 0 0; }');
|
||||||
|
htmlParts.push('.content { background: #f9f9f9; padding: 20px; border: 1px solid #ddd; }');
|
||||||
|
htmlParts.push('.field { margin-bottom: 15px; }');
|
||||||
|
htmlParts.push('.label { font-weight: bold; color: #555; }');
|
||||||
|
htmlParts.push('.value { margin-top: 5px; }');
|
||||||
|
htmlParts.push('.error { background: #fff3f3; border-left: 4px solid #e74c3c; padding: 10px; }');
|
||||||
|
htmlParts.push('.footer { text-align: center; color: #888; font-size: 12px; margin-top: 20px; }');
|
||||||
|
htmlParts.push('</style>');
|
||||||
|
htmlParts.push('</head>');
|
||||||
|
htmlParts.push('<body>');
|
||||||
|
htmlParts.push('<div class="container">');
|
||||||
|
|
||||||
|
// Header
|
||||||
|
htmlParts.push('<div class="header">');
|
||||||
|
htmlParts.push(`<h2 style="margin: 0;">${this.formatEventName(context.eventType)}</h2>`);
|
||||||
|
htmlParts.push('</div>');
|
||||||
|
|
||||||
|
// Content
|
||||||
|
htmlParts.push('<div class="content">');
|
||||||
|
|
||||||
|
if (context.sessionId) {
|
||||||
|
htmlParts.push('<div class="field">');
|
||||||
|
htmlParts.push('<div class="label">Session</div>');
|
||||||
|
htmlParts.push(`<div class="value"><code>${context.sessionId}</code></div>`);
|
||||||
|
htmlParts.push('</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.questionText) {
|
||||||
|
const truncated = context.questionText.length > 500
|
||||||
|
? context.questionText.slice(0, 500) + '...'
|
||||||
|
: context.questionText;
|
||||||
|
htmlParts.push('<div class="field">');
|
||||||
|
htmlParts.push('<div class="label">Question</div>');
|
||||||
|
htmlParts.push(`<div class="value">${this.escapeHtml(truncated).replace(/\n/g, '<br>')}</div>`);
|
||||||
|
htmlParts.push('</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.taskDescription) {
|
||||||
|
const truncated = context.taskDescription.length > 500
|
||||||
|
? context.taskDescription.slice(0, 500) + '...'
|
||||||
|
: context.taskDescription;
|
||||||
|
htmlParts.push('<div class="field">');
|
||||||
|
htmlParts.push('<div class="label">Task</div>');
|
||||||
|
htmlParts.push(`<div class="value">${this.escapeHtml(truncated).replace(/\n/g, '<br>')}</div>`);
|
||||||
|
htmlParts.push('</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.errorMessage) {
|
||||||
|
const truncated = context.errorMessage.length > 500
|
||||||
|
? context.errorMessage.slice(0, 500) + '...'
|
||||||
|
: context.errorMessage;
|
||||||
|
htmlParts.push('<div class="field">');
|
||||||
|
htmlParts.push('<div class="label">Error</div>');
|
||||||
|
htmlParts.push(`<div class="value error">${this.escapeHtml(truncated).replace(/\n/g, '<br>')}</div>`);
|
||||||
|
htmlParts.push('</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlParts.push('<div class="field">');
|
||||||
|
htmlParts.push('<div class="label">Timestamp</div>');
|
||||||
|
htmlParts.push(`<div class="value">${new Date(context.timestamp).toLocaleString()}</div>`);
|
||||||
|
htmlParts.push('</div>');
|
||||||
|
|
||||||
|
htmlParts.push('</div>'); // content
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
htmlParts.push('<div class="footer">');
|
||||||
|
htmlParts.push('Sent by CCW Remote Notification System');
|
||||||
|
htmlParts.push('</div>');
|
||||||
|
|
||||||
|
htmlParts.push('</div>'); // container
|
||||||
|
htmlParts.push('</body>');
|
||||||
|
htmlParts.push('</html>');
|
||||||
|
|
||||||
|
return { subject, html: htmlParts.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a URL is safe from SSRF attacks
|
* Check if a URL is safe from SSRF attacks
|
||||||
* Blocks private IP ranges, loopback, and link-local addresses
|
* Blocks private IP ranges, loopback, and link-local addresses
|
||||||
@@ -556,7 +1100,7 @@ class RemoteNotificationService {
|
|||||||
*/
|
*/
|
||||||
async testPlatform(
|
async testPlatform(
|
||||||
platform: NotificationPlatform,
|
platform: NotificationPlatform,
|
||||||
config: DiscordConfig | TelegramConfig | WebhookConfig
|
config: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig
|
||||||
): Promise<{ success: boolean; error?: string; responseTime?: number }> {
|
): Promise<{ success: boolean; error?: string; responseTime?: number }> {
|
||||||
const testContext: NotificationContext = {
|
const testContext: NotificationContext = {
|
||||||
eventType: 'task-completed',
|
eventType: 'task-completed',
|
||||||
@@ -575,6 +1119,14 @@ class RemoteNotificationService {
|
|||||||
return await this.sendTelegram(testContext, config as TelegramConfig, 10000);
|
return await this.sendTelegram(testContext, config as TelegramConfig, 10000);
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
return await this.sendWebhook(testContext, config as WebhookConfig, 10000);
|
return await this.sendWebhook(testContext, config as WebhookConfig, 10000);
|
||||||
|
case 'feishu':
|
||||||
|
return await this.sendFeishu(testContext, config as FeishuConfig, 10000);
|
||||||
|
case 'dingtalk':
|
||||||
|
return await this.sendDingTalk(testContext, config as DingTalkConfig, 10000);
|
||||||
|
case 'wecom':
|
||||||
|
return await this.sendWeCom(testContext, config as WeComConfig, 10000);
|
||||||
|
case 'email':
|
||||||
|
return await this.sendEmail(testContext, config as EmailConfig, 30000); // Longer timeout for email
|
||||||
default:
|
default:
|
||||||
return { success: false, error: `Unknown platform: ${platform}` };
|
return { success: false, error: `Unknown platform: ${platform}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,17 +163,22 @@ function normalizeSimpleQuestion(simple: SimpleQuestion): Question {
|
|||||||
type = 'input';
|
type = 'input';
|
||||||
}
|
}
|
||||||
|
|
||||||
const options: QuestionOption[] | undefined = simple.options?.map((opt) => ({
|
let defaultValue: string | undefined;
|
||||||
value: opt.label,
|
const options: QuestionOption[] | undefined = simple.options?.map((opt) => {
|
||||||
label: opt.label,
|
const isDefault = opt.isDefault === true
|
||||||
description: opt.description,
|
|| /\(Recommended\)/i.test(opt.label);
|
||||||
}));
|
if (isDefault && !defaultValue) {
|
||||||
|
defaultValue = opt.label;
|
||||||
|
}
|
||||||
|
return { value: opt.label, label: opt.label, description: opt.description };
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: simple.header,
|
id: simple.header,
|
||||||
type,
|
type,
|
||||||
title: simple.question,
|
title: simple.question,
|
||||||
options,
|
options,
|
||||||
|
...(defaultValue !== undefined && { defaultValue }),
|
||||||
} as Question;
|
} as Question;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +197,7 @@ function isSimpleFormat(params: Record<string, unknown>): params is { questions:
|
|||||||
* @param surfaceId - Surface ID for the question
|
* @param surfaceId - Surface ID for the question
|
||||||
* @returns A2UI surface update object
|
* @returns A2UI surface update object
|
||||||
*/
|
*/
|
||||||
function generateQuestionSurface(question: Question, surfaceId: string): {
|
function generateQuestionSurface(question: Question, surfaceId: string, timeoutMs: number): {
|
||||||
surfaceUpdate: {
|
surfaceUpdate: {
|
||||||
surfaceId: string;
|
surfaceId: string;
|
||||||
components: unknown[];
|
components: unknown[];
|
||||||
@@ -274,6 +279,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
|
|||||||
label: { literalString: opt.label },
|
label: { literalString: opt.label },
|
||||||
value: opt.value,
|
value: opt.value,
|
||||||
description: opt.description ? { literalString: opt.description } : undefined,
|
description: opt.description ? { literalString: opt.description } : undefined,
|
||||||
|
isDefault: question.defaultValue !== undefined && opt.value === String(question.defaultValue),
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
// Add "Other" option for custom input
|
// Add "Other" option for custom input
|
||||||
@@ -281,6 +287,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
|
|||||||
label: { literalString: 'Other' },
|
label: { literalString: 'Other' },
|
||||||
value: '__other__',
|
value: '__other__',
|
||||||
description: { literalString: 'Provide a custom answer' },
|
description: { literalString: 'Provide a custom answer' },
|
||||||
|
isDefault: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use RadioGroup for direct selection display (not dropdown)
|
// Use RadioGroup for direct selection display (not dropdown)
|
||||||
@@ -411,6 +418,8 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
|
|||||||
questionType: question.type,
|
questionType: question.type,
|
||||||
options: question.options,
|
options: question.options,
|
||||||
required: question.required,
|
required: question.required,
|
||||||
|
timeoutAt: new Date(Date.now() + timeoutMs).toISOString(),
|
||||||
|
...(question.defaultValue !== undefined && { defaultValue: question.defaultValue }),
|
||||||
},
|
},
|
||||||
/** Display mode: 'popup' for centered dialog (interactive questions) */
|
/** Display mode: 'popup' for centered dialog (interactive questions) */
|
||||||
displayMode: 'popup' as const,
|
displayMode: 'popup' as const,
|
||||||
@@ -451,6 +460,16 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (pendingQuestions.has(question.id)) {
|
if (pendingQuestions.has(question.id)) {
|
||||||
pendingQuestions.delete(question.id);
|
pendingQuestions.delete(question.id);
|
||||||
|
if (question.defaultValue !== undefined) {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
surfaceId,
|
||||||
|
cancelled: false,
|
||||||
|
answers: [{ questionId: question.id, value: question.defaultValue as string | string[] | boolean, cancelled: false }],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
autoSelected: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
resolve({
|
resolve({
|
||||||
success: false,
|
success: false,
|
||||||
surfaceId,
|
surfaceId,
|
||||||
@@ -460,11 +479,12 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
|||||||
error: 'Question timed out',
|
error: 'Question timed out',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, params.timeout || DEFAULT_TIMEOUT_MS);
|
}, params.timeout || DEFAULT_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send A2UI surface via WebSocket to frontend
|
// Send A2UI surface via WebSocket to frontend
|
||||||
const a2uiSurface = generateQuestionSurface(question, surfaceId);
|
const a2uiSurface = generateQuestionSurface(question, surfaceId, params.timeout || DEFAULT_TIMEOUT_MS);
|
||||||
const sentCount = a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate);
|
const sentCount = a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate);
|
||||||
|
|
||||||
// Trigger remote notification for ask-user-question event (if enabled)
|
// Trigger remote notification for ask-user-question event (if enabled)
|
||||||
@@ -594,9 +614,17 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
|
|||||||
if (isComposite && Array.isArray(parsed.answers)) {
|
if (isComposite && Array.isArray(parsed.answers)) {
|
||||||
const ok = handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
|
const ok = handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
|
||||||
console.error(`[A2UI-Poll] handleMultiAnswer result: ${ok}`);
|
console.error(`[A2UI-Poll] handleMultiAnswer result: ${ok}`);
|
||||||
|
if (!ok && pendingQuestions.has(questionId)) {
|
||||||
|
// Answer consumed but delivery failed; keep polling for a new answer
|
||||||
|
setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
} else if (!isComposite && parsed.answer) {
|
} else if (!isComposite && parsed.answer) {
|
||||||
const ok = handleAnswer(parsed.answer as QuestionAnswer);
|
const ok = handleAnswer(parsed.answer as QuestionAnswer);
|
||||||
console.error(`[A2UI-Poll] handleAnswer result: ${ok}`);
|
console.error(`[A2UI-Poll] handleAnswer result: ${ok}`);
|
||||||
|
if (!ok && pendingQuestions.has(questionId)) {
|
||||||
|
// Answer consumed but validation/delivery failed; keep polling for a new answer
|
||||||
|
setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`[A2UI-Poll] Unexpected response shape, keep polling`);
|
console.error(`[A2UI-Poll] Unexpected response shape, keep polling`);
|
||||||
setTimeout(poll, POLL_INTERVAL_MS);
|
setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
@@ -873,6 +901,7 @@ function generateMultiQuestionSurface(
|
|||||||
label: { literalString: opt.label },
|
label: { literalString: opt.label },
|
||||||
value: opt.value,
|
value: opt.value,
|
||||||
description: opt.description ? { literalString: opt.description } : undefined,
|
description: opt.description ? { literalString: opt.description } : undefined,
|
||||||
|
isDefault: question.defaultValue !== undefined && opt.value === String(question.defaultValue),
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
// Add "Other" option for custom input
|
// Add "Other" option for custom input
|
||||||
@@ -880,6 +909,7 @@ function generateMultiQuestionSurface(
|
|||||||
label: { literalString: 'Other' },
|
label: { literalString: 'Other' },
|
||||||
value: '__other__',
|
value: '__other__',
|
||||||
description: { literalString: 'Provide a custom answer' },
|
description: { literalString: 'Provide a custom answer' },
|
||||||
|
isDefault: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
components.push({
|
components.push({
|
||||||
@@ -997,7 +1027,8 @@ async function executeSimpleFormat(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.result.cancelled) {
|
// Propagate inner failures (e.g. timeout) — don't mask them as success
|
||||||
|
if (result.result.cancelled || !result.result.success) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,6 +1089,24 @@ async function executeSimpleFormat(
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (pendingQuestions.has(compositeId)) {
|
if (pendingQuestions.has(compositeId)) {
|
||||||
pendingQuestions.delete(compositeId);
|
pendingQuestions.delete(compositeId);
|
||||||
|
// Collect default values from each sub-question
|
||||||
|
const defaultAnswers: QuestionAnswer[] = [];
|
||||||
|
for (const simpleQ of questions) {
|
||||||
|
const q = normalizeSimpleQuestion(simpleQ);
|
||||||
|
if (q.defaultValue !== undefined) {
|
||||||
|
defaultAnswers.push({ questionId: q.id, value: q.defaultValue as string | string[] | boolean, cancelled: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (defaultAnswers.length > 0) {
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
surfaceId,
|
||||||
|
cancelled: false,
|
||||||
|
answers: defaultAnswers,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
autoSelected: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
resolve({
|
resolve({
|
||||||
success: false,
|
success: false,
|
||||||
surfaceId,
|
surfaceId,
|
||||||
@@ -1067,6 +1116,7 @@ async function executeSimpleFormat(
|
|||||||
error: 'Question timed out',
|
error: 'Question timed out',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, timeout ?? DEFAULT_TIMEOUT_MS);
|
}, timeout ?? DEFAULT_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
// Remote Notification Types
|
// Remote Notification Types
|
||||||
// ========================================
|
// ========================================
|
||||||
// Type definitions for remote notification system
|
// Type definitions for remote notification system
|
||||||
// Supports Discord, Telegram, and Generic Webhook platforms
|
// Supports Discord, Telegram, Feishu, DingTalk, WeCom, Email, and Generic Webhook platforms
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supported notification platforms
|
* Supported notification platforms
|
||||||
*/
|
*/
|
||||||
export type NotificationPlatform = 'discord' | 'telegram' | 'webhook';
|
export type NotificationPlatform = 'discord' | 'telegram' | 'feishu' | 'dingtalk' | 'wecom' | 'email' | 'webhook';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event types that can trigger notifications
|
* Event types that can trigger notifications
|
||||||
@@ -47,6 +47,66 @@ export interface TelegramConfig {
|
|||||||
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2';
|
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feishu (Lark) platform configuration
|
||||||
|
*/
|
||||||
|
export interface FeishuConfig {
|
||||||
|
/** Whether Feishu notifications are enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Feishu webhook URL */
|
||||||
|
webhookUrl: string;
|
||||||
|
/** Use rich card format (default: true) */
|
||||||
|
useCard?: boolean;
|
||||||
|
/** Custom title for notifications */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DingTalk platform configuration
|
||||||
|
*/
|
||||||
|
export interface DingTalkConfig {
|
||||||
|
/** Whether DingTalk notifications are enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
/** DingTalk webhook URL */
|
||||||
|
webhookUrl: string;
|
||||||
|
/** Optional keywords for security check */
|
||||||
|
keywords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WeCom (WeChat Work) platform configuration
|
||||||
|
*/
|
||||||
|
export interface WeComConfig {
|
||||||
|
/** Whether WeCom notifications are enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
/** WeCom webhook URL */
|
||||||
|
webhookUrl: string;
|
||||||
|
/** Mentioned user IDs (@all for all members) */
|
||||||
|
mentionedList?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email SMTP platform configuration
|
||||||
|
*/
|
||||||
|
export interface EmailConfig {
|
||||||
|
/** Whether Email notifications are enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
/** SMTP server host */
|
||||||
|
host: string;
|
||||||
|
/** SMTP server port */
|
||||||
|
port: number;
|
||||||
|
/** Use secure connection (TLS) */
|
||||||
|
secure?: boolean;
|
||||||
|
/** SMTP username */
|
||||||
|
username: string;
|
||||||
|
/** SMTP password */
|
||||||
|
password: string;
|
||||||
|
/** Sender email address */
|
||||||
|
from: string;
|
||||||
|
/** Recipient email addresses */
|
||||||
|
to: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic Webhook platform configuration
|
* Generic Webhook platform configuration
|
||||||
*/
|
*/
|
||||||
@@ -85,6 +145,10 @@ export interface RemoteNotificationConfig {
|
|||||||
platforms: {
|
platforms: {
|
||||||
discord?: DiscordConfig;
|
discord?: DiscordConfig;
|
||||||
telegram?: TelegramConfig;
|
telegram?: TelegramConfig;
|
||||||
|
feishu?: FeishuConfig;
|
||||||
|
dingtalk?: DingTalkConfig;
|
||||||
|
wecom?: WeComConfig;
|
||||||
|
email?: EmailConfig;
|
||||||
webhook?: WebhookConfig;
|
webhook?: WebhookConfig;
|
||||||
};
|
};
|
||||||
/** Event-to-platform mappings */
|
/** Event-to-platform mappings */
|
||||||
@@ -192,6 +256,22 @@ export function maskSensitiveConfig(config: RemoteNotificationConfig): RemoteNot
|
|||||||
...config.platforms.telegram,
|
...config.platforms.telegram,
|
||||||
botToken: maskToken(config.platforms.telegram.botToken),
|
botToken: maskToken(config.platforms.telegram.botToken),
|
||||||
} : undefined,
|
} : undefined,
|
||||||
|
feishu: config.platforms.feishu ? {
|
||||||
|
...config.platforms.feishu,
|
||||||
|
webhookUrl: maskWebhookUrl(config.platforms.feishu.webhookUrl),
|
||||||
|
} : undefined,
|
||||||
|
dingtalk: config.platforms.dingtalk ? {
|
||||||
|
...config.platforms.dingtalk,
|
||||||
|
webhookUrl: maskWebhookUrl(config.platforms.dingtalk.webhookUrl),
|
||||||
|
} : undefined,
|
||||||
|
wecom: config.platforms.wecom ? {
|
||||||
|
...config.platforms.wecom,
|
||||||
|
webhookUrl: maskWebhookUrl(config.platforms.wecom.webhookUrl),
|
||||||
|
} : undefined,
|
||||||
|
email: config.platforms.email ? {
|
||||||
|
...config.platforms.email,
|
||||||
|
password: maskToken(config.platforms.email.password),
|
||||||
|
} : undefined,
|
||||||
webhook: config.platforms.webhook ? {
|
webhook: config.platforms.webhook ? {
|
||||||
...config.platforms.webhook,
|
...config.platforms.webhook,
|
||||||
// Don't mask webhook URL as it's needed for display
|
// Don't mask webhook URL as it's needed for display
|
||||||
|
|||||||
1
ccw/tsconfig.tsbuildinfo
Normal file
1
ccw/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -125,6 +125,13 @@ CODEXLENS_DEBUG=false
|
|||||||
"tool": "gemini",
|
"tool": "gemini",
|
||||||
"timeout_ms": 300000,
|
"timeout_ms": 300000,
|
||||||
"batch_size": 5
|
"batch_size": 5
|
||||||
|
},
|
||||||
|
"parsing": {
|
||||||
|
"use_astgrep": false
|
||||||
|
},
|
||||||
|
"indexing": {
|
||||||
|
"static_graph_enabled": false,
|
||||||
|
"static_graph_relationship_types": ["imports", "inherits"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -167,6 +174,32 @@ CODEXLENS_DEBUG=false
|
|||||||
| `timeout_ms` | int | 超时时间 (毫秒) |
|
| `timeout_ms` | int | 超时时间 (毫秒) |
|
||||||
| `batch_size` | int | 批处理大小 |
|
| `batch_size` | int | 批处理大小 |
|
||||||
|
|
||||||
|
### Parsing 设置
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `use_astgrep` | bool | 优先使用 ast-grep 解析关系(实验性;当前主要用于 Python relationships) |
|
||||||
|
|
||||||
|
### Indexing 设置(静态图)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `static_graph_enabled` | bool | 索引时将 relationships 写入全局 `global_relationships`,用于搜索阶段静态图扩展 |
|
||||||
|
| `static_graph_relationship_types` | array | 允许持久化的关系类型:`imports` / `inherits` / `calls` |
|
||||||
|
|
||||||
|
**CLI 覆盖(单次运行,不写入 settings.json)**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 索引时启用静态图 relationships + 使用 ast-grep(如果可用)
|
||||||
|
codexlens index init --use-astgrep --static-graph --static-graph-types imports,inherits,calls
|
||||||
|
```
|
||||||
|
|
||||||
|
**Search staged 静态图扩展(高级)**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codexlens search --cascade-strategy staged --staged-stage2-mode static_global_graph
|
||||||
|
```
|
||||||
|
|
||||||
## FastEmbed 模型配置文件
|
## FastEmbed 模型配置文件
|
||||||
|
|
||||||
使用 `fastembed` 后端时的预定义模型:
|
使用 `fastembed` 后端时的预定义模型:
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ dependencies = [
|
|||||||
"pathspec>=0.11",
|
"pathspec>=0.11",
|
||||||
"watchdog>=3.0",
|
"watchdog>=3.0",
|
||||||
# ast-grep for pattern-based AST matching (PyO3 bindings)
|
# ast-grep for pattern-based AST matching (PyO3 bindings)
|
||||||
# Note: May have compatibility issues with Python 3.13
|
# ast-grep-py 0.40+ supports Python 3.13
|
||||||
"ast-grep-py>=0.3.0; python_version < '3.13'",
|
"ast-grep-py>=0.40.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -126,6 +126,21 @@ def index_init(
|
|||||||
no_embeddings: bool = typer.Option(False, "--no-embeddings", help="Skip automatic embedding generation (if semantic deps installed)."),
|
no_embeddings: bool = typer.Option(False, "--no-embeddings", help="Skip automatic embedding generation (if semantic deps installed)."),
|
||||||
backend: Optional[str] = typer.Option(None, "--backend", "-b", help="Embedding backend: fastembed (local) or litellm (remote API). Defaults to settings.json config."),
|
backend: Optional[str] = typer.Option(None, "--backend", "-b", help="Embedding backend: fastembed (local) or litellm (remote API). Defaults to settings.json config."),
|
||||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="Embedding model: profile name for fastembed or model name for litellm. Defaults to settings.json config."),
|
model: Optional[str] = typer.Option(None, "--model", "-m", help="Embedding model: profile name for fastembed or model name for litellm. Defaults to settings.json config."),
|
||||||
|
use_astgrep: Optional[bool] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--use-astgrep/--no-use-astgrep",
|
||||||
|
help="Prefer ast-grep parsers when available (experimental). Overrides settings.json config.",
|
||||||
|
),
|
||||||
|
static_graph: Optional[bool] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--static-graph/--no-static-graph",
|
||||||
|
help="Persist global relationships during indexing for static graph expansion. Overrides settings.json config.",
|
||||||
|
),
|
||||||
|
static_graph_types: Optional[str] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--static-graph-types",
|
||||||
|
help="Comma-separated relationship types to persist: imports,inherits,calls. Overrides settings.json config.",
|
||||||
|
),
|
||||||
max_workers: int = typer.Option(1, "--max-workers", min=1, help="Max concurrent API calls for embedding generation. Recommended: 4-8 for litellm backend."),
|
max_workers: int = typer.Option(1, "--max-workers", min=1, help="Max concurrent API calls for embedding generation. Recommended: 4-8 for litellm backend."),
|
||||||
json_mode: bool = typer.Option(False, "--json", help="Output JSON response."),
|
json_mode: bool = typer.Option(False, "--json", help="Output JSON response."),
|
||||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
|
||||||
@@ -154,6 +169,33 @@ def index_init(
|
|||||||
|
|
||||||
# Fallback to settings.json config if CLI params not provided
|
# Fallback to settings.json config if CLI params not provided
|
||||||
config.load_settings() # Ensure settings are loaded
|
config.load_settings() # Ensure settings are loaded
|
||||||
|
|
||||||
|
# Apply CLI overrides for parsing/indexing behavior
|
||||||
|
if use_astgrep is not None:
|
||||||
|
config.use_astgrep = bool(use_astgrep)
|
||||||
|
if static_graph is not None:
|
||||||
|
config.static_graph_enabled = bool(static_graph)
|
||||||
|
if static_graph_types is not None:
|
||||||
|
allowed = {"imports", "inherits", "calls"}
|
||||||
|
parsed = [
|
||||||
|
t.strip().lower()
|
||||||
|
for t in static_graph_types.split(",")
|
||||||
|
if t.strip()
|
||||||
|
]
|
||||||
|
invalid = [t for t in parsed if t not in allowed]
|
||||||
|
if invalid:
|
||||||
|
msg = (
|
||||||
|
"Invalid --static-graph-types. Must be a comma-separated list of: "
|
||||||
|
f"{', '.join(sorted(allowed))}. Got: {invalid}"
|
||||||
|
)
|
||||||
|
if json_mode:
|
||||||
|
print_json(success=False, error=msg)
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Error:[/red] {msg}")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
if parsed:
|
||||||
|
config.static_graph_relationship_types = parsed
|
||||||
|
|
||||||
actual_backend = backend or config.embedding_backend
|
actual_backend = backend or config.embedding_backend
|
||||||
actual_model = model or config.embedding_model
|
actual_model = model or config.embedding_model
|
||||||
|
|
||||||
@@ -412,8 +454,10 @@ def watch(
|
|||||||
|
|
||||||
manager: WatcherManager | None = None
|
manager: WatcherManager | None = None
|
||||||
try:
|
try:
|
||||||
|
watch_config = Config.load()
|
||||||
manager = WatcherManager(
|
manager = WatcherManager(
|
||||||
root_path=base_path,
|
root_path=base_path,
|
||||||
|
config=watch_config,
|
||||||
watcher_config=watcher_config,
|
watcher_config=watcher_config,
|
||||||
on_indexed=on_indexed,
|
on_indexed=on_indexed,
|
||||||
)
|
)
|
||||||
@@ -459,7 +503,7 @@ def search(
|
|||||||
None,
|
None,
|
||||||
"--staged-stage2-mode",
|
"--staged-stage2-mode",
|
||||||
hidden=True,
|
hidden=True,
|
||||||
help="[Advanced] Stage 2 expansion mode for cascade strategy 'staged': precomputed | realtime.",
|
help="[Advanced] Stage 2 expansion mode for cascade strategy 'staged': precomputed | realtime | static_global_graph.",
|
||||||
),
|
),
|
||||||
# Hidden deprecated parameter for backward compatibility
|
# Hidden deprecated parameter for backward compatibility
|
||||||
mode: Optional[str] = typer.Option(None, "--mode", hidden=True, help="[DEPRECATED] Use --method instead."),
|
mode: Optional[str] = typer.Option(None, "--mode", hidden=True, help="[DEPRECATED] Use --method instead."),
|
||||||
@@ -615,8 +659,8 @@ def search(
|
|||||||
# Optional staged cascade overrides (only meaningful for cascade strategy 'staged')
|
# Optional staged cascade overrides (only meaningful for cascade strategy 'staged')
|
||||||
if staged_stage2_mode is not None:
|
if staged_stage2_mode is not None:
|
||||||
stage2 = staged_stage2_mode.strip().lower()
|
stage2 = staged_stage2_mode.strip().lower()
|
||||||
if stage2 not in {"precomputed", "realtime"}:
|
if stage2 not in {"precomputed", "realtime", "static_global_graph"}:
|
||||||
msg = "Invalid --staged-stage2-mode. Must be: precomputed | realtime."
|
msg = "Invalid --staged-stage2-mode. Must be: precomputed | realtime | static_global_graph."
|
||||||
if json_mode:
|
if json_mode:
|
||||||
print_json(success=False, error=msg)
|
print_json(success=False, error=msg)
|
||||||
else:
|
else:
|
||||||
@@ -810,7 +854,7 @@ def inspect(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Analyze a single file and display symbols."""
|
"""Analyze a single file and display symbols."""
|
||||||
_configure_logging(verbose, json_mode)
|
_configure_logging(verbose, json_mode)
|
||||||
config = Config()
|
config = Config.load()
|
||||||
factory = ParserFactory(config)
|
factory = ParserFactory(config)
|
||||||
|
|
||||||
file_path = file.expanduser().resolve()
|
file_path = file.expanduser().resolve()
|
||||||
@@ -3145,8 +3189,10 @@ def watch(
|
|||||||
console.print("[dim]Press Ctrl+C to stop[/dim]\n")
|
console.print("[dim]Press Ctrl+C to stop[/dim]\n")
|
||||||
|
|
||||||
# Create and start watcher manager
|
# Create and start watcher manager
|
||||||
|
watch_config = Config.load()
|
||||||
manager = WatcherManager(
|
manager = WatcherManager(
|
||||||
root_path=watch_path,
|
root_path=watch_path,
|
||||||
|
config=watch_config,
|
||||||
watcher_config=watcher_config,
|
watcher_config=watcher_config,
|
||||||
on_indexed=lambda result: _display_index_result(result),
|
on_indexed=lambda result: _display_index_result(result),
|
||||||
)
|
)
|
||||||
@@ -3681,7 +3727,7 @@ def index_update(
|
|||||||
registry = RegistryStore()
|
registry = RegistryStore()
|
||||||
registry.initialize()
|
registry.initialize()
|
||||||
mapper = PathMapper()
|
mapper = PathMapper()
|
||||||
config = Config()
|
config = Config.load()
|
||||||
|
|
||||||
resolved_path = file_path.resolve()
|
resolved_path = file_path.resolve()
|
||||||
|
|
||||||
@@ -3776,7 +3822,7 @@ def index_all(
|
|||||||
from codexlens.config import Config
|
from codexlens.config import Config
|
||||||
from codexlens.storage.index_tree import IndexTreeBuilder
|
from codexlens.storage.index_tree import IndexTreeBuilder
|
||||||
|
|
||||||
config = Config()
|
config = Config.load()
|
||||||
languages = _parse_languages(language)
|
languages = _parse_languages(language)
|
||||||
registry = RegistryStore()
|
registry = RegistryStore()
|
||||||
registry.initialize()
|
registry.initialize()
|
||||||
|
|||||||
@@ -294,6 +294,15 @@ class Config:
|
|||||||
"timeout_ms": self.llm_timeout_ms,
|
"timeout_ms": self.llm_timeout_ms,
|
||||||
"batch_size": self.llm_batch_size,
|
"batch_size": self.llm_batch_size,
|
||||||
},
|
},
|
||||||
|
"parsing": {
|
||||||
|
# Prefer ast-grep processors when available (experimental).
|
||||||
|
"use_astgrep": self.use_astgrep,
|
||||||
|
},
|
||||||
|
"indexing": {
|
||||||
|
# Persist global relationship edges during index build for static graph expansion.
|
||||||
|
"static_graph_enabled": self.static_graph_enabled,
|
||||||
|
"static_graph_relationship_types": self.static_graph_relationship_types,
|
||||||
|
},
|
||||||
"reranker": {
|
"reranker": {
|
||||||
"enabled": self.enable_cross_encoder_rerank,
|
"enabled": self.enable_cross_encoder_rerank,
|
||||||
"backend": self.reranker_backend,
|
"backend": self.reranker_backend,
|
||||||
@@ -413,6 +422,34 @@ class Config:
|
|||||||
if "fine_k" in cascade:
|
if "fine_k" in cascade:
|
||||||
self.cascade_fine_k = cascade["fine_k"]
|
self.cascade_fine_k = cascade["fine_k"]
|
||||||
|
|
||||||
|
# Load parsing settings
|
||||||
|
parsing = settings.get("parsing", {})
|
||||||
|
if isinstance(parsing, dict) and "use_astgrep" in parsing:
|
||||||
|
self.use_astgrep = bool(parsing["use_astgrep"])
|
||||||
|
|
||||||
|
# Load indexing settings
|
||||||
|
indexing = settings.get("indexing", {})
|
||||||
|
if isinstance(indexing, dict):
|
||||||
|
if "static_graph_enabled" in indexing:
|
||||||
|
self.static_graph_enabled = bool(indexing["static_graph_enabled"])
|
||||||
|
if "static_graph_relationship_types" in indexing:
|
||||||
|
raw_types = indexing["static_graph_relationship_types"]
|
||||||
|
if isinstance(raw_types, list):
|
||||||
|
allowed = {"imports", "inherits", "calls"}
|
||||||
|
cleaned = []
|
||||||
|
for item in raw_types:
|
||||||
|
val = str(item).strip().lower()
|
||||||
|
if val and val in allowed:
|
||||||
|
cleaned.append(val)
|
||||||
|
if cleaned:
|
||||||
|
self.static_graph_relationship_types = cleaned
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
"Invalid indexing.static_graph_relationship_types in %s: %r (expected list)",
|
||||||
|
self.settings_path,
|
||||||
|
raw_types,
|
||||||
|
)
|
||||||
|
|
||||||
# Load API settings
|
# Load API settings
|
||||||
api = settings.get("api", {})
|
api = settings.get("api", {})
|
||||||
if "max_workers" in api:
|
if "max_workers" in api:
|
||||||
|
|||||||
@@ -299,12 +299,25 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
|
|||||||
if func_name:
|
if func_name:
|
||||||
all_matches.append((start_line, end_line, "func_def", func_name, node))
|
all_matches.append((start_line, end_line, "func_def", func_name, node))
|
||||||
|
|
||||||
# Get import matches
|
# Get import matches (process import_with_alias first to avoid duplicates)
|
||||||
|
import_alias_positions: set = set()
|
||||||
|
|
||||||
|
# Process import with alias: import X as Y
|
||||||
|
import_alias_matches = self.run_ast_grep(source_code, get_pattern("import_with_alias"))
|
||||||
|
for node in import_alias_matches:
|
||||||
|
module = self._get_match(node, "MODULE")
|
||||||
|
alias = self._get_match(node, "ALIAS")
|
||||||
|
start_line, end_line = self._get_line_range(node)
|
||||||
|
if module and alias:
|
||||||
|
import_alias_positions.add(start_line)
|
||||||
|
all_matches.append((start_line, end_line, "import_alias", f"{module}:{alias}", node))
|
||||||
|
|
||||||
|
# Process simple imports: import X (skip lines with aliases)
|
||||||
import_matches = self.run_ast_grep(source_code, get_pattern("import_stmt"))
|
import_matches = self.run_ast_grep(source_code, get_pattern("import_stmt"))
|
||||||
for node in import_matches:
|
for node in import_matches:
|
||||||
module = self._get_match(node, "MODULE")
|
module = self._get_match(node, "MODULE")
|
||||||
start_line, end_line = self._get_line_range(node)
|
start_line, end_line = self._get_line_range(node)
|
||||||
if module:
|
if module and start_line not in import_alias_positions:
|
||||||
all_matches.append((start_line, end_line, "import", module, node))
|
all_matches.append((start_line, end_line, "import", module, node))
|
||||||
|
|
||||||
from_matches = self.run_ast_grep(source_code, get_pattern("import_from"))
|
from_matches = self.run_ast_grep(source_code, get_pattern("import_from"))
|
||||||
@@ -429,7 +442,7 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
|
|||||||
))
|
))
|
||||||
|
|
||||||
elif match_type == "import":
|
elif match_type == "import":
|
||||||
# Process import statement
|
# Process simple import statement
|
||||||
module = symbol
|
module = symbol
|
||||||
# Simple import: add base name to alias map
|
# Simple import: add base name to alias map
|
||||||
base_name = module.split(".", 1)[0]
|
base_name = module.split(".", 1)[0]
|
||||||
@@ -443,6 +456,22 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
|
|||||||
source_line=start_line,
|
source_line=start_line,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
elif match_type == "import_alias":
|
||||||
|
# Process import with alias: import X as Y
|
||||||
|
parts = symbol.split(":", 1)
|
||||||
|
module = parts[0]
|
||||||
|
alias = parts[1] if len(parts) > 1 else ""
|
||||||
|
if alias:
|
||||||
|
update_aliases({alias: module})
|
||||||
|
relationships.append(CodeRelationship(
|
||||||
|
source_symbol=get_current_scope(),
|
||||||
|
target_symbol=module,
|
||||||
|
relationship_type=RelationshipType.IMPORTS,
|
||||||
|
source_file=source_file,
|
||||||
|
target_file=None,
|
||||||
|
source_line=start_line,
|
||||||
|
))
|
||||||
|
|
||||||
elif match_type == "from_import":
|
elif match_type == "from_import":
|
||||||
# Process from-import statement
|
# Process from-import statement
|
||||||
parts = symbol.split(":", 1)
|
parts = symbol.split(":", 1)
|
||||||
@@ -647,6 +676,22 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
|
|||||||
return match.group(1).strip()
|
return match.group(1).strip()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def _extract_import_names_from_text(self, import_text: str) -> str:
|
||||||
|
"""Extract imported names from from-import statement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
import_text: Full text of import statement (e.g., "from typing import List, Dict")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Names text (e.g., "List, Dict") or empty string
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
# Match "from MODULE import NAMES" - extract NAMES
|
||||||
|
match = re.search(r'from\s+[\w.]+\s+import\s+(.+)$', import_text, re.MULTILINE)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
def extract_calls(
|
def extract_calls(
|
||||||
self,
|
self,
|
||||||
source_code: str,
|
source_code: str,
|
||||||
@@ -736,16 +781,19 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
|
|||||||
relationships: List[CodeRelationship] = []
|
relationships: List[CodeRelationship] = []
|
||||||
alias_map: Dict[str, str] = {}
|
alias_map: Dict[str, str] = {}
|
||||||
|
|
||||||
# Process simple imports: import X
|
# Track processed lines to avoid duplicates
|
||||||
import_matches = self.run_ast_grep(source_code, get_pattern("import_stmt"))
|
processed_lines: set = set()
|
||||||
for node in import_matches:
|
|
||||||
|
# Process import with alias FIRST: import X as Y
|
||||||
|
alias_matches = self.run_ast_grep(source_code, get_pattern("import_with_alias"))
|
||||||
|
for node in alias_matches:
|
||||||
module = self._get_match(node, "MODULE")
|
module = self._get_match(node, "MODULE")
|
||||||
|
alias = self._get_match(node, "ALIAS")
|
||||||
line = self._get_line_number(node)
|
line = self._get_line_number(node)
|
||||||
|
|
||||||
if module:
|
if module and alias:
|
||||||
# Add to alias map: first part of module
|
alias_map[alias] = module
|
||||||
base_name = module.split(".", 1)[0]
|
processed_lines.add(line)
|
||||||
alias_map[base_name] = module
|
|
||||||
|
|
||||||
relationships.append(CodeRelationship(
|
relationships.append(CodeRelationship(
|
||||||
source_symbol=source_symbol,
|
source_symbol=source_symbol,
|
||||||
@@ -756,15 +804,16 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
|
|||||||
source_line=line,
|
source_line=line,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Process import with alias: import X as Y
|
# Process simple imports: import X (skip lines already processed)
|
||||||
alias_matches = self.run_ast_grep(source_code, get_pattern("import_with_alias"))
|
import_matches = self.run_ast_grep(source_code, get_pattern("import_stmt"))
|
||||||
for node in alias_matches:
|
for node in import_matches:
|
||||||
module = self._get_match(node, "MODULE")
|
module = self._get_match(node, "MODULE")
|
||||||
alias = self._get_match(node, "ALIAS")
|
|
||||||
line = self._get_line_number(node)
|
line = self._get_line_number(node)
|
||||||
|
|
||||||
if module and alias:
|
if module and line not in processed_lines:
|
||||||
alias_map[alias] = module
|
# Add to alias map: first part of module
|
||||||
|
base_name = module.split(".", 1)[0]
|
||||||
|
alias_map[base_name] = module
|
||||||
|
|
||||||
relationships.append(CodeRelationship(
|
relationships.append(CodeRelationship(
|
||||||
source_symbol=source_symbol,
|
source_symbol=source_symbol,
|
||||||
@@ -779,7 +828,6 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
|
|||||||
from_matches = self.run_ast_grep(source_code, get_pattern("import_from"))
|
from_matches = self.run_ast_grep(source_code, get_pattern("import_from"))
|
||||||
for node in from_matches:
|
for node in from_matches:
|
||||||
module = self._get_match(node, "MODULE")
|
module = self._get_match(node, "MODULE")
|
||||||
names = self._get_match(node, "NAMES")
|
|
||||||
line = self._get_line_number(node)
|
line = self._get_line_number(node)
|
||||||
|
|
||||||
if module:
|
if module:
|
||||||
@@ -793,6 +841,10 @@ class AstGrepPythonProcessor(BaseAstGrepProcessor):
|
|||||||
source_line=line,
|
source_line=line,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Parse names from node text (ast-grep-py 0.40+ doesn't capture $$$ multi-match)
|
||||||
|
node_text = self._binding._get_node_text(node) if self._binding else ""
|
||||||
|
names = self._extract_import_names_from_text(node_text)
|
||||||
|
|
||||||
# Add aliases for imported names
|
# Add aliases for imported names
|
||||||
if names and names != "*":
|
if names and names != "*":
|
||||||
for name in names.split(","):
|
for name in names.split(","):
|
||||||
|
|||||||
@@ -24,11 +24,16 @@ class Parser(Protocol):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class SimpleRegexParser:
|
class SimpleRegexParser:
|
||||||
language_id: str
|
language_id: str
|
||||||
|
config: Optional[Config] = None
|
||||||
|
|
||||||
def parse(self, text: str, path: Path) -> IndexedFile:
|
def parse(self, text: str, path: Path) -> IndexedFile:
|
||||||
# Try tree-sitter first for supported languages
|
# Try tree-sitter first for supported languages
|
||||||
if self.language_id in {"python", "javascript", "typescript"}:
|
if self.language_id in {"python", "javascript", "typescript"}:
|
||||||
ts_parser = TreeSitterSymbolParser(self.language_id, path)
|
ts_parser = TreeSitterSymbolParser(
|
||||||
|
self.language_id,
|
||||||
|
path,
|
||||||
|
config=self.config,
|
||||||
|
)
|
||||||
if ts_parser.is_available():
|
if ts_parser.is_available():
|
||||||
indexed = ts_parser.parse(text, path)
|
indexed = ts_parser.parse(text, path)
|
||||||
if indexed is not None:
|
if indexed is not None:
|
||||||
@@ -73,7 +78,10 @@ class ParserFactory:
|
|||||||
|
|
||||||
def get_parser(self, language_id: str) -> Parser:
|
def get_parser(self, language_id: str) -> Parser:
|
||||||
if language_id not in self._parsers:
|
if language_id not in self._parsers:
|
||||||
self._parsers[language_id] = SimpleRegexParser(language_id)
|
self._parsers[language_id] = SimpleRegexParser(
|
||||||
|
language_id,
|
||||||
|
config=self.config,
|
||||||
|
)
|
||||||
return self._parsers[language_id]
|
return self._parsers[language_id]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -291,7 +291,9 @@ class TreeSitterSymbolParser:
|
|||||||
source_file = str(path.resolve())
|
source_file = str(path.resolve())
|
||||||
relationships: List[CodeRelationship] = []
|
relationships: List[CodeRelationship] = []
|
||||||
|
|
||||||
scope_stack: List[str] = []
|
# Use a synthetic module scope so module-level imports/calls can be recorded
|
||||||
|
# (useful for static global graph persistence).
|
||||||
|
scope_stack: List[str] = ["<module>"]
|
||||||
alias_stack: List[Dict[str, str]] = [{}]
|
alias_stack: List[Dict[str, str]] = [{}]
|
||||||
|
|
||||||
def record_import(target_symbol: str, source_line: int) -> None:
|
def record_import(target_symbol: str, source_line: int) -> None:
|
||||||
@@ -398,7 +400,9 @@ class TreeSitterSymbolParser:
|
|||||||
source_file = str(path.resolve())
|
source_file = str(path.resolve())
|
||||||
relationships: List[CodeRelationship] = []
|
relationships: List[CodeRelationship] = []
|
||||||
|
|
||||||
scope_stack: List[str] = []
|
# Use a synthetic module scope so module-level imports/calls can be recorded
|
||||||
|
# (useful for static global graph persistence).
|
||||||
|
scope_stack: List[str] = ["<module>"]
|
||||||
alias_stack: List[Dict[str, str]] = [{}]
|
alias_stack: List[Dict[str, str]] = [{}]
|
||||||
|
|
||||||
def record_import(target_symbol: str, source_line: int) -> None:
|
def record_import(target_symbol: str, source_line: int) -> None:
|
||||||
|
|||||||
@@ -519,6 +519,7 @@ class IndexTreeBuilder:
|
|||||||
"global_symbol_index_enabled": self.config.global_symbol_index_enabled,
|
"global_symbol_index_enabled": self.config.global_symbol_index_enabled,
|
||||||
"static_graph_enabled": self.config.static_graph_enabled,
|
"static_graph_enabled": self.config.static_graph_enabled,
|
||||||
"static_graph_relationship_types": self.config.static_graph_relationship_types,
|
"static_graph_relationship_types": self.config.static_graph_relationship_types,
|
||||||
|
"use_astgrep": getattr(self.config, "use_astgrep", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
worker_args = [
|
worker_args = [
|
||||||
@@ -984,6 +985,7 @@ def _build_dir_worker(args: tuple) -> DirBuildResult:
|
|||||||
global_symbol_index_enabled=bool(config_dict.get("global_symbol_index_enabled", True)),
|
global_symbol_index_enabled=bool(config_dict.get("global_symbol_index_enabled", True)),
|
||||||
static_graph_enabled=bool(config_dict.get("static_graph_enabled", False)),
|
static_graph_enabled=bool(config_dict.get("static_graph_enabled", False)),
|
||||||
static_graph_relationship_types=list(config_dict.get("static_graph_relationship_types", ["imports", "inherits"])),
|
static_graph_relationship_types=list(config_dict.get("static_graph_relationship_types", ["imports", "inherits"])),
|
||||||
|
use_astgrep=bool(config_dict.get("use_astgrep", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
parser_factory = ParserFactory(config)
|
parser_factory = ParserFactory(config)
|
||||||
|
|||||||
@@ -89,7 +89,18 @@ class IncrementalIndexer:
|
|||||||
project_info = self.registry.get_project(source_root)
|
project_info = self.registry.get_project(source_root)
|
||||||
if project_info:
|
if project_info:
|
||||||
project_id = project_info.id
|
project_id = project_info.id
|
||||||
|
try:
|
||||||
self._global_index = GlobalSymbolIndex(global_db_path, project_id=project_id)
|
self._global_index = GlobalSymbolIndex(global_db_path, project_id=project_id)
|
||||||
|
# Ensure schema exists (best-effort). The DB should already be initialized
|
||||||
|
# by `codexlens index init`, but watcher/index-update should be robust.
|
||||||
|
self._global_index.initialize()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to initialize global symbol index at %s: %s",
|
||||||
|
global_db_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
self._global_index = None
|
||||||
|
|
||||||
return self._global_index
|
return self._global_index
|
||||||
|
|
||||||
@@ -262,6 +273,34 @@ class IncrementalIndexer:
|
|||||||
# Update merkle root
|
# Update merkle root
|
||||||
store.update_merkle_root()
|
store.update_merkle_root()
|
||||||
|
|
||||||
|
# Update global relationships for static graph expansion (best-effort).
|
||||||
|
if getattr(self.config, "static_graph_enabled", False):
|
||||||
|
try:
|
||||||
|
source_root = self.mapper.get_project_root(path) or dir_path
|
||||||
|
index_root = self.mapper.source_to_index_dir(source_root)
|
||||||
|
global_index = self._get_global_index(index_root, source_root=source_root)
|
||||||
|
if global_index is not None:
|
||||||
|
allowed_types = set(
|
||||||
|
getattr(
|
||||||
|
self.config,
|
||||||
|
"static_graph_relationship_types",
|
||||||
|
["imports", "inherits"],
|
||||||
|
)
|
||||||
|
or []
|
||||||
|
)
|
||||||
|
filtered_rels = [
|
||||||
|
r
|
||||||
|
for r in (indexed_file.relationships or [])
|
||||||
|
if r.relationship_type.value in allowed_types
|
||||||
|
]
|
||||||
|
global_index.update_file_relationships(path, filtered_rels)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to update global relationships for %s: %s",
|
||||||
|
path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug("Indexed file: %s (%d symbols)", path, len(indexed_file.symbols))
|
logger.debug("Indexed file: %s (%d symbols)", path, len(indexed_file.symbols))
|
||||||
|
|
||||||
return FileIndexResult(
|
return FileIndexResult(
|
||||||
@@ -329,6 +368,21 @@ class IncrementalIndexer:
|
|||||||
try:
|
try:
|
||||||
store.remove_file(str(path))
|
store.remove_file(str(path))
|
||||||
store.update_merkle_root()
|
store.update_merkle_root()
|
||||||
|
|
||||||
|
# Best-effort cleanup of static graph relationships (keeps global DB consistent).
|
||||||
|
if getattr(self.config, "static_graph_enabled", False):
|
||||||
|
try:
|
||||||
|
source_root = self.mapper.get_project_root(path) or dir_path
|
||||||
|
index_root = self.mapper.source_to_index_dir(source_root)
|
||||||
|
global_index = self._get_global_index(index_root, source_root=source_root)
|
||||||
|
if global_index is not None:
|
||||||
|
global_index.delete_file_relationships(path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to delete global relationships for %s: %s",
|
||||||
|
path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
logger.debug("Removed file from index: %s", path)
|
logger.debug("Removed file from index: %s", path)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -377,6 +377,43 @@ class TestParserFactory:
|
|||||||
finally:
|
finally:
|
||||||
del os.environ["CODEXLENS_DATA_DIR"]
|
del os.environ["CODEXLENS_DATA_DIR"]
|
||||||
|
|
||||||
|
def test_factory_passes_config_to_treesitter(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Ensure ParserFactory config is forwarded into TreeSitterSymbolParser."""
|
||||||
|
from codexlens.entities import IndexedFile
|
||||||
|
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
class FakeTreeSitterSymbolParser:
|
||||||
|
def __init__(self, language_id, path=None, config=None) -> None:
|
||||||
|
captured["config"] = config
|
||||||
|
self.language_id = language_id
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def parse(self, text: str, path: Path) -> IndexedFile:
|
||||||
|
return IndexedFile(
|
||||||
|
path=str(path.resolve()),
|
||||||
|
language=self.language_id,
|
||||||
|
symbols=[],
|
||||||
|
chunks=[],
|
||||||
|
relationships=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"codexlens.parsers.factory.TreeSitterSymbolParser",
|
||||||
|
FakeTreeSitterSymbolParser,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.use_astgrep = True
|
||||||
|
|
||||||
|
factory = ParserFactory(config)
|
||||||
|
parser = factory.get_parser("python")
|
||||||
|
parser.parse("def hello():\n pass\n", Path("test.py"))
|
||||||
|
|
||||||
|
assert captured.get("config") is config
|
||||||
|
|
||||||
|
|
||||||
class TestParserEdgeCases:
|
class TestParserEdgeCases:
|
||||||
"""Edge case tests for parsers."""
|
"""Edge case tests for parsers."""
|
||||||
|
|||||||
Reference in New Issue
Block a user