mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-04 01:40:45 +08:00
feat: add Accordion component for UI and Zustand store for coordinator management
- Implemented Accordion component using Radix UI for collapsible sections. - Created Zustand store to manage coordinator execution state, command chains, logs, and interactive questions. - Added validation tests for CLI settings type definitions, ensuring type safety and correct behavior of helper functions.
This commit is contained in:
@@ -1,367 +0,0 @@
|
||||
---
|
||||
name: ccw view
|
||||
description: Dashboard - Open CCW workflow dashboard for managing tasks and sessions
|
||||
category: general
|
||||
---
|
||||
|
||||
# CCW View Command
|
||||
|
||||
Open the CCW workflow dashboard for visualizing and managing project tasks, sessions, and workflow execution status.
|
||||
|
||||
## Description
|
||||
|
||||
`ccw view` launches an interactive web dashboard that provides:
|
||||
- **Workflow Overview**: Visualize current workflow status and command chain execution
|
||||
- **Session Management**: View and manage active workflow sessions
|
||||
- **Task Tracking**: Monitor TODO items and task progress
|
||||
- **Workspace Switching**: Switch between different project workspaces
|
||||
- **Real-time Updates**: Live updates of command execution and status
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Open dashboard for current workspace
|
||||
ccw view
|
||||
|
||||
# Specify workspace path
|
||||
ccw view --path /path/to/workspace
|
||||
|
||||
# Custom port (default: 3456)
|
||||
ccw view --port 3000
|
||||
|
||||
# Bind to specific host
|
||||
ccw view --host 0.0.0.0 --port 3456
|
||||
|
||||
# Open without launching browser
|
||||
ccw view --no-browser
|
||||
|
||||
# Show URL without opening browser
|
||||
ccw view --no-browser
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `--path <path>` | Current directory | Workspace path to display |
|
||||
| `--port <port>` | 3456 | Server port for dashboard |
|
||||
| `--host <host>` | 127.0.0.1 | Server host/bind address |
|
||||
| `--no-browser` | false | Don't launch browser automatically |
|
||||
| `-h, --help` | - | Show help message |
|
||||
|
||||
## Features
|
||||
|
||||
### Dashboard Sections
|
||||
|
||||
#### 1. **Workflow Overview**
|
||||
- Current workflow status
|
||||
- Command chain visualization (with Minimum Execution Units marked)
|
||||
- Live progress tracking
|
||||
- Error alerts
|
||||
|
||||
#### 2. **Session Management**
|
||||
- List active sessions by type (workflow, review, tdd)
|
||||
- Session details (created time, last activity, session ID)
|
||||
- Quick actions (resume, pause, complete)
|
||||
- Session logs/history
|
||||
|
||||
#### 3. **Task Tracking**
|
||||
- TODO list with status indicators
|
||||
- Progress percentage
|
||||
- Task grouping by workflow stage
|
||||
- Quick inline task updates
|
||||
|
||||
#### 4. **Workspace Switcher**
|
||||
- Browse available workspaces
|
||||
- Switch context with one click
|
||||
- Recent workspaces list
|
||||
|
||||
#### 5. **Command History**
|
||||
- Recent commands executed
|
||||
- Execution time and status
|
||||
- Quick re-run options
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `R` | Refresh dashboard |
|
||||
| `Cmd/Ctrl + J` | Jump to session search |
|
||||
| `Cmd/Ctrl + K` | Open command palette |
|
||||
| `?` | Show help |
|
||||
|
||||
## Multi-Instance Support
|
||||
|
||||
The dashboard supports multiple concurrent instances:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Workspace A on port 3456
|
||||
ccw view --path ~/projects/workspace-a
|
||||
|
||||
# Terminal 2: Workspace B on port 3457
|
||||
ccw view --path ~/projects/workspace-b --port 3457
|
||||
|
||||
# Switching workspaces on the same port
|
||||
ccw view --path ~/projects/workspace-c # Auto-switches existing server
|
||||
```
|
||||
|
||||
When the server is already running and you execute `ccw view` with a different path:
|
||||
1. Detects running server on the port
|
||||
2. Sends workspace switch request
|
||||
3. Updates dashboard to new workspace
|
||||
4. Opens browser with updated context
|
||||
|
||||
## Server Lifecycle
|
||||
|
||||
### Startup
|
||||
|
||||
```
|
||||
ccw view
|
||||
├─ Check if server running on port
|
||||
│ ├─ If yes: Send switch-path request
|
||||
│ └─ If no: Start new server
|
||||
├─ Launch browser (unless --no-browser)
|
||||
└─ Display dashboard URL
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
The dashboard server continues running until:
|
||||
- User explicitly stops it (Ctrl+C)
|
||||
- All connections close after timeout
|
||||
- System shutdown
|
||||
|
||||
### Multiple Workspaces
|
||||
|
||||
Switching to a different workspace keeps the same server instance:
|
||||
```
|
||||
Server State Before: workspace-a on port 3456
|
||||
ccw view --path ~/projects/workspace-b
|
||||
Server State After: workspace-b on port 3456 (same instance)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Set default port
|
||||
export CCW_VIEW_PORT=4000
|
||||
ccw view # Uses port 4000
|
||||
|
||||
# Set default host
|
||||
export CCW_VIEW_HOST=localhost
|
||||
ccw view --port 3456 # Binds to localhost:3456
|
||||
|
||||
# Disable browser launch by default
|
||||
export CCW_VIEW_NO_BROWSER=true
|
||||
ccw view # Won't auto-launch browser
|
||||
```
|
||||
|
||||
## Integration with CCW Workflows
|
||||
|
||||
The dashboard is fully integrated with CCW commands:
|
||||
|
||||
### Viewing Workflow Progress
|
||||
|
||||
```bash
|
||||
# Start a workflow
|
||||
ccw "Add user authentication"
|
||||
|
||||
# In another terminal, view progress
|
||||
ccw view # Shows execution progress in real-time
|
||||
```
|
||||
|
||||
### Session Management from Dashboard
|
||||
|
||||
- Start new session: Click "New Session" button
|
||||
- Resume paused session: Sessions list → Resume button
|
||||
- View session logs: Click session name
|
||||
- Complete session: Sessions list → Complete button
|
||||
|
||||
### Real-time Command Execution
|
||||
|
||||
- View active command chain execution
|
||||
- Watch command transition through Minimum Execution Units
|
||||
- See error alerts and recovery options
|
||||
- View command output logs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
```bash
|
||||
# Use different port
|
||||
ccw view --port 3457
|
||||
|
||||
# Or kill existing server
|
||||
lsof -i :3456 # Find process
|
||||
kill -9 <pid> # Kill it
|
||||
ccw view # Start fresh
|
||||
```
|
||||
|
||||
### Dashboard Not Loading
|
||||
|
||||
```bash
|
||||
# Try without browser
|
||||
ccw view --no-browser
|
||||
|
||||
# Check server logs
|
||||
tail -f ~/.ccw/logs/dashboard.log
|
||||
|
||||
# Verify network access
|
||||
curl http://localhost:3456/api/health
|
||||
```
|
||||
|
||||
### Workspace Path Not Found
|
||||
|
||||
```bash
|
||||
# Use full absolute path
|
||||
ccw view --path "$(pwd)"
|
||||
|
||||
# Or specify explicit path
|
||||
ccw view --path ~/projects/my-project
|
||||
```
|
||||
|
||||
## Related Commands
|
||||
|
||||
- **`/ccw`** - Main workflow orchestrator
|
||||
- **`/workflow:session:list`** - List workflow sessions
|
||||
- **`/workflow:session:resume`** - Resume paused session
|
||||
- **`/memory:compact`** - Compact session memory for dashboard display
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Dashboard View
|
||||
|
||||
```bash
|
||||
cd ~/projects/my-app
|
||||
ccw view
|
||||
# → Launches http://localhost:3456 in browser
|
||||
```
|
||||
|
||||
### Network-Accessible Dashboard
|
||||
|
||||
```bash
|
||||
# Allow remote access
|
||||
ccw view --host 0.0.0.0 --port 3000
|
||||
# → Dashboard accessible at http://machine-ip:3000
|
||||
```
|
||||
|
||||
### Multiple Workspaces on Different Ports
|
||||
|
||||
```bash
|
||||
# Terminal 1: Main project
|
||||
ccw view --path ~/projects/main --port 3456
|
||||
|
||||
# Terminal 2: Side project
|
||||
ccw view --path ~/projects/side --port 3457
|
||||
|
||||
# View both simultaneously
|
||||
# → http://localhost:3456 (main)
|
||||
# → http://localhost:3457 (side)
|
||||
```
|
||||
|
||||
### Headless Dashboard
|
||||
|
||||
```bash
|
||||
# Run dashboard without browser
|
||||
ccw view --port 3000 --no-browser
|
||||
echo "Dashboard available at http://localhost:3000"
|
||||
|
||||
# Share URL with team
|
||||
# Can be proxied through nginx/port forwarding
|
||||
```
|
||||
|
||||
### Environment-Based Configuration
|
||||
|
||||
```bash
|
||||
# Script for CI/CD
|
||||
export CCW_VIEW_HOST=0.0.0.0
|
||||
export CCW_VIEW_PORT=8080
|
||||
ccw view --path /workspace
|
||||
|
||||
# → Dashboard accessible on port 8080 to all interfaces
|
||||
```
|
||||
|
||||
## Dashboard Pages
|
||||
|
||||
### Overview Page (`/`)
|
||||
- Current workflow status
|
||||
- Active sessions summary
|
||||
- Recent commands
|
||||
- System health indicators
|
||||
|
||||
### Sessions Page (`/sessions`)
|
||||
- All sessions (grouped by type)
|
||||
- Session details and metadata
|
||||
- Session logs viewer
|
||||
- Quick actions (resume/complete)
|
||||
|
||||
### Tasks Page (`/tasks`)
|
||||
- Current TODO items
|
||||
- Progress tracking
|
||||
- Inline task editing
|
||||
- Workflow history
|
||||
|
||||
### Workspace Page (`/workspace`)
|
||||
- Current workspace info
|
||||
- Available workspaces
|
||||
- Workspace switcher
|
||||
- Workspace settings
|
||||
|
||||
### Settings Page (`/settings`)
|
||||
- Port configuration
|
||||
- Theme preferences
|
||||
- Auto-refresh settings
|
||||
- Export settings
|
||||
|
||||
## Server Health Monitoring
|
||||
|
||||
The dashboard includes health monitoring:
|
||||
|
||||
```bash
|
||||
# Check health endpoint
|
||||
curl http://localhost:3456/api/health
|
||||
# → { "status": "ok", "uptime": 12345 }
|
||||
|
||||
# Monitor metrics
|
||||
curl http://localhost:3456/api/metrics
|
||||
# → { "sessions": 3, "tasks": 15, "lastUpdate": "2025-01-29T10:30:00Z" }
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Custom Port with Dynamic Discovery
|
||||
|
||||
```bash
|
||||
# Find next available port
|
||||
available_port=$(find-available-port 3456)
|
||||
ccw view --port $available_port
|
||||
|
||||
# Display in CI/CD
|
||||
echo "Dashboard: http://localhost:$available_port"
|
||||
```
|
||||
|
||||
### Dashboard Behind Proxy
|
||||
|
||||
```bash
|
||||
# Configure nginx reverse proxy
|
||||
# Proxy http://proxy.example.com/dashboard → http://localhost:3456
|
||||
|
||||
ccw view --host 127.0.0.1 --port 3456
|
||||
|
||||
# Access via proxy
|
||||
# http://proxy.example.com/dashboard
|
||||
```
|
||||
|
||||
### Session Export from Dashboard
|
||||
|
||||
- View → Sessions → Export JSON
|
||||
- Exports session metadata and progress
|
||||
- Useful for record-keeping and reporting
|
||||
|
||||
## See Also
|
||||
|
||||
- **CCW Commands**: `/ccw` - Auto workflow orchestration
|
||||
- **Session Management**: `/workflow:session:start`, `/workflow:session:list`
|
||||
- **Task Tracking**: `TodoWrite` tool for programmatic task management
|
||||
- **Workflow Status**: `/workflow:status` for CLI-based status view
|
||||
@@ -613,7 +613,13 @@ User agrees with current direction, wants deeper code analysis
|
||||
- Current design allows horizontal scaling without session affinity
|
||||
```
|
||||
|
||||
## Usage Recommendations (Requires User Confirmation)
|
||||
## Usage Recommendations(Requires User Confirmation)
|
||||
|
||||
**When to Execute Directly :**
|
||||
- Short, focused analysis tasks (single module/component)
|
||||
- Clear, well-defined topics with limited scope
|
||||
- Quick information gathering without multi-round iteration
|
||||
- Follow-up analysis building on existing session
|
||||
|
||||
**Use `Skill(skill="workflow:analyze-with-file", args="\"topic\"")` when:**
|
||||
- Exploring a complex topic collaboratively
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
---
|
||||
name: workflow:lite-lite-lite
|
||||
description: Ultra-lightweight multi-tool analysis and direct execution. No artifacts for simple tasks; auto-creates planning docs in .workflow/.scratchpad/ for complex tasks. Auto tool selection based on task analysis, user-driven iteration via AskUser.
|
||||
argument-hint: "[-y|--yes] <task description>"
|
||||
allowed-tools: TodoWrite(*), Task(*), AskUserQuestion(*), Read(*), Bash(*), Write(*), mcp__ace-tool__search_context(*), mcp__ccw-tools__write_file(*)
|
||||
---
|
||||
|
||||
## Auto Mode
|
||||
|
||||
When `--yes` or `-y`: Skip clarification questions, auto-select tools, execute directly with recommended settings.
|
||||
|
||||
# Ultra-Lite Multi-Tool Workflow
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
/workflow:lite-lite-lite "Fix the login bug"
|
||||
/workflow:lite-lite-lite "Refactor payment module for multi-gateway support"
|
||||
```
|
||||
|
||||
**Core Philosophy**: Minimal friction, maximum velocity. Simple tasks = no artifacts. Complex tasks = lightweight planning doc in `.workflow/.scratchpad/`.
|
||||
|
||||
## Overview
|
||||
|
||||
**Complexity-aware workflow**: Clarify → Assess Complexity → Select Tools → Multi-Mode Analysis → Decision → Direct Execution
|
||||
|
||||
**vs multi-cli-plan**: No IMPL_PLAN.md, plan.json, synthesis.json - state in memory or lightweight scratchpad doc for complex tasks.
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```
|
||||
Phase 1: Clarify Requirements → AskUser for missing details
|
||||
Phase 1.5: Assess Complexity → Determine if planning doc needed
|
||||
Phase 2: Select Tools (CLI → Mode → Agent) → 3-step selection
|
||||
Phase 3: Multi-Mode Analysis → Execute with --resume chaining
|
||||
Phase 4: User Decision → Execute / Refine / Change / Cancel
|
||||
Phase 5: Direct Execution → No plan files (simple) or scratchpad doc (complex)
|
||||
```
|
||||
|
||||
## Phase 1: Clarify Requirements
|
||||
|
||||
```javascript
|
||||
const taskDescription = $ARGUMENTS
|
||||
|
||||
if (taskDescription.length < 20 || isAmbiguous(taskDescription)) {
|
||||
AskUserQuestion({
|
||||
questions: [{
|
||||
question: "Please provide more details: target files/modules, expected behavior, constraints?",
|
||||
header: "Details",
|
||||
options: [
|
||||
{ label: "I'll provide more", description: "Add more context" },
|
||||
{ label: "Continue analysis", description: "Let tools explore autonomously" }
|
||||
],
|
||||
multiSelect: false
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
// Optional: Quick ACE Context for complex tasks
|
||||
mcp__ace-tool__search_context({
|
||||
project_root_path: process.cwd(),
|
||||
query: `${taskDescription} implementation patterns`
|
||||
})
|
||||
```
|
||||
|
||||
## Phase 1.5: Assess Complexity
|
||||
|
||||
| Level | Creates Plan Doc | Trigger Keywords |
|
||||
|-------|------------------|------------------|
|
||||
| **simple** | ❌ | (default) |
|
||||
| **moderate** | ✅ | module, system, service, integration, multiple |
|
||||
| **complex** | ✅ | refactor, migrate, security, auth, payment, database |
|
||||
|
||||
```javascript
|
||||
// Complexity detection (after ACE query)
|
||||
const isComplex = /refactor|migrate|security|auth|payment|database/i.test(taskDescription)
|
||||
const isModerate = /module|system|service|integration|multiple/i.test(taskDescription) || aceContext?.relevant_files?.length > 2
|
||||
|
||||
if (isComplex || isModerate) {
|
||||
const planPath = `.workflow/.scratchpad/lite3-${taskSlug}-${dateStr}.md`
|
||||
// Create planning doc with: Task, Status, Complexity, Analysis Summary, Execution Plan, Progress Log
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 2: Select Tools
|
||||
|
||||
### Tool Definitions
|
||||
|
||||
**CLI Tools** (from cli-tools.json):
|
||||
```javascript
|
||||
const cliConfig = JSON.parse(Read("~/.claude/cli-tools.json"))
|
||||
const cliTools = Object.entries(cliConfig.tools)
|
||||
.filter(([_, config]) => config.enabled)
|
||||
.map(([name, config]) => ({
|
||||
name, type: 'cli',
|
||||
tags: config.tags || [],
|
||||
model: config.primaryModel,
|
||||
toolType: config.type // builtin, cli-wrapper, api-endpoint
|
||||
}))
|
||||
```
|
||||
|
||||
**Sub Agents**:
|
||||
|
||||
| Agent | Strengths | canExecute |
|
||||
|-------|-----------|------------|
|
||||
| **code-developer** | Code implementation, test writing | ✅ |
|
||||
| **Explore** | Fast code exploration, pattern discovery | ❌ |
|
||||
| **cli-explore-agent** | Dual-source analysis (Bash+CLI) | ❌ |
|
||||
| **cli-discuss-agent** | Multi-CLI collaboration, cross-verification | ❌ |
|
||||
| **debug-explore-agent** | Hypothesis-driven debugging | ❌ |
|
||||
| **context-search-agent** | Multi-layer file discovery, dependency analysis | ❌ |
|
||||
| **test-fix-agent** | Test execution, failure diagnosis, code fixing | ✅ |
|
||||
| **universal-executor** | General execution, multi-domain adaptation | ✅ |
|
||||
|
||||
**Analysis Modes**:
|
||||
|
||||
| Mode | Pattern | Use Case | minCLIs |
|
||||
|------|---------|----------|---------|
|
||||
| **Parallel** | `A \|\| B \|\| C → Aggregate` | Fast multi-perspective | 1+ |
|
||||
| **Sequential** | `A → B(resume) → C(resume)` | Incremental deepening | 2+ |
|
||||
| **Collaborative** | `A → B → A → B → Synthesize` | Multi-round refinement | 2+ |
|
||||
| **Debate** | `A(propose) → B(challenge) → A(defend)` | Adversarial validation | 2 |
|
||||
| **Challenge** | `A(analyze) → B(challenge)` | Find flaws and risks | 2 |
|
||||
|
||||
### Three-Step Selection Flow
|
||||
|
||||
```javascript
|
||||
// Step 1: Select CLIs (multiSelect)
|
||||
AskUserQuestion({
|
||||
questions: [{
|
||||
question: "Select CLI tools for analysis (1-3 for collaboration modes)",
|
||||
header: "CLI Tools",
|
||||
options: cliTools.map(cli => ({
|
||||
label: cli.name,
|
||||
description: cli.tags.length > 0 ? cli.tags.join(', ') : cli.model || 'general'
|
||||
})),
|
||||
multiSelect: true
|
||||
}]
|
||||
})
|
||||
|
||||
// Step 2: Select Mode (filtered by CLI count)
|
||||
const availableModes = analysisModes.filter(m => selectedCLIs.length >= m.minCLIs)
|
||||
AskUserQuestion({
|
||||
questions: [{
|
||||
question: "Select analysis mode",
|
||||
header: "Mode",
|
||||
options: availableModes.map(m => ({
|
||||
label: m.label,
|
||||
description: `${m.description} [${m.pattern}]`
|
||||
})),
|
||||
multiSelect: false
|
||||
}]
|
||||
})
|
||||
|
||||
// Step 3: Select Agent for execution
|
||||
AskUserQuestion({
|
||||
questions: [{
|
||||
question: "Select Sub Agent for execution",
|
||||
header: "Agent",
|
||||
options: agents.map(a => ({ label: a.name, description: a.strength })),
|
||||
multiSelect: false
|
||||
}]
|
||||
})
|
||||
|
||||
// Confirm selection
|
||||
AskUserQuestion({
|
||||
questions: [{
|
||||
question: "Confirm selection?",
|
||||
header: "Confirm",
|
||||
options: [
|
||||
{ label: "Confirm and continue", description: `${selectedMode.label} with ${selectedCLIs.length} CLIs` },
|
||||
{ label: "Re-select CLIs", description: "Choose different CLI tools" },
|
||||
{ label: "Re-select Mode", description: "Choose different analysis mode" },
|
||||
{ label: "Re-select Agent", description: "Choose different Sub Agent" }
|
||||
],
|
||||
multiSelect: false
|
||||
}]
|
||||
})
|
||||
```
|
||||
|
||||
## Phase 3: Multi-Mode Analysis
|
||||
|
||||
### Universal CLI Prompt Template
|
||||
|
||||
```javascript
|
||||
// Unified prompt builder - used by all modes
|
||||
function buildPrompt({ purpose, tasks, expected, rules, taskDescription }) {
|
||||
return `
|
||||
PURPOSE: ${purpose}: ${taskDescription}
|
||||
TASK: ${tasks.map(t => `• ${t}`).join(' ')}
|
||||
MODE: analysis
|
||||
CONTEXT: @**/*
|
||||
EXPECTED: ${expected}
|
||||
CONSTRAINTS: ${rules}
|
||||
`
|
||||
}
|
||||
|
||||
// Execute CLI with prompt
|
||||
function execCLI(cli, prompt, options = {}) {
|
||||
const { resume, background = false } = options
|
||||
const resumeFlag = resume ? `--resume ${resume}` : ''
|
||||
return Bash({
|
||||
command: `ccw cli -p "${prompt}" --tool ${cli.name} --mode analysis ${resumeFlag}`,
|
||||
run_in_background: background
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Prompt Presets by Role
|
||||
|
||||
| Role | PURPOSE | TASKS | EXPECTED | RULES |
|
||||
|------|---------|-------|----------|-------|
|
||||
| **initial** | Initial analysis | Identify files, Analyze approach, List changes | Root cause, files, changes, risks | Focus on actionable insights |
|
||||
| **extend** | Build on previous | Review previous, Extend, Add insights | Extended analysis building on findings | Build incrementally, avoid repetition |
|
||||
| **synthesize** | Refine and synthesize | Review, Identify gaps, Synthesize | Refined synthesis with new perspectives | Add value not repetition |
|
||||
| **propose** | Propose comprehensive analysis | Analyze thoroughly, Propose solution, State assumptions | Well-reasoned proposal with trade-offs | Be clear about assumptions |
|
||||
| **challenge** | Challenge and stress-test | Identify weaknesses, Question assumptions, Suggest alternatives | Critique with counter-arguments | Be adversarial but constructive |
|
||||
| **defend** | Respond to challenges | Address challenges, Defend valid aspects, Propose refined solution | Refined proposal incorporating feedback | Be open to criticism, synthesize |
|
||||
| **criticize** | Find flaws ruthlessly | Find logical flaws, Identify edge cases, Rate criticisms | Critique with severity: [CRITICAL]/[HIGH]/[MEDIUM]/[LOW] | Be ruthlessly critical |
|
||||
|
||||
```javascript
|
||||
const PROMPTS = {
|
||||
initial: { purpose: 'Initial analysis', tasks: ['Identify affected files', 'Analyze implementation approach', 'List specific changes'], expected: 'Root cause, files to modify, key changes, risks', rules: 'Focus on actionable insights' },
|
||||
extend: { purpose: 'Build on previous analysis', tasks: ['Review previous findings', 'Extend analysis', 'Add new insights'], expected: 'Extended analysis building on previous', rules: 'Build incrementally, avoid repetition' },
|
||||
synthesize: { purpose: 'Refine and synthesize', tasks: ['Review previous', 'Identify gaps', 'Add insights', 'Synthesize findings'], expected: 'Refined synthesis with new perspectives', rules: 'Build collaboratively, add value' },
|
||||
propose: { purpose: 'Propose comprehensive analysis', tasks: ['Analyze thoroughly', 'Propose solution', 'State assumptions clearly'], expected: 'Well-reasoned proposal with trade-offs', rules: 'Be clear about assumptions' },
|
||||
challenge: { purpose: 'Challenge and stress-test', tasks: ['Identify weaknesses', 'Question assumptions', 'Suggest alternatives', 'Highlight overlooked risks'], expected: 'Constructive critique with counter-arguments', rules: 'Be adversarial but constructive' },
|
||||
defend: { purpose: 'Respond to challenges', tasks: ['Address each challenge', 'Defend valid aspects', 'Acknowledge valid criticisms', 'Propose refined solution'], expected: 'Refined proposal incorporating alternatives', rules: 'Be open to criticism, synthesize best ideas' },
|
||||
criticize: { purpose: 'Stress-test and find weaknesses', tasks: ['Find logical flaws', 'Identify missed edge cases', 'Propose alternatives', 'Rate criticisms (High/Medium/Low)'], expected: 'Detailed critique with severity ratings', rules: 'Be ruthlessly critical, find every flaw' }
|
||||
}
|
||||
```
|
||||
|
||||
### Mode Implementations
|
||||
|
||||
```javascript
|
||||
// Parallel: All CLIs run simultaneously
|
||||
async function executeParallel(clis, task) {
|
||||
return await Promise.all(clis.map(cli =>
|
||||
execCLI(cli, buildPrompt({ ...PROMPTS.initial, taskDescription: task }), { background: true })
|
||||
))
|
||||
}
|
||||
|
||||
// Sequential: Each CLI builds on previous via --resume
|
||||
async function executeSequential(clis, task) {
|
||||
const results = []
|
||||
let prevId = null
|
||||
for (const cli of clis) {
|
||||
const preset = prevId ? PROMPTS.extend : PROMPTS.initial
|
||||
const result = await execCLI(cli, buildPrompt({ ...preset, taskDescription: task }), { resume: prevId })
|
||||
results.push(result)
|
||||
prevId = extractSessionId(result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Collaborative: Multi-round synthesis
|
||||
async function executeCollaborative(clis, task, rounds = 2) {
|
||||
const results = []
|
||||
let prevId = null
|
||||
for (let r = 0; r < rounds; r++) {
|
||||
for (const cli of clis) {
|
||||
const preset = !prevId ? PROMPTS.initial : PROMPTS.synthesize
|
||||
const result = await execCLI(cli, buildPrompt({ ...preset, taskDescription: task }), { resume: prevId })
|
||||
results.push({ cli: cli.name, round: r, result })
|
||||
prevId = extractSessionId(result)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Debate: Propose → Challenge → Defend
|
||||
async function executeDebate(clis, task) {
|
||||
const [cliA, cliB] = clis
|
||||
const results = []
|
||||
|
||||
const propose = await execCLI(cliA, buildPrompt({ ...PROMPTS.propose, taskDescription: task }))
|
||||
results.push({ phase: 'propose', cli: cliA.name, result: propose })
|
||||
|
||||
const challenge = await execCLI(cliB, buildPrompt({ ...PROMPTS.challenge, taskDescription: task }), { resume: extractSessionId(propose) })
|
||||
results.push({ phase: 'challenge', cli: cliB.name, result: challenge })
|
||||
|
||||
const defend = await execCLI(cliA, buildPrompt({ ...PROMPTS.defend, taskDescription: task }), { resume: extractSessionId(challenge) })
|
||||
results.push({ phase: 'defend', cli: cliA.name, result: defend })
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Challenge: Analyze → Criticize
|
||||
async function executeChallenge(clis, task) {
|
||||
const [cliA, cliB] = clis
|
||||
const results = []
|
||||
|
||||
const analyze = await execCLI(cliA, buildPrompt({ ...PROMPTS.initial, taskDescription: task }))
|
||||
results.push({ phase: 'analyze', cli: cliA.name, result: analyze })
|
||||
|
||||
const criticize = await execCLI(cliB, buildPrompt({ ...PROMPTS.criticize, taskDescription: task }), { resume: extractSessionId(analyze) })
|
||||
results.push({ phase: 'challenge', cli: cliB.name, result: criticize })
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
### Mode Router & Result Aggregation
|
||||
|
||||
```javascript
|
||||
async function executeAnalysis(mode, clis, taskDescription) {
|
||||
switch (mode.name) {
|
||||
case 'parallel': return await executeParallel(clis, taskDescription)
|
||||
case 'sequential': return await executeSequential(clis, taskDescription)
|
||||
case 'collaborative': return await executeCollaborative(clis, taskDescription)
|
||||
case 'debate': return await executeDebate(clis, taskDescription)
|
||||
case 'challenge': return await executeChallenge(clis, taskDescription)
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateResults(mode, results) {
|
||||
const base = { mode: mode.name, pattern: mode.pattern, tools_used: results.map(r => r.cli || 'unknown') }
|
||||
|
||||
switch (mode.name) {
|
||||
case 'parallel':
|
||||
return { ...base, findings: results.map(parseOutput), consensus: findCommonPoints(results), divergences: findDifferences(results) }
|
||||
case 'sequential':
|
||||
return { ...base, evolution: results.map((r, i) => ({ step: i + 1, analysis: parseOutput(r) })), finalAnalysis: parseOutput(results.at(-1)) }
|
||||
case 'collaborative':
|
||||
return { ...base, rounds: groupByRound(results), synthesis: extractSynthesis(results.at(-1)) }
|
||||
case 'debate':
|
||||
return { ...base, proposal: parseOutput(results.find(r => r.phase === 'propose')?.result),
|
||||
challenges: parseOutput(results.find(r => r.phase === 'challenge')?.result),
|
||||
resolution: parseOutput(results.find(r => r.phase === 'defend')?.result), confidence: calculateDebateConfidence(results) }
|
||||
case 'challenge':
|
||||
return { ...base, originalAnalysis: parseOutput(results.find(r => r.phase === 'analyze')?.result),
|
||||
critiques: parseCritiques(results.find(r => r.phase === 'challenge')?.result), riskScore: calculateRiskScore(results) }
|
||||
}
|
||||
}
|
||||
|
||||
// If planPath exists: update Analysis Summary & Execution Plan sections
|
||||
```
|
||||
|
||||
## Phase 4: User Decision
|
||||
|
||||
```javascript
|
||||
function presentSummary(analysis) {
|
||||
console.log(`## Analysis Result\n**Mode**: ${analysis.mode} (${analysis.pattern})\n**Tools**: ${analysis.tools_used.join(' → ')}`)
|
||||
|
||||
switch (analysis.mode) {
|
||||
case 'parallel':
|
||||
console.log(`### Consensus\n${analysis.consensus.map(c => `- ${c}`).join('\n')}\n### Divergences\n${analysis.divergences.map(d => `- ${d}`).join('\n')}`)
|
||||
break
|
||||
case 'sequential':
|
||||
console.log(`### Evolution\n${analysis.evolution.map(e => `**Step ${e.step}**: ${e.analysis.summary}`).join('\n')}\n### Final\n${analysis.finalAnalysis.summary}`)
|
||||
break
|
||||
case 'collaborative':
|
||||
console.log(`### Rounds\n${Object.entries(analysis.rounds).map(([r, a]) => `**Round ${r}**: ${a.map(x => x.cli).join(' + ')}`).join('\n')}\n### Synthesis\n${analysis.synthesis}`)
|
||||
break
|
||||
case 'debate':
|
||||
console.log(`### Debate\n**Proposal**: ${analysis.proposal.summary}\n**Challenges**: ${analysis.challenges.points?.length || 0} points\n**Resolution**: ${analysis.resolution.summary}\n**Confidence**: ${analysis.confidence}%`)
|
||||
break
|
||||
case 'challenge':
|
||||
console.log(`### Challenge\n**Original**: ${analysis.originalAnalysis.summary}\n**Critiques**: ${analysis.critiques.length} issues\n${analysis.critiques.map(c => `- [${c.severity}] ${c.description}`).join('\n')}\n**Risk Score**: ${analysis.riskScore}/100`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
AskUserQuestion({
|
||||
questions: [{
|
||||
question: "How to proceed?",
|
||||
header: "Next Step",
|
||||
options: [
|
||||
{ label: "Execute directly", description: "Implement immediately" },
|
||||
{ label: "Refine analysis", description: "Add constraints, re-analyze" },
|
||||
{ label: "Change tools", description: "Different tool combination" },
|
||||
{ label: "Cancel", description: "End workflow" }
|
||||
],
|
||||
multiSelect: false
|
||||
}]
|
||||
})
|
||||
// If planPath exists: record decision to Decisions Made table
|
||||
// Routing: Execute → Phase 5 | Refine → Phase 3 | Change → Phase 2 | Cancel → End
|
||||
```
|
||||
|
||||
## Phase 5: Direct Execution
|
||||
|
||||
```javascript
|
||||
// Simple tasks: No artifacts | Complex tasks: Update scratchpad doc
|
||||
const executionAgents = agents.filter(a => a.canExecute)
|
||||
const executionTool = selectedAgent.canExecute ? selectedAgent : selectedCLIs[0]
|
||||
|
||||
if (executionTool.type === 'agent') {
|
||||
Task({
|
||||
subagent_type: executionTool.name,
|
||||
run_in_background: false,
|
||||
description: `Execute: ${taskDescription.slice(0, 30)}`,
|
||||
prompt: `## Task\n${taskDescription}\n\n## Analysis Results\n${JSON.stringify(aggregatedAnalysis, null, 2)}\n\n## Instructions\n1. Apply changes to identified files\n2. Follow recommended approach\n3. Handle identified risks\n4. Verify changes work correctly`
|
||||
})
|
||||
} else {
|
||||
Bash({
|
||||
command: `ccw cli -p "
|
||||
PURPOSE: Implement solution: ${taskDescription}
|
||||
TASK: ${extractedTasks.join(' • ')}
|
||||
MODE: write
|
||||
CONTEXT: @${affectedFiles.join(' @')}
|
||||
EXPECTED: Working implementation with all changes applied
|
||||
CONSTRAINTS: Follow existing patterns
|
||||
" --tool ${executionTool.name} --mode write`,
|
||||
run_in_background: false
|
||||
})
|
||||
}
|
||||
// If planPath exists: update Status to completed/failed, append to Progress Log
|
||||
```
|
||||
|
||||
## TodoWrite Structure
|
||||
|
||||
```javascript
|
||||
TodoWrite({ todos: [
|
||||
{ content: "Phase 1: Clarify requirements", status: "in_progress", activeForm: "Clarifying requirements" },
|
||||
{ content: "Phase 1.5: Assess complexity", status: "pending", activeForm: "Assessing complexity" },
|
||||
{ content: "Phase 2: Select tools", status: "pending", activeForm: "Selecting tools" },
|
||||
{ content: "Phase 3: Multi-mode analysis", status: "pending", activeForm: "Running analysis" },
|
||||
{ content: "Phase 4: User decision", status: "pending", activeForm: "Awaiting decision" },
|
||||
{ content: "Phase 5: Direct execution", status: "pending", activeForm: "Executing" }
|
||||
]})
|
||||
```
|
||||
|
||||
## Iteration Patterns
|
||||
|
||||
| Pattern | Flow |
|
||||
|---------|------|
|
||||
| **Direct** | Phase 1 → 2 → 3 → 4(execute) → 5 |
|
||||
| **Refinement** | Phase 3 → 4(refine) → 3 → 4 → 5 |
|
||||
| **Tool Adjust** | Phase 2(adjust) → 3 → 4 → 5 |
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
| CLI timeout | Retry with secondary model |
|
||||
| No enabled tools | Ask user to enable tools in cli-tools.json |
|
||||
| Task unclear | Default to first CLI + code-developer |
|
||||
| Ambiguous task | Force clarification via AskUser |
|
||||
| Execution fails | Present error, ask user for direction |
|
||||
| Plan doc write fails | Continue without doc (degrade to zero-artifact mode) |
|
||||
| Scratchpad dir missing | Auto-create `.workflow/.scratchpad/` |
|
||||
|
||||
## Comparison with multi-cli-plan
|
||||
|
||||
| Aspect | lite-lite-lite | multi-cli-plan |
|
||||
|--------|----------------|----------------|
|
||||
| **Artifacts** | Conditional (scratchpad doc for complex tasks) | Always (IMPL_PLAN.md, plan.json, synthesis.json) |
|
||||
| **Session** | Stateless (--resume chaining) | Persistent session folder |
|
||||
| **Tool Selection** | 3-step (CLI → Mode → Agent) | Config-driven fixed tools |
|
||||
| **Analysis Modes** | 5 modes with --resume | Fixed synthesis rounds |
|
||||
| **Complexity** | Auto-detected (simple/moderate/complex) | Assumed complex |
|
||||
| **Best For** | Quick analysis, simple-to-moderate tasks | Complex multi-step implementations |
|
||||
|
||||
## Post-Completion Expansion
|
||||
|
||||
完成后询问用户是否扩展为issue(test/enhance/refactor/doc),选中项调用 `/issue:new "{summary} - {dimension}"`
|
||||
|
||||
## Related Commands
|
||||
|
||||
```bash
|
||||
/workflow:multi-cli-plan "complex task" # Full planning workflow
|
||||
/workflow:lite-plan "task" # Single CLI planning
|
||||
/workflow:lite-execute --in-memory # Direct execution
|
||||
```
|
||||
@@ -1,259 +0,0 @@
|
||||
---
|
||||
name: ccw-loop
|
||||
description: Stateless iterative development loop workflow with documented progress. Supports develop, debug, and validate phases with file-based state tracking. Triggers on "ccw-loop", "dev loop", "development loop", "开发循环", "迭代开发".
|
||||
allowed-tools: Task(*), AskUserQuestion(*), Read(*), Grep(*), Glob(*), Bash(*), Edit(*), Write(*), TodoWrite(*)
|
||||
---
|
||||
|
||||
# CCW Loop - Stateless Iterative Development Workflow
|
||||
|
||||
无状态迭代开发循环工作流,支持开发 (develop)、调试 (debug)、验证 (validate) 三个阶段,每个阶段都有独立的文件记录进展。
|
||||
|
||||
## Arguments
|
||||
|
||||
| Arg | Required | Description |
|
||||
|-----|----------|-------------|
|
||||
| task | No | Task description (for new loop, mutually exclusive with --loop-id) |
|
||||
| --loop-id | No | Existing loop ID to continue (from API or previous session) |
|
||||
| --auto | No | Auto-cycle mode (develop → debug → validate → complete) |
|
||||
|
||||
## Unified Architecture (API + Skill Integration)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Dashboard (UI) │
|
||||
│ [Create] [Start] [Pause] [Resume] [Stop] [View Progress] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ loop-v2-routes.ts (Control Plane) │
|
||||
│ │
|
||||
│ State: .loop/{loopId}.json (MASTER) │
|
||||
│ Tasks: .loop/{loopId}.tasks.jsonl │
|
||||
│ │
|
||||
│ /start → Trigger ccw-loop skill with --loop-id │
|
||||
│ /pause → Set status='paused' (skill checks before action) │
|
||||
│ /stop → Set status='failed' (skill terminates) │
|
||||
│ /resume → Set status='running' (skill continues) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ccw-loop Skill (Execution Plane) │
|
||||
│ │
|
||||
│ Reads/Writes: .loop/{loopId}.json (unified state) │
|
||||
│ Writes: .loop/{loopId}.progress/* (progress files) │
|
||||
│ │
|
||||
│ BEFORE each action: │
|
||||
│ → Check status: paused/stopped → exit gracefully │
|
||||
│ → running → continue with action │
|
||||
│ │
|
||||
│ Actions: init → develop → debug → validate → complete │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **统一状态**: API 和 Skill 共享 `.loop/{loopId}.json` 状态文件
|
||||
2. **控制信号**: Skill 每个 Action 前检查 status 字段 (paused/stopped)
|
||||
3. **文件驱动**: 所有进度、理解、结果都记录在 `.loop/{loopId}.progress/`
|
||||
4. **可恢复**: 任何时候可以继续之前的循环 (`--loop-id`)
|
||||
5. **双触发**: 支持 API 触发 (`--loop-id`) 和直接调用 (task description)
|
||||
6. **Gemini 辅助**: 使用 CLI 工具进行深度分析和假设验证
|
||||
|
||||
## Execution Modes
|
||||
|
||||
### Mode 1: Interactive (交互式)
|
||||
|
||||
用户手动选择每个动作,适合复杂任务。
|
||||
|
||||
```
|
||||
用户 → 选择动作 → 执行 → 查看结果 → 选择下一动作
|
||||
```
|
||||
|
||||
### Mode 2: Auto-Loop (自动循环)
|
||||
|
||||
按预设顺序自动执行,适合标准开发流程。
|
||||
|
||||
```
|
||||
Develop → Debug → Validate → (如有问题) → Develop → ...
|
||||
```
|
||||
|
||||
## Session Structure (Unified Location)
|
||||
|
||||
```
|
||||
.loop/
|
||||
├── {loopId}.json # 主状态文件 (API + Skill 共享)
|
||||
├── {loopId}.tasks.jsonl # 任务列表 (API 管理)
|
||||
└── {loopId}.progress/ # Skill 进度文件
|
||||
├── develop.md # 开发进度记录
|
||||
├── debug.md # 理解演变文档
|
||||
├── validate.md # 验证报告
|
||||
├── changes.log # 代码变更日志 (NDJSON)
|
||||
└── debug.log # 调试日志 (NDJSON)
|
||||
```
|
||||
|
||||
## Directory Setup
|
||||
|
||||
```javascript
|
||||
// loopId 来源:
|
||||
// 1. API 触发时: 从 --loop-id 参数获取
|
||||
// 2. 直接调用时: 生成新的 loop-v2-{timestamp}-{random}
|
||||
|
||||
const loopId = args['--loop-id'] || generateLoopId()
|
||||
const loopFile = `.loop/${loopId}.json`
|
||||
const progressDir = `.loop/${loopId}.progress`
|
||||
|
||||
// 创建进度目录
|
||||
Bash(`mkdir -p "${progressDir}"`)
|
||||
```
|
||||
|
||||
## Action Catalog
|
||||
|
||||
| Action | Purpose | Output Files | CLI Integration |
|
||||
|--------|---------|--------------|-----------------|
|
||||
| [action-init](phases/actions/action-init.md) | 初始化循环会话 | meta.json, state.json | - |
|
||||
| [action-develop-with-file](phases/actions/action-develop-with-file.md) | 开发任务执行 | progress.md, tasks.json | gemini --mode write |
|
||||
| [action-debug-with-file](phases/actions/action-debug-with-file.md) | 假设驱动调试 | understanding.md, hypotheses.json | gemini --mode analysis |
|
||||
| [action-validate-with-file](phases/actions/action-validate-with-file.md) | 测试与验证 | validation.md, test-results.json | gemini --mode analysis |
|
||||
| [action-complete](phases/actions/action-complete.md) | 完成循环 | summary.md | - |
|
||||
| [action-menu](phases/actions/action-menu.md) | 显示操作菜单 | - | - |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# 启动新循环 (直接调用)
|
||||
/ccw-loop "实现用户认证功能"
|
||||
|
||||
# 继续现有循环 (API 触发或手动恢复)
|
||||
/ccw-loop --loop-id loop-v2-20260122-abc123
|
||||
|
||||
# 自动循环模式
|
||||
/ccw-loop --auto "修复登录bug并添加测试"
|
||||
|
||||
# API 触发自动循环
|
||||
/ccw-loop --loop-id loop-v2-20260122-abc123 --auto
|
||||
```
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ /ccw-loop [<task> | --loop-id <id>] [--auto] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Parameter Detection: │
|
||||
│ ├─ IF --loop-id provided: │
|
||||
│ │ ├─ Read .loop/{loopId}.json │
|
||||
│ │ ├─ Validate status === 'running' │
|
||||
│ │ └─ Continue from skill_state.current_action │
|
||||
│ └─ ELSE (task description): │
|
||||
│ ├─ Generate new loopId │
|
||||
│ ├─ Create .loop/{loopId}.json │
|
||||
│ └─ Initialize with action-init │
|
||||
│ │
|
||||
│ 2. Orchestrator Loop: │
|
||||
│ ├─ Read state from .loop/{loopId}.json │
|
||||
│ ├─ Check control signals: │
|
||||
│ │ ├─ status === 'paused' → Exit (wait for resume) │
|
||||
│ │ ├─ status === 'failed' → Exit with error │
|
||||
│ │ └─ status === 'running' → Continue │
|
||||
│ ├─ Show menu / auto-select next action │
|
||||
│ ├─ Execute action │
|
||||
│ ├─ Update .loop/{loopId}.progress/{action}.md │
|
||||
│ ├─ Update .loop/{loopId}.json (skill_state) │
|
||||
│ └─ Loop or exit based on user choice / completion │
|
||||
│ │
|
||||
│ 3. Action Execution: │
|
||||
│ ├─ BEFORE: checkControlSignals() → exit if paused/stopped │
|
||||
│ ├─ Develop: Plan → Implement → Document progress │
|
||||
│ ├─ Debug: Hypothesize → Instrument → Analyze → Fix │
|
||||
│ ├─ Validate: Test → Check → Report │
|
||||
│ └─ AFTER: Update skill_state in .loop/{loopId}.json │
|
||||
│ │
|
||||
│ 4. Termination: │
|
||||
│ ├─ Control signal: paused (graceful exit, wait resume) │
|
||||
│ ├─ Control signal: stopped (failed state) │
|
||||
│ ├─ User exits (interactive mode) │
|
||||
│ ├─ All tasks completed (status → completed) │
|
||||
│ └─ Max iterations reached │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Reference Documents
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [phases/orchestrator.md](phases/orchestrator.md) | 编排器:状态读取 + 动作选择 |
|
||||
| [phases/state-schema.md](phases/state-schema.md) | 状态结构定义 |
|
||||
| [specs/loop-requirements.md](specs/loop-requirements.md) | 循环需求规范 |
|
||||
| [specs/action-catalog.md](specs/action-catalog.md) | 动作目录 |
|
||||
| [templates/progress-template.md](templates/progress-template.md) | 进度文档模板 |
|
||||
| [templates/understanding-template.md](templates/understanding-template.md) | 理解文档模板 |
|
||||
|
||||
## Integration with Loop Monitor (Dashboard)
|
||||
|
||||
此 Skill 与 CCW Dashboard 的 Loop Monitor 实现 **控制平面 + 执行平面** 分离架构:
|
||||
|
||||
### Control Plane (Dashboard/API → loop-v2-routes.ts)
|
||||
|
||||
1. **创建循环**: `POST /api/loops/v2` → 创建 `.loop/{loopId}.json`
|
||||
2. **启动执行**: `POST /api/loops/v2/:loopId/start` → 触发 `/ccw-loop --loop-id {loopId} --auto`
|
||||
3. **暂停执行**: `POST /api/loops/v2/:loopId/pause` → 设置 `status='paused'` (Skill 下次检查时退出)
|
||||
4. **恢复执行**: `POST /api/loops/v2/:loopId/resume` → 设置 `status='running'` → 重新触发 Skill
|
||||
5. **停止执行**: `POST /api/loops/v2/:loopId/stop` → 设置 `status='failed'`
|
||||
|
||||
### Execution Plane (ccw-loop Skill)
|
||||
|
||||
1. **读取状态**: 从 `.loop/{loopId}.json` 读取 API 设置的状态
|
||||
2. **检查控制**: 每个 Action 前检查 `status` 字段
|
||||
3. **执行动作**: develop → debug → validate → complete
|
||||
4. **更新进度**: 写入 `.loop/{loopId}.progress/*.md` 和更新 `skill_state`
|
||||
5. **状态同步**: Dashboard 通过读取 `.loop/{loopId}.json` 获取进度
|
||||
|
||||
## CLI Integration Points
|
||||
|
||||
### Develop Phase
|
||||
```bash
|
||||
ccw cli -p "PURPOSE: Implement {task}...
|
||||
TASK: • Analyze requirements • Write code • Update progress
|
||||
MODE: write
|
||||
CONTEXT: @progress.md @tasks.json
|
||||
EXPECTED: Implementation + updated progress.md
|
||||
" --tool gemini --mode write --rule development-implement-feature
|
||||
```
|
||||
|
||||
### Debug Phase
|
||||
```bash
|
||||
ccw cli -p "PURPOSE: Generate debugging hypotheses...
|
||||
TASK: • Analyze error • Generate hypotheses • Add instrumentation
|
||||
MODE: analysis
|
||||
CONTEXT: @understanding.md @debug.log
|
||||
EXPECTED: Hypotheses + instrumentation plan
|
||||
" --tool gemini --mode analysis --rule analysis-diagnose-bug-root-cause
|
||||
```
|
||||
|
||||
### Validate Phase
|
||||
```bash
|
||||
ccw cli -p "PURPOSE: Validate implementation...
|
||||
TASK: • Run tests • Check coverage • Verify requirements
|
||||
MODE: analysis
|
||||
CONTEXT: @validation.md @test-results.json
|
||||
EXPECTED: Validation report
|
||||
" --tool gemini --mode analysis --rule analysis-review-code-quality
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| Session not found | Create new session |
|
||||
| State file corrupted | Rebuild from file contents |
|
||||
| CLI tool fails | Fallback to manual analysis |
|
||||
| Tests fail | Loop back to develop/debug |
|
||||
| >10 iterations | Warn user, suggest break |
|
||||
|
||||
## Post-Completion Expansion
|
||||
|
||||
完成后询问用户是否扩展为 issue (test/enhance/refactor/doc),选中项调用 `/issue:new "{summary} - {dimension}"`
|
||||
@@ -1,320 +0,0 @@
|
||||
# Action: Complete
|
||||
|
||||
完成 CCW Loop 会话,生成总结报告。
|
||||
|
||||
## Purpose
|
||||
|
||||
- 生成完成报告
|
||||
- 汇总所有阶段成果
|
||||
- 提供后续建议
|
||||
- 询问是否扩展为 Issue
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [ ] state.initialized === true
|
||||
- [ ] state.status === 'running'
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: 汇总统计
|
||||
|
||||
```javascript
|
||||
const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const sessionFolder = `.workflow/.loop/${state.session_id}`
|
||||
|
||||
const stats = {
|
||||
// 时间统计
|
||||
duration: Date.now() - new Date(state.created_at).getTime(),
|
||||
iterations: state.iteration_count,
|
||||
|
||||
// 开发统计
|
||||
develop: {
|
||||
total_tasks: state.develop.total_count,
|
||||
completed_tasks: state.develop.completed_count,
|
||||
completion_rate: state.develop.total_count > 0
|
||||
? (state.develop.completed_count / state.develop.total_count * 100).toFixed(1)
|
||||
: 0
|
||||
},
|
||||
|
||||
// 调试统计
|
||||
debug: {
|
||||
iterations: state.debug.iteration,
|
||||
hypotheses_tested: state.debug.hypotheses.length,
|
||||
root_cause_found: state.debug.confirmed_hypothesis !== null
|
||||
},
|
||||
|
||||
// 验证统计
|
||||
validate: {
|
||||
runs: state.validate.test_results.length,
|
||||
passed: state.validate.passed,
|
||||
coverage: state.validate.coverage,
|
||||
failed_tests: state.validate.failed_tests.length
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n生成完成报告...')
|
||||
```
|
||||
|
||||
### Step 2: 生成总结报告
|
||||
|
||||
```javascript
|
||||
const summaryReport = `# CCW Loop Session Summary
|
||||
|
||||
**Session ID**: ${state.session_id}
|
||||
**Task**: ${state.task_description}
|
||||
**Started**: ${state.created_at}
|
||||
**Completed**: ${getUtc8ISOString()}
|
||||
**Duration**: ${formatDuration(stats.duration)}
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
${state.validate.passed
|
||||
? '✅ **任务成功完成** - 所有测试通过,验证成功'
|
||||
: state.develop.completed_count === state.develop.total_count
|
||||
? '⚠️ **开发完成,验证未通过** - 需要进一步调试'
|
||||
: '⏸️ **任务部分完成** - 仍有待处理项'}
|
||||
|
||||
---
|
||||
|
||||
## Development Phase
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tasks | ${stats.develop.total_tasks} |
|
||||
| Completed | ${stats.develop.completed_tasks} |
|
||||
| Completion Rate | ${stats.develop.completion_rate}% |
|
||||
|
||||
### Completed Tasks
|
||||
|
||||
${state.develop.tasks.filter(t => t.status === 'completed').map(t => `
|
||||
- ✅ ${t.description}
|
||||
- Files: ${t.files_changed?.join(', ') || 'N/A'}
|
||||
- Completed: ${t.completed_at}
|
||||
`).join('\n')}
|
||||
|
||||
### Pending Tasks
|
||||
|
||||
${state.develop.tasks.filter(t => t.status !== 'completed').map(t => `
|
||||
- ⏳ ${t.description}
|
||||
`).join('\n') || '_None_'}
|
||||
|
||||
---
|
||||
|
||||
## Debug Phase
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Iterations | ${stats.debug.iterations} |
|
||||
| Hypotheses Tested | ${stats.debug.hypotheses_tested} |
|
||||
| Root Cause Found | ${stats.debug.root_cause_found ? 'Yes' : 'No'} |
|
||||
|
||||
${stats.debug.root_cause_found ? `
|
||||
### Confirmed Root Cause
|
||||
|
||||
**${state.debug.confirmed_hypothesis}**: ${state.debug.hypotheses.find(h => h.id === state.debug.confirmed_hypothesis)?.description || 'N/A'}
|
||||
` : ''}
|
||||
|
||||
### Hypothesis Summary
|
||||
|
||||
${state.debug.hypotheses.map(h => `
|
||||
- **${h.id}**: ${h.status.toUpperCase()}
|
||||
- ${h.description}
|
||||
`).join('\n') || '_No hypotheses tested_'}
|
||||
|
||||
---
|
||||
|
||||
## Validation Phase
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Test Runs | ${stats.validate.runs} |
|
||||
| Status | ${stats.validate.passed ? 'PASSED' : 'FAILED'} |
|
||||
| Coverage | ${stats.validate.coverage || 'N/A'}% |
|
||||
| Failed Tests | ${stats.validate.failed_tests} |
|
||||
|
||||
${stats.validate.failed_tests > 0 ? `
|
||||
### Failed Tests
|
||||
|
||||
${state.validate.failed_tests.map(t => `- ❌ ${t}`).join('\n')}
|
||||
` : ''}
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
${listModifiedFiles(sessionFolder)}
|
||||
|
||||
---
|
||||
|
||||
## Key Learnings
|
||||
|
||||
${state.debug.iteration > 0 ? `
|
||||
### From Debugging
|
||||
|
||||
${extractLearnings(state.debug.hypotheses)}
|
||||
` : ''}
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
${generateRecommendations(stats, state)}
|
||||
|
||||
---
|
||||
|
||||
## Session Artifacts
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| \`develop/progress.md\` | Development progress timeline |
|
||||
| \`develop/tasks.json\` | Task list with status |
|
||||
| \`debug/understanding.md\` | Debug exploration and learnings |
|
||||
| \`debug/hypotheses.json\` | Hypothesis history |
|
||||
| \`validate/validation.md\` | Validation report |
|
||||
| \`validate/test-results.json\` | Test execution results |
|
||||
|
||||
---
|
||||
|
||||
*Generated by CCW Loop at ${getUtc8ISOString()}*
|
||||
`
|
||||
|
||||
Write(`${sessionFolder}/summary.md`, summaryReport)
|
||||
console.log(`\n报告已保存: ${sessionFolder}/summary.md`)
|
||||
```
|
||||
|
||||
### Step 3: 询问后续扩展
|
||||
|
||||
```javascript
|
||||
console.log('\n' + '═'.repeat(60))
|
||||
console.log(' 任务已完成')
|
||||
console.log('═'.repeat(60))
|
||||
|
||||
const expansionResponse = await AskUserQuestion({
|
||||
questions: [{
|
||||
question: "是否将发现扩展为 Issue?",
|
||||
header: "扩展选项",
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: "测试 (Test)", description: "添加更多测试用例" },
|
||||
{ label: "增强 (Enhance)", description: "功能增强建议" },
|
||||
{ label: "重构 (Refactor)", description: "代码重构建议" },
|
||||
{ label: "文档 (Doc)", description: "文档更新需求" },
|
||||
{ label: "否,直接完成", description: "不创建 Issue" }
|
||||
]
|
||||
}]
|
||||
})
|
||||
|
||||
const selectedExpansions = expansionResponse["扩展选项"]
|
||||
|
||||
if (selectedExpansions && !selectedExpansions.includes("否,直接完成")) {
|
||||
for (const expansion of selectedExpansions) {
|
||||
const dimension = expansion.split(' ')[0].toLowerCase()
|
||||
const issueSummary = `${state.task_description} - ${dimension}`
|
||||
|
||||
console.log(`\n创建 Issue: ${issueSummary}`)
|
||||
|
||||
// 调用 /issue:new 创建 issue
|
||||
await Bash({
|
||||
command: `/issue:new "${issueSummary}"`,
|
||||
run_in_background: false
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: 最终输出
|
||||
|
||||
```javascript
|
||||
console.log(`
|
||||
═══════════════════════════════════════════════════════════
|
||||
✅ CCW Loop 会话完成
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
会话 ID: ${state.session_id}
|
||||
用时: ${formatDuration(stats.duration)}
|
||||
迭代: ${stats.iterations}
|
||||
|
||||
开发: ${stats.develop.completed_tasks}/${stats.develop.total_tasks} 任务完成
|
||||
调试: ${stats.debug.iterations} 次迭代
|
||||
验证: ${stats.validate.passed ? '通过 ✅' : '未通过 ❌'}
|
||||
|
||||
报告: ${sessionFolder}/summary.md
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
`)
|
||||
```
|
||||
|
||||
## State Updates
|
||||
|
||||
```javascript
|
||||
return {
|
||||
stateUpdates: {
|
||||
status: 'completed',
|
||||
completed_at: getUtc8ISOString(),
|
||||
summary: stats
|
||||
},
|
||||
continue: false,
|
||||
message: `会话 ${state.session_id} 已完成`
|
||||
}
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
```javascript
|
||||
function formatDuration(ms) {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
} else {
|
||||
return `${seconds}s`
|
||||
}
|
||||
}
|
||||
|
||||
function generateRecommendations(stats, state) {
|
||||
const recommendations = []
|
||||
|
||||
if (stats.develop.completion_rate < 100) {
|
||||
recommendations.push('- 完成剩余开发任务')
|
||||
}
|
||||
|
||||
if (!stats.validate.passed) {
|
||||
recommendations.push('- 修复失败的测试')
|
||||
}
|
||||
|
||||
if (stats.validate.coverage && stats.validate.coverage < 80) {
|
||||
recommendations.push(`- 提高测试覆盖率 (当前: ${stats.validate.coverage}%)`)
|
||||
}
|
||||
|
||||
if (stats.debug.iterations > 3 && !stats.debug.root_cause_found) {
|
||||
recommendations.push('- 考虑代码重构以简化调试')
|
||||
}
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push('- 考虑代码审查')
|
||||
recommendations.push('- 更新相关文档')
|
||||
recommendations.push('- 准备部署')
|
||||
}
|
||||
|
||||
return recommendations.join('\n')
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error Type | Recovery |
|
||||
|------------|----------|
|
||||
| 报告生成失败 | 显示基本统计,跳过文件写入 |
|
||||
| Issue 创建失败 | 记录错误,继续完成 |
|
||||
|
||||
## Next Actions
|
||||
|
||||
- 无 (终止状态)
|
||||
- 如需继续: 使用 `ccw-loop --resume {session-id}` 重新打开会话
|
||||
@@ -1,485 +0,0 @@
|
||||
# Action: Debug With File
|
||||
|
||||
假设驱动调试,记录理解演变到 understanding.md,支持 Gemini 辅助分析和假设生成。
|
||||
|
||||
## Purpose
|
||||
|
||||
执行假设驱动的调试流程,包括:
|
||||
- 定位错误源
|
||||
- 生成可测试假设
|
||||
- 添加 NDJSON 日志
|
||||
- 分析日志证据
|
||||
- 纠正错误理解
|
||||
- 应用修复
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [ ] state.initialized === true
|
||||
- [ ] state.status === 'running'
|
||||
|
||||
## Session Setup
|
||||
|
||||
```javascript
|
||||
const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const sessionFolder = `.workflow/.loop/${state.session_id}`
|
||||
const debugFolder = `${sessionFolder}/debug`
|
||||
const understandingPath = `${debugFolder}/understanding.md`
|
||||
const hypothesesPath = `${debugFolder}/hypotheses.json`
|
||||
const debugLogPath = `${debugFolder}/debug.log`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mode Detection
|
||||
|
||||
```javascript
|
||||
// 自动检测模式
|
||||
const understandingExists = fs.existsSync(understandingPath)
|
||||
const logHasContent = fs.existsSync(debugLogPath) && fs.statSync(debugLogPath).size > 0
|
||||
|
||||
const debugMode = logHasContent ? 'analyze' : (understandingExists ? 'continue' : 'explore')
|
||||
|
||||
console.log(`Debug mode: ${debugMode}`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Explore Mode (首次调试)
|
||||
|
||||
### Step 1.1: 定位错误源
|
||||
|
||||
```javascript
|
||||
if (debugMode === 'explore') {
|
||||
// 询问用户 bug 描述
|
||||
const bugInput = await AskUserQuestion({
|
||||
questions: [{
|
||||
question: "请描述遇到的 bug 或错误信息:",
|
||||
header: "Bug 描述",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "手动输入", description: "输入错误描述或堆栈" },
|
||||
{ label: "从测试失败", description: "从验证阶段的失败测试中获取" }
|
||||
]
|
||||
}]
|
||||
})
|
||||
|
||||
const bugDescription = bugInput["Bug 描述"]
|
||||
|
||||
// 提取关键词并搜索
|
||||
const searchResults = await Task({
|
||||
subagent_type: 'Explore',
|
||||
run_in_background: false,
|
||||
prompt: `Search codebase for error patterns related to: ${bugDescription}`
|
||||
})
|
||||
|
||||
// 分析搜索结果,识别受影响的位置
|
||||
const affectedLocations = analyzeSearchResults(searchResults)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 1.2: 记录初始理解
|
||||
|
||||
```javascript
|
||||
// 创建 understanding.md
|
||||
const initialUnderstanding = `# Understanding Document
|
||||
|
||||
**Session ID**: ${state.session_id}
|
||||
**Bug Description**: ${bugDescription}
|
||||
**Started**: ${getUtc8ISOString()}
|
||||
|
||||
---
|
||||
|
||||
## Exploration Timeline
|
||||
|
||||
### Iteration 1 - Initial Exploration (${getUtc8ISOString()})
|
||||
|
||||
#### Current Understanding
|
||||
|
||||
Based on bug description and initial code search:
|
||||
|
||||
- Error pattern: ${errorPattern}
|
||||
- Affected areas: ${affectedLocations.map(l => l.file).join(', ')}
|
||||
- Initial hypothesis: ${initialThoughts}
|
||||
|
||||
#### Evidence from Code Search
|
||||
|
||||
${searchResults.map(r => `
|
||||
**Keyword: "${r.keyword}"**
|
||||
- Found in: ${r.files.join(', ')}
|
||||
- Key findings: ${r.insights}
|
||||
`).join('\n')}
|
||||
|
||||
#### Next Steps
|
||||
|
||||
- Generate testable hypotheses
|
||||
- Add instrumentation
|
||||
- Await reproduction
|
||||
|
||||
---
|
||||
|
||||
## Current Consolidated Understanding
|
||||
|
||||
${initialConsolidatedUnderstanding}
|
||||
`
|
||||
|
||||
Write(understandingPath, initialUnderstanding)
|
||||
```
|
||||
|
||||
### Step 1.3: Gemini 辅助假设生成
|
||||
|
||||
```bash
|
||||
ccw cli -p "
|
||||
PURPOSE: Generate debugging hypotheses for: ${bugDescription}
|
||||
Success criteria: Testable hypotheses with clear evidence criteria
|
||||
|
||||
TASK:
|
||||
• Analyze error pattern and code search results
|
||||
• Identify 3-5 most likely root causes
|
||||
• For each hypothesis, specify:
|
||||
- What might be wrong
|
||||
- What evidence would confirm/reject it
|
||||
- Where to add instrumentation
|
||||
• Rank by likelihood
|
||||
|
||||
MODE: analysis
|
||||
|
||||
CONTEXT: @${understandingPath} | Search results in understanding.md
|
||||
|
||||
EXPECTED:
|
||||
- Structured hypothesis list (JSON format)
|
||||
- Each hypothesis with: id, description, testable_condition, logging_point, evidence_criteria
|
||||
- Likelihood ranking (1=most likely)
|
||||
|
||||
CONSTRAINTS: Focus on testable conditions
|
||||
" --tool gemini --mode analysis --rule analysis-diagnose-bug-root-cause
|
||||
```
|
||||
|
||||
### Step 1.4: 保存假设
|
||||
|
||||
```javascript
|
||||
const hypotheses = {
|
||||
iteration: 1,
|
||||
timestamp: getUtc8ISOString(),
|
||||
bug_description: bugDescription,
|
||||
hypotheses: [
|
||||
{
|
||||
id: "H1",
|
||||
description: "...",
|
||||
testable_condition: "...",
|
||||
logging_point: "file.ts:func:42",
|
||||
evidence_criteria: {
|
||||
confirm: "...",
|
||||
reject: "..."
|
||||
},
|
||||
likelihood: 1,
|
||||
status: "pending"
|
||||
}
|
||||
// ...
|
||||
],
|
||||
gemini_insights: "...",
|
||||
corrected_assumptions: []
|
||||
}
|
||||
|
||||
Write(hypothesesPath, JSON.stringify(hypotheses, null, 2))
|
||||
```
|
||||
|
||||
### Step 1.5: 添加 NDJSON 日志
|
||||
|
||||
```javascript
|
||||
// 为每个假设添加日志点
|
||||
for (const hypothesis of hypotheses.hypotheses) {
|
||||
const [file, func, line] = hypothesis.logging_point.split(':')
|
||||
|
||||
const logStatement = `console.log(JSON.stringify({
|
||||
hid: "${hypothesis.id}",
|
||||
ts: Date.now(),
|
||||
func: "${func}",
|
||||
data: { /* 相关数据 */ }
|
||||
}))`
|
||||
|
||||
// 使用 Edit 工具添加日志
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Analyze Mode (有日志后)
|
||||
|
||||
### Step 2.1: 解析调试日志
|
||||
|
||||
```javascript
|
||||
if (debugMode === 'analyze') {
|
||||
// 读取 NDJSON 日志
|
||||
const logContent = Read(debugLogPath)
|
||||
const entries = logContent.split('\n')
|
||||
.filter(l => l.trim())
|
||||
.map(l => JSON.parse(l))
|
||||
|
||||
// 按假设分组
|
||||
const byHypothesis = groupBy(entries, 'hid')
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2.2: Gemini 辅助证据分析
|
||||
|
||||
```bash
|
||||
ccw cli -p "
|
||||
PURPOSE: Analyze debug log evidence to validate/correct hypotheses for: ${bugDescription}
|
||||
Success criteria: Clear verdict per hypothesis + corrected understanding
|
||||
|
||||
TASK:
|
||||
• Parse log entries by hypothesis
|
||||
• Evaluate evidence against expected criteria
|
||||
• Determine verdict: confirmed | rejected | inconclusive
|
||||
• Identify incorrect assumptions from previous understanding
|
||||
• Suggest corrections to understanding
|
||||
|
||||
MODE: analysis
|
||||
|
||||
CONTEXT:
|
||||
@${debugLogPath}
|
||||
@${understandingPath}
|
||||
@${hypothesesPath}
|
||||
|
||||
EXPECTED:
|
||||
- Per-hypothesis verdict with reasoning
|
||||
- Evidence summary
|
||||
- List of incorrect assumptions with corrections
|
||||
- Updated consolidated understanding
|
||||
- Root cause if confirmed, or next investigation steps
|
||||
|
||||
CONSTRAINTS: Evidence-based reasoning only, no speculation
|
||||
" --tool gemini --mode analysis --rule analysis-diagnose-bug-root-cause
|
||||
```
|
||||
|
||||
### Step 2.3: 更新理解文档
|
||||
|
||||
```javascript
|
||||
// 追加新迭代到 understanding.md
|
||||
const iteration = state.debug.iteration + 1
|
||||
|
||||
const analysisEntry = `
|
||||
### Iteration ${iteration} - Evidence Analysis (${getUtc8ISOString()})
|
||||
|
||||
#### Log Analysis Results
|
||||
|
||||
${results.map(r => `
|
||||
**${r.id}**: ${r.verdict.toUpperCase()}
|
||||
- Evidence: ${JSON.stringify(r.evidence)}
|
||||
- Reasoning: ${r.reason}
|
||||
`).join('\n')}
|
||||
|
||||
#### Corrected Understanding
|
||||
|
||||
Previous misunderstandings identified and corrected:
|
||||
|
||||
${corrections.map(c => `
|
||||
- ~~${c.wrong}~~ → ${c.corrected}
|
||||
- Why wrong: ${c.reason}
|
||||
- Evidence: ${c.evidence}
|
||||
`).join('\n')}
|
||||
|
||||
#### New Insights
|
||||
|
||||
${newInsights.join('\n- ')}
|
||||
|
||||
#### Gemini Analysis
|
||||
|
||||
${geminiAnalysis}
|
||||
|
||||
${confirmedHypothesis ? `
|
||||
#### Root Cause Identified
|
||||
|
||||
**${confirmedHypothesis.id}**: ${confirmedHypothesis.description}
|
||||
|
||||
Evidence supporting this conclusion:
|
||||
${confirmedHypothesis.supportingEvidence}
|
||||
` : `
|
||||
#### Next Steps
|
||||
|
||||
${nextSteps}
|
||||
`}
|
||||
|
||||
---
|
||||
|
||||
## Current Consolidated Understanding (Updated)
|
||||
|
||||
### What We Know
|
||||
|
||||
- ${validUnderstanding1}
|
||||
- ${validUnderstanding2}
|
||||
|
||||
### What Was Disproven
|
||||
|
||||
- ~~${wrongAssumption}~~ (Evidence: ${disproofEvidence})
|
||||
|
||||
### Current Investigation Focus
|
||||
|
||||
${currentFocus}
|
||||
|
||||
### Remaining Questions
|
||||
|
||||
- ${openQuestion1}
|
||||
- ${openQuestion2}
|
||||
`
|
||||
|
||||
const existingContent = Read(understandingPath)
|
||||
Write(understandingPath, existingContent + analysisEntry)
|
||||
```
|
||||
|
||||
### Step 2.4: 更新假设状态
|
||||
|
||||
```javascript
|
||||
const hypothesesData = JSON.parse(Read(hypothesesPath))
|
||||
|
||||
// 更新假设状态
|
||||
hypothesesData.hypotheses = hypothesesData.hypotheses.map(h => ({
|
||||
...h,
|
||||
status: results.find(r => r.id === h.id)?.verdict || h.status,
|
||||
evidence: results.find(r => r.id === h.id)?.evidence || h.evidence,
|
||||
verdict_reason: results.find(r => r.id === h.id)?.reason || h.verdict_reason
|
||||
}))
|
||||
|
||||
hypothesesData.iteration++
|
||||
hypothesesData.timestamp = getUtc8ISOString()
|
||||
|
||||
Write(hypothesesPath, JSON.stringify(hypothesesData, null, 2))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fix & Verification
|
||||
|
||||
### Step 3.1: 应用修复
|
||||
|
||||
```javascript
|
||||
if (confirmedHypothesis) {
|
||||
console.log(`\n根因确认: ${confirmedHypothesis.description}`)
|
||||
console.log('准备应用修复...')
|
||||
|
||||
// 使用 Gemini 生成修复代码
|
||||
const fixPrompt = `
|
||||
PURPOSE: Fix the identified root cause
|
||||
Root Cause: ${confirmedHypothesis.description}
|
||||
Evidence: ${confirmedHypothesis.supportingEvidence}
|
||||
|
||||
TASK:
|
||||
• Generate fix code
|
||||
• Ensure backward compatibility
|
||||
• Add tests if needed
|
||||
|
||||
MODE: write
|
||||
|
||||
CONTEXT: @${confirmedHypothesis.logging_point.split(':')[0]}
|
||||
|
||||
EXPECTED: Fixed code + verification steps
|
||||
`
|
||||
|
||||
await Bash({
|
||||
command: `ccw cli -p "${fixPrompt}" --tool gemini --mode write --rule development-debug-runtime-issues`,
|
||||
run_in_background: false
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3.2: 记录解决方案
|
||||
|
||||
```javascript
|
||||
const resolutionEntry = `
|
||||
### Resolution (${getUtc8ISOString()})
|
||||
|
||||
#### Fix Applied
|
||||
|
||||
- Modified files: ${modifiedFiles.join(', ')}
|
||||
- Fix description: ${fixDescription}
|
||||
- Root cause addressed: ${rootCause}
|
||||
|
||||
#### Verification Results
|
||||
|
||||
${verificationResults}
|
||||
|
||||
#### Lessons Learned
|
||||
|
||||
1. ${lesson1}
|
||||
2. ${lesson2}
|
||||
|
||||
#### Key Insights for Future
|
||||
|
||||
- ${insight1}
|
||||
- ${insight2}
|
||||
`
|
||||
|
||||
const existingContent = Read(understandingPath)
|
||||
Write(understandingPath, existingContent + resolutionEntry)
|
||||
```
|
||||
|
||||
### Step 3.3: 清理日志
|
||||
|
||||
```javascript
|
||||
// 移除调试日志
|
||||
// (可选,根据用户选择)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Updates
|
||||
|
||||
```javascript
|
||||
return {
|
||||
stateUpdates: {
|
||||
debug: {
|
||||
current_bug: bugDescription,
|
||||
hypotheses: hypothesesData.hypotheses,
|
||||
confirmed_hypothesis: confirmedHypothesis?.id || null,
|
||||
iteration: hypothesesData.iteration,
|
||||
last_analysis_at: getUtc8ISOString(),
|
||||
understanding_updated: true
|
||||
},
|
||||
last_action: 'action-debug-with-file'
|
||||
},
|
||||
continue: true,
|
||||
message: confirmedHypothesis
|
||||
? `根因确认: ${confirmedHypothesis.description}\n修复已应用,请验证`
|
||||
: `分析完成,需要更多证据\n请复现 bug 后再次执行`
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error Type | Recovery |
|
||||
|------------|----------|
|
||||
| 空 debug.log | 提示用户复现 bug |
|
||||
| 所有假设被否定 | 使用 Gemini 生成新假设 |
|
||||
| 修复无效 | 记录失败尝试,迭代 |
|
||||
| >5 迭代 | 建议升级到 /workflow:lite-fix |
|
||||
| Gemini 不可用 | 回退到手动分析 |
|
||||
|
||||
## Understanding Document Template
|
||||
|
||||
参考 [templates/understanding-template.md](../../templates/understanding-template.md)
|
||||
|
||||
## CLI Integration
|
||||
|
||||
### 假设生成
|
||||
```bash
|
||||
ccw cli -p "PURPOSE: Generate debugging hypotheses..." --tool gemini --mode analysis --rule analysis-diagnose-bug-root-cause
|
||||
```
|
||||
|
||||
### 证据分析
|
||||
```bash
|
||||
ccw cli -p "PURPOSE: Analyze debug log evidence..." --tool gemini --mode analysis --rule analysis-diagnose-bug-root-cause
|
||||
```
|
||||
|
||||
### 生成修复
|
||||
```bash
|
||||
ccw cli -p "PURPOSE: Fix the identified root cause..." --tool gemini --mode write --rule development-debug-runtime-issues
|
||||
```
|
||||
|
||||
## Next Actions (Hints)
|
||||
|
||||
- 根因确认: `action-validate-with-file` (验证修复)
|
||||
- 需要更多证据: 等待用户复现,再次执行此动作
|
||||
- 所有假设否定: 重新执行此动作生成新假设
|
||||
- 用户选择: `action-menu` (返回菜单)
|
||||
@@ -1,365 +0,0 @@
|
||||
# Action: Develop With File
|
||||
|
||||
增量开发任务执行,记录进度到 progress.md,支持 Gemini 辅助实现。
|
||||
|
||||
## Purpose
|
||||
|
||||
执行开发任务并记录进度,包括:
|
||||
- 分析任务需求
|
||||
- 使用 Gemini/CLI 实现代码
|
||||
- 记录代码变更
|
||||
- 更新进度文档
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [ ] state.status === 'running'
|
||||
- [ ] state.skill_state !== null
|
||||
- [ ] state.skill_state.develop.tasks.some(t => t.status === 'pending')
|
||||
|
||||
## Session Setup (Unified Location)
|
||||
|
||||
```javascript
|
||||
const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
// 统一位置: .loop/{loopId}
|
||||
const loopId = state.loop_id
|
||||
const loopFile = `.loop/${loopId}.json`
|
||||
const progressDir = `.loop/${loopId}.progress`
|
||||
const progressPath = `${progressDir}/develop.md`
|
||||
const changesLogPath = `${progressDir}/changes.log`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 0: Check Control Signals (CRITICAL)
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* CRITICAL: 每个 Action 必须在开始时检查控制信号
|
||||
* 如果 API 设置了 paused/stopped,Skill 应立即退出
|
||||
*/
|
||||
function checkControlSignals(loopId) {
|
||||
const state = JSON.parse(Read(`.loop/${loopId}.json`))
|
||||
|
||||
switch (state.status) {
|
||||
case 'paused':
|
||||
console.log('⏸️ Loop paused by API. Exiting action.')
|
||||
return { continue: false, reason: 'paused' }
|
||||
|
||||
case 'failed':
|
||||
console.log('⏹️ Loop stopped by API. Exiting action.')
|
||||
return { continue: false, reason: 'stopped' }
|
||||
|
||||
case 'running':
|
||||
return { continue: true, reason: 'running' }
|
||||
|
||||
default:
|
||||
return { continue: false, reason: 'unknown_status' }
|
||||
}
|
||||
}
|
||||
|
||||
// Execute check
|
||||
const control = checkControlSignals(loopId)
|
||||
if (!control.continue) {
|
||||
return {
|
||||
skillStateUpdates: { current_action: null },
|
||||
continue: false,
|
||||
message: `Action terminated: ${control.reason}`
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 1: 加载任务列表
|
||||
|
||||
```javascript
|
||||
// 读取任务列表 (从 skill_state)
|
||||
let tasks = state.skill_state?.develop?.tasks || []
|
||||
|
||||
// 如果任务列表为空,询问用户创建
|
||||
if (tasks.length === 0) {
|
||||
// 使用 Gemini 分析任务描述,生成任务列表
|
||||
const analysisPrompt = `
|
||||
PURPOSE: 分析开发任务并分解为可执行步骤
|
||||
Success: 生成 3-7 个具体、可验证的子任务
|
||||
|
||||
TASK:
|
||||
• 分析任务描述: ${state.task_description}
|
||||
• 识别关键功能点
|
||||
• 分解为独立子任务
|
||||
• 为每个子任务指定工具和模式
|
||||
|
||||
MODE: analysis
|
||||
|
||||
CONTEXT: @package.json @src/**/*.ts | Memory: 项目结构
|
||||
|
||||
EXPECTED:
|
||||
JSON 格式:
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-001",
|
||||
"description": "任务描述",
|
||||
"tool": "gemini",
|
||||
"mode": "write",
|
||||
"files": ["src/xxx.ts"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
const result = await Task({
|
||||
subagent_type: 'cli-execution-agent',
|
||||
run_in_background: false,
|
||||
prompt: `Execute Gemini CLI with prompt: ${analysisPrompt}`
|
||||
})
|
||||
|
||||
tasks = JSON.parse(result).tasks
|
||||
}
|
||||
|
||||
// 找到第一个待处理任务
|
||||
const currentTask = tasks.find(t => t.status === 'pending')
|
||||
|
||||
if (!currentTask) {
|
||||
return {
|
||||
skillStateUpdates: {
|
||||
develop: { ...state.skill_state.develop, current_task: null }
|
||||
},
|
||||
continue: true,
|
||||
message: '所有开发任务已完成'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: 执行开发任务
|
||||
|
||||
```javascript
|
||||
console.log(`\n执行任务: ${currentTask.description}`)
|
||||
|
||||
// 更新任务状态
|
||||
currentTask.status = 'in_progress'
|
||||
|
||||
// 使用 Gemini 实现
|
||||
const implementPrompt = `
|
||||
PURPOSE: 实现开发任务
|
||||
Task: ${currentTask.description}
|
||||
Success criteria: 代码实现完成,测试通过
|
||||
|
||||
TASK:
|
||||
• 分析现有代码结构
|
||||
• 实现功能代码
|
||||
• 添加必要的类型定义
|
||||
• 确保代码风格一致
|
||||
|
||||
MODE: write
|
||||
|
||||
CONTEXT: @${currentTask.files?.join(' @') || 'src/**/*.ts'}
|
||||
|
||||
EXPECTED:
|
||||
- 完整的代码实现
|
||||
- 代码变更列表
|
||||
- 简要实现说明
|
||||
|
||||
CONSTRAINTS: 遵循现有代码风格 | 不破坏现有功能
|
||||
`
|
||||
|
||||
const implementResult = await Bash({
|
||||
command: `ccw cli -p "${implementPrompt}" --tool gemini --mode write --rule development-implement-feature`,
|
||||
run_in_background: false
|
||||
})
|
||||
|
||||
// 记录代码变更
|
||||
const timestamp = getUtc8ISOString()
|
||||
const changeEntry = {
|
||||
timestamp,
|
||||
task_id: currentTask.id,
|
||||
description: currentTask.description,
|
||||
files_changed: currentTask.files || [],
|
||||
result: 'success'
|
||||
}
|
||||
|
||||
// 追加到 changes.log (NDJSON 格式)
|
||||
const changesContent = Read(changesLogPath) || ''
|
||||
Write(changesLogPath, changesContent + JSON.stringify(changeEntry) + '\n')
|
||||
```
|
||||
|
||||
### Step 3: 更新进度文档
|
||||
|
||||
```javascript
|
||||
const timestamp = getUtc8ISOString()
|
||||
const iteration = state.develop.completed_count + 1
|
||||
|
||||
// 读取现有进度文档
|
||||
let progressContent = Read(progressPath) || ''
|
||||
|
||||
// 如果是新文档,添加头部
|
||||
if (!progressContent) {
|
||||
progressContent = `# Development Progress
|
||||
|
||||
**Session ID**: ${state.session_id}
|
||||
**Task**: ${state.task_description}
|
||||
**Started**: ${timestamp}
|
||||
|
||||
---
|
||||
|
||||
## Progress Timeline
|
||||
|
||||
`
|
||||
}
|
||||
|
||||
// 追加本次进度
|
||||
const progressEntry = `
|
||||
### Iteration ${iteration} - ${currentTask.description} (${timestamp})
|
||||
|
||||
#### Task Details
|
||||
|
||||
- **ID**: ${currentTask.id}
|
||||
- **Tool**: ${currentTask.tool}
|
||||
- **Mode**: ${currentTask.mode}
|
||||
|
||||
#### Implementation Summary
|
||||
|
||||
${implementResult.summary || '实现完成'}
|
||||
|
||||
#### Files Changed
|
||||
|
||||
${currentTask.files?.map(f => `- \`${f}\``).join('\n') || '- No files specified'}
|
||||
|
||||
#### Status: COMPLETED
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
Write(progressPath, progressContent + progressEntry)
|
||||
|
||||
// 更新任务状态
|
||||
currentTask.status = 'completed'
|
||||
currentTask.completed_at = timestamp
|
||||
```
|
||||
|
||||
### Step 4: 更新任务列表文件
|
||||
|
||||
```javascript
|
||||
// 更新 tasks.json
|
||||
const updatedTasks = tasks.map(t =>
|
||||
t.id === currentTask.id ? currentTask : t
|
||||
)
|
||||
|
||||
Write(tasksPath, JSON.stringify(updatedTasks, null, 2))
|
||||
```
|
||||
|
||||
## State Updates
|
||||
|
||||
```javascript
|
||||
return {
|
||||
stateUpdates: {
|
||||
develop: {
|
||||
tasks: updatedTasks,
|
||||
current_task_id: null,
|
||||
completed_count: state.develop.completed_count + 1,
|
||||
total_count: updatedTasks.length,
|
||||
last_progress_at: getUtc8ISOString()
|
||||
},
|
||||
last_action: 'action-develop-with-file'
|
||||
},
|
||||
continue: true,
|
||||
message: `任务完成: ${currentTask.description}\n进度: ${state.develop.completed_count + 1}/${updatedTasks.length}`
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error Type | Recovery |
|
||||
|------------|----------|
|
||||
| Gemini CLI 失败 | 提示用户手动实现,记录到 progress.md |
|
||||
| 文件写入失败 | 重试一次,失败则记录错误 |
|
||||
| 任务解析失败 | 询问用户手动输入任务 |
|
||||
|
||||
## Progress Document Template
|
||||
|
||||
```markdown
|
||||
# Development Progress
|
||||
|
||||
**Session ID**: LOOP-xxx-2026-01-22
|
||||
**Task**: 实现用户认证功能
|
||||
**Started**: 2026-01-22T10:00:00+08:00
|
||||
|
||||
---
|
||||
|
||||
## Progress Timeline
|
||||
|
||||
### Iteration 1 - 分析登录组件 (2026-01-22T10:05:00+08:00)
|
||||
|
||||
#### Task Details
|
||||
|
||||
- **ID**: task-001
|
||||
- **Tool**: gemini
|
||||
- **Mode**: analysis
|
||||
|
||||
#### Implementation Summary
|
||||
|
||||
分析了现有登录组件结构,识别了需要修改的文件和依赖关系。
|
||||
|
||||
#### Files Changed
|
||||
|
||||
- `src/components/Login.tsx`
|
||||
- `src/hooks/useAuth.ts`
|
||||
|
||||
#### Status: COMPLETED
|
||||
|
||||
---
|
||||
|
||||
### Iteration 2 - 实现登录 API (2026-01-22T10:15:00+08:00)
|
||||
|
||||
...
|
||||
|
||||
---
|
||||
|
||||
## Current Statistics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tasks | 5 |
|
||||
| Completed | 2 |
|
||||
| In Progress | 1 |
|
||||
| Pending | 2 |
|
||||
| Progress | 40% |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] 完成剩余任务
|
||||
- [ ] 运行测试
|
||||
- [ ] 代码审查
|
||||
```
|
||||
|
||||
## CLI Integration
|
||||
|
||||
### 任务分析
|
||||
```bash
|
||||
ccw cli -p "PURPOSE: 分解开发任务为子任务
|
||||
TASK: • 分析任务描述 • 识别功能点 • 生成任务列表
|
||||
MODE: analysis
|
||||
CONTEXT: @package.json @src/**/*
|
||||
EXPECTED: JSON 任务列表
|
||||
" --tool gemini --mode analysis --rule planning-breakdown-task-steps
|
||||
```
|
||||
|
||||
### 代码实现
|
||||
```bash
|
||||
ccw cli -p "PURPOSE: 实现功能代码
|
||||
TASK: • 分析需求 • 编写代码 • 添加类型
|
||||
MODE: write
|
||||
CONTEXT: @src/xxx.ts
|
||||
EXPECTED: 完整实现
|
||||
" --tool gemini --mode write --rule development-implement-feature
|
||||
```
|
||||
|
||||
## Next Actions (Hints)
|
||||
|
||||
- 所有任务完成: `action-debug-with-file` (开始调试)
|
||||
- 任务失败: `action-develop-with-file` (重试或下一个任务)
|
||||
- 用户选择: `action-menu` (返回菜单)
|
||||
@@ -1,200 +0,0 @@
|
||||
# Action: Initialize
|
||||
|
||||
初始化 CCW Loop 会话,创建目录结构和初始状态。
|
||||
|
||||
## Purpose
|
||||
|
||||
- 创建会话目录结构
|
||||
- 初始化状态文件
|
||||
- 分析任务描述生成初始任务列表
|
||||
- 准备执行环境
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [ ] state.status === 'pending'
|
||||
- [ ] state.initialized === false
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: 创建目录结构
|
||||
|
||||
```javascript
|
||||
const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const taskSlug = state.task_description.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 30)
|
||||
const dateStr = getUtc8ISOString().substring(0, 10)
|
||||
const sessionId = `LOOP-${taskSlug}-${dateStr}`
|
||||
const sessionFolder = `.workflow/.loop/${sessionId}`
|
||||
|
||||
Bash(`mkdir -p "${sessionFolder}/develop"`)
|
||||
Bash(`mkdir -p "${sessionFolder}/debug"`)
|
||||
Bash(`mkdir -p "${sessionFolder}/validate"`)
|
||||
|
||||
console.log(`Session created: ${sessionId}`)
|
||||
console.log(`Location: ${sessionFolder}`)
|
||||
```
|
||||
|
||||
### Step 2: 创建元数据文件
|
||||
|
||||
```javascript
|
||||
const meta = {
|
||||
session_id: sessionId,
|
||||
task_description: state.task_description,
|
||||
created_at: getUtc8ISOString(),
|
||||
mode: state.mode || 'interactive'
|
||||
}
|
||||
|
||||
Write(`${sessionFolder}/meta.json`, JSON.stringify(meta, null, 2))
|
||||
```
|
||||
|
||||
### Step 3: 分析任务生成开发任务列表
|
||||
|
||||
```javascript
|
||||
// 使用 Gemini 分析任务描述
|
||||
console.log('\n分析任务描述...')
|
||||
|
||||
const analysisPrompt = `
|
||||
PURPOSE: 分析开发任务并分解为可执行步骤
|
||||
Success: 生成 3-7 个具体、可验证的子任务
|
||||
|
||||
TASK:
|
||||
• 分析任务描述: ${state.task_description}
|
||||
• 识别关键功能点
|
||||
• 分解为独立子任务
|
||||
• 为每个子任务指定工具和模式
|
||||
|
||||
MODE: analysis
|
||||
|
||||
CONTEXT: @package.json @src/**/*.ts (如存在)
|
||||
|
||||
EXPECTED:
|
||||
JSON 格式:
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-001",
|
||||
"description": "任务描述",
|
||||
"tool": "gemini",
|
||||
"mode": "write",
|
||||
"priority": 1
|
||||
}
|
||||
],
|
||||
"estimated_complexity": "low|medium|high",
|
||||
"key_files": ["file1.ts", "file2.ts"]
|
||||
}
|
||||
|
||||
CONSTRAINTS: 生成实际可执行的任务
|
||||
`
|
||||
|
||||
const result = await Bash({
|
||||
command: `ccw cli -p "${analysisPrompt}" --tool gemini --mode analysis --rule planning-breakdown-task-steps`,
|
||||
run_in_background: false
|
||||
})
|
||||
|
||||
const analysis = JSON.parse(result.stdout)
|
||||
const tasks = analysis.tasks.map((t, i) => ({
|
||||
...t,
|
||||
id: t.id || `task-${String(i + 1).padStart(3, '0')}`,
|
||||
status: 'pending',
|
||||
created_at: getUtc8ISOString(),
|
||||
completed_at: null,
|
||||
files_changed: []
|
||||
}))
|
||||
|
||||
// 保存任务列表
|
||||
Write(`${sessionFolder}/develop/tasks.json`, JSON.stringify(tasks, null, 2))
|
||||
```
|
||||
|
||||
### Step 4: 初始化进度文档
|
||||
|
||||
```javascript
|
||||
const progressInitial = `# Development Progress
|
||||
|
||||
**Session ID**: ${sessionId}
|
||||
**Task**: ${state.task_description}
|
||||
**Started**: ${getUtc8ISOString()}
|
||||
**Estimated Complexity**: ${analysis.estimated_complexity}
|
||||
|
||||
---
|
||||
|
||||
## Task List
|
||||
|
||||
${tasks.map((t, i) => `${i + 1}. [ ] ${t.description}`).join('\n')}
|
||||
|
||||
## Key Files
|
||||
|
||||
${analysis.key_files?.map(f => `- \`${f}\``).join('\n') || '- To be determined'}
|
||||
|
||||
---
|
||||
|
||||
## Progress Timeline
|
||||
|
||||
`
|
||||
|
||||
Write(`${sessionFolder}/develop/progress.md`, progressInitial)
|
||||
```
|
||||
|
||||
### Step 5: 显示初始化结果
|
||||
|
||||
```javascript
|
||||
console.log(`\n✅ 会话初始化完成`)
|
||||
console.log(`\n任务列表 (${tasks.length} 项):`)
|
||||
tasks.forEach((t, i) => {
|
||||
console.log(` ${i + 1}. ${t.description} [${t.tool}/${t.mode}]`)
|
||||
})
|
||||
console.log(`\n预估复杂度: ${analysis.estimated_complexity}`)
|
||||
console.log(`\n执行 'develop' 开始开发,或 'menu' 查看更多选项`)
|
||||
```
|
||||
|
||||
## State Updates
|
||||
|
||||
```javascript
|
||||
return {
|
||||
stateUpdates: {
|
||||
session_id: sessionId,
|
||||
status: 'running',
|
||||
initialized: true,
|
||||
develop: {
|
||||
tasks: tasks,
|
||||
current_task_id: null,
|
||||
completed_count: 0,
|
||||
total_count: tasks.length,
|
||||
last_progress_at: null
|
||||
},
|
||||
debug: {
|
||||
current_bug: null,
|
||||
hypotheses: [],
|
||||
confirmed_hypothesis: null,
|
||||
iteration: 0,
|
||||
last_analysis_at: null,
|
||||
understanding_updated: false
|
||||
},
|
||||
validate: {
|
||||
test_results: [],
|
||||
coverage: null,
|
||||
passed: false,
|
||||
failed_tests: [],
|
||||
last_run_at: null
|
||||
},
|
||||
context: {
|
||||
estimated_complexity: analysis.estimated_complexity,
|
||||
key_files: analysis.key_files
|
||||
}
|
||||
},
|
||||
continue: true,
|
||||
message: `会话 ${sessionId} 已初始化\n${tasks.length} 个开发任务待执行`
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error Type | Recovery |
|
||||
|------------|----------|
|
||||
| 目录创建失败 | 检查权限,重试 |
|
||||
| Gemini 分析失败 | 提示用户手动输入任务 |
|
||||
| 任务解析失败 | 使用默认任务列表 |
|
||||
|
||||
## Next Actions
|
||||
|
||||
- 成功: `action-menu` (显示操作菜单) 或 `action-develop-with-file` (直接开始开发)
|
||||
- 失败: 报错退出
|
||||
@@ -1,192 +0,0 @@
|
||||
# Action: Menu
|
||||
|
||||
显示交互式操作菜单,让用户选择下一步操作。
|
||||
|
||||
## Purpose
|
||||
|
||||
- 显示当前状态摘要
|
||||
- 提供操作选项
|
||||
- 接收用户选择
|
||||
- 返回下一个动作
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [ ] state.initialized === true
|
||||
- [ ] state.status === 'running'
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: 生成状态摘要
|
||||
|
||||
```javascript
|
||||
const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
// 开发进度
|
||||
const developProgress = state.develop.total_count > 0
|
||||
? `${state.develop.completed_count}/${state.develop.total_count} (${(state.develop.completed_count / state.develop.total_count * 100).toFixed(0)}%)`
|
||||
: '未开始'
|
||||
|
||||
// 调试状态
|
||||
const debugStatus = state.debug.confirmed_hypothesis
|
||||
? `✅ 已确认根因`
|
||||
: state.debug.iteration > 0
|
||||
? `🔍 迭代 ${state.debug.iteration}`
|
||||
: '未开始'
|
||||
|
||||
// 验证状态
|
||||
const validateStatus = state.validate.passed
|
||||
? `✅ 通过`
|
||||
: state.validate.test_results.length > 0
|
||||
? `❌ ${state.validate.failed_tests.length} 个失败`
|
||||
: '未运行'
|
||||
|
||||
const statusSummary = `
|
||||
═══════════════════════════════════════════════════════════
|
||||
CCW Loop - ${state.session_id}
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
任务: ${state.task_description}
|
||||
迭代: ${state.iteration_count}
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 开发 (Develop) │ ${developProgress.padEnd(20)} │
|
||||
│ 调试 (Debug) │ ${debugStatus.padEnd(20)} │
|
||||
│ 验证 (Validate) │ ${validateStatus.padEnd(20)} │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
`
|
||||
|
||||
console.log(statusSummary)
|
||||
```
|
||||
|
||||
### Step 2: 显示操作选项
|
||||
|
||||
```javascript
|
||||
const options = [
|
||||
{
|
||||
label: "📝 继续开发 (Develop)",
|
||||
description: state.develop.completed_count < state.develop.total_count
|
||||
? `执行下一个开发任务`
|
||||
: "所有任务已完成,可添加新任务",
|
||||
action: "action-develop-with-file"
|
||||
},
|
||||
{
|
||||
label: "🔍 开始调试 (Debug)",
|
||||
description: state.debug.iteration > 0
|
||||
? "继续假设驱动调试"
|
||||
: "开始新的调试会话",
|
||||
action: "action-debug-with-file"
|
||||
},
|
||||
{
|
||||
label: "✅ 运行验证 (Validate)",
|
||||
description: "运行测试并检查覆盖率",
|
||||
action: "action-validate-with-file"
|
||||
},
|
||||
{
|
||||
label: "📊 查看详情 (Status)",
|
||||
description: "查看详细进度和文件",
|
||||
action: "action-status"
|
||||
},
|
||||
{
|
||||
label: "🏁 完成循环 (Complete)",
|
||||
description: "结束当前循环",
|
||||
action: "action-complete"
|
||||
},
|
||||
{
|
||||
label: "🚪 退出 (Exit)",
|
||||
description: "保存状态并退出",
|
||||
action: "exit"
|
||||
}
|
||||
]
|
||||
|
||||
const response = await AskUserQuestion({
|
||||
questions: [{
|
||||
question: "选择下一步操作:",
|
||||
header: "操作",
|
||||
multiSelect: false,
|
||||
options: options.map(o => ({
|
||||
label: o.label,
|
||||
description: o.description
|
||||
}))
|
||||
}]
|
||||
})
|
||||
|
||||
const selectedLabel = response["操作"]
|
||||
const selectedOption = options.find(o => o.label === selectedLabel)
|
||||
const nextAction = selectedOption?.action || 'action-menu'
|
||||
```
|
||||
|
||||
### Step 3: 处理特殊选项
|
||||
|
||||
```javascript
|
||||
if (nextAction === 'exit') {
|
||||
console.log('\n保存状态并退出...')
|
||||
return {
|
||||
stateUpdates: {
|
||||
status: 'user_exit'
|
||||
},
|
||||
continue: false,
|
||||
message: '会话已保存,使用 --resume 可继续'
|
||||
}
|
||||
}
|
||||
|
||||
if (nextAction === 'action-status') {
|
||||
// 显示详细状态
|
||||
const sessionFolder = `.workflow/.loop/${state.session_id}`
|
||||
|
||||
console.log('\n=== 开发进度 ===')
|
||||
const progress = Read(`${sessionFolder}/develop/progress.md`)
|
||||
console.log(progress?.substring(0, 500) + '...')
|
||||
|
||||
console.log('\n=== 调试状态 ===')
|
||||
if (state.debug.hypotheses.length > 0) {
|
||||
state.debug.hypotheses.forEach(h => {
|
||||
console.log(` ${h.id}: ${h.status} - ${h.description.substring(0, 50)}...`)
|
||||
})
|
||||
} else {
|
||||
console.log(' 尚未开始调试')
|
||||
}
|
||||
|
||||
console.log('\n=== 验证结果 ===')
|
||||
if (state.validate.test_results.length > 0) {
|
||||
const latest = state.validate.test_results[state.validate.test_results.length - 1]
|
||||
console.log(` 最近运行: ${latest.timestamp}`)
|
||||
console.log(` 通过率: ${latest.summary.pass_rate}%`)
|
||||
} else {
|
||||
console.log(' 尚未运行验证')
|
||||
}
|
||||
|
||||
// 返回菜单
|
||||
return {
|
||||
stateUpdates: {},
|
||||
continue: true,
|
||||
nextAction: 'action-menu',
|
||||
message: ''
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State Updates
|
||||
|
||||
```javascript
|
||||
return {
|
||||
stateUpdates: {
|
||||
// 不更新状态,仅返回下一个动作
|
||||
},
|
||||
continue: true,
|
||||
nextAction: nextAction,
|
||||
message: `执行: ${selectedOption?.label || nextAction}`
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error Type | Recovery |
|
||||
|------------|----------|
|
||||
| 用户取消 | 返回菜单 |
|
||||
| 无效选择 | 重新显示菜单 |
|
||||
|
||||
## Next Actions
|
||||
|
||||
根据用户选择动态决定下一个动作。
|
||||
@@ -1,307 +0,0 @@
|
||||
# Action: Validate With File
|
||||
|
||||
运行测试并验证实现,记录结果到 validation.md,支持 Gemini 辅助分析测试覆盖率和质量。
|
||||
|
||||
## Purpose
|
||||
|
||||
执行测试验证流程,包括:
|
||||
- 运行单元测试
|
||||
- 运行集成测试
|
||||
- 检查代码覆盖率
|
||||
- 生成验证报告
|
||||
- 分析失败原因
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [ ] state.initialized === true
|
||||
- [ ] state.status === 'running'
|
||||
- [ ] state.develop.completed_count > 0 || state.debug.confirmed_hypothesis !== null
|
||||
|
||||
## Session Setup
|
||||
|
||||
```javascript
|
||||
const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const sessionFolder = `.workflow/.loop/${state.session_id}`
|
||||
const validateFolder = `${sessionFolder}/validate`
|
||||
const validationPath = `${validateFolder}/validation.md`
|
||||
const testResultsPath = `${validateFolder}/test-results.json`
|
||||
const coveragePath = `${validateFolder}/coverage.json`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: 运行测试
|
||||
|
||||
```javascript
|
||||
console.log('\n运行测试...')
|
||||
|
||||
// 检测测试框架
|
||||
const packageJson = JSON.parse(Read('package.json'))
|
||||
const testScript = packageJson.scripts?.test || 'npm test'
|
||||
|
||||
// 运行测试并捕获输出
|
||||
const testResult = await Bash({
|
||||
command: testScript,
|
||||
timeout: 300000 // 5分钟
|
||||
})
|
||||
|
||||
// 解析测试输出
|
||||
const testResults = parseTestOutput(testResult.stdout)
|
||||
```
|
||||
|
||||
### Step 2: 检查覆盖率
|
||||
|
||||
```javascript
|
||||
// 运行覆盖率检查
|
||||
let coverageData = null
|
||||
|
||||
if (packageJson.scripts?.['test:coverage']) {
|
||||
const coverageResult = await Bash({
|
||||
command: 'npm run test:coverage',
|
||||
timeout: 300000
|
||||
})
|
||||
|
||||
// 解析覆盖率报告
|
||||
coverageData = parseCoverageReport(coverageResult.stdout)
|
||||
|
||||
Write(coveragePath, JSON.stringify(coverageData, null, 2))
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Gemini 辅助分析
|
||||
|
||||
```bash
|
||||
ccw cli -p "
|
||||
PURPOSE: Analyze test results and coverage
|
||||
Success criteria: Identify quality issues and suggest improvements
|
||||
|
||||
TASK:
|
||||
• Analyze test execution results
|
||||
• Review code coverage metrics
|
||||
• Identify missing test cases
|
||||
• Suggest quality improvements
|
||||
• Verify requirements coverage
|
||||
|
||||
MODE: analysis
|
||||
|
||||
CONTEXT:
|
||||
@${testResultsPath}
|
||||
@${coveragePath}
|
||||
@${sessionFolder}/develop/progress.md
|
||||
|
||||
EXPECTED:
|
||||
- Quality assessment report
|
||||
- Failed tests analysis
|
||||
- Coverage gaps identification
|
||||
- Improvement recommendations
|
||||
- Pass/Fail decision with rationale
|
||||
|
||||
CONSTRAINTS: Evidence-based quality assessment
|
||||
" --tool gemini --mode analysis --rule analysis-review-code-quality
|
||||
```
|
||||
|
||||
### Step 4: 生成验证报告
|
||||
|
||||
```javascript
|
||||
const timestamp = getUtc8ISOString()
|
||||
const iteration = (state.validate.test_results?.length || 0) + 1
|
||||
|
||||
const validationReport = `# Validation Report
|
||||
|
||||
**Session ID**: ${state.session_id}
|
||||
**Task**: ${state.task_description}
|
||||
**Validated**: ${timestamp}
|
||||
|
||||
---
|
||||
|
||||
## Iteration ${iteration} - Validation Run
|
||||
|
||||
### Test Execution Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tests | ${testResults.total} |
|
||||
| Passed | ${testResults.passed} |
|
||||
| Failed | ${testResults.failed} |
|
||||
| Skipped | ${testResults.skipped} |
|
||||
| Duration | ${testResults.duration_ms}ms |
|
||||
| **Pass Rate** | **${(testResults.passed / testResults.total * 100).toFixed(1)}%** |
|
||||
|
||||
### Coverage Report
|
||||
|
||||
${coverageData ? `
|
||||
| File | Statements | Branches | Functions | Lines |
|
||||
|------|------------|----------|-----------|-------|
|
||||
${coverageData.files.map(f => `| ${f.path} | ${f.statements}% | ${f.branches}% | ${f.functions}% | ${f.lines}% |`).join('\n')}
|
||||
|
||||
**Overall Coverage**: ${coverageData.overall.statements}%
|
||||
` : '_No coverage data available_'}
|
||||
|
||||
### Failed Tests
|
||||
|
||||
${testResults.failed > 0 ? `
|
||||
${testResults.failures.map(f => `
|
||||
#### ${f.test_name}
|
||||
|
||||
- **Suite**: ${f.suite}
|
||||
- **Error**: ${f.error_message}
|
||||
- **Stack**:
|
||||
\`\`\`
|
||||
${f.stack_trace}
|
||||
\`\`\`
|
||||
`).join('\n')}
|
||||
` : '_All tests passed_'}
|
||||
|
||||
### Gemini Quality Analysis
|
||||
|
||||
${geminiAnalysis}
|
||||
|
||||
### Recommendations
|
||||
|
||||
${recommendations.map(r => `- ${r}`).join('\n')}
|
||||
|
||||
---
|
||||
|
||||
## Validation Decision
|
||||
|
||||
**Result**: ${testResults.passed === testResults.total ? '✅ PASS' : '❌ FAIL'}
|
||||
|
||||
**Rationale**: ${validationDecision}
|
||||
|
||||
${testResults.passed !== testResults.total ? `
|
||||
### Next Actions
|
||||
|
||||
1. Review failed tests
|
||||
2. Debug failures using action-debug-with-file
|
||||
3. Fix issues and re-run validation
|
||||
` : `
|
||||
### Next Actions
|
||||
|
||||
1. Consider code review
|
||||
2. Prepare for deployment
|
||||
3. Update documentation
|
||||
`}
|
||||
`
|
||||
|
||||
// 写入验证报告
|
||||
Write(validationPath, validationReport)
|
||||
```
|
||||
|
||||
### Step 5: 保存测试结果
|
||||
|
||||
```javascript
|
||||
const testResultsData = {
|
||||
iteration,
|
||||
timestamp,
|
||||
summary: {
|
||||
total: testResults.total,
|
||||
passed: testResults.passed,
|
||||
failed: testResults.failed,
|
||||
skipped: testResults.skipped,
|
||||
pass_rate: (testResults.passed / testResults.total * 100).toFixed(1),
|
||||
duration_ms: testResults.duration_ms
|
||||
},
|
||||
tests: testResults.tests,
|
||||
failures: testResults.failures,
|
||||
coverage: coverageData?.overall || null
|
||||
}
|
||||
|
||||
Write(testResultsPath, JSON.stringify(testResultsData, null, 2))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Updates
|
||||
|
||||
```javascript
|
||||
const validationPassed = testResults.failed === 0 && testResults.passed > 0
|
||||
|
||||
return {
|
||||
stateUpdates: {
|
||||
validate: {
|
||||
test_results: [...(state.validate.test_results || []), testResultsData],
|
||||
coverage: coverageData?.overall.statements || null,
|
||||
passed: validationPassed,
|
||||
failed_tests: testResults.failures.map(f => f.test_name),
|
||||
last_run_at: getUtc8ISOString()
|
||||
},
|
||||
last_action: 'action-validate-with-file'
|
||||
},
|
||||
continue: true,
|
||||
message: validationPassed
|
||||
? `验证通过 ✅\n测试: ${testResults.passed}/${testResults.total}\n覆盖率: ${coverageData?.overall.statements || 'N/A'}%`
|
||||
: `验证失败 ❌\n失败: ${testResults.failed}/${testResults.total}\n建议进入调试模式`
|
||||
}
|
||||
```
|
||||
|
||||
## Test Output Parsers
|
||||
|
||||
### Jest/Vitest Parser
|
||||
|
||||
```javascript
|
||||
function parseJestOutput(stdout) {
|
||||
const testPattern = /Tests:\s+(\d+) passed.*?(\d+) failed.*?(\d+) total/
|
||||
const match = stdout.match(testPattern)
|
||||
|
||||
return {
|
||||
total: parseInt(match[3]),
|
||||
passed: parseInt(match[1]),
|
||||
failed: parseInt(match[2]),
|
||||
// ... parse individual test results
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pytest Parser
|
||||
|
||||
```javascript
|
||||
function parsePytestOutput(stdout) {
|
||||
const summaryPattern = /(\d+) passed.*?(\d+) failed.*?(\d+) error/
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error Type | Recovery |
|
||||
|------------|----------|
|
||||
| Tests don't run | 检查测试脚本配置,提示用户 |
|
||||
| All tests fail | 建议进入 debug 模式 |
|
||||
| Coverage tool missing | 跳过覆盖率检查,仅运行测试 |
|
||||
| Timeout | 增加超时时间或拆分测试 |
|
||||
|
||||
## Validation Report Template
|
||||
|
||||
参考 [templates/validation-template.md](../../templates/validation-template.md)
|
||||
|
||||
## CLI Integration
|
||||
|
||||
### 质量分析
|
||||
```bash
|
||||
ccw cli -p "PURPOSE: Analyze test results and coverage...
|
||||
TASK: • Review results • Identify gaps • Suggest improvements
|
||||
MODE: analysis
|
||||
CONTEXT: @test-results.json @coverage.json
|
||||
EXPECTED: Quality assessment
|
||||
" --tool gemini --mode analysis --rule analysis-review-code-quality
|
||||
```
|
||||
|
||||
### 测试生成 (如覆盖率低)
|
||||
```bash
|
||||
ccw cli -p "PURPOSE: Generate missing test cases...
|
||||
TASK: • Analyze uncovered code • Write tests
|
||||
MODE: write
|
||||
CONTEXT: @coverage.json @src/**/*
|
||||
EXPECTED: Test code
|
||||
" --tool gemini --mode write --rule development-generate-tests
|
||||
```
|
||||
|
||||
## Next Actions (Hints)
|
||||
|
||||
- 验证通过: `action-complete` (完成循环)
|
||||
- 验证失败: `action-debug-with-file` (调试失败测试)
|
||||
- 覆盖率低: `action-develop-with-file` (添加测试)
|
||||
- 用户选择: `action-menu` (返回菜单)
|
||||
@@ -1,486 +0,0 @@
|
||||
# Orchestrator
|
||||
|
||||
根据当前状态选择并执行下一个动作,实现无状态循环工作流。与 API (loop-v2-routes.ts) 协作实现控制平面/执行平面分离。
|
||||
|
||||
## Role
|
||||
|
||||
检查控制信号 → 读取文件状态 → 选择动作 → 执行 → 更新文件 → 循环,直到完成或被外部暂停/停止。
|
||||
|
||||
## State Management (Unified Location)
|
||||
|
||||
### 读取状态
|
||||
|
||||
```javascript
|
||||
const getUtc8ISOString = () => new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
/**
|
||||
* 读取循环状态 (统一位置)
|
||||
* @param loopId - Loop ID (e.g., "loop-v2-20260122-abc123")
|
||||
*/
|
||||
function readLoopState(loopId) {
|
||||
const stateFile = `.loop/${loopId}.json`
|
||||
|
||||
if (!fs.existsSync(stateFile)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const state = JSON.parse(Read(stateFile))
|
||||
return state
|
||||
}
|
||||
```
|
||||
|
||||
### 更新状态
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 更新循环状态 (只更新 skill_state 部分,不修改 API 字段)
|
||||
* @param loopId - Loop ID
|
||||
* @param updates - 更新内容 (skill_state 字段)
|
||||
*/
|
||||
function updateLoopState(loopId, updates) {
|
||||
const stateFile = `.loop/${loopId}.json`
|
||||
const currentState = readLoopState(loopId)
|
||||
|
||||
if (!currentState) {
|
||||
throw new Error(`Loop state not found: ${loopId}`)
|
||||
}
|
||||
|
||||
// 只更新 skill_state 和 updated_at
|
||||
const newState = {
|
||||
...currentState,
|
||||
updated_at: getUtc8ISOString(),
|
||||
skill_state: {
|
||||
...currentState.skill_state,
|
||||
...updates
|
||||
}
|
||||
}
|
||||
|
||||
Write(stateFile, JSON.stringify(newState, null, 2))
|
||||
return newState
|
||||
}
|
||||
```
|
||||
|
||||
### 创建新循环状态 (直接调用时)
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 创建新的循环状态 (仅在直接调用时使用,API 触发时状态已存在)
|
||||
*/
|
||||
function createLoopState(loopId, taskDescription) {
|
||||
const stateFile = `.loop/${loopId}.json`
|
||||
const now = getUtc8ISOString()
|
||||
|
||||
const state = {
|
||||
// API 兼容字段
|
||||
loop_id: loopId,
|
||||
title: taskDescription.substring(0, 100),
|
||||
description: taskDescription,
|
||||
max_iterations: 10,
|
||||
status: 'running', // 直接调用时设为 running
|
||||
current_iteration: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
// Skill 扩展字段
|
||||
skill_state: null // 由 action-init 初始化
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
Bash(`mkdir -p ".loop"`)
|
||||
Bash(`mkdir -p ".loop/${loopId}.progress"`)
|
||||
|
||||
Write(stateFile, JSON.stringify(state, null, 2))
|
||||
return state
|
||||
}
|
||||
```
|
||||
|
||||
## Control Signal Checking
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 检查 API 控制信号
|
||||
* 必须在每个 Action 开始前调用
|
||||
* @returns { continue: boolean, reason: string }
|
||||
*/
|
||||
function checkControlSignals(loopId) {
|
||||
const state = readLoopState(loopId)
|
||||
|
||||
if (!state) {
|
||||
return { continue: false, reason: 'state_not_found' }
|
||||
}
|
||||
|
||||
switch (state.status) {
|
||||
case 'paused':
|
||||
// API 暂停了循环,Skill 应退出等待 resume
|
||||
console.log(`⏸️ Loop paused by API. Waiting for resume...`)
|
||||
return { continue: false, reason: 'paused' }
|
||||
|
||||
case 'failed':
|
||||
// API 停止了循环 (用户手动停止)
|
||||
console.log(`⏹️ Loop stopped by API.`)
|
||||
return { continue: false, reason: 'stopped' }
|
||||
|
||||
case 'completed':
|
||||
// 已完成
|
||||
console.log(`✅ Loop already completed.`)
|
||||
return { continue: false, reason: 'completed' }
|
||||
|
||||
case 'created':
|
||||
// API 创建但未启动 (不应该走到这里)
|
||||
console.log(`⚠️ Loop not started by API.`)
|
||||
return { continue: false, reason: 'not_started' }
|
||||
|
||||
case 'running':
|
||||
// 正常继续
|
||||
return { continue: true, reason: 'running' }
|
||||
|
||||
default:
|
||||
console.log(`⚠️ Unknown status: ${state.status}`)
|
||||
return { continue: false, reason: 'unknown_status' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Decision Logic
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 选择下一个 Action (基于 skill_state)
|
||||
*/
|
||||
function selectNextAction(state, mode = 'interactive') {
|
||||
const skillState = state.skill_state
|
||||
|
||||
// 1. 终止条件检查 (API status)
|
||||
if (state.status === 'completed') return null
|
||||
if (state.status === 'failed') return null
|
||||
if (state.current_iteration >= state.max_iterations) {
|
||||
console.warn(`已达到最大迭代次数 (${state.max_iterations})`)
|
||||
return 'action-complete'
|
||||
}
|
||||
|
||||
// 2. 初始化检查
|
||||
if (!skillState || !skillState.current_action) {
|
||||
return 'action-init'
|
||||
}
|
||||
|
||||
// 3. 模式判断
|
||||
if (mode === 'interactive') {
|
||||
return 'action-menu' // 显示菜单让用户选择
|
||||
}
|
||||
|
||||
// 4. 自动模式:基于状态自动选择
|
||||
if (mode === 'auto') {
|
||||
// 按优先级:develop → debug → validate
|
||||
|
||||
// 如果有待开发任务
|
||||
const hasPendingDevelop = skillState.develop?.tasks?.some(t => t.status === 'pending')
|
||||
if (hasPendingDevelop) {
|
||||
return 'action-develop-with-file'
|
||||
}
|
||||
|
||||
// 如果开发完成但未调试
|
||||
if (skillState.last_action === 'action-develop-with-file') {
|
||||
const needsDebug = skillState.develop?.completed < skillState.develop?.total
|
||||
if (needsDebug) {
|
||||
return 'action-debug-with-file'
|
||||
}
|
||||
}
|
||||
|
||||
// 如果调试完成但未验证
|
||||
if (skillState.last_action === 'action-debug-with-file' ||
|
||||
skillState.debug?.confirmed_hypothesis) {
|
||||
return 'action-validate-with-file'
|
||||
}
|
||||
|
||||
// 如果验证失败,回到开发
|
||||
if (skillState.last_action === 'action-validate-with-file') {
|
||||
if (!skillState.validate?.passed) {
|
||||
return 'action-develop-with-file'
|
||||
}
|
||||
}
|
||||
|
||||
// 全部通过,完成
|
||||
if (skillState.validate?.passed && !hasPendingDevelop) {
|
||||
return 'action-complete'
|
||||
}
|
||||
|
||||
// 默认:开发
|
||||
return 'action-develop-with-file'
|
||||
}
|
||||
|
||||
// 5. 默认完成
|
||||
return 'action-complete'
|
||||
}
|
||||
```
|
||||
|
||||
## Execution Loop
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 运行编排器
|
||||
* @param options.loopId - 现有 Loop ID (API 触发时)
|
||||
* @param options.task - 任务描述 (直接调用时)
|
||||
* @param options.mode - 'interactive' | 'auto'
|
||||
*/
|
||||
async function runOrchestrator(options = {}) {
|
||||
const { loopId: existingLoopId, task, mode = 'interactive' } = options
|
||||
|
||||
console.log('=== CCW Loop Orchestrator Started ===')
|
||||
|
||||
// 1. 确定 loopId
|
||||
let loopId
|
||||
let state
|
||||
|
||||
if (existingLoopId) {
|
||||
// API 触发:使用现有 loopId
|
||||
loopId = existingLoopId
|
||||
state = readLoopState(loopId)
|
||||
|
||||
if (!state) {
|
||||
console.error(`Loop not found: ${loopId}`)
|
||||
return { status: 'error', message: 'Loop not found' }
|
||||
}
|
||||
|
||||
console.log(`Resuming loop: ${loopId}`)
|
||||
console.log(`Status: ${state.status}`)
|
||||
|
||||
} else if (task) {
|
||||
// 直接调用:创建新 loopId
|
||||
const timestamp = getUtc8ISOString().replace(/[-:]/g, '').split('.')[0]
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
loopId = `loop-v2-${timestamp}-${random}`
|
||||
|
||||
console.log(`Creating new loop: ${loopId}`)
|
||||
console.log(`Task: ${task}`)
|
||||
|
||||
state = createLoopState(loopId, task)
|
||||
|
||||
} else {
|
||||
console.error('Either --loop-id or task description is required')
|
||||
return { status: 'error', message: 'Missing loopId or task' }
|
||||
}
|
||||
|
||||
const progressDir = `.loop/${loopId}.progress`
|
||||
|
||||
// 2. 主循环
|
||||
let iteration = state.current_iteration || 0
|
||||
|
||||
while (iteration < state.max_iterations) {
|
||||
iteration++
|
||||
|
||||
// ========================================
|
||||
// CRITICAL: Check control signals first
|
||||
// ========================================
|
||||
const control = checkControlSignals(loopId)
|
||||
if (!control.continue) {
|
||||
console.log(`\n🛑 Loop terminated: ${control.reason}`)
|
||||
break
|
||||
}
|
||||
|
||||
// 重新读取状态 (可能被 API 更新)
|
||||
state = readLoopState(loopId)
|
||||
|
||||
console.log(`\n[Iteration ${iteration}] Status: ${state.status}`)
|
||||
|
||||
// 选择下一个动作
|
||||
const actionId = selectNextAction(state, mode)
|
||||
|
||||
if (!actionId) {
|
||||
console.log('No action selected, terminating.')
|
||||
break
|
||||
}
|
||||
|
||||
console.log(`[Iteration ${iteration}] Executing: ${actionId}`)
|
||||
|
||||
// 更新 current_iteration
|
||||
state = {
|
||||
...state,
|
||||
current_iteration: iteration,
|
||||
updated_at: getUtc8ISOString()
|
||||
}
|
||||
Write(`.loop/${loopId}.json`, JSON.stringify(state, null, 2))
|
||||
|
||||
// 执行动作
|
||||
try {
|
||||
const actionPromptFile = `.claude/skills/ccw-loop/phases/actions/${actionId}.md`
|
||||
|
||||
if (!fs.existsSync(actionPromptFile)) {
|
||||
console.error(`Action file not found: ${actionPromptFile}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const actionPrompt = Read(actionPromptFile)
|
||||
|
||||
// 构建 Agent 提示
|
||||
const agentPrompt = `
|
||||
[LOOP CONTEXT]
|
||||
Loop ID: ${loopId}
|
||||
State File: .loop/${loopId}.json
|
||||
Progress Dir: ${progressDir}
|
||||
|
||||
[CURRENT STATE]
|
||||
${JSON.stringify(state, null, 2)}
|
||||
|
||||
[ACTION INSTRUCTIONS]
|
||||
${actionPrompt}
|
||||
|
||||
[TASK]
|
||||
You are executing ${actionId} for loop: ${state.title || state.description}
|
||||
|
||||
[CONTROL SIGNALS]
|
||||
Before executing, check if status is still 'running'.
|
||||
If status is 'paused' or 'failed', exit gracefully.
|
||||
|
||||
[RETURN]
|
||||
Return JSON with:
|
||||
- skillStateUpdates: Object with skill_state fields to update
|
||||
- continue: Boolean indicating if loop should continue
|
||||
- message: String with user message
|
||||
`
|
||||
|
||||
const result = await Task({
|
||||
subagent_type: 'universal-executor',
|
||||
run_in_background: false,
|
||||
description: `Execute ${actionId}`,
|
||||
prompt: agentPrompt
|
||||
})
|
||||
|
||||
// 解析结果
|
||||
const actionResult = JSON.parse(result)
|
||||
|
||||
// 更新状态 (只更新 skill_state)
|
||||
updateLoopState(loopId, {
|
||||
current_action: null,
|
||||
last_action: actionId,
|
||||
completed_actions: [
|
||||
...(state.skill_state?.completed_actions || []),
|
||||
actionId
|
||||
],
|
||||
...actionResult.skillStateUpdates
|
||||
})
|
||||
|
||||
// 显示消息
|
||||
if (actionResult.message) {
|
||||
console.log(`\n${actionResult.message}`)
|
||||
}
|
||||
|
||||
// 检查是否继续
|
||||
if (actionResult.continue === false) {
|
||||
console.log('Action requested termination.')
|
||||
break
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error executing ${actionId}: ${error.message}`)
|
||||
|
||||
// 错误处理
|
||||
updateLoopState(loopId, {
|
||||
current_action: null,
|
||||
errors: [
|
||||
...(state.skill_state?.errors || []),
|
||||
{
|
||||
action: actionId,
|
||||
message: error.message,
|
||||
timestamp: getUtc8ISOString()
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (iteration >= state.max_iterations) {
|
||||
console.log(`\n⚠️ Reached maximum iterations (${state.max_iterations})`)
|
||||
console.log('Consider breaking down the task or taking a break.')
|
||||
}
|
||||
|
||||
console.log('\n=== CCW Loop Orchestrator Finished ===')
|
||||
|
||||
// 返回最终状态
|
||||
const finalState = readLoopState(loopId)
|
||||
return {
|
||||
status: finalState.status,
|
||||
loop_id: loopId,
|
||||
iterations: iteration,
|
||||
final_state: finalState
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Action Catalog
|
||||
|
||||
| Action | Purpose | Preconditions | Effects |
|
||||
|--------|---------|---------------|---------|
|
||||
| [action-init](actions/action-init.md) | 初始化会话 | status=pending | initialized=true |
|
||||
| [action-menu](actions/action-menu.md) | 显示操作菜单 | initialized=true | 用户选择下一动作 |
|
||||
| [action-develop-with-file](actions/action-develop-with-file.md) | 开发任务 | initialized=true | 更新 progress.md |
|
||||
| [action-debug-with-file](actions/action-debug-with-file.md) | 假设调试 | initialized=true | 更新 understanding.md |
|
||||
| [action-validate-with-file](actions/action-validate-with-file.md) | 测试验证 | initialized=true | 更新 validation.md |
|
||||
| [action-complete](actions/action-complete.md) | 完成循环 | validation_passed=true | status=completed |
|
||||
|
||||
## Termination Conditions
|
||||
|
||||
1. **API 暂停**: `state.status === 'paused'` (Skill 退出,等待 resume)
|
||||
2. **API 停止**: `state.status === 'failed'` (Skill 终止)
|
||||
3. **任务完成**: `state.status === 'completed'`
|
||||
4. **迭代限制**: `state.current_iteration >= state.max_iterations`
|
||||
5. **Action 请求终止**: `actionResult.continue === false`
|
||||
|
||||
## Error Recovery
|
||||
|
||||
| Error Type | Recovery Strategy |
|
||||
|------------|-------------------|
|
||||
| 动作执行失败 | 记录错误,增加 error_count,继续下一动作 |
|
||||
| 状态文件损坏 | 从其他文件重建状态 (progress.md, understanding.md 等) |
|
||||
| 用户中止 | 保存当前状态,允许 --resume 恢复 |
|
||||
| CLI 工具失败 | 回退到手动分析模式 |
|
||||
|
||||
## Mode Strategies
|
||||
|
||||
### Interactive Mode (默认)
|
||||
|
||||
每次显示菜单,让用户选择动作:
|
||||
|
||||
```
|
||||
当前状态: 开发中
|
||||
可用操作:
|
||||
1. 继续开发 (develop)
|
||||
2. 开始调试 (debug)
|
||||
3. 运行验证 (validate)
|
||||
4. 查看进度 (status)
|
||||
5. 退出 (exit)
|
||||
|
||||
请选择:
|
||||
```
|
||||
|
||||
### Auto Mode (自动循环)
|
||||
|
||||
按预设流程自动执行:
|
||||
|
||||
```
|
||||
Develop → Debug → Validate →
|
||||
↓ (如验证失败)
|
||||
Develop (修复) → Debug → Validate → 完成
|
||||
```
|
||||
|
||||
## State Machine (API Status)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> created: API creates loop
|
||||
created --> running: API /start → Trigger Skill
|
||||
running --> paused: API /pause → Set status
|
||||
running --> completed: action-complete
|
||||
running --> failed: API /stop OR error
|
||||
paused --> running: API /resume → Re-trigger Skill
|
||||
completed --> [*]
|
||||
failed --> [*]
|
||||
|
||||
note right of paused
|
||||
Skill checks status before each action
|
||||
If paused, Skill exits gracefully
|
||||
end note
|
||||
|
||||
note right of running
|
||||
Skill executes: init → develop → debug → validate
|
||||
end note
|
||||
```
|
||||
@@ -1,474 +0,0 @@
|
||||
# State Schema
|
||||
|
||||
CCW Loop 的状态结构定义(统一版本)。
|
||||
|
||||
## 状态文件
|
||||
|
||||
**位置**: `.loop/{loopId}.json` (统一位置,API + Skill 共享)
|
||||
|
||||
**旧版本位置** (仅向后兼容): `.workflow/.loop/{session-id}/state.json`
|
||||
|
||||
## 结构定义
|
||||
|
||||
### 统一状态接口 (Unified Loop State)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Unified Loop State - API 和 Skill 共享的状态结构
|
||||
* API (loop-v2-routes.ts) 拥有状态的主控权
|
||||
* Skill (ccw-loop) 读取和更新此状态
|
||||
*/
|
||||
interface LoopState {
|
||||
// =====================================================
|
||||
// API FIELDS (from loop-v2-routes.ts)
|
||||
// 这些字段由 API 管理,Skill 只读
|
||||
// =====================================================
|
||||
|
||||
loop_id: string // Loop ID, e.g., "loop-v2-20260122-abc123"
|
||||
title: string // Loop 标题
|
||||
description: string // Loop 描述
|
||||
max_iterations: number // 最大迭代次数
|
||||
status: 'created' | 'running' | 'paused' | 'completed' | 'failed'
|
||||
current_iteration: number // 当前迭代次数
|
||||
created_at: string // 创建时间 (ISO8601)
|
||||
updated_at: string // 最后更新时间 (ISO8601)
|
||||
completed_at?: string // 完成时间 (ISO8601)
|
||||
failure_reason?: string // 失败原因
|
||||
|
||||
// =====================================================
|
||||
// SKILL EXTENSION FIELDS
|
||||
// 这些字段由 Skill 管理,API 只读
|
||||
// =====================================================
|
||||
|
||||
skill_state?: {
|
||||
// 当前执行动作
|
||||
current_action: 'init' | 'develop' | 'debug' | 'validate' | 'complete' | null
|
||||
last_action: string | null
|
||||
completed_actions: string[]
|
||||
mode: 'interactive' | 'auto'
|
||||
|
||||
// === 开发阶段 ===
|
||||
develop: {
|
||||
total: number
|
||||
completed: number
|
||||
current_task?: string
|
||||
tasks: DevelopTask[]
|
||||
last_progress_at: string | null
|
||||
}
|
||||
|
||||
// === 调试阶段 ===
|
||||
debug: {
|
||||
active_bug?: string
|
||||
hypotheses_count: number
|
||||
hypotheses: Hypothesis[]
|
||||
confirmed_hypothesis: string | null
|
||||
iteration: number
|
||||
last_analysis_at: string | null
|
||||
}
|
||||
|
||||
// === 验证阶段 ===
|
||||
validate: {
|
||||
pass_rate: number // 测试通过率 (0-100)
|
||||
coverage: number // 覆盖率 (0-100)
|
||||
test_results: TestResult[]
|
||||
passed: boolean
|
||||
failed_tests: string[]
|
||||
last_run_at: string | null
|
||||
}
|
||||
|
||||
// === 错误追踪 ===
|
||||
errors: Array<{
|
||||
action: string
|
||||
message: string
|
||||
timestamp: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
interface DevelopTask {
|
||||
id: string
|
||||
description: string
|
||||
tool: 'gemini' | 'qwen' | 'codex' | 'bash'
|
||||
mode: 'analysis' | 'write'
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||
files_changed: string[]
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
interface Hypothesis {
|
||||
id: string // H1, H2, ...
|
||||
description: string
|
||||
testable_condition: string
|
||||
logging_point: string
|
||||
evidence_criteria: {
|
||||
confirm: string
|
||||
reject: string
|
||||
}
|
||||
likelihood: number // 1 = 最可能
|
||||
status: 'pending' | 'confirmed' | 'rejected' | 'inconclusive'
|
||||
evidence: Record<string, any> | null
|
||||
verdict_reason: string | null
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
test_name: string
|
||||
suite: string
|
||||
status: 'passed' | 'failed' | 'skipped'
|
||||
duration_ms: number
|
||||
error_message: string | null
|
||||
stack_trace: string | null
|
||||
}
|
||||
```
|
||||
|
||||
## 初始状态
|
||||
|
||||
### 由 API 创建时 (Dashboard 触发)
|
||||
|
||||
```json
|
||||
{
|
||||
"loop_id": "loop-v2-20260122-abc123",
|
||||
"title": "Implement user authentication",
|
||||
"description": "Add login/logout functionality",
|
||||
"max_iterations": 10,
|
||||
"status": "created",
|
||||
"current_iteration": 0,
|
||||
"created_at": "2026-01-22T10:00:00+08:00",
|
||||
"updated_at": "2026-01-22T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 由 Skill 初始化后 (action-init)
|
||||
|
||||
```json
|
||||
{
|
||||
"loop_id": "loop-v2-20260122-abc123",
|
||||
"title": "Implement user authentication",
|
||||
"description": "Add login/logout functionality",
|
||||
"max_iterations": 10,
|
||||
"status": "running",
|
||||
"current_iteration": 0,
|
||||
"created_at": "2026-01-22T10:00:00+08:00",
|
||||
"updated_at": "2026-01-22T10:00:05+08:00",
|
||||
|
||||
"skill_state": {
|
||||
"current_action": "init",
|
||||
"last_action": null,
|
||||
"completed_actions": [],
|
||||
"mode": "auto",
|
||||
|
||||
"develop": {
|
||||
"total": 3,
|
||||
"completed": 0,
|
||||
"current_task": null,
|
||||
"tasks": [
|
||||
{ "id": "task-001", "description": "Create auth component", "status": "pending" }
|
||||
],
|
||||
"last_progress_at": null
|
||||
},
|
||||
|
||||
"debug": {
|
||||
"active_bug": null,
|
||||
"hypotheses_count": 0,
|
||||
"hypotheses": [],
|
||||
"confirmed_hypothesis": null,
|
||||
"iteration": 0,
|
||||
"last_analysis_at": null
|
||||
},
|
||||
|
||||
"validate": {
|
||||
"pass_rate": 0,
|
||||
"coverage": 0,
|
||||
"test_results": [],
|
||||
"passed": false,
|
||||
"failed_tests": [],
|
||||
"last_run_at": null
|
||||
},
|
||||
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 控制信号检查 (Control Signals)
|
||||
|
||||
Skill 在每个 Action 开始前必须检查控制信号:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 检查 API 控制信号
|
||||
* @returns { continue: boolean, action: 'pause_exit' | 'stop_exit' | 'continue' }
|
||||
*/
|
||||
function checkControlSignals(loopId) {
|
||||
const state = JSON.parse(Read(`.loop/${loopId}.json`))
|
||||
|
||||
switch (state.status) {
|
||||
case 'paused':
|
||||
// API 暂停了循环,Skill 应退出等待 resume
|
||||
return { continue: false, action: 'pause_exit' }
|
||||
|
||||
case 'failed':
|
||||
// API 停止了循环 (用户手动停止)
|
||||
return { continue: false, action: 'stop_exit' }
|
||||
|
||||
case 'running':
|
||||
// 正常继续
|
||||
return { continue: true, action: 'continue' }
|
||||
|
||||
default:
|
||||
// 异常状态
|
||||
return { continue: false, action: 'stop_exit' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 在 Action 中使用
|
||||
|
||||
```markdown
|
||||
## Execution
|
||||
|
||||
### Step 1: Check Control Signals
|
||||
|
||||
\`\`\`javascript
|
||||
const control = checkControlSignals(loopId)
|
||||
if (!control.continue) {
|
||||
// 输出退出原因
|
||||
console.log(`Loop ${control.action}: status = ${state.status}`)
|
||||
|
||||
// 如果是 pause_exit,保存当前进度
|
||||
if (control.action === 'pause_exit') {
|
||||
updateSkillState(loopId, { current_action: 'paused' })
|
||||
}
|
||||
|
||||
return // 退出 Action
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Step 2: Execute Action Logic
|
||||
...
|
||||
```
|
||||
|
||||
## 状态转换规则
|
||||
|
||||
### 1. 初始化 (action-init)
|
||||
|
||||
```javascript
|
||||
// Skill 初始化后
|
||||
{
|
||||
// API 字段更新
|
||||
status: 'created' → 'running', // 或保持 'running' 如果 API 已设置
|
||||
updated_at: timestamp,
|
||||
|
||||
// Skill 字段初始化
|
||||
skill_state: {
|
||||
current_action: 'init',
|
||||
mode: 'auto',
|
||||
develop: {
|
||||
tasks: [...parsed_tasks],
|
||||
total: N,
|
||||
completed: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 开发进行中 (action-develop-with-file)
|
||||
|
||||
```javascript
|
||||
// 开发任务执行后
|
||||
{
|
||||
updated_at: timestamp,
|
||||
current_iteration: state.current_iteration + 1,
|
||||
|
||||
skill_state: {
|
||||
current_action: 'develop',
|
||||
last_action: 'action-develop-with-file',
|
||||
completed_actions: [...state.skill_state.completed_actions, 'action-develop-with-file'],
|
||||
develop: {
|
||||
current_task: 'task-xxx',
|
||||
completed: N+1,
|
||||
last_progress_at: timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 调试进行中 (action-debug-with-file)
|
||||
|
||||
```javascript
|
||||
// 调试执行后
|
||||
{
|
||||
updated_at: timestamp,
|
||||
current_iteration: state.current_iteration + 1,
|
||||
|
||||
skill_state: {
|
||||
current_action: 'debug',
|
||||
last_action: 'action-debug-with-file',
|
||||
debug: {
|
||||
active_bug: '...',
|
||||
hypotheses_count: N,
|
||||
hypotheses: [...new_hypotheses],
|
||||
iteration: N+1,
|
||||
last_analysis_at: timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 验证完成 (action-validate-with-file)
|
||||
|
||||
```javascript
|
||||
// 验证执行后
|
||||
{
|
||||
updated_at: timestamp,
|
||||
current_iteration: state.current_iteration + 1,
|
||||
|
||||
skill_state: {
|
||||
current_action: 'validate',
|
||||
last_action: 'action-validate-with-file',
|
||||
validate: {
|
||||
test_results: [...results],
|
||||
pass_rate: 95.5,
|
||||
coverage: 85.0,
|
||||
passed: true | false,
|
||||
failed_tests: ['test1', 'test2'],
|
||||
last_run_at: timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 完成 (action-complete)
|
||||
|
||||
```javascript
|
||||
// 循环完成后
|
||||
{
|
||||
status: 'running' → 'completed',
|
||||
completed_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
|
||||
skill_state: {
|
||||
current_action: 'complete',
|
||||
last_action: 'action-complete'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 状态派生字段
|
||||
|
||||
以下字段可从状态计算得出,不需要存储:
|
||||
|
||||
```javascript
|
||||
// 开发完成度
|
||||
const developProgress = state.develop.total_count > 0
|
||||
? (state.develop.completed_count / state.develop.total_count) * 100
|
||||
: 0
|
||||
|
||||
// 是否有待开发任务
|
||||
const hasPendingDevelop = state.develop.tasks.some(t => t.status === 'pending')
|
||||
|
||||
// 调试是否完成
|
||||
const debugCompleted = state.debug.confirmed_hypothesis !== null
|
||||
|
||||
// 验证是否通过
|
||||
const validationPassed = state.validate.passed && state.validate.test_results.length > 0
|
||||
|
||||
// 整体进度
|
||||
const overallProgress = (
|
||||
(developProgress * 0.5) +
|
||||
(debugCompleted ? 25 : 0) +
|
||||
(validationPassed ? 25 : 0)
|
||||
)
|
||||
```
|
||||
|
||||
## 文件同步
|
||||
|
||||
### 统一位置 (Unified Location)
|
||||
|
||||
状态与文件的对应关系:
|
||||
|
||||
| 状态字段 | 同步文件 | 同步时机 |
|
||||
|----------|----------|----------|
|
||||
| 整个 LoopState | `.loop/{loopId}.json` | 每次状态变更 (主文件) |
|
||||
| `skill_state.develop` | `.loop/{loopId}.progress/develop.md` | 每次开发操作后 |
|
||||
| `skill_state.debug` | `.loop/{loopId}.progress/debug.md` | 每次调试操作后 |
|
||||
| `skill_state.validate` | `.loop/{loopId}.progress/validate.md` | 每次验证操作后 |
|
||||
| 代码变更日志 | `.loop/{loopId}.progress/changes.log` | 每次文件修改 (NDJSON) |
|
||||
| 调试日志 | `.loop/{loopId}.progress/debug.log` | 每次调试日志 (NDJSON) |
|
||||
|
||||
### 文件结构示例
|
||||
|
||||
```
|
||||
.loop/
|
||||
├── loop-v2-20260122-abc123.json # 主状态文件 (API + Skill)
|
||||
├── loop-v2-20260122-abc123.tasks.jsonl # 任务列表 (API 管理)
|
||||
└── loop-v2-20260122-abc123.progress/ # Skill 进度文件
|
||||
├── develop.md # 开发进度
|
||||
├── debug.md # 调试理解
|
||||
├── validate.md # 验证报告
|
||||
├── changes.log # 代码变更 (NDJSON)
|
||||
└── debug.log # 调试日志 (NDJSON)
|
||||
```
|
||||
|
||||
## 状态恢复
|
||||
|
||||
如果主状态文件 `.loop/{loopId}.json` 损坏,可以从进度文件重建 skill_state:
|
||||
|
||||
```javascript
|
||||
function rebuildSkillStateFromProgress(loopId) {
|
||||
const progressDir = `.loop/${loopId}.progress`
|
||||
|
||||
// 尝试从进度文件解析状态
|
||||
const skill_state = {
|
||||
develop: parseProgressFile(`${progressDir}/develop.md`),
|
||||
debug: parseProgressFile(`${progressDir}/debug.md`),
|
||||
validate: parseProgressFile(`${progressDir}/validate.md`)
|
||||
}
|
||||
|
||||
return skill_state
|
||||
}
|
||||
|
||||
// 解析进度 Markdown 文件
|
||||
function parseProgressFile(filePath) {
|
||||
const content = Read(filePath)
|
||||
if (!content) return null
|
||||
|
||||
// 从 Markdown 表格和结构中提取数据
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
### 恢复策略
|
||||
|
||||
1. **API 字段**: 无法恢复 - 需要从 API 重新获取或用户手动输入
|
||||
2. **skill_state 字段**: 可以从 `.progress/` 目录的 Markdown 文件解析
|
||||
3. **任务列表**: 从 `.loop/{loopId}.tasks.jsonl` 恢复
|
||||
|
||||
## 状态验证
|
||||
|
||||
```javascript
|
||||
function validateState(state) {
|
||||
const errors = []
|
||||
|
||||
// 必需字段
|
||||
if (!state.session_id) errors.push('Missing session_id')
|
||||
if (!state.task_description) errors.push('Missing task_description')
|
||||
|
||||
// 状态一致性
|
||||
if (state.initialized && state.status === 'pending') {
|
||||
errors.push('Inconsistent: initialized but status is pending')
|
||||
}
|
||||
|
||||
if (state.status === 'completed' && !state.validate.passed) {
|
||||
errors.push('Inconsistent: completed but validation not passed')
|
||||
}
|
||||
|
||||
// 开发任务一致性
|
||||
const completedTasks = state.develop.tasks.filter(t => t.status === 'completed').length
|
||||
if (completedTasks !== state.develop.completed_count) {
|
||||
errors.push('Inconsistent: completed_count mismatch')
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
```
|
||||
@@ -1,300 +0,0 @@
|
||||
# Action Catalog
|
||||
|
||||
CCW Loop 所有可用动作的目录和说明。
|
||||
|
||||
## Available Actions
|
||||
|
||||
| Action | Purpose | Preconditions | Effects | CLI Integration |
|
||||
|--------|---------|---------------|---------|-----------------|
|
||||
| [action-init](../phases/actions/action-init.md) | 初始化会话 | status=pending, initialized=false | status→running, initialized→true, 创建目录和任务列表 | Gemini 任务分解 |
|
||||
| [action-menu](../phases/actions/action-menu.md) | 显示操作菜单 | initialized=true, status=running | 返回用户选择的动作 | - |
|
||||
| [action-develop-with-file](../phases/actions/action-develop-with-file.md) | 执行开发任务 | initialized=true, pending tasks > 0 | 更新 progress.md, 完成一个任务 | Gemini 代码实现 |
|
||||
| [action-debug-with-file](../phases/actions/action-debug-with-file.md) | 假设驱动调试 | initialized=true | 更新 understanding.md, hypotheses.json | Gemini 假设生成和证据分析 |
|
||||
| [action-validate-with-file](../phases/actions/action-validate-with-file.md) | 运行测试验证 | initialized=true, develop > 0 or debug confirmed | 更新 validation.md, test-results.json | Gemini 质量分析 |
|
||||
| [action-complete](../phases/actions/action-complete.md) | 完成循环 | initialized=true | status→completed, 生成 summary.md | - |
|
||||
|
||||
## Action Dependencies Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
START([用户启动 /ccw-loop]) --> INIT[action-init]
|
||||
INIT --> MENU[action-menu]
|
||||
|
||||
MENU --> DEVELOP[action-develop-with-file]
|
||||
MENU --> DEBUG[action-debug-with-file]
|
||||
MENU --> VALIDATE[action-validate-with-file]
|
||||
MENU --> STATUS[action-status]
|
||||
MENU --> COMPLETE[action-complete]
|
||||
MENU --> EXIT([退出])
|
||||
|
||||
DEVELOP --> MENU
|
||||
DEBUG --> MENU
|
||||
VALIDATE --> MENU
|
||||
STATUS --> MENU
|
||||
COMPLETE --> END([结束])
|
||||
EXIT --> END
|
||||
|
||||
style INIT fill:#e1f5fe
|
||||
style MENU fill:#fff3e0
|
||||
style DEVELOP fill:#e8f5e9
|
||||
style DEBUG fill:#fce4ec
|
||||
style VALIDATE fill:#f3e5f5
|
||||
style COMPLETE fill:#c8e6c9
|
||||
```
|
||||
|
||||
## Action Execution Matrix
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
| State | Auto-Selected Action | User Options |
|
||||
|-------|---------------------|--------------|
|
||||
| pending | action-init | - |
|
||||
| running, !initialized | action-init | - |
|
||||
| running, initialized | action-menu | All actions |
|
||||
|
||||
### Auto Mode
|
||||
|
||||
| Condition | Selected Action |
|
||||
|-----------|----------------|
|
||||
| pending_develop_tasks > 0 | action-develop-with-file |
|
||||
| last_action=develop, !debug_completed | action-debug-with-file |
|
||||
| last_action=debug, !validation_completed | action-validate-with-file |
|
||||
| validation_failed | action-develop-with-file (fix) |
|
||||
| validation_passed, no pending | action-complete |
|
||||
|
||||
## Action Inputs/Outputs
|
||||
|
||||
### action-init
|
||||
|
||||
**Inputs**:
|
||||
- state.task_description
|
||||
- User input (optional)
|
||||
|
||||
**Outputs**:
|
||||
- meta.json
|
||||
- state.json (初始化)
|
||||
- develop/tasks.json
|
||||
- develop/progress.md
|
||||
|
||||
**State Changes**:
|
||||
```javascript
|
||||
{
|
||||
status: 'pending' → 'running',
|
||||
initialized: false → true,
|
||||
develop.tasks: [] → [task1, task2, ...]
|
||||
}
|
||||
```
|
||||
|
||||
### action-develop-with-file
|
||||
|
||||
**Inputs**:
|
||||
- state.develop.tasks
|
||||
- User selection (如有多个待处理任务)
|
||||
|
||||
**Outputs**:
|
||||
- develop/progress.md (追加)
|
||||
- develop/tasks.json (更新)
|
||||
- develop/changes.log (追加)
|
||||
|
||||
**State Changes**:
|
||||
```javascript
|
||||
{
|
||||
develop.current_task_id: null → 'task-xxx' → null,
|
||||
develop.completed_count: N → N+1,
|
||||
last_action: X → 'action-develop-with-file'
|
||||
}
|
||||
```
|
||||
|
||||
### action-debug-with-file
|
||||
|
||||
**Inputs**:
|
||||
- Bug description (用户输入或从测试失败获取)
|
||||
- debug.log (如已有)
|
||||
|
||||
**Outputs**:
|
||||
- debug/understanding.md (追加)
|
||||
- debug/hypotheses.json (更新)
|
||||
- Code changes (添加日志或修复)
|
||||
|
||||
**State Changes**:
|
||||
```javascript
|
||||
{
|
||||
debug.current_bug: null → 'bug description',
|
||||
debug.hypotheses: [...updated],
|
||||
debug.iteration: N → N+1,
|
||||
debug.confirmed_hypothesis: null → 'H1' (如确认)
|
||||
}
|
||||
```
|
||||
|
||||
### action-validate-with-file
|
||||
|
||||
**Inputs**:
|
||||
- 测试脚本 (从 package.json)
|
||||
- 覆盖率工具 (可选)
|
||||
|
||||
**Outputs**:
|
||||
- validate/validation.md (追加)
|
||||
- validate/test-results.json (更新)
|
||||
- validate/coverage.json (更新)
|
||||
|
||||
**State Changes**:
|
||||
```javascript
|
||||
{
|
||||
validate.test_results: [...new results],
|
||||
validate.coverage: null → 85.5,
|
||||
validate.passed: false → true,
|
||||
validate.failed_tests: ['test1', 'test2'] → []
|
||||
}
|
||||
```
|
||||
|
||||
### action-complete
|
||||
|
||||
**Inputs**:
|
||||
- state (完整状态)
|
||||
- User choices (扩展选项)
|
||||
|
||||
**Outputs**:
|
||||
- summary.md
|
||||
- Issues (如选择扩展)
|
||||
|
||||
**State Changes**:
|
||||
```javascript
|
||||
{
|
||||
status: 'running' → 'completed',
|
||||
completed_at: null → timestamp
|
||||
}
|
||||
```
|
||||
|
||||
## Action Sequences
|
||||
|
||||
### Typical Happy Path
|
||||
|
||||
```
|
||||
action-init
|
||||
→ action-develop-with-file (task 1)
|
||||
→ action-develop-with-file (task 2)
|
||||
→ action-develop-with-file (task 3)
|
||||
→ action-validate-with-file
|
||||
→ PASS
|
||||
→ action-complete
|
||||
```
|
||||
|
||||
### Debug Iteration Path
|
||||
|
||||
```
|
||||
action-init
|
||||
→ action-develop-with-file (task 1)
|
||||
→ action-validate-with-file
|
||||
→ FAIL
|
||||
→ action-debug-with-file (探索)
|
||||
→ action-debug-with-file (分析)
|
||||
→ Root cause found
|
||||
→ action-validate-with-file
|
||||
→ PASS
|
||||
→ action-complete
|
||||
```
|
||||
|
||||
### Multi-Iteration Path
|
||||
|
||||
```
|
||||
action-init
|
||||
→ action-develop-with-file (task 1)
|
||||
→ action-debug-with-file
|
||||
→ action-develop-with-file (task 2)
|
||||
→ action-validate-with-file
|
||||
→ FAIL
|
||||
→ action-debug-with-file
|
||||
→ action-validate-with-file
|
||||
→ PASS
|
||||
→ action-complete
|
||||
```
|
||||
|
||||
## Error Scenarios
|
||||
|
||||
### CLI Tool Failure
|
||||
|
||||
```
|
||||
action-develop-with-file
|
||||
→ Gemini CLI fails
|
||||
→ Fallback to manual implementation
|
||||
→ Prompt user for code
|
||||
→ Continue
|
||||
```
|
||||
|
||||
### Test Failure
|
||||
|
||||
```
|
||||
action-validate-with-file
|
||||
→ Tests fail
|
||||
→ Record failed tests
|
||||
→ Suggest action-debug-with-file
|
||||
→ User chooses debug or manual fix
|
||||
```
|
||||
|
||||
### Max Iterations Reached
|
||||
|
||||
```
|
||||
state.iteration_count >= 10
|
||||
→ Warning message
|
||||
→ Suggest break or task split
|
||||
→ Allow continue or exit
|
||||
```
|
||||
|
||||
## Action Extensions
|
||||
|
||||
### Adding New Actions
|
||||
|
||||
To add a new action:
|
||||
|
||||
1. Create `phases/actions/action-{name}.md`
|
||||
2. Define preconditions, execution, state updates
|
||||
3. Add to this catalog
|
||||
4. Update orchestrator.md decision logic
|
||||
5. Add to action-menu.md options
|
||||
|
||||
### Action Template
|
||||
|
||||
```markdown
|
||||
# Action: {Name}
|
||||
|
||||
{Brief description}
|
||||
|
||||
## Purpose
|
||||
|
||||
{Detailed purpose}
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [ ] condition1
|
||||
- [ ] condition2
|
||||
|
||||
## Execution
|
||||
|
||||
### Step 1: {Step Name}
|
||||
|
||||
\`\`\`javascript
|
||||
// code
|
||||
\`\`\`
|
||||
|
||||
## State Updates
|
||||
|
||||
\`\`\`javascript
|
||||
return {
|
||||
stateUpdates: {
|
||||
// updates
|
||||
},
|
||||
continue: true,
|
||||
message: "..."
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error Type | Recovery |
|
||||
|------------|----------|
|
||||
| ... | ... |
|
||||
|
||||
## Next Actions (Hints)
|
||||
|
||||
- condition: next_action
|
||||
```
|
||||
@@ -1,192 +0,0 @@
|
||||
# Loop Requirements Specification
|
||||
|
||||
CCW Loop 的核心需求和约束定义。
|
||||
|
||||
## Core Requirements
|
||||
|
||||
### 1. 无状态循环
|
||||
|
||||
**Requirement**: 每次执行从文件读取状态,执行后写回文件,不依赖内存状态。
|
||||
|
||||
**Rationale**: 支持随时中断和恢复,状态持久化。
|
||||
|
||||
**Validation**:
|
||||
- [ ] 每个 action 开始时从文件读取状态
|
||||
- [ ] 每个 action 结束时将状态写回文件
|
||||
- [ ] 无全局变量或内存状态依赖
|
||||
|
||||
### 2. 文件驱动进度
|
||||
|
||||
**Requirement**: 所有进度、理解、验证结果都记录在专用 Markdown 文件中。
|
||||
|
||||
**Rationale**: 可审计、可回顾、团队可见。
|
||||
|
||||
**Validation**:
|
||||
- [ ] develop/progress.md 记录开发进度
|
||||
- [ ] debug/understanding.md 记录理解演变
|
||||
- [ ] validate/validation.md 记录验证结果
|
||||
- [ ] 所有文件使用 Markdown 格式,易读
|
||||
|
||||
### 3. CLI 工具集成
|
||||
|
||||
**Requirement**: 关键决策点使用 Gemini/CLI 进行深度分析。
|
||||
|
||||
**Rationale**: 利用 LLM 能力提高质量。
|
||||
|
||||
**Validation**:
|
||||
- [ ] 任务分解使用 Gemini
|
||||
- [ ] 假设生成使用 Gemini
|
||||
- [ ] 证据分析使用 Gemini
|
||||
- [ ] 质量评估使用 Gemini
|
||||
|
||||
### 4. 用户控制循环
|
||||
|
||||
**Requirement**: 支持交互式和自动循环两种模式,用户可随时介入。
|
||||
|
||||
**Rationale**: 灵活性,适应不同场景。
|
||||
|
||||
**Validation**:
|
||||
- [ ] 交互模式:每步显示菜单
|
||||
- [ ] 自动模式:按预设流程执行
|
||||
- [ ] 用户可随时退出
|
||||
- [ ] 状态可恢复
|
||||
|
||||
### 5. 可恢复性
|
||||
|
||||
**Requirement**: 任何时候中断后,可以从上次位置继续。
|
||||
|
||||
**Rationale**: 长时间任务支持,意外中断恢复。
|
||||
|
||||
**Validation**:
|
||||
- [ ] 状态保存在 state.json
|
||||
- [ ] 使用 --resume 可继续
|
||||
- [ ] 历史记录完整保留
|
||||
|
||||
## Quality Standards
|
||||
|
||||
### Completeness
|
||||
|
||||
| Dimension | Threshold |
|
||||
|-----------|-----------|
|
||||
| 进度文档完整性 | 每个任务都有记录 |
|
||||
| 理解文档演变 | 每次迭代都有更新 |
|
||||
| 验证报告详尽 | 包含所有测试结果 |
|
||||
|
||||
### Consistency
|
||||
|
||||
| Dimension | Threshold |
|
||||
|-----------|-----------|
|
||||
| 文件格式一致 | 所有 Markdown 文件使用相同模板 |
|
||||
| 状态同步一致 | state.json 与文件内容匹配 |
|
||||
| 时间戳格式 | 统一使用 ISO8601 格式 |
|
||||
|
||||
### Usability
|
||||
|
||||
| Dimension | Threshold |
|
||||
|-----------|-----------|
|
||||
| 菜单易用性 | 选项清晰,描述准确 |
|
||||
| 进度可见性 | 随时可查看当前状态 |
|
||||
| 错误提示 | 错误消息清晰,提供恢复建议 |
|
||||
|
||||
## Constraints
|
||||
|
||||
### 1. 文件结构约束
|
||||
|
||||
```
|
||||
.workflow/.loop/{session-id}/
|
||||
├── meta.json # 只写一次,不再修改
|
||||
├── state.json # 每次 action 后更新
|
||||
├── develop/
|
||||
│ ├── progress.md # 只追加,不删除
|
||||
│ ├── tasks.json # 任务状态更新
|
||||
│ └── changes.log # NDJSON 格式,只追加
|
||||
├── debug/
|
||||
│ ├── understanding.md # 只追加,记录时间线
|
||||
│ ├── hypotheses.json # 更新假设状态
|
||||
│ └── debug.log # NDJSON 格式
|
||||
└── validate/
|
||||
├── validation.md # 每次验证追加
|
||||
├── test-results.json # 累积测试结果
|
||||
└── coverage.json # 最新覆盖率
|
||||
```
|
||||
|
||||
### 2. 命名约束
|
||||
|
||||
- Session ID: `LOOP-{slug}-{YYYY-MM-DD}`
|
||||
- Task ID: `task-{NNN}` (三位数字)
|
||||
- Hypothesis ID: `H{N}` (单字母+数字)
|
||||
|
||||
### 3. 状态转换约束
|
||||
|
||||
```
|
||||
pending → running → completed
|
||||
↓
|
||||
user_exit
|
||||
↓
|
||||
failed
|
||||
```
|
||||
|
||||
Only allow: `pending→running`, `running→completed/user_exit/failed`
|
||||
|
||||
### 4. 错误限制约束
|
||||
|
||||
- 最大错误次数: 3
|
||||
- 超过 3 次错误 → 自动终止
|
||||
- 每次错误 → 记录到 state.errors[]
|
||||
|
||||
### 5. 迭代限制约束
|
||||
|
||||
- 最大迭代次数: 10 (警告)
|
||||
- 超过 10 次 → 警告用户,但不强制停止
|
||||
- 建议拆分任务或休息
|
||||
|
||||
## Integration Requirements
|
||||
|
||||
### 1. Dashboard 集成
|
||||
|
||||
**Requirement**: 与 CCW Dashboard Loop Monitor 无缝集成。
|
||||
|
||||
**Specification**:
|
||||
- Dashboard 创建 Loop → 调用此 Skill
|
||||
- state.json → Dashboard 实时显示
|
||||
- 任务列表双向同步
|
||||
- 状态控制按钮映射到 actions
|
||||
|
||||
### 2. Issue 系统集成
|
||||
|
||||
**Requirement**: 完成后可扩展为 Issue。
|
||||
|
||||
**Specification**:
|
||||
- 支持维度: test, enhance, refactor, doc
|
||||
- 调用 `/issue:new "{summary} - {dimension}"`
|
||||
- 自动填充上下文
|
||||
|
||||
### 3. CLI 工具集成
|
||||
|
||||
**Requirement**: 使用 CCW CLI 工具进行分析和实现。
|
||||
|
||||
**Specification**:
|
||||
- 任务分解: `--rule planning-breakdown-task-steps`
|
||||
- 代码实现: `--rule development-implement-feature`
|
||||
- 根因分析: `--rule analysis-diagnose-bug-root-cause`
|
||||
- 质量评估: `--rule analysis-review-code-quality`
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
### Performance
|
||||
|
||||
- Session 初始化: < 5s
|
||||
- Action 执行: < 30s (不含 CLI 调用)
|
||||
- 状态读写: < 1s
|
||||
|
||||
### Reliability
|
||||
|
||||
- 状态文件损坏恢复: 支持从其他文件重建
|
||||
- CLI 工具失败降级: 回退到手动模式
|
||||
- 错误重试: 支持一次自动重试
|
||||
|
||||
### Maintainability
|
||||
|
||||
- 文档化: 所有 action 都有清晰说明
|
||||
- 模块化: 每个 action 独立可测
|
||||
- 可扩展: 易于添加新 action
|
||||
@@ -1,175 +0,0 @@
|
||||
# Progress Document Template
|
||||
|
||||
开发进度文档的标准模板。
|
||||
|
||||
## Template Structure
|
||||
|
||||
```markdown
|
||||
# Development Progress
|
||||
|
||||
**Session ID**: {{session_id}}
|
||||
**Task**: {{task_description}}
|
||||
**Started**: {{started_at}}
|
||||
**Estimated Complexity**: {{complexity}}
|
||||
|
||||
---
|
||||
|
||||
## Task List
|
||||
|
||||
{{#each tasks}}
|
||||
{{@index}}. [{{#if completed}}x{{else}} {{/if}}] {{description}}
|
||||
{{/each}}
|
||||
|
||||
## Key Files
|
||||
|
||||
{{#each key_files}}
|
||||
- `{{this}}`
|
||||
{{/each}}
|
||||
|
||||
---
|
||||
|
||||
## Progress Timeline
|
||||
|
||||
{{#each iterations}}
|
||||
### Iteration {{@index}} - {{task_name}} ({{timestamp}})
|
||||
|
||||
#### Task Details
|
||||
|
||||
- **ID**: {{task_id}}
|
||||
- **Tool**: {{tool}}
|
||||
- **Mode**: {{mode}}
|
||||
|
||||
#### Implementation Summary
|
||||
|
||||
{{summary}}
|
||||
|
||||
#### Files Changed
|
||||
|
||||
{{#each files_changed}}
|
||||
- `{{this}}`
|
||||
{{/each}}
|
||||
|
||||
#### Status: {{status}}
|
||||
|
||||
---
|
||||
{{/each}}
|
||||
|
||||
## Current Statistics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tasks | {{total_tasks}} |
|
||||
| Completed | {{completed_tasks}} |
|
||||
| In Progress | {{in_progress_tasks}} |
|
||||
| Pending | {{pending_tasks}} |
|
||||
| Progress | {{progress_percentage}}% |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
{{#each next_steps}}
|
||||
- [ ] {{this}}
|
||||
{{/each}}
|
||||
```
|
||||
|
||||
## Template Variables
|
||||
|
||||
| Variable | Type | Source | Description |
|
||||
|----------|------|--------|-------------|
|
||||
| `session_id` | string | state.session_id | 会话 ID |
|
||||
| `task_description` | string | state.task_description | 任务描述 |
|
||||
| `started_at` | string | state.created_at | 开始时间 |
|
||||
| `complexity` | string | state.context.estimated_complexity | 预估复杂度 |
|
||||
| `tasks` | array | state.develop.tasks | 任务列表 |
|
||||
| `key_files` | array | state.context.key_files | 关键文件 |
|
||||
| `iterations` | array | 从文件解析 | 迭代历史 |
|
||||
| `total_tasks` | number | state.develop.total_count | 总任务数 |
|
||||
| `completed_tasks` | number | state.develop.completed_count | 已完成数 |
|
||||
|
||||
## Usage Example
|
||||
|
||||
```javascript
|
||||
const progressTemplate = Read('.claude/skills/ccw-loop/templates/progress-template.md')
|
||||
|
||||
function renderProgress(state) {
|
||||
let content = progressTemplate
|
||||
|
||||
// 替换简单变量
|
||||
content = content.replace('{{session_id}}', state.session_id)
|
||||
content = content.replace('{{task_description}}', state.task_description)
|
||||
content = content.replace('{{started_at}}', state.created_at)
|
||||
content = content.replace('{{complexity}}', state.context?.estimated_complexity || 'unknown')
|
||||
|
||||
// 替换任务列表
|
||||
const taskList = state.develop.tasks.map((t, i) => {
|
||||
const checkbox = t.status === 'completed' ? 'x' : ' '
|
||||
return `${i + 1}. [${checkbox}] ${t.description}`
|
||||
}).join('\n')
|
||||
content = content.replace('{{#each tasks}}...{{/each}}', taskList)
|
||||
|
||||
// 替换统计
|
||||
content = content.replace('{{total_tasks}}', state.develop.total_count)
|
||||
content = content.replace('{{completed_tasks}}', state.develop.completed_count)
|
||||
// ...
|
||||
|
||||
return content
|
||||
}
|
||||
```
|
||||
|
||||
## Section Templates
|
||||
|
||||
### Task Entry
|
||||
|
||||
```markdown
|
||||
### Iteration {{N}} - {{task_name}} ({{timestamp}})
|
||||
|
||||
#### Task Details
|
||||
|
||||
- **ID**: {{task_id}}
|
||||
- **Tool**: {{tool}}
|
||||
- **Mode**: {{mode}}
|
||||
|
||||
#### Implementation Summary
|
||||
|
||||
{{summary}}
|
||||
|
||||
#### Files Changed
|
||||
|
||||
{{#each files}}
|
||||
- `{{this}}`
|
||||
{{/each}}
|
||||
|
||||
#### Status: COMPLETED
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
### Statistics Table
|
||||
|
||||
```markdown
|
||||
## Current Statistics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tasks | {{total}} |
|
||||
| Completed | {{completed}} |
|
||||
| In Progress | {{in_progress}} |
|
||||
| Pending | {{pending}} |
|
||||
| Progress | {{percentage}}% |
|
||||
```
|
||||
|
||||
### Next Steps
|
||||
|
||||
```markdown
|
||||
## Next Steps
|
||||
|
||||
{{#if all_completed}}
|
||||
- [ ] Run validation tests
|
||||
- [ ] Code review
|
||||
- [ ] Update documentation
|
||||
{{else}}
|
||||
- [ ] Complete remaining {{pending}} tasks
|
||||
- [ ] Review completed work
|
||||
{{/if}}
|
||||
```
|
||||
@@ -1,303 +0,0 @@
|
||||
# Understanding Document Template
|
||||
|
||||
调试理解演变文档的标准模板。
|
||||
|
||||
## Template Structure
|
||||
|
||||
```markdown
|
||||
# Understanding Document
|
||||
|
||||
**Session ID**: {{session_id}}
|
||||
**Bug Description**: {{bug_description}}
|
||||
**Started**: {{started_at}}
|
||||
|
||||
---
|
||||
|
||||
## Exploration Timeline
|
||||
|
||||
{{#each iterations}}
|
||||
### Iteration {{number}} - {{title}} ({{timestamp}})
|
||||
|
||||
{{#if is_exploration}}
|
||||
#### Current Understanding
|
||||
|
||||
Based on bug description and initial code search:
|
||||
|
||||
- Error pattern: {{error_pattern}}
|
||||
- Affected areas: {{affected_areas}}
|
||||
- Initial hypothesis: {{initial_thoughts}}
|
||||
|
||||
#### Evidence from Code Search
|
||||
|
||||
{{#each search_results}}
|
||||
**Keyword: "{{keyword}}"**
|
||||
- Found in: {{files}}
|
||||
- Key findings: {{insights}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
{{#if has_hypotheses}}
|
||||
#### Hypotheses Generated (Gemini-Assisted)
|
||||
|
||||
{{#each hypotheses}}
|
||||
**{{id}}** (Likelihood: {{likelihood}}): {{description}}
|
||||
- Logging at: {{logging_point}}
|
||||
- Testing: {{testable_condition}}
|
||||
- Evidence to confirm: {{confirm_criteria}}
|
||||
- Evidence to reject: {{reject_criteria}}
|
||||
{{/each}}
|
||||
|
||||
**Gemini Insights**: {{gemini_insights}}
|
||||
{{/if}}
|
||||
|
||||
{{#if is_analysis}}
|
||||
#### Log Analysis Results
|
||||
|
||||
{{#each results}}
|
||||
**{{id}}**: {{verdict}}
|
||||
- Evidence: {{evidence}}
|
||||
- Reasoning: {{reason}}
|
||||
{{/each}}
|
||||
|
||||
#### Corrected Understanding
|
||||
|
||||
Previous misunderstandings identified and corrected:
|
||||
|
||||
{{#each corrections}}
|
||||
- ~~{{wrong}}~~ → {{corrected}}
|
||||
- Why wrong: {{reason}}
|
||||
- Evidence: {{evidence}}
|
||||
{{/each}}
|
||||
|
||||
#### New Insights
|
||||
|
||||
{{#each insights}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
|
||||
#### Gemini Analysis
|
||||
|
||||
{{gemini_analysis}}
|
||||
{{/if}}
|
||||
|
||||
{{#if root_cause_found}}
|
||||
#### Root Cause Identified
|
||||
|
||||
**{{hypothesis_id}}**: {{description}}
|
||||
|
||||
Evidence supporting this conclusion:
|
||||
{{supporting_evidence}}
|
||||
{{else}}
|
||||
#### Next Steps
|
||||
|
||||
{{next_steps}}
|
||||
{{/if}}
|
||||
|
||||
---
|
||||
{{/each}}
|
||||
|
||||
## Current Consolidated Understanding
|
||||
|
||||
### What We Know
|
||||
|
||||
{{#each valid_understandings}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
|
||||
### What Was Disproven
|
||||
|
||||
{{#each disproven}}
|
||||
- ~~{{assumption}}~~ (Evidence: {{evidence}})
|
||||
{{/each}}
|
||||
|
||||
### Current Investigation Focus
|
||||
|
||||
{{current_focus}}
|
||||
|
||||
### Remaining Questions
|
||||
|
||||
{{#each questions}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
```
|
||||
|
||||
## Template Variables
|
||||
|
||||
| Variable | Type | Source | Description |
|
||||
|----------|------|--------|-------------|
|
||||
| `session_id` | string | state.session_id | 会话 ID |
|
||||
| `bug_description` | string | state.debug.current_bug | Bug 描述 |
|
||||
| `iterations` | array | 从文件解析 | 迭代历史 |
|
||||
| `hypotheses` | array | state.debug.hypotheses | 假设列表 |
|
||||
| `valid_understandings` | array | 从 Gemini 分析 | 有效理解 |
|
||||
| `disproven` | array | 从假设状态 | 被否定的假设 |
|
||||
|
||||
## Section Templates
|
||||
|
||||
### Exploration Section
|
||||
|
||||
```markdown
|
||||
### Iteration {{N}} - Initial Exploration ({{timestamp}})
|
||||
|
||||
#### Current Understanding
|
||||
|
||||
Based on bug description and initial code search:
|
||||
|
||||
- Error pattern: {{pattern}}
|
||||
- Affected areas: {{areas}}
|
||||
- Initial hypothesis: {{thoughts}}
|
||||
|
||||
#### Evidence from Code Search
|
||||
|
||||
{{#each search_results}}
|
||||
**Keyword: "{{keyword}}"**
|
||||
- Found in: {{files}}
|
||||
- Key findings: {{insights}}
|
||||
{{/each}}
|
||||
|
||||
#### Next Steps
|
||||
|
||||
- Generate testable hypotheses
|
||||
- Add instrumentation
|
||||
- Await reproduction
|
||||
```
|
||||
|
||||
### Hypothesis Section
|
||||
|
||||
```markdown
|
||||
#### Hypotheses Generated (Gemini-Assisted)
|
||||
|
||||
| ID | Description | Likelihood | Status |
|
||||
|----|-------------|------------|--------|
|
||||
{{#each hypotheses}}
|
||||
| {{id}} | {{description}} | {{likelihood}} | {{status}} |
|
||||
{{/each}}
|
||||
|
||||
**Details:**
|
||||
|
||||
{{#each hypotheses}}
|
||||
**{{id}}**: {{description}}
|
||||
- Logging at: `{{logging_point}}`
|
||||
- Testing: {{testable_condition}}
|
||||
- Confirm: {{evidence_criteria.confirm}}
|
||||
- Reject: {{evidence_criteria.reject}}
|
||||
{{/each}}
|
||||
```
|
||||
|
||||
### Analysis Section
|
||||
|
||||
```markdown
|
||||
### Iteration {{N}} - Evidence Analysis ({{timestamp}})
|
||||
|
||||
#### Log Analysis Results
|
||||
|
||||
{{#each results}}
|
||||
**{{id}}**: **{{verdict}}**
|
||||
- Evidence: \`{{evidence}}\`
|
||||
- Reasoning: {{reason}}
|
||||
{{/each}}
|
||||
|
||||
#### Corrected Understanding
|
||||
|
||||
| Previous Assumption | Corrected To | Reason |
|
||||
|---------------------|--------------|--------|
|
||||
{{#each corrections}}
|
||||
| ~~{{wrong}}~~ | {{corrected}} | {{reason}} |
|
||||
{{/each}}
|
||||
|
||||
#### Gemini Analysis
|
||||
|
||||
{{gemini_analysis}}
|
||||
```
|
||||
|
||||
### Consolidated Understanding Section
|
||||
|
||||
```markdown
|
||||
## Current Consolidated Understanding
|
||||
|
||||
### What We Know
|
||||
|
||||
{{#each valid}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
|
||||
### What Was Disproven
|
||||
|
||||
{{#each disproven}}
|
||||
- ~~{{this.assumption}}~~ (Evidence: {{this.evidence}})
|
||||
{{/each}}
|
||||
|
||||
### Current Investigation Focus
|
||||
|
||||
{{focus}}
|
||||
|
||||
### Remaining Questions
|
||||
|
||||
{{#each questions}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
```
|
||||
|
||||
### Resolution Section
|
||||
|
||||
```markdown
|
||||
### Resolution ({{timestamp}})
|
||||
|
||||
#### Fix Applied
|
||||
|
||||
- Modified files: {{files}}
|
||||
- Fix description: {{description}}
|
||||
- Root cause addressed: {{root_cause}}
|
||||
|
||||
#### Verification Results
|
||||
|
||||
{{verification}}
|
||||
|
||||
#### Lessons Learned
|
||||
|
||||
{{#each lessons}}
|
||||
{{@index}}. {{this}}
|
||||
{{/each}}
|
||||
|
||||
#### Key Insights for Future
|
||||
|
||||
{{#each insights}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
```
|
||||
|
||||
## Consolidation Rules
|
||||
|
||||
更新 "Current Consolidated Understanding" 时遵循以下规则:
|
||||
|
||||
1. **简化被否定项**: 移到 "What Was Disproven",只保留单行摘要
|
||||
2. **保留有效见解**: 将确认的发现提升到 "What We Know"
|
||||
3. **避免重复**: 不在合并部分重复时间线细节
|
||||
4. **关注当前状态**: 描述现在知道什么,而不是过程
|
||||
5. **保留关键纠正**: 保留重要的 wrong→right 转换供学习
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
**错误示例 (冗余)**:
|
||||
```markdown
|
||||
## Current Consolidated Understanding
|
||||
|
||||
In iteration 1 we thought X, but in iteration 2 we found Y, then in iteration 3...
|
||||
Also we checked A and found B, and then we checked C...
|
||||
```
|
||||
|
||||
**正确示例 (精简)**:
|
||||
```markdown
|
||||
## Current Consolidated Understanding
|
||||
|
||||
### What We Know
|
||||
- Error occurs during runtime update, not initialization
|
||||
- Config value is None (not missing key)
|
||||
|
||||
### What Was Disproven
|
||||
- ~~Initialization error~~ (Timing evidence)
|
||||
- ~~Missing key hypothesis~~ (Key exists)
|
||||
|
||||
### Current Investigation Focus
|
||||
Why is config value None during update?
|
||||
```
|
||||
@@ -1,258 +0,0 @@
|
||||
# Validation Report Template
|
||||
|
||||
验证报告的标准模板。
|
||||
|
||||
## Template Structure
|
||||
|
||||
```markdown
|
||||
# Validation Report
|
||||
|
||||
**Session ID**: {{session_id}}
|
||||
**Task**: {{task_description}}
|
||||
**Validated**: {{timestamp}}
|
||||
|
||||
---
|
||||
|
||||
## Iteration {{iteration}} - Validation Run
|
||||
|
||||
### Test Execution Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tests | {{total_tests}} |
|
||||
| Passed | {{passed_tests}} |
|
||||
| Failed | {{failed_tests}} |
|
||||
| Skipped | {{skipped_tests}} |
|
||||
| Duration | {{duration}}ms |
|
||||
| **Pass Rate** | **{{pass_rate}}%** |
|
||||
|
||||
### Coverage Report
|
||||
|
||||
{{#if has_coverage}}
|
||||
| File | Statements | Branches | Functions | Lines |
|
||||
|------|------------|----------|-----------|-------|
|
||||
{{#each coverage_files}}
|
||||
| {{path}} | {{statements}}% | {{branches}}% | {{functions}}% | {{lines}}% |
|
||||
{{/each}}
|
||||
|
||||
**Overall Coverage**: {{overall_coverage}}%
|
||||
{{else}}
|
||||
_No coverage data available_
|
||||
{{/if}}
|
||||
|
||||
### Failed Tests
|
||||
|
||||
{{#if has_failures}}
|
||||
{{#each failures}}
|
||||
#### {{test_name}}
|
||||
|
||||
- **Suite**: {{suite}}
|
||||
- **Error**: {{error_message}}
|
||||
- **Stack**:
|
||||
\`\`\`
|
||||
{{stack_trace}}
|
||||
\`\`\`
|
||||
{{/each}}
|
||||
{{else}}
|
||||
_All tests passed_
|
||||
{{/if}}
|
||||
|
||||
### Gemini Quality Analysis
|
||||
|
||||
{{gemini_analysis}}
|
||||
|
||||
### Recommendations
|
||||
|
||||
{{#each recommendations}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
|
||||
---
|
||||
|
||||
## Validation Decision
|
||||
|
||||
**Result**: {{#if passed}}✅ PASS{{else}}❌ FAIL{{/if}}
|
||||
|
||||
**Rationale**: {{rationale}}
|
||||
|
||||
{{#if not_passed}}
|
||||
### Next Actions
|
||||
|
||||
1. Review failed tests
|
||||
2. Debug failures using action-debug-with-file
|
||||
3. Fix issues and re-run validation
|
||||
{{else}}
|
||||
### Next Actions
|
||||
|
||||
1. Consider code review
|
||||
2. Prepare for deployment
|
||||
3. Update documentation
|
||||
{{/if}}
|
||||
```
|
||||
|
||||
## Template Variables
|
||||
|
||||
| Variable | Type | Source | Description |
|
||||
|----------|------|--------|-------------|
|
||||
| `session_id` | string | state.session_id | 会话 ID |
|
||||
| `task_description` | string | state.task_description | 任务描述 |
|
||||
| `timestamp` | string | 当前时间 | 验证时间 |
|
||||
| `iteration` | number | 从文件计算 | 验证迭代次数 |
|
||||
| `total_tests` | number | 测试输出 | 总测试数 |
|
||||
| `passed_tests` | number | 测试输出 | 通过数 |
|
||||
| `failed_tests` | number | 测试输出 | 失败数 |
|
||||
| `pass_rate` | number | 计算得出 | 通过率 |
|
||||
| `coverage_files` | array | 覆盖率报告 | 文件覆盖率 |
|
||||
| `failures` | array | 测试输出 | 失败测试详情 |
|
||||
| `gemini_analysis` | string | Gemini CLI | 质量分析 |
|
||||
| `recommendations` | array | Gemini CLI | 建议列表 |
|
||||
|
||||
## Section Templates
|
||||
|
||||
### Test Summary
|
||||
|
||||
```markdown
|
||||
### Test Execution Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tests | {{total}} |
|
||||
| Passed | {{passed}} |
|
||||
| Failed | {{failed}} |
|
||||
| Skipped | {{skipped}} |
|
||||
| Duration | {{duration}}ms |
|
||||
| **Pass Rate** | **{{rate}}%** |
|
||||
```
|
||||
|
||||
### Coverage Table
|
||||
|
||||
```markdown
|
||||
### Coverage Report
|
||||
|
||||
| File | Statements | Branches | Functions | Lines |
|
||||
|------|------------|----------|-----------|-------|
|
||||
{{#each files}}
|
||||
| `{{path}}` | {{statements}}% | {{branches}}% | {{functions}}% | {{lines}}% |
|
||||
{{/each}}
|
||||
|
||||
**Overall Coverage**: {{overall}}%
|
||||
|
||||
**Coverage Thresholds**:
|
||||
- ✅ Good: ≥ 80%
|
||||
- ⚠️ Warning: 60-79%
|
||||
- ❌ Poor: < 60%
|
||||
```
|
||||
|
||||
### Failed Test Details
|
||||
|
||||
```markdown
|
||||
### Failed Tests
|
||||
|
||||
{{#each failures}}
|
||||
#### ❌ {{test_name}}
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Suite | {{suite}} |
|
||||
| Error | {{error_message}} |
|
||||
| Duration | {{duration}}ms |
|
||||
|
||||
**Stack Trace**:
|
||||
\`\`\`
|
||||
{{stack_trace}}
|
||||
\`\`\`
|
||||
|
||||
**Possible Causes**:
|
||||
{{#each possible_causes}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
|
||||
---
|
||||
{{/each}}
|
||||
```
|
||||
|
||||
### Quality Analysis
|
||||
|
||||
```markdown
|
||||
### Gemini Quality Analysis
|
||||
|
||||
#### Code Quality Assessment
|
||||
|
||||
| Dimension | Score | Status |
|
||||
|-----------|-------|--------|
|
||||
| Correctness | {{correctness}}/10 | {{correctness_status}} |
|
||||
| Completeness | {{completeness}}/10 | {{completeness_status}} |
|
||||
| Reliability | {{reliability}}/10 | {{reliability_status}} |
|
||||
| Maintainability | {{maintainability}}/10 | {{maintainability_status}} |
|
||||
|
||||
#### Key Findings
|
||||
|
||||
{{#each findings}}
|
||||
- **{{severity}}**: {{description}}
|
||||
{{/each}}
|
||||
|
||||
#### Recommendations
|
||||
|
||||
{{#each recommendations}}
|
||||
{{@index}}. {{this}}
|
||||
{{/each}}
|
||||
```
|
||||
|
||||
### Decision Section
|
||||
|
||||
```markdown
|
||||
## Validation Decision
|
||||
|
||||
**Result**: {{#if passed}}✅ PASS{{else}}❌ FAIL{{/if}}
|
||||
|
||||
**Rationale**:
|
||||
{{rationale}}
|
||||
|
||||
**Confidence Level**: {{confidence}}
|
||||
|
||||
### Decision Matrix
|
||||
|
||||
| Criteria | Status | Weight | Score |
|
||||
|----------|--------|--------|-------|
|
||||
| All tests pass | {{tests_pass}} | 40% | {{tests_score}} |
|
||||
| Coverage ≥ 80% | {{coverage_pass}} | 30% | {{coverage_score}} |
|
||||
| No critical issues | {{no_critical}} | 20% | {{critical_score}} |
|
||||
| Quality analysis pass | {{quality_pass}} | 10% | {{quality_score}} |
|
||||
| **Total** | | 100% | **{{total_score}}** |
|
||||
|
||||
**Threshold**: 70% to pass
|
||||
|
||||
### Next Actions
|
||||
|
||||
{{#if passed}}
|
||||
1. ✅ Code review (recommended)
|
||||
2. ✅ Update documentation
|
||||
3. ✅ Prepare for deployment
|
||||
{{else}}
|
||||
1. ❌ Review failed tests
|
||||
2. ❌ Debug failures
|
||||
3. ❌ Fix issues and re-run
|
||||
{{/if}}
|
||||
```
|
||||
|
||||
## Historical Comparison
|
||||
|
||||
```markdown
|
||||
## Validation History
|
||||
|
||||
| Iteration | Date | Pass Rate | Coverage | Status |
|
||||
|-----------|------|-----------|----------|--------|
|
||||
{{#each history}}
|
||||
| {{iteration}} | {{date}} | {{pass_rate}}% | {{coverage}}% | {{status}} |
|
||||
{{/each}}
|
||||
|
||||
### Trend Analysis
|
||||
|
||||
{{#if improving}}
|
||||
📈 **Improving**: Pass rate increased from {{previous_rate}}% to {{current_rate}}%
|
||||
{{else if declining}}
|
||||
📉 **Declining**: Pass rate decreased from {{previous_rate}}% to {{current_rate}}%
|
||||
{{else}}
|
||||
➡️ **Stable**: Pass rate remains at {{current_rate}}%
|
||||
{{/if}}
|
||||
```
|
||||
@@ -1,124 +0,0 @@
|
||||
---
|
||||
name: Prompt Enhancer
|
||||
description: Transform vague prompts into actionable specs using intelligent analysis and session memory. Use when user input contains -e or --enhance flag.
|
||||
allowed-tools: (none)
|
||||
---
|
||||
|
||||
# Prompt Enhancer
|
||||
|
||||
**Transform**: Vague intent → Structured specification (Memory-based, Direct Output)
|
||||
|
||||
**Languages**: English + Chinese (中英文语义识别)
|
||||
|
||||
## Process (Internal → Direct Output)
|
||||
|
||||
**Internal Analysis**: Intelligently extract session context, identify tech stack, and structure into actionable format.
|
||||
|
||||
**Output**: Direct structured prompt (no intermediate steps shown)
|
||||
|
||||
## Output Format
|
||||
|
||||
**Dynamic Structure**: Adapt fields based on task type and context needs. Not all fields are required.
|
||||
|
||||
**Core Fields** (always present):
|
||||
- **INTENT**: One-sentence technical goal
|
||||
- **ACTION**: Concrete steps with technical details
|
||||
|
||||
**Optional Fields** (include when relevant):
|
||||
- **TECH STACK**: Relevant technologies (when tech-specific)
|
||||
- **CONTEXT**: Session memory findings (when context matters)
|
||||
- **ATTENTION**: Critical constraints (when risks/requirements exist)
|
||||
- **SCOPE**: Affected modules/files (for multi-module tasks)
|
||||
- **METRICS**: Success criteria (for optimization/performance tasks)
|
||||
- **DEPENDENCIES**: Related components (for integration tasks)
|
||||
|
||||
**Example (Simple Task)**:
|
||||
```
|
||||
📋 ENHANCED PROMPT
|
||||
|
||||
INTENT: Fix authentication token validation in JWT middleware
|
||||
|
||||
ACTION:
|
||||
1. Review token expiration logic in auth middleware
|
||||
2. Add proper error handling for expired tokens
|
||||
3. Test with valid/expired/malformed tokens
|
||||
```
|
||||
|
||||
**Example (Complex Task)**:
|
||||
```
|
||||
📋 ENHANCED PROMPT
|
||||
|
||||
INTENT: Optimize API performance with caching and database indexing
|
||||
|
||||
TECH STACK:
|
||||
- Redis: Response caching
|
||||
- PostgreSQL: Query optimization
|
||||
|
||||
CONTEXT:
|
||||
- API response times >2s mentioned in previous conversation
|
||||
- PostgreSQL slow query logs show N+1 problems
|
||||
|
||||
ACTION:
|
||||
1. Profile endpoints to identify slow queries
|
||||
2. Add PostgreSQL indexes on frequently queried columns
|
||||
3. Implement Redis caching for read-heavy endpoints
|
||||
4. Add cache invalidation on data updates
|
||||
|
||||
METRICS:
|
||||
- Target: <500ms API response time
|
||||
- Cache hit ratio: >80%
|
||||
|
||||
ATTENTION:
|
||||
- Maintain backward compatibility with existing API contracts
|
||||
- Handle cache invalidation correctly to avoid stale data
|
||||
```
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Trigger (-e/--enhance) → Internal Analysis → Dynamic Output
|
||||
↓ ↓ ↓
|
||||
User Input Assess Task Type Select Fields
|
||||
Extract Memory Context Structure Prompt
|
||||
```
|
||||
|
||||
1. **Detect**: User input contains `-e` or `--enhance`
|
||||
2. **Analyze**:
|
||||
- Determine task type (fix/optimize/implement/refactor)
|
||||
- Extract relevant session context
|
||||
- Identify tech stack and constraints
|
||||
3. **Structure**:
|
||||
- Always include: INTENT + ACTION
|
||||
- Conditionally add: TECH STACK, CONTEXT, ATTENTION, METRICS, etc.
|
||||
4. **Output**: Present dynamically structured prompt
|
||||
|
||||
## Enhancement Guidelines (Internal)
|
||||
|
||||
**Always Include**:
|
||||
- Clear, actionable INTENT
|
||||
- Concrete ACTION steps with technical details
|
||||
|
||||
**Add When Relevant**:
|
||||
- TECH STACK: Task involves specific technologies
|
||||
- CONTEXT: Session memory provides useful background
|
||||
- ATTENTION: Security/compatibility/performance concerns exist
|
||||
- SCOPE: Multi-module or cross-component changes
|
||||
- METRICS: Performance/optimization goals need measurement
|
||||
- DEPENDENCIES: Integration points matter
|
||||
|
||||
**Quality Checks**:
|
||||
- Make vague intent explicit
|
||||
- Resolve ambiguous references
|
||||
- Add testing/validation steps
|
||||
- Include constraints from memory
|
||||
|
||||
## Best Practices
|
||||
|
||||
- ✅ Trigger only on `-e`/`--enhance` flags
|
||||
- ✅ Use **dynamic field selection** based on task type
|
||||
- ✅ Extract **memory context ONLY** (no file reading)
|
||||
- ✅ Always include INTENT + ACTION as core fields
|
||||
- ✅ Add optional fields only when relevant to task
|
||||
- ✅ Direct output (no intermediate steps shown)
|
||||
- ❌ NO tool calls
|
||||
- ❌ NO file operations (Bash, Read, Glob, Grep)
|
||||
- ❌ NO fixed template - adapt to task needs
|
||||
@@ -1,196 +0,0 @@
|
||||
---
|
||||
name: text-formatter
|
||||
description: Transform and optimize text content with intelligent formatting. Output BBCode + Markdown hybrid format optimized for forums. Triggers on "format text", "text formatter", "排版", "格式化文本", "BBCode".
|
||||
allowed-tools: Task, AskUserQuestion, Read, Write, Bash, Glob
|
||||
---
|
||||
|
||||
# Text Formatter
|
||||
|
||||
Transform and optimize text content with intelligent structure analysis. Output format: **BBCode + Markdown hybrid** optimized for forum publishing.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Text Formatter Architecture (BBCode + MD Mode) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Phase 1: Input Collection → 接收文本/文件 │
|
||||
│ ↓ │
|
||||
│ Phase 2: Content Analysis → 分析结构、识别 Callout/Admonition │
|
||||
│ ↓ │
|
||||
│ Phase 3: Format Transform → 转换为 BBCode+MD 格式 │
|
||||
│ ↓ │
|
||||
│ Phase 4: Output & Preview → 保存文件 + 预览 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Single Format Output**: BBCode + Markdown hybrid (forum optimized)
|
||||
2. **Pixel-Based Sizing**: size=150/120/100/80 (not 1-7 levels)
|
||||
3. **Forum Compatibility**: Only use widely-supported BBCode tags
|
||||
4. **Markdown Separators**: Use `---` for horizontal rules (not `[hr]`)
|
||||
5. **No Alignment Tags**: `[align]` not supported, avoid usage
|
||||
|
||||
---
|
||||
|
||||
## Format Specification
|
||||
|
||||
### Supported BBCode Tags
|
||||
|
||||
| Tag | Usage | Example |
|
||||
|-----|-------|---------|
|
||||
| `[size=N]` | Font size (pixels) | `[size=120]Title[/size]` |
|
||||
| `[color=X]` | Text color (hex/name) | `[color=#2196F3]Blue[/color]` 或 `[color=blue]` |
|
||||
| `[b]` | Bold | `[b]Bold text[/b]` |
|
||||
| `[i]` | Italic | `[i]Italic[/i]` |
|
||||
| `[s]` | Strikethrough | `[s]deleted[/s]` |
|
||||
| `[u]` | Underline | `[u]underlined[/u]` |
|
||||
| `[quote]` | Quote block | `[quote]Content[/quote]` |
|
||||
| `[code]` | Code block | `[code]code[/code]` |
|
||||
| `[img]` | Image | `[img]url[/img]` |
|
||||
| `[url]` | Link | `[url=link]text[/url]` |
|
||||
| `[list]` | List container | `[list][*]item[/list]` |
|
||||
| `[spoiler]` | Collapsible content | `[spoiler=标题]隐藏内容[/spoiler]` |
|
||||
|
||||
### HTML to BBCode Conversion
|
||||
|
||||
| HTML Input | BBCode Output |
|
||||
|------------|---------------|
|
||||
| `<mark>高亮</mark>` | `[color=yellow]高亮[/color]` |
|
||||
| `<u>下划线</u>` | `[u]下划线[/u]` |
|
||||
| `<details><summary>标题</summary>内容</details>` | `[spoiler=标题]内容[/spoiler]` |
|
||||
|
||||
### Unsupported Tags (Avoid!)
|
||||
|
||||
| Tag | Reason | Alternative |
|
||||
|-----|--------|-------------|
|
||||
| `[align]` | Not rendered | Remove or use default left |
|
||||
| `[hr]` | Shows as text | Use Markdown `---` |
|
||||
| `<div>` | HTML not supported | Use BBCode only |
|
||||
| `[table]` | Limited support | Use list or code block |
|
||||
|
||||
### Size Hierarchy (Pixels)
|
||||
|
||||
| Element | Size | Color | Usage |
|
||||
|---------|------|-------|-------|
|
||||
| **Main Title** | 150 | #2196F3 | Document title |
|
||||
| **Section Title** | 120 | #2196F3 | Major sections (## H2) |
|
||||
| **Subsection** | 100 | #333 | Sub-sections (### H3) |
|
||||
| **Normal Text** | (default) | - | Body content |
|
||||
| **Notes/Gray** | 80 | gray | Footnotes, metadata |
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Color | Hex | Semantic Usage |
|
||||
|-------|-----|----------------|
|
||||
| **Blue** | #2196F3 | Titles, links, info |
|
||||
| **Green** | #4CAF50 | Success, tips, features |
|
||||
| **Orange** | #FF9800 | Warnings, caution |
|
||||
| **Red** | #F44336 | Errors, danger, important |
|
||||
| **Purple** | #9C27B0 | Examples, code |
|
||||
| **Gray** | gray | Notes, metadata |
|
||||
|
||||
---
|
||||
|
||||
## Mandatory Prerequisites
|
||||
|
||||
> Read before execution:
|
||||
|
||||
| Document | Purpose | Priority |
|
||||
|----------|---------|----------|
|
||||
| [specs/format-rules.md](specs/format-rules.md) | Format conversion rules | **P0** |
|
||||
| [specs/element-mapping.md](specs/element-mapping.md) | Element type mappings | P1 |
|
||||
| [specs/callout-types.md](specs/callout-types.md) | Callout/Admonition types | P1 |
|
||||
|
||||
---
|
||||
|
||||
## Execution Flow
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Phase 1: Input Collection │
|
||||
│ - Ask: paste text OR file path │
|
||||
│ - Output: input-config.json │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ Phase 2: Content Analysis │
|
||||
│ - Detect structure: headings, lists, code blocks, tables │
|
||||
│ - Identify Callouts/Admonitions (>[!type]) │
|
||||
│ - Output: analysis.json │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ Phase 3: Format Transform │
|
||||
│ - Apply BBCode + MD rules from specs/format-rules.md │
|
||||
│ - Convert elements with pixel-based sizes │
|
||||
│ - Use Markdown --- for separators │
|
||||
│ - Output: formatted content │
|
||||
├────────────────────────────────────────────────────────────────┤
|
||||
│ Phase 4: Output & Preview │
|
||||
│ - Save to .bbcode.txt file │
|
||||
│ - Display preview │
|
||||
│ - Output: final file │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Callout/Admonition Support
|
||||
|
||||
支持 Obsidian 风格的 Callout 语法,转换为 BBCode quote:
|
||||
|
||||
```markdown
|
||||
> [!NOTE]
|
||||
> 这是一个提示信息
|
||||
|
||||
> [!WARNING]
|
||||
> 这是一个警告信息
|
||||
```
|
||||
|
||||
转换结果:
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color=#2196F3][b]📝 注意[/b][/color][/size]
|
||||
|
||||
这是一个提示信息
|
||||
[/quote]
|
||||
```
|
||||
|
||||
| Type | Color | Icon |
|
||||
|------|-------|------|
|
||||
| NOTE/INFO | #2196F3 | 📝 |
|
||||
| TIP/HINT | #4CAF50 | 💡 |
|
||||
| SUCCESS | #4CAF50 | ✅ |
|
||||
| WARNING/CAUTION | #FF9800 | ⚠️ |
|
||||
| DANGER/ERROR | #F44336 | ❌ |
|
||||
| EXAMPLE | #9C27B0 | 📋 |
|
||||
|
||||
## Directory Setup
|
||||
|
||||
```javascript
|
||||
const timestamp = new Date().toISOString().slice(0,10).replace(/-/g, '');
|
||||
const workDir = `.workflow/.scratchpad/text-formatter-${timestamp}`;
|
||||
|
||||
Bash(`mkdir -p "${workDir}"`);
|
||||
```
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
.workflow/.scratchpad/text-formatter-{date}/
|
||||
├── input-config.json # 输入配置
|
||||
├── analysis.json # 内容分析结果
|
||||
└── output.bbcode.txt # BBCode+MD 输出
|
||||
```
|
||||
|
||||
## Reference Documents
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [phases/01-input-collection.md](phases/01-input-collection.md) | 收集输入内容 |
|
||||
| [phases/02-content-analysis.md](phases/02-content-analysis.md) | 分析内容结构 |
|
||||
| [phases/03-format-transform.md](phases/03-format-transform.md) | 格式转换 |
|
||||
| [phases/04-output-preview.md](phases/04-output-preview.md) | 输出和预览 |
|
||||
| [specs/format-rules.md](specs/format-rules.md) | 格式转换规则 |
|
||||
| [specs/element-mapping.md](specs/element-mapping.md) | 元素映射表 |
|
||||
| [specs/callout-types.md](specs/callout-types.md) | Callout 类型定义 |
|
||||
| [templates/bbcode-template.md](templates/bbcode-template.md) | BBCode 模板 |
|
||||
@@ -1,111 +0,0 @@
|
||||
# Phase 1: Input Collection
|
||||
|
||||
收集用户输入的文本内容。
|
||||
|
||||
## Objective
|
||||
|
||||
- 获取用户输入内容(直接粘贴或文件路径)
|
||||
- 生成输入配置文件
|
||||
|
||||
**注意**: 输出格式固定为 BBCode + Markdown 混合格式(论坛优化),无需选择。
|
||||
|
||||
## Input
|
||||
|
||||
- 来源: 用户交互
|
||||
- 配置: 无前置依赖
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: 询问输入方式
|
||||
|
||||
```javascript
|
||||
const inputMethod = await AskUserQuestion({
|
||||
questions: [
|
||||
{
|
||||
question: "请选择输入方式",
|
||||
header: "输入方式",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "直接粘贴文本", description: "在对话中粘贴要格式化的内容" },
|
||||
{ label: "指定文件路径", description: "读取指定文件的内容" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: 获取内容
|
||||
|
||||
```javascript
|
||||
let content = '';
|
||||
|
||||
if (inputMethod["输入方式"] === "直接粘贴文本") {
|
||||
// 提示用户粘贴内容
|
||||
const textInput = await AskUserQuestion({
|
||||
questions: [
|
||||
{
|
||||
question: "请粘贴要格式化的文本内容(粘贴后选择确认)",
|
||||
header: "文本内容",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "已粘贴完成", description: "确认已在上方粘贴内容" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
// 从用户消息中提取文本内容
|
||||
content = extractUserText();
|
||||
} else {
|
||||
// 询问文件路径
|
||||
const filePath = await AskUserQuestion({
|
||||
questions: [
|
||||
{
|
||||
question: "请输入文件路径",
|
||||
header: "文件路径",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "已输入路径", description: "确认路径已在上方输入" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
content = Read(extractedFilePath);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: 保存配置
|
||||
|
||||
```javascript
|
||||
const config = {
|
||||
input_method: inputMethod["输入方式"],
|
||||
target_format: "BBCode+MD", // 固定格式
|
||||
original_content: content,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
Write(`${workDir}/input-config.json`, JSON.stringify(config, null, 2));
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
- **File**: `input-config.json`
|
||||
- **Format**: JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"input_method": "直接粘贴文本",
|
||||
"target_format": "BBCode+MD",
|
||||
"original_content": "...",
|
||||
"timestamp": "2026-01-13T..."
|
||||
}
|
||||
```
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
- [ ] 成功获取用户输入内容
|
||||
- [ ] 内容非空且有效
|
||||
- [ ] 配置文件已保存
|
||||
|
||||
## Next Phase
|
||||
|
||||
→ [Phase 2: Content Analysis](02-content-analysis.md)
|
||||
@@ -1,248 +0,0 @@
|
||||
# Phase 2: Content Analysis
|
||||
|
||||
分析输入内容的结构和语义元素。
|
||||
|
||||
## Objective
|
||||
|
||||
- 识别内容结构(标题、段落、列表等)
|
||||
- 检测特殊元素(代码块、表格、链接等)
|
||||
- 生成结构化分析结果
|
||||
|
||||
## Input
|
||||
|
||||
- 依赖: `input-config.json`
|
||||
- 配置: `{workDir}/input-config.json`
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: 加载输入
|
||||
|
||||
```javascript
|
||||
const config = JSON.parse(Read(`${workDir}/input-config.json`));
|
||||
const content = config.original_content;
|
||||
```
|
||||
|
||||
### Step 2: 结构分析
|
||||
|
||||
```javascript
|
||||
function analyzeStructure(text) {
|
||||
const analysis = {
|
||||
elements: [],
|
||||
stats: {
|
||||
headings: 0,
|
||||
paragraphs: 0,
|
||||
lists: 0,
|
||||
code_blocks: 0,
|
||||
tables: 0,
|
||||
links: 0,
|
||||
images: 0,
|
||||
quotes: 0,
|
||||
callouts: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Callout 检测正则 (Obsidian 风格)
|
||||
const CALLOUT_PATTERN = /^>\s*\[!(\w+)\](?:\s+(.+))?$/;
|
||||
|
||||
const lines = text.split('\n');
|
||||
let currentElement = null;
|
||||
let inCodeBlock = false;
|
||||
let inList = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// 检测代码块
|
||||
if (line.match(/^```/)) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
if (inCodeBlock) {
|
||||
analysis.elements.push({
|
||||
type: 'code_block',
|
||||
start: i,
|
||||
language: line.replace(/^```/, '').trim()
|
||||
});
|
||||
analysis.stats.code_blocks++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock) continue;
|
||||
|
||||
// 检测标题 (Markdown 或纯文本模式)
|
||||
if (line.match(/^#{1,6}\s/)) {
|
||||
const level = line.match(/^(#+)/)[1].length;
|
||||
analysis.elements.push({
|
||||
type: 'heading',
|
||||
level: level,
|
||||
content: line.replace(/^#+\s*/, ''),
|
||||
line: i
|
||||
});
|
||||
analysis.stats.headings++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检测列表
|
||||
if (line.match(/^[\s]*[-*+]\s/) || line.match(/^[\s]*\d+\.\s/)) {
|
||||
if (!inList) {
|
||||
analysis.elements.push({
|
||||
type: 'list',
|
||||
start: i,
|
||||
ordered: line.match(/^\d+\./) !== null
|
||||
});
|
||||
analysis.stats.lists++;
|
||||
inList = true;
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
inList = false;
|
||||
}
|
||||
|
||||
// 检测 Callout (Obsidian 风格) - 优先于普通引用
|
||||
const calloutMatch = line.match(CALLOUT_PATTERN);
|
||||
if (calloutMatch) {
|
||||
const calloutType = calloutMatch[1].toLowerCase();
|
||||
const calloutTitle = calloutMatch[2] || null;
|
||||
// 收集 Callout 内容行
|
||||
const calloutContent = [];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].startsWith('>')) {
|
||||
calloutContent.push(lines[j].replace(/^>\s*/, ''));
|
||||
j++;
|
||||
}
|
||||
analysis.elements.push({
|
||||
type: 'callout',
|
||||
calloutType: calloutType,
|
||||
title: calloutTitle,
|
||||
content: calloutContent.join('\n'),
|
||||
start: i,
|
||||
end: j - 1
|
||||
});
|
||||
analysis.stats.callouts++;
|
||||
i = j - 1; // 跳过已处理的行
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检测普通引用
|
||||
if (line.match(/^>\s/)) {
|
||||
analysis.elements.push({
|
||||
type: 'quote',
|
||||
content: line.replace(/^>\s*/, ''),
|
||||
line: i
|
||||
});
|
||||
analysis.stats.quotes++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检测表格
|
||||
if (line.match(/^\|.*\|$/)) {
|
||||
analysis.elements.push({
|
||||
type: 'table_row',
|
||||
line: i
|
||||
});
|
||||
if (!analysis.elements.find(e => e.type === 'table')) {
|
||||
analysis.stats.tables++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检测链接
|
||||
const links = line.match(/\[([^\]]+)\]\(([^)]+)\)/g);
|
||||
if (links) {
|
||||
analysis.stats.links += links.length;
|
||||
}
|
||||
|
||||
// 检测图片
|
||||
const images = line.match(/!\[([^\]]*)\]\(([^)]+)\)/g);
|
||||
if (images) {
|
||||
analysis.stats.images += images.length;
|
||||
}
|
||||
|
||||
// 普通段落
|
||||
if (line.trim() && !line.match(/^[-=]{3,}$/)) {
|
||||
analysis.elements.push({
|
||||
type: 'paragraph',
|
||||
line: i,
|
||||
preview: line.substring(0, 50)
|
||||
});
|
||||
analysis.stats.paragraphs++;
|
||||
}
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
const analysis = analyzeStructure(content);
|
||||
```
|
||||
|
||||
### Step 3: 语义增强
|
||||
|
||||
```javascript
|
||||
// 识别特殊语义
|
||||
function enhanceSemantics(text, analysis) {
|
||||
const enhanced = { ...analysis };
|
||||
|
||||
// 检测关键词强调
|
||||
const boldPatterns = text.match(/\*\*[^*]+\*\*/g) || [];
|
||||
const italicPatterns = text.match(/\*[^*]+\*/g) || [];
|
||||
|
||||
enhanced.semantics = {
|
||||
emphasis: {
|
||||
bold: boldPatterns.length,
|
||||
italic: italicPatterns.length
|
||||
},
|
||||
estimated_reading_time: Math.ceil(text.split(/\s+/).length / 200) // 200 words/min
|
||||
};
|
||||
|
||||
return enhanced;
|
||||
}
|
||||
|
||||
const enhancedAnalysis = enhanceSemantics(content, analysis);
|
||||
```
|
||||
|
||||
### Step 4: 保存分析结果
|
||||
|
||||
```javascript
|
||||
Write(`${workDir}/analysis.json`, JSON.stringify(enhancedAnalysis, null, 2));
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
- **File**: `analysis.json`
|
||||
- **Format**: JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"elements": [
|
||||
{ "type": "heading", "level": 1, "content": "Title", "line": 0 },
|
||||
{ "type": "paragraph", "line": 2, "preview": "..." },
|
||||
{ "type": "callout", "calloutType": "warning", "title": "注意事项", "content": "...", "start": 4, "end": 6 },
|
||||
{ "type": "code_block", "start": 8, "language": "javascript" }
|
||||
],
|
||||
"stats": {
|
||||
"headings": 3,
|
||||
"paragraphs": 10,
|
||||
"lists": 2,
|
||||
"code_blocks": 1,
|
||||
"tables": 0,
|
||||
"links": 5,
|
||||
"images": 0,
|
||||
"quotes": 1,
|
||||
"callouts": 2
|
||||
},
|
||||
"semantics": {
|
||||
"emphasis": { "bold": 5, "italic": 3 },
|
||||
"estimated_reading_time": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
- [ ] 所有结构元素已识别
|
||||
- [ ] 统计信息准确
|
||||
- [ ] 语义增强完成
|
||||
- [ ] 分析文件已保存
|
||||
|
||||
## Next Phase
|
||||
|
||||
→ [Phase 3: Format Transform](03-format-transform.md)
|
||||
@@ -1,245 +0,0 @@
|
||||
# Phase 3: Format Transform
|
||||
|
||||
将内容转换为 BBCode + Markdown 混合格式(论坛优化)。
|
||||
|
||||
## Objective
|
||||
|
||||
- 根据分析结果转换内容
|
||||
- 应用像素级字号规则
|
||||
- 处理 Callout/标注语法
|
||||
- 生成论坛兼容的输出
|
||||
|
||||
## Input
|
||||
|
||||
- 依赖: `input-config.json`, `analysis.json`
|
||||
- 规范: `specs/format-rules.md`, `specs/element-mapping.md`
|
||||
|
||||
## Format Specification
|
||||
|
||||
### Size Hierarchy (Pixels)
|
||||
|
||||
| Element | Size | Color | Usage |
|
||||
|---------|------|-------|-------|
|
||||
| **H1** | 150 | #2196F3 | 文档主标题 |
|
||||
| **H2** | 120 | #2196F3 | 章节标题 |
|
||||
| **H3** | 100 | #333 | 子标题 |
|
||||
| **H4+** | (默认) | - | 仅加粗 |
|
||||
| **Notes** | 80 | gray | 备注/元数据 |
|
||||
|
||||
### Unsupported Tags (禁止使用)
|
||||
|
||||
| Tag | Reason | Alternative |
|
||||
|-----|--------|-------------|
|
||||
| `[align]` | 不渲染 | 删除,使用默认左对齐 |
|
||||
| `[hr]` | 显示为文本 | 使用 Markdown `---` |
|
||||
| `[table]` | 支持有限 | 转为列表或代码块 |
|
||||
| HTML tags | 不支持 | 仅使用 BBCode |
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: 加载配置和分析
|
||||
|
||||
```javascript
|
||||
const config = JSON.parse(Read(`${workDir}/input-config.json`));
|
||||
const analysis = JSON.parse(Read(`${workDir}/analysis.json`));
|
||||
const content = config.original_content;
|
||||
```
|
||||
|
||||
### Step 2: Callout 配置
|
||||
|
||||
```javascript
|
||||
// Callout 类型映射(像素级字号)
|
||||
const CALLOUT_CONFIG = {
|
||||
// 信息类
|
||||
note: { icon: '📝', color: '#2196F3', label: '注意' },
|
||||
info: { icon: 'ℹ️', color: '#2196F3', label: '信息' },
|
||||
abstract: { icon: '📄', color: '#2196F3', label: '摘要' },
|
||||
summary: { icon: '📄', color: '#2196F3', label: '摘要' },
|
||||
tldr: { icon: '📄', color: '#2196F3', label: '摘要' },
|
||||
|
||||
// 成功/提示类
|
||||
tip: { icon: '💡', color: '#4CAF50', label: '提示' },
|
||||
hint: { icon: '💡', color: '#4CAF50', label: '提示' },
|
||||
success: { icon: '✅', color: '#4CAF50', label: '成功' },
|
||||
check: { icon: '✅', color: '#4CAF50', label: '完成' },
|
||||
done: { icon: '✅', color: '#4CAF50', label: '完成' },
|
||||
|
||||
// 警告类
|
||||
warning: { icon: '⚠️', color: '#FF9800', label: '警告' },
|
||||
caution: { icon: '⚠️', color: '#FF9800', label: '注意' },
|
||||
attention: { icon: '⚠️', color: '#FF9800', label: '注意' },
|
||||
question: { icon: '❓', color: '#FF9800', label: '问题' },
|
||||
help: { icon: '❓', color: '#FF9800', label: '帮助' },
|
||||
faq: { icon: '❓', color: '#FF9800', label: 'FAQ' },
|
||||
todo: { icon: '📋', color: '#FF9800', label: '待办' },
|
||||
|
||||
// 错误/危险类
|
||||
danger: { icon: '❌', color: '#F44336', label: '危险' },
|
||||
error: { icon: '❌', color: '#F44336', label: '错误' },
|
||||
bug: { icon: '🐛', color: '#F44336', label: 'Bug' },
|
||||
important: { icon: '⭐', color: '#F44336', label: '重要' },
|
||||
|
||||
// 其他
|
||||
example: { icon: '📋', color: '#9C27B0', label: '示例' },
|
||||
quote: { icon: '💬', color: 'gray', label: '引用' },
|
||||
cite: { icon: '💬', color: 'gray', label: '引用' }
|
||||
};
|
||||
|
||||
// Callout 检测正则 (支持 +/- 折叠标记)
|
||||
const CALLOUT_PATTERN = /^>\s*\[!(\w+)\][+-]?(?:\s+(.+))?$/;
|
||||
```
|
||||
|
||||
### Step 3: Callout 解析器
|
||||
|
||||
```javascript
|
||||
function parseCallouts(text) {
|
||||
const lines = text.split('\n');
|
||||
const result = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const match = lines[i].match(CALLOUT_PATTERN);
|
||||
if (match) {
|
||||
const type = match[1].toLowerCase();
|
||||
const title = match[2] || null;
|
||||
const content = [];
|
||||
i++;
|
||||
|
||||
// 收集 Callout 内容行
|
||||
while (i < lines.length && lines[i].startsWith('>')) {
|
||||
content.push(lines[i].replace(/^>\s*/, ''));
|
||||
i++;
|
||||
}
|
||||
|
||||
result.push({
|
||||
isCallout: true,
|
||||
type,
|
||||
title,
|
||||
content: content.join('\n')
|
||||
});
|
||||
} else {
|
||||
result.push({ isCallout: false, line: lines[i] });
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: BBCode+MD 转换器
|
||||
|
||||
```javascript
|
||||
function formatBBCodeMD(text) {
|
||||
let result = text;
|
||||
|
||||
// ===== 标题转换 (像素级字号) =====
|
||||
result = result.replace(/^######\s*(.+)$/gm, '[b]$1[/b]');
|
||||
result = result.replace(/^#####\s*(.+)$/gm, '[b]$1[/b]');
|
||||
result = result.replace(/^####\s*(.+)$/gm, '[b]$1[/b]');
|
||||
result = result.replace(/^###\s*(.+)$/gm, '[size=100][color=#333][b]$1[/b][/color][/size]');
|
||||
result = result.replace(/^##\s*(.+)$/gm, '[size=120][color=#2196F3][b]$1[/b][/color][/size]');
|
||||
result = result.replace(/^#\s*(.+)$/gm, '[size=150][color=#2196F3][b]$1[/b][/color][/size]');
|
||||
|
||||
// ===== 文本样式 =====
|
||||
result = result.replace(/\*\*\*(.+?)\*\*\*/g, '[b][i]$1[/i][/b]');
|
||||
result = result.replace(/\*\*(.+?)\*\*/g, '[b]$1[/b]');
|
||||
result = result.replace(/__(.+?)__/g, '[b]$1[/b]');
|
||||
result = result.replace(/\*(.+?)\*/g, '[i]$1[/i]');
|
||||
result = result.replace(/_(.+?)_/g, '[i]$1[/i]');
|
||||
result = result.replace(/~~(.+?)~~/g, '[s]$1[/s]');
|
||||
result = result.replace(/==(.+?)==/g, '[color=yellow]$1[/color]');
|
||||
|
||||
// ===== HTML 转 BBCode =====
|
||||
result = result.replace(/<mark>(.+?)<\/mark>/g, '[color=yellow]$1[/color]');
|
||||
result = result.replace(/<u>(.+?)<\/u>/g, '[u]$1[/u]');
|
||||
result = result.replace(/<details>\s*<summary>(.+?)<\/summary>\s*([\s\S]*?)<\/details>/g,
|
||||
'[spoiler=$1]$2[/spoiler]');
|
||||
|
||||
// ===== 代码 =====
|
||||
result = result.replace(/```(\w*)\n([\s\S]*?)```/g, '[code]$2[/code]');
|
||||
// 行内代码保持原样 (部分论坛不支持 font=monospace)
|
||||
|
||||
// ===== 链接和图片 =====
|
||||
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[url=$2]$1[/url]');
|
||||
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[img]$2[/img]');
|
||||
|
||||
// ===== 引用 (非 Callout) =====
|
||||
result = result.replace(/^>\s+(.+)$/gm, '[quote]$1[/quote]');
|
||||
|
||||
// ===== 列表 (使用 • 符号) =====
|
||||
result = result.replace(/^[-*+]\s+(.+)$/gm, '• $1');
|
||||
|
||||
// ===== 分隔线 (保持 Markdown 语法) =====
|
||||
// `---` 在混合格式中通常可用,不转换为 [hr]
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Callout 转换
|
||||
|
||||
```javascript
|
||||
function convertCallouts(text) {
|
||||
const parsed = parseCallouts(text);
|
||||
|
||||
return parsed.map(item => {
|
||||
if (item.isCallout) {
|
||||
const cfg = CALLOUT_CONFIG[item.type] || CALLOUT_CONFIG.note;
|
||||
const displayTitle = item.title || cfg.label;
|
||||
|
||||
// 使用 [quote] 包裹,标题使用 size=100
|
||||
return `[quote]
|
||||
[size=100][color=${cfg.color}][b]${cfg.icon} ${displayTitle}[/b][/color][/size]
|
||||
|
||||
${item.content}
|
||||
[/quote]`;
|
||||
}
|
||||
return item.line;
|
||||
}).join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: 执行转换
|
||||
|
||||
```javascript
|
||||
// 1. 先处理 Callouts
|
||||
let formattedContent = convertCallouts(content);
|
||||
|
||||
// 2. 再进行通用 BBCode+MD 转换
|
||||
formattedContent = formatBBCodeMD(formattedContent);
|
||||
|
||||
// 3. 清理多余空行
|
||||
formattedContent = formattedContent.replace(/\n{3,}/g, '\n\n');
|
||||
```
|
||||
|
||||
### Step 7: 保存转换结果
|
||||
|
||||
```javascript
|
||||
const outputFile = 'output.bbcode.txt';
|
||||
Write(`${workDir}/${outputFile}`, formattedContent);
|
||||
|
||||
// 更新配置
|
||||
config.output_file = outputFile;
|
||||
config.formatted_content = formattedContent;
|
||||
Write(`${workDir}/input-config.json`, JSON.stringify(config, null, 2));
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
- **File**: `output.bbcode.txt`
|
||||
- **Format**: BBCode + Markdown 混合格式
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
- [ ] 标题使用像素值 (150/120/100)
|
||||
- [ ] 未使用 `[align]` 标签
|
||||
- [ ] 未使用 `[hr]` 标签
|
||||
- [ ] 分隔线使用 `---`
|
||||
- [ ] Callout 正确转换为 [quote]
|
||||
- [ ] 颜色值使用 hex 格式
|
||||
- [ ] 内容完整无丢失
|
||||
|
||||
## Next Phase
|
||||
|
||||
→ [Phase 4: Output & Preview](04-output-preview.md)
|
||||
@@ -1,183 +0,0 @@
|
||||
# Phase 4: Output & Preview
|
||||
|
||||
输出最终结果并提供预览。
|
||||
|
||||
## Objective
|
||||
|
||||
- 保存格式化后的内容到文件
|
||||
- 提供预览功能
|
||||
- 显示转换统计信息
|
||||
|
||||
## Input
|
||||
|
||||
- 依赖: `input-config.json`, `output.*`
|
||||
- 配置: `{workDir}/input-config.json`
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: 加载结果
|
||||
|
||||
```javascript
|
||||
const config = JSON.parse(Read(`${workDir}/input-config.json`));
|
||||
const analysis = JSON.parse(Read(`${workDir}/analysis.json`));
|
||||
const outputFile = `${workDir}/${config.output_file}`;
|
||||
const formattedContent = Read(outputFile);
|
||||
```
|
||||
|
||||
### Step 2: 生成统计摘要
|
||||
|
||||
```javascript
|
||||
const summary = {
|
||||
input: {
|
||||
method: config.input_method,
|
||||
original_length: config.original_content.length,
|
||||
word_count: config.original_content.split(/\s+/).length
|
||||
},
|
||||
output: {
|
||||
format: config.target_format,
|
||||
file: outputFile,
|
||||
length: formattedContent.length
|
||||
},
|
||||
elements: analysis.stats,
|
||||
reading_time: analysis.semantics?.estimated_reading_time || 1
|
||||
};
|
||||
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════════╗
|
||||
║ Text Formatter Summary ║
|
||||
╠════════════════════════════════════════════════════════════════╣
|
||||
║ Input: ${summary.input.word_count} words (${summary.input.original_length} chars)
|
||||
║ Output: ${summary.output.format} → ${summary.output.file}
|
||||
║ Elements Converted:
|
||||
║ • Headings: ${summary.elements.headings}
|
||||
║ • Paragraphs: ${summary.elements.paragraphs}
|
||||
║ • Lists: ${summary.elements.lists}
|
||||
║ • Code Blocks: ${summary.elements.code_blocks}
|
||||
║ • Links: ${summary.elements.links}
|
||||
║ Estimated Reading Time: ${summary.reading_time} min
|
||||
╚════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
```
|
||||
|
||||
### Step 3: HTML 预览(如适用)
|
||||
|
||||
```javascript
|
||||
if (config.target_format === 'HTML') {
|
||||
// 生成完整 HTML 文件用于预览
|
||||
const previewHtml = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Text Formatter Preview</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.content {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 { color: #333; margin-top: 1.5em; }
|
||||
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; }
|
||||
pre { background: #282c34; color: #abb2bf; padding: 1rem; border-radius: 6px; overflow-x: auto; }
|
||||
pre code { background: none; padding: 0; }
|
||||
blockquote { border-left: 4px solid #ddd; margin: 0; padding-left: 1rem; color: #666; }
|
||||
a { color: #0066cc; }
|
||||
img { max-width: 100%; }
|
||||
hr { border: none; border-top: 1px solid #ddd; margin: 2rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
${formattedContent}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
Write(`${workDir}/preview.html`, previewHtml);
|
||||
|
||||
// 可选:在浏览器中打开预览
|
||||
// Bash(`start "${workDir}/preview.html"`); // Windows
|
||||
// Bash(`open "${workDir}/preview.html"`); // macOS
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: 显示输出内容
|
||||
|
||||
```javascript
|
||||
// 显示格式化后的内容
|
||||
console.log('\n=== Formatted Content ===\n');
|
||||
console.log(formattedContent);
|
||||
console.log('\n=========================\n');
|
||||
|
||||
// 提示用户
|
||||
console.log(`
|
||||
📁 Output saved to: ${outputFile}
|
||||
${config.target_format === 'HTML' ? '🌐 Preview available: ' + workDir + '/preview.html' : ''}
|
||||
|
||||
💡 Tips:
|
||||
- Copy the content above for immediate use
|
||||
- Or access the saved file at the path shown
|
||||
`);
|
||||
```
|
||||
|
||||
### Step 5: 询问后续操作
|
||||
|
||||
```javascript
|
||||
const nextAction = await AskUserQuestion({
|
||||
questions: [
|
||||
{
|
||||
question: "需要执行什么操作?",
|
||||
header: "后续操作",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "完成", description: "结束格式化流程" },
|
||||
{ label: "转换为其他格式", description: "选择另一种输出格式" },
|
||||
{ label: "重新编辑", description: "修改原始内容后重新格式化" }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (nextAction["后续操作"] === "转换为其他格式") {
|
||||
// 返回 Phase 1 选择新格式
|
||||
console.log('请重新运行 /text-formatter 选择其他格式');
|
||||
}
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
- **File**: `output.{ext}` (最终输出)
|
||||
- **File**: `preview.html` (HTML 预览,仅 HTML 格式)
|
||||
- **Console**: 统计摘要和格式化内容
|
||||
|
||||
## Final Output Structure
|
||||
|
||||
```
|
||||
{workDir}/
|
||||
├── input-config.json # 配置信息
|
||||
├── analysis.json # 分析结果
|
||||
├── output.md # Markdown 输出(如选择)
|
||||
├── output.bbcode.txt # BBCode 输出(如选择)
|
||||
├── output.html # HTML 输出(如选择)
|
||||
└── preview.html # HTML 预览页面
|
||||
```
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
- [ ] 输出文件已保存
|
||||
- [ ] 统计信息正确显示
|
||||
- [ ] 预览功能可用(HTML)
|
||||
- [ ] 用户可访问输出内容
|
||||
|
||||
## Completion
|
||||
|
||||
此为最终阶段,格式化流程完成。
|
||||
@@ -1,293 +0,0 @@
|
||||
# Callout Types
|
||||
|
||||
Obsidian 风格的 Callout/Admonition 类型定义和转换规则。
|
||||
|
||||
## When to Use
|
||||
|
||||
| Phase | Usage | Section |
|
||||
|-------|-------|---------|
|
||||
| Phase 2 | 检测 Callout | Detection patterns |
|
||||
| Phase 3 | 格式转换 | Conversion rules |
|
||||
|
||||
---
|
||||
|
||||
## Callout 语法
|
||||
|
||||
### Obsidian 原生语法
|
||||
|
||||
```markdown
|
||||
> [!TYPE] 可选标题
|
||||
> 内容行1
|
||||
> 内容行2
|
||||
```
|
||||
|
||||
### 支持的类型
|
||||
|
||||
| Type | Alias | Icon | Color | 用途 |
|
||||
|------|-------|------|-------|------|
|
||||
| `note` | - | 📝 | blue | 普通提示 |
|
||||
| `info` | - | ℹ️ | blue | 信息说明 |
|
||||
| `tip` | `hint` | 💡 | green | 技巧提示 |
|
||||
| `success` | `check`, `done` | ✅ | green | 成功状态 |
|
||||
| `warning` | `caution`, `attention` | ⚠️ | orange | 警告信息 |
|
||||
| `danger` | `error` | ❌ | red | 危险/错误 |
|
||||
| `bug` | - | 🐛 | red | Bug 说明 |
|
||||
| `example` | - | 📋 | purple | 示例内容 |
|
||||
| `quote` | `cite` | 💬 | gray | 引用内容 |
|
||||
| `abstract` | `summary`, `tldr` | 📄 | cyan | 摘要 |
|
||||
| `question` | `help`, `faq` | ❓ | yellow | 问题/FAQ |
|
||||
| `todo` | - | 📌 | orange | 待办事项 |
|
||||
|
||||
---
|
||||
|
||||
## 检测 Pattern
|
||||
|
||||
```javascript
|
||||
// Callout 检测正则
|
||||
const CALLOUT_PATTERN = /^>\s*\[!(\w+)\](?:\s+(.+))?$/;
|
||||
|
||||
// 检测函数
|
||||
function detectCallout(line) {
|
||||
const match = line.match(CALLOUT_PATTERN);
|
||||
if (match) {
|
||||
return {
|
||||
type: match[1].toLowerCase(),
|
||||
title: match[2] || null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析完整 Callout 块
|
||||
function parseCalloutBlock(lines, startIndex) {
|
||||
const firstLine = lines[startIndex];
|
||||
const calloutInfo = detectCallout(firstLine);
|
||||
|
||||
if (!calloutInfo) return null;
|
||||
|
||||
const content = [];
|
||||
let i = startIndex + 1;
|
||||
|
||||
while (i < lines.length && lines[i].startsWith('>')) {
|
||||
content.push(lines[i].replace(/^>\s*/, ''));
|
||||
i++;
|
||||
}
|
||||
|
||||
return {
|
||||
...calloutInfo,
|
||||
content: content.join('\n'),
|
||||
endIndex: i - 1
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 转换规则
|
||||
|
||||
### BBCode 转换
|
||||
|
||||
```javascript
|
||||
const CALLOUT_BBCODE = {
|
||||
note: {
|
||||
icon: '📝',
|
||||
color: '#2196F3',
|
||||
label: '注意'
|
||||
},
|
||||
info: {
|
||||
icon: 'ℹ️',
|
||||
color: '#2196F3',
|
||||
label: '信息'
|
||||
},
|
||||
tip: {
|
||||
icon: '💡',
|
||||
color: '#4CAF50',
|
||||
label: '提示'
|
||||
},
|
||||
success: {
|
||||
icon: '✅',
|
||||
color: '#4CAF50',
|
||||
label: '成功'
|
||||
},
|
||||
warning: {
|
||||
icon: '⚠️',
|
||||
color: '#FF9800',
|
||||
label: '警告'
|
||||
},
|
||||
danger: {
|
||||
icon: '❌',
|
||||
color: '#F44336',
|
||||
label: '危险'
|
||||
},
|
||||
bug: {
|
||||
icon: '🐛',
|
||||
color: '#F44336',
|
||||
label: 'Bug'
|
||||
},
|
||||
example: {
|
||||
icon: '📋',
|
||||
color: '#9C27B0',
|
||||
label: '示例'
|
||||
},
|
||||
quote: {
|
||||
icon: '💬',
|
||||
color: '#9E9E9E',
|
||||
label: '引用'
|
||||
},
|
||||
question: {
|
||||
icon: '❓',
|
||||
color: '#FFEB3B',
|
||||
label: '问题'
|
||||
}
|
||||
};
|
||||
|
||||
function calloutToBBCode(type, title, content, style = 'forum') {
|
||||
const config = CALLOUT_BBCODE[type] || CALLOUT_BBCODE.note;
|
||||
const displayTitle = title || config.label;
|
||||
|
||||
if (style === 'compact') {
|
||||
return `[quote][b]${config.icon} ${displayTitle}[/b]
|
||||
${content}[/quote]`;
|
||||
}
|
||||
|
||||
// Forum style - more visual
|
||||
return `[quote]
|
||||
[color=${config.color}][size=4][b]${config.icon} ${displayTitle}[/b][/size][/color]
|
||||
|
||||
${content}
|
||||
[/quote]`;
|
||||
}
|
||||
```
|
||||
|
||||
### HTML 转换
|
||||
|
||||
```javascript
|
||||
function calloutToHTML(type, title, content) {
|
||||
const config = CALLOUT_BBCODE[type] || CALLOUT_BBCODE.note;
|
||||
const displayTitle = title || config.label;
|
||||
|
||||
return `<div class="callout callout-${type}">
|
||||
<div class="callout-title">
|
||||
<span class="callout-icon">${config.icon}</span>
|
||||
<span class="callout-title-text">${displayTitle}</span>
|
||||
</div>
|
||||
<div class="callout-content">
|
||||
${content}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
### Hybrid 转换
|
||||
|
||||
```javascript
|
||||
function calloutToHybrid(type, title, content) {
|
||||
const config = CALLOUT_BBCODE[type] || CALLOUT_BBCODE.note;
|
||||
const displayTitle = title || config.label;
|
||||
|
||||
// HTML container + BBCode styling + MD content
|
||||
return `<div class="callout ${type}">
|
||||
|
||||
[color=${config.color}][b]${config.icon} ${displayTitle}[/b][/color]
|
||||
|
||||
${content}
|
||||
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Callout CSS 样式
|
||||
|
||||
```css
|
||||
/* Base callout styles */
|
||||
.callout {
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
border-left: 4px solid;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.callout-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.callout-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
/* Type-specific colors */
|
||||
.callout-note, .callout-info {
|
||||
border-color: #2196F3;
|
||||
background: #E3F2FD;
|
||||
}
|
||||
|
||||
.callout-tip, .callout-success {
|
||||
border-color: #4CAF50;
|
||||
background: #E8F5E9;
|
||||
}
|
||||
|
||||
.callout-warning {
|
||||
border-color: #FF9800;
|
||||
background: #FFF3E0;
|
||||
}
|
||||
|
||||
.callout-danger, .callout-bug {
|
||||
border-color: #F44336;
|
||||
background: #FFEBEE;
|
||||
}
|
||||
|
||||
.callout-example {
|
||||
border-color: #9C27B0;
|
||||
background: #F3E5F5;
|
||||
}
|
||||
|
||||
.callout-quote {
|
||||
border-color: #9E9E9E;
|
||||
background: #FAFAFA;
|
||||
}
|
||||
|
||||
.callout-question {
|
||||
border-color: #FFC107;
|
||||
background: #FFFDE7;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 折叠 Callout
|
||||
|
||||
支持可折叠的 Callout 语法:
|
||||
|
||||
```markdown
|
||||
> [!NOTE]+ 默认展开
|
||||
> 内容
|
||||
|
||||
> [!NOTE]- 默认折叠
|
||||
> 内容
|
||||
```
|
||||
|
||||
### BBCode 折叠
|
||||
|
||||
```bbcode
|
||||
[collapse=📝 注意]
|
||||
内容
|
||||
[/collapse]
|
||||
```
|
||||
|
||||
### HTML 折叠
|
||||
|
||||
```html
|
||||
<details class="callout callout-note">
|
||||
<summary>📝 注意</summary>
|
||||
<div class="callout-content">
|
||||
内容
|
||||
</div>
|
||||
</details>
|
||||
```
|
||||
@@ -1,226 +0,0 @@
|
||||
# Element Mapping
|
||||
|
||||
内容元素到 BBCode + Markdown 混合格式的映射表。
|
||||
|
||||
## When to Use
|
||||
|
||||
| Phase | Usage | Section |
|
||||
|-------|-------|---------|
|
||||
| Phase 2 | 元素识别 | Detection patterns |
|
||||
| Phase 3 | 格式转换 | Conversion rules |
|
||||
|
||||
---
|
||||
|
||||
## Element Detection Patterns
|
||||
|
||||
### 标题检测
|
||||
|
||||
| 类型 | Pattern | 示例 |
|
||||
|------|---------|------|
|
||||
| ATX 标题 | `/^#{1,6}\s+(.+)$/` | `# Title`, `## Subtitle` |
|
||||
| Setext H1 | `/^(.+)\n={3,}$/` | `Title\n====` |
|
||||
| Setext H2 | `/^(.+)\n-{3,}$/` | `Subtitle\n----` |
|
||||
|
||||
### 列表检测
|
||||
|
||||
| 类型 | Pattern | 示例 |
|
||||
|------|---------|------|
|
||||
| 无序列表 | `/^[\s]*[-*+]\s+(.+)$/` | `- item`, `* item` |
|
||||
| 有序列表 | `/^[\s]*\d+\.\s+(.+)$/` | `1. item`, `2. item` |
|
||||
| 任务列表 | `/^[\s]*[-*]\s+\[([ x])\]\s+(.+)$/` | `- [ ] todo`, `- [x] done` |
|
||||
|
||||
### Callout 检测
|
||||
|
||||
| 类型 | Pattern | 示例 |
|
||||
|------|---------|------|
|
||||
| Callout 开始 | `/^>\s*\[!(\w+)\](?:\s+(.+))?$/` | `> [!NOTE] 标题` |
|
||||
| Callout 内容 | `/^>\s*(.*)$/` | `> 内容行` |
|
||||
| 可折叠展开 | `/^>\s*\[!(\w+)\]\+/` | `> [!NOTE]+` |
|
||||
| 可折叠收起 | `/^>\s*\[!(\w+)\]-/` | `> [!NOTE]-` |
|
||||
|
||||
### 代码检测
|
||||
|
||||
| 类型 | Pattern | 示例 |
|
||||
|------|---------|------|
|
||||
| 代码块开始 | `/^```(\w*)$/` | ` ```js ` |
|
||||
| 代码块结束 | `/^```$/` | ` ``` ` |
|
||||
| 行内代码 | `/`([^`]+)`/` | `` `code` `` |
|
||||
|
||||
### 其他元素
|
||||
|
||||
| 类型 | Pattern | 示例 |
|
||||
|------|---------|------|
|
||||
| 链接 | `/\[([^\]]+)\]\(([^)]+)\)/` | `[text](url)` |
|
||||
| 图片 | `/!\[([^\]]*)\]\(([^)]+)\)/` | `` |
|
||||
| 普通引用 | `/^>\s+(.+)$/` | `> quote` |
|
||||
| 分隔线 | `/^[-*_]{3,}$/` | `---`, `***` |
|
||||
| 高亮 | `/==(.+?)==/` | `==highlight==` |
|
||||
| 粗体 | `/\*\*(.+?)\*\*/` | `**bold**` |
|
||||
| 斜体 | `/\*(.+?)\*/` | `*italic*` |
|
||||
| 删除线 | `/~~(.+?)~~/` | `~~strike~~` |
|
||||
|
||||
### HTML 元素检测
|
||||
|
||||
| 类型 | Pattern | 示例 |
|
||||
|------|---------|------|
|
||||
| 高亮 | `/<mark>(.+?)<\/mark>/` | `<mark>高亮</mark>` |
|
||||
| 折叠块 | `/<details>\s*<summary>(.+?)<\/summary>([\s\S]*?)<\/details>/` | `<details><summary>标题</summary>内容</details>` |
|
||||
| 下划线 | `/<u>(.+?)<\/u>/` | `<u>下划线</u>` |
|
||||
|
||||
---
|
||||
|
||||
## Element Conversion Matrix
|
||||
|
||||
### 标题映射 (Pixel-Based)
|
||||
|
||||
| Element | Markdown | BBCode Output |
|
||||
|---------|----------|---------------|
|
||||
| **H1** | `# text` | `[size=150][color=#2196F3][b]text[/b][/color][/size]` |
|
||||
| **H2** | `## text` | `[size=120][color=#2196F3][b]text[/b][/color][/size]` |
|
||||
| **H3** | `### text` | `[size=100][color=#333][b]text[/b][/color][/size]` |
|
||||
| **H4** | `#### text` | `[b]text[/b]` |
|
||||
| **H5** | `##### text` | `[b]text[/b]` |
|
||||
| **H6** | `###### text` | `[b]text[/b]` |
|
||||
|
||||
### 文本样式映射
|
||||
|
||||
| Element | Markdown/HTML | BBCode |
|
||||
|---------|---------------|--------|
|
||||
| **Bold** | `**text**` 或 `__text__` | `[b]text[/b]` |
|
||||
| **Italic** | `*text*` 或 `_text_` | `[i]text[/i]` |
|
||||
| **Bold+Italic** | `***text***` | `[b][i]text[/i][/b]` |
|
||||
| **Strike** | `~~text~~` | `[s]text[/s]` |
|
||||
| **Underline** | `<u>text</u>` | `[u]text[/u]` |
|
||||
| **Highlight** | `==text==` 或 `<mark>text</mark>` | `[color=yellow]text[/color]` |
|
||||
| **Code (inline)** | `` `text` `` | 保持原样 |
|
||||
|
||||
### HTML 转 BBCode 映射
|
||||
|
||||
| HTML | BBCode |
|
||||
|------|--------|
|
||||
| `<mark>text</mark>` | `[color=yellow]text[/color]` |
|
||||
| `<u>text</u>` | `[u]text[/u]` |
|
||||
| `<details><summary>标题</summary>内容</details>` | `[spoiler=标题]内容[/spoiler]` |
|
||||
|
||||
### 块级元素映射
|
||||
|
||||
| Element | Markdown | BBCode |
|
||||
|---------|----------|--------|
|
||||
| **Code Block** | ` ```lang\ncode\n``` ` | `[code]code[/code]` |
|
||||
| **Quote** | `> text` | `[quote]text[/quote]` |
|
||||
| **HR** | `---` | `---` (保持 Markdown) |
|
||||
| **List Item** | `- text` | `• text` |
|
||||
| **Paragraph** | `text\n\ntext` | `text\n\ntext` |
|
||||
|
||||
### 链接和媒体映射
|
||||
|
||||
| Element | Markdown | BBCode |
|
||||
|---------|----------|--------|
|
||||
| **Link** | `[text](url)` | `[url=url]text[/url]` |
|
||||
| **Image** | `` | `[img]url[/img]` |
|
||||
|
||||
---
|
||||
|
||||
## Callout Mapping
|
||||
|
||||
### 类型到样式映射
|
||||
|
||||
| Callout Type | Color | Icon | Label |
|
||||
|--------------|-------|------|-------|
|
||||
| note | #2196F3 | 📝 | 注意 |
|
||||
| info | #2196F3 | ℹ️ | 信息 |
|
||||
| tip | #4CAF50 | 💡 | 提示 |
|
||||
| hint | #4CAF50 | 💡 | 提示 |
|
||||
| success | #4CAF50 | ✅ | 成功 |
|
||||
| check | #4CAF50 | ✅ | 完成 |
|
||||
| done | #4CAF50 | ✅ | 完成 |
|
||||
| warning | #FF9800 | ⚠️ | 警告 |
|
||||
| caution | #FF9800 | ⚠️ | 注意 |
|
||||
| attention | #FF9800 | ⚠️ | 注意 |
|
||||
| danger | #F44336 | ❌ | 危险 |
|
||||
| error | #F44336 | ❌ | 错误 |
|
||||
| bug | #F44336 | 🐛 | Bug |
|
||||
| example | #9C27B0 | 📋 | 示例 |
|
||||
| question | #FF9800 | ❓ | 问题 |
|
||||
| help | #FF9800 | ❓ | 帮助 |
|
||||
| faq | #FF9800 | ❓ | FAQ |
|
||||
| quote | gray | 💬 | 引用 |
|
||||
| cite | gray | 💬 | 引用 |
|
||||
| abstract | #2196F3 | 📄 | 摘要 |
|
||||
| summary | #2196F3 | 📄 | 摘要 |
|
||||
| tldr | #2196F3 | 📄 | 摘要 |
|
||||
| todo | #FF9800 | 📋 | 待办 |
|
||||
| important | #F44336 | ⭐ | 重要 |
|
||||
|
||||
### Callout 输出模板
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color={color}][b]{icon} {title}[/b][/color][/size]
|
||||
|
||||
{content}
|
||||
[/quote]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unsupported Elements
|
||||
|
||||
### 不支持转换的元素
|
||||
|
||||
| 元素 | 原因 | 降级方案 |
|
||||
|------|------|----------|
|
||||
| 表格 | BBCode 表格支持有限 | 转为代码块或列表 |
|
||||
| 脚注 | 不支持 | 转为括号注释 `(注: ...)` |
|
||||
| 数学公式 | 不支持 | 保留原始文本 |
|
||||
| 嵌入内容 | 不支持 | 转为链接 |
|
||||
| 任务列表 | 复选框不支持 | 转为普通列表 `☐`/`☑` |
|
||||
|
||||
### 降级示例
|
||||
|
||||
**表格**:
|
||||
```
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
→ 降级为:
|
||||
|
||||
A: 1
|
||||
B: 2
|
||||
```
|
||||
|
||||
**脚注**:
|
||||
```
|
||||
文本[^1]
|
||||
|
||||
[^1]: 脚注内容
|
||||
|
||||
→ 降级为:
|
||||
|
||||
文本 (注: 脚注内容)
|
||||
```
|
||||
|
||||
**任务列表**:
|
||||
```
|
||||
- [ ] 待办
|
||||
- [x] 已完成
|
||||
|
||||
→ 降级为:
|
||||
|
||||
☐ 待办
|
||||
☑ 已完成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### 转换验证
|
||||
|
||||
- [ ] 所有 H1-H3 使用像素值 size (150/120/100)
|
||||
- [ ] 未使用 `[align]` 标签
|
||||
- [ ] 未使用 `[hr]` 标签
|
||||
- [ ] 分隔线保持 `---`
|
||||
- [ ] Callout 正确识别并转换
|
||||
- [ ] 颜色值使用 hex 格式
|
||||
@@ -1,273 +0,0 @@
|
||||
# Format Conversion Rules
|
||||
|
||||
BBCode + Markdown 混合格式转换规则(论坛优化)。
|
||||
|
||||
## When to Use
|
||||
|
||||
| Phase | Usage | Section |
|
||||
|-------|-------|---------|
|
||||
| Phase 3 | 格式转换 | All sections |
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Pixel-Based Sizing
|
||||
|
||||
**重要**: 使用像素值而非 1-7 级别
|
||||
|
||||
| 元素 | Size (px) | 说明 |
|
||||
|------|-----------|------|
|
||||
| 主标题 (H1) | 150 | 文档标题 |
|
||||
| 章节标题 (H2) | 120 | 主要章节 |
|
||||
| 子标题 (H3) | 100 | 子章节 |
|
||||
| 正文 | (默认) | 不指定 size |
|
||||
| 备注/灰色 | 80 | 脚注、元数据 |
|
||||
|
||||
### 2. Supported Tags Only
|
||||
|
||||
**支持的 BBCode 标签**:
|
||||
- `[size=N]` - 字号(像素值)
|
||||
- `[color=X]` - 颜色(hex 或名称,如 `[color=blue]`、`[color=#2196F3]`)
|
||||
- `[b]`, `[i]`, `[s]`, `[u]` - 粗体、斜体、删除线、下划线
|
||||
- `[quote]` - 引用块
|
||||
- `[code]` - 代码块
|
||||
- `[url]`, `[img]` - 链接、图片
|
||||
- `[list]`, `[*]` - 列表
|
||||
- `[spoiler]` 或 `[spoiler=标题]` - 折叠/隐藏内容
|
||||
|
||||
**禁止使用的标签**:
|
||||
- `[align]` - 不渲染,显示为文本
|
||||
- `[hr]` - 不渲染,使用 Markdown `---`
|
||||
- `[table]` - 支持有限,避免使用
|
||||
|
||||
**HTML 标签转换** (输入时支持,转换为 BBCode):
|
||||
- `<mark>text</mark>` → `[color=yellow]text[/color]`
|
||||
- `<details><summary>标题</summary>内容</details>` → `[spoiler=标题]内容[/spoiler]`
|
||||
- 其他 HTML 标签 (`<div>`, `<span>`) - 删除
|
||||
|
||||
### 3. Markdown as Separator
|
||||
|
||||
分隔线使用 Markdown 语法:`---`
|
||||
|
||||
---
|
||||
|
||||
## Element Conversion Rules
|
||||
|
||||
### 标题转换
|
||||
|
||||
| Markdown | BBCode Output |
|
||||
|----------|---------------|
|
||||
| `# H1` | `[size=150][color=#2196F3][b]H1[/b][/color][/size]` |
|
||||
| `## H2` | `[size=120][color=#2196F3][b]H2[/b][/color][/size]` |
|
||||
| `### H3` | `[size=100][color=#333][b]H3[/b][/color][/size]` |
|
||||
| `#### H4+` | `[b]H4[/b]` (不加 size) |
|
||||
|
||||
### 文本样式
|
||||
|
||||
| Markdown/HTML | BBCode |
|
||||
|---------------|--------|
|
||||
| `**bold**` 或 `__bold__` | `[b]bold[/b]` |
|
||||
| `*italic*` 或 `_italic_` | `[i]italic[/i]` |
|
||||
| `***both***` | `[b][i]both[/i][/b]` |
|
||||
| `~~strike~~` | `[s]strike[/s]` |
|
||||
| `==highlight==` 或 `<mark>text</mark>` | `[color=yellow]highlight[/color]` |
|
||||
| (无 MD 语法) | `[u]underline[/u]` |
|
||||
|
||||
### 折叠内容
|
||||
|
||||
| HTML | BBCode |
|
||||
|------|--------|
|
||||
| `<details><summary>标题</summary>内容</details>` | `[spoiler=标题]内容[/spoiler]` |
|
||||
| (无 HTML) | `[spoiler]隐藏内容[/spoiler]` |
|
||||
|
||||
### 代码
|
||||
|
||||
| Markdown | BBCode |
|
||||
|----------|--------|
|
||||
| `` `inline` `` | 保持原样或 `[color=#9C27B0]inline[/color]` |
|
||||
| ` ```code``` ` | `[code]code[/code]` |
|
||||
|
||||
### 链接和图片
|
||||
|
||||
| Markdown | BBCode |
|
||||
|----------|--------|
|
||||
| `[text](url)` | `[url=url]text[/url]` |
|
||||
| `` | `[img]url[/img]` |
|
||||
|
||||
### 列表
|
||||
|
||||
```
|
||||
Markdown:
|
||||
- item 1
|
||||
- item 2
|
||||
- nested
|
||||
|
||||
BBCode:
|
||||
• item 1
|
||||
• item 2
|
||||
• nested
|
||||
```
|
||||
|
||||
注意:使用 `•` 符号而非 `[list][*]`,因为部分论坛渲染有问题。
|
||||
|
||||
### 引用
|
||||
|
||||
```
|
||||
Markdown:
|
||||
> quote text
|
||||
|
||||
BBCode:
|
||||
[quote]
|
||||
quote text
|
||||
[/quote]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Callout (标注) 转换
|
||||
|
||||
### Obsidian Callout 语法
|
||||
|
||||
```markdown
|
||||
> [!TYPE] 可选标题
|
||||
> 内容行 1
|
||||
> 内容行 2
|
||||
```
|
||||
|
||||
### 支持的 Callout 类型
|
||||
|
||||
| Type | Color | Icon | 中文标签 |
|
||||
|------|-------|------|----------|
|
||||
| note, info | #2196F3 | 📝 | 注意 / 信息 |
|
||||
| tip, hint | #4CAF50 | 💡 | 提示 |
|
||||
| success, check, done | #4CAF50 | ✅ | 成功 |
|
||||
| warning, caution, attention | #FF9800 | ⚠️ | 警告 |
|
||||
| danger, error, bug | #F44336 | ❌ | 危险 / 错误 |
|
||||
| example | #9C27B0 | 📋 | 示例 |
|
||||
| question, help, faq | #FF9800 | ❓ | 问题 |
|
||||
| quote, cite | gray | 💬 | 引用 |
|
||||
| abstract, summary, tldr | #2196F3 | 📄 | 摘要 |
|
||||
|
||||
### Callout 转换模板
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color={color}][b]{icon} {title}[/b][/color][/size]
|
||||
|
||||
{content}
|
||||
[/quote]
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```markdown
|
||||
> [!WARNING] 注意事项
|
||||
> 这是警告内容
|
||||
```
|
||||
|
||||
转换为:
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color=#FF9800][b]⚠️ 注意事项[/b][/color][/size]
|
||||
|
||||
这是警告内容
|
||||
[/quote]
|
||||
```
|
||||
|
||||
### 可折叠 Callout
|
||||
|
||||
Obsidian 支持 `> [!NOTE]+` (展开) 和 `> [!NOTE]-` (折叠)。
|
||||
|
||||
由于 BBCode 不支持折叠,统一转换为普通 quote。
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
### 语义颜色
|
||||
|
||||
| 语义 | Hex | 使用场景 |
|
||||
|------|-----|----------|
|
||||
| Primary | #2196F3 | 标题、链接、信息 |
|
||||
| Success | #4CAF50 | 成功、提示、特性 |
|
||||
| Warning | #FF9800 | 警告、注意 |
|
||||
| Error | #F44336 | 错误、危险 |
|
||||
| Purple | #9C27B0 | 示例、代码 |
|
||||
| Gray | gray | 备注、元数据 |
|
||||
| Dark | #333 | 子标题 |
|
||||
|
||||
### 颜色使用规则
|
||||
|
||||
1. **标题颜色**: H1/H2 使用 #2196F3,H3 使用 #333
|
||||
2. **Callout 颜色**: 根据类型使用语义颜色
|
||||
3. **备注颜色**: 使用 gray
|
||||
4. **强调颜色**: 根据语义选择(成功用绿色,警告用橙色)
|
||||
|
||||
---
|
||||
|
||||
## Spacing Rules
|
||||
|
||||
### 空行控制
|
||||
|
||||
| 元素 | 前空行 | 后空行 |
|
||||
|------|--------|--------|
|
||||
| 标题 | 1 | 1 |
|
||||
| 段落 | 0 | 1 |
|
||||
| 列表 | 0 | 1 |
|
||||
| 代码块 | 1 | 1 |
|
||||
| Callout | 1 | 1 |
|
||||
| 分隔线 `---` | 1 | 1 |
|
||||
|
||||
### 示例输出结构
|
||||
|
||||
```bbcode
|
||||
[size=150][color=#2196F3][b]文档标题[/b][/color][/size]
|
||||
|
||||
[size=80][color=gray]作者 | 日期[/color][/size]
|
||||
|
||||
---
|
||||
|
||||
[size=120][color=#2196F3][b]第一章节[/b][/color][/size]
|
||||
|
||||
正文内容...
|
||||
|
||||
[quote]
|
||||
[size=100][color=#4CAF50][b]💡 提示[/b][/color][/size]
|
||||
|
||||
提示内容
|
||||
[/quote]
|
||||
|
||||
---
|
||||
|
||||
[size=120][color=#2196F3][b]第二章节[/b][/color][/size]
|
||||
|
||||
更多内容...
|
||||
|
||||
---
|
||||
|
||||
[size=80][color=gray]— 全文完 —[/color][/size]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
### 转换完整性
|
||||
|
||||
- [ ] 所有标题使用像素值 size
|
||||
- [ ] 未使用 `[align]` 或 `[hr]`
|
||||
- [ ] 分隔线使用 `---`
|
||||
- [ ] Callout 正确转换为 quote
|
||||
- [ ] 颜色符合语义规范
|
||||
- [ ] 空行控制正确
|
||||
|
||||
### 常见错误
|
||||
|
||||
| 错误 | 正确做法 |
|
||||
|------|----------|
|
||||
| `[size=5]` | `[size=120]` |
|
||||
| `[align=center]` | 删除,默认左对齐 |
|
||||
| `[hr]` | 使用 `---` |
|
||||
| `<div class="...">` | 删除 HTML 标签 |
|
||||
@@ -1,350 +0,0 @@
|
||||
# BBCode Template
|
||||
|
||||
论坛优化的 BBCode + Markdown 混合模板(像素级字号)。
|
||||
|
||||
## 核心规则
|
||||
|
||||
### 字号体系 (Pixels)
|
||||
|
||||
| 元素 | Size | 说明 |
|
||||
|------|------|------|
|
||||
| 主标题 | 150 | 文档标题 |
|
||||
| 章节标题 | 120 | H2 级别 |
|
||||
| 子标题 | 100 | H3 级别 |
|
||||
| 正文 | (默认) | 不指定 |
|
||||
| 备注 | 80 | 灰色小字 |
|
||||
|
||||
### 禁止使用
|
||||
|
||||
- `[align]` - 不渲染
|
||||
- `[hr]` - 不渲染,用 `---`
|
||||
- `[table]` - 支持有限
|
||||
- HTML 标签
|
||||
|
||||
---
|
||||
|
||||
## 文档模板
|
||||
|
||||
### 基础文档结构
|
||||
|
||||
```bbcode
|
||||
[size=150][color=#2196F3][b]{{title}}[/b][/color][/size]
|
||||
|
||||
[size=80][color=gray]{{metadata}}[/color][/size]
|
||||
|
||||
---
|
||||
|
||||
{{introduction}}
|
||||
|
||||
---
|
||||
|
||||
[size=120][color=#2196F3][b]{{section1_title}}[/b][/color][/size]
|
||||
|
||||
{{section1_content}}
|
||||
|
||||
---
|
||||
|
||||
[size=120][color=#2196F3][b]{{section2_title}}[/b][/color][/size]
|
||||
|
||||
{{section2_content}}
|
||||
|
||||
---
|
||||
|
||||
[size=80][color=gray]— 全文完 —[/color][/size]
|
||||
```
|
||||
|
||||
### 带目录的文档
|
||||
|
||||
```bbcode
|
||||
[size=150][color=#2196F3][b]{{title}}[/b][/color][/size]
|
||||
|
||||
[size=80][color=gray]{{author}} | {{date}}[/color][/size]
|
||||
|
||||
---
|
||||
|
||||
[size=100][b]📋 目录[/b][/size]
|
||||
|
||||
• {{section1_title}}
|
||||
• {{section2_title}}
|
||||
• {{section3_title}}
|
||||
|
||||
---
|
||||
|
||||
[size=120][color=#2196F3][b]{{section1_title}}[/b][/color][/size]
|
||||
|
||||
{{section1_content}}
|
||||
|
||||
---
|
||||
|
||||
[size=120][color=#2196F3][b]{{section2_title}}[/b][/color][/size]
|
||||
|
||||
{{section2_content}}
|
||||
|
||||
---
|
||||
|
||||
[size=120][color=#2196F3][b]{{section3_title}}[/b][/color][/size]
|
||||
|
||||
{{section3_content}}
|
||||
|
||||
---
|
||||
|
||||
[size=80][color=gray]— 全文完 —[/color][/size]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Callout 模板
|
||||
|
||||
### 提示 (Note/Info)
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color=#2196F3][b]📝 {{title}}[/b][/color][/size]
|
||||
|
||||
{{content}}
|
||||
[/quote]
|
||||
```
|
||||
|
||||
### 技巧 (Tip/Hint)
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color=#4CAF50][b]💡 {{title}}[/b][/color][/size]
|
||||
|
||||
{{content}}
|
||||
[/quote]
|
||||
```
|
||||
|
||||
### 成功 (Success)
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color=#4CAF50][b]✅ {{title}}[/b][/color][/size]
|
||||
|
||||
{{content}}
|
||||
[/quote]
|
||||
```
|
||||
|
||||
### 警告 (Warning/Caution)
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color=#FF9800][b]⚠️ {{title}}[/b][/color][/size]
|
||||
|
||||
{{content}}
|
||||
[/quote]
|
||||
```
|
||||
|
||||
### 危险/错误 (Danger/Error)
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color=#F44336][b]❌ {{title}}[/b][/color][/size]
|
||||
|
||||
{{content}}
|
||||
[/quote]
|
||||
```
|
||||
|
||||
### 示例 (Example)
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color=#9C27B0][b]📋 {{title}}[/b][/color][/size]
|
||||
|
||||
{{content}}
|
||||
[/quote]
|
||||
```
|
||||
|
||||
### 问题 (Question/FAQ)
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color=#FF9800][b]❓ {{title}}[/b][/color][/size]
|
||||
|
||||
{{content}}
|
||||
[/quote]
|
||||
```
|
||||
|
||||
### 重要 (Important)
|
||||
|
||||
```bbcode
|
||||
[quote]
|
||||
[size=100][color=#F44336][b]⭐ {{title}}[/b][/color][/size]
|
||||
|
||||
{{content}}
|
||||
[/quote]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 代码展示模板
|
||||
|
||||
### 单代码块
|
||||
|
||||
```bbcode
|
||||
[size=100][color=#9C27B0][b]代码示例[/b][/color][/size]
|
||||
|
||||
[code]
|
||||
{{code}}
|
||||
[/code]
|
||||
|
||||
[size=80][color=gray]说明: {{description}}[/color][/size]
|
||||
```
|
||||
|
||||
### 带标题的代码
|
||||
|
||||
```bbcode
|
||||
[size=100][b]{{code_title}}[/b][/size]
|
||||
|
||||
[code]
|
||||
{{code}}
|
||||
[/code]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 特性展示模板
|
||||
|
||||
### 特性列表
|
||||
|
||||
```bbcode
|
||||
[size=120][color=#2196F3][b]功能特性[/b][/color][/size]
|
||||
|
||||
• [color=#4CAF50][b]✨ {{feature1}}[/b][/color] — {{desc1}}
|
||||
• [color=#2196F3][b]🚀 {{feature2}}[/b][/color] — {{desc2}}
|
||||
• [color=#FF9800][b]⚡ {{feature3}}[/b][/color] — {{desc3}}
|
||||
```
|
||||
|
||||
### 详细特性卡片
|
||||
|
||||
```bbcode
|
||||
[size=120][color=#2196F3][b]功能特性[/b][/color][/size]
|
||||
|
||||
[quote]
|
||||
[size=100][color=#4CAF50][b]✨ {{feature1_title}}[/b][/color][/size]
|
||||
|
||||
{{feature1_description}}
|
||||
|
||||
[size=80][color=gray]适用场景: {{feature1_use_case}}[/color][/size]
|
||||
[/quote]
|
||||
|
||||
[quote]
|
||||
[size=100][color=#2196F3][b]🚀 {{feature2_title}}[/b][/color][/size]
|
||||
|
||||
{{feature2_description}}
|
||||
|
||||
[size=80][color=gray]适用场景: {{feature2_use_case}}[/color][/size]
|
||||
[/quote]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步骤指南模板
|
||||
|
||||
```bbcode
|
||||
[size=120][color=#2196F3][b]操作步骤[/b][/color][/size]
|
||||
|
||||
[size=100][color=#2196F3][b]步骤 1: {{step1_title}}[/b][/color][/size]
|
||||
|
||||
{{step1_content}}
|
||||
|
||||
[quote]
|
||||
[size=100][color=#FF9800][b]💡 提示[/b][/color][/size]
|
||||
|
||||
{{step1_tip}}
|
||||
[/quote]
|
||||
|
||||
[size=100][color=#2196F3][b]步骤 2: {{step2_title}}[/b][/color][/size]
|
||||
|
||||
{{step2_content}}
|
||||
|
||||
[size=100][color=#2196F3][b]步骤 3: {{step3_title}}[/b][/color][/size]
|
||||
|
||||
{{step3_content}}
|
||||
|
||||
---
|
||||
|
||||
[color=#4CAF50][b]✅ 完成![/b][/color] {{completion_message}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 版本更新模板
|
||||
|
||||
```bbcode
|
||||
[size=150][color=#673AB7][b]🎉 版本 {{version}} 更新日志[/b][/color][/size]
|
||||
|
||||
---
|
||||
|
||||
[size=120][color=#4CAF50][b]✨ 新功能[/b][/color][/size]
|
||||
|
||||
• [b]{{new_feature1}}[/b]: {{new_feature1_desc}}
|
||||
• [b]{{new_feature2}}[/b]: {{new_feature2_desc}}
|
||||
|
||||
[size=120][color=#2196F3][b]🔧 改进[/b][/color][/size]
|
||||
|
||||
• {{improvement1}}
|
||||
• {{improvement2}}
|
||||
|
||||
[size=120][color=#F44336][b]🐛 修复[/b][/color][/size]
|
||||
|
||||
• {{bugfix1}}
|
||||
• {{bugfix2}}
|
||||
|
||||
---
|
||||
|
||||
[url={{download_url}}][b]📥 立即下载[/b][/url]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ 模板
|
||||
|
||||
```bbcode
|
||||
[size=120][color=#2196F3][b]❓ 常见问题[/b][/color][/size]
|
||||
|
||||
---
|
||||
|
||||
[size=100][color=#333][b]Q: {{question1}}[/b][/color][/size]
|
||||
|
||||
[b]A:[/b] {{answer1}}
|
||||
|
||||
---
|
||||
|
||||
[size=100][color=#333][b]Q: {{question2}}[/b][/color][/size]
|
||||
|
||||
[b]A:[/b] {{answer2}}
|
||||
|
||||
---
|
||||
|
||||
[size=100][color=#333][b]Q: {{question3}}[/b][/color][/size]
|
||||
|
||||
[b]A:[/b] {{answer3}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 转换检查清单
|
||||
|
||||
### 必须检查
|
||||
|
||||
- [ ] 标题使用像素值 (150/120/100)
|
||||
- [ ] 分隔线使用 `---`
|
||||
- [ ] 未使用 `[align]`
|
||||
- [ ] 未使用 `[hr]`
|
||||
- [ ] 未使用 HTML 标签
|
||||
- [ ] Callout 标题 size=100
|
||||
- [ ] 灰色备注 size=80
|
||||
|
||||
### 颜色规范
|
||||
|
||||
| 用途 | 颜色 |
|
||||
|------|------|
|
||||
| 主标题 | #2196F3 |
|
||||
| 章节标题 | #2196F3 |
|
||||
| 子标题 | #333 |
|
||||
| 成功/提示 | #4CAF50 |
|
||||
| 警告 | #FF9800 |
|
||||
| 错误/危险 | #F44336 |
|
||||
| 示例 | #9C27B0 |
|
||||
| 备注 | gray |
|
||||
32
ccw/frontend/package-lock.json
generated
32
ccw/frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@formatjs/icu-messageformat-parser": "^2.11.4",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -1392,6 +1393,37 @@
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-accordion": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
|
||||
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"dependencies": {
|
||||
"@formatjs/icu-messageformat-parser": "^2.11.4",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { router } from './router';
|
||||
import queryClient from './lib/query-client';
|
||||
import type { Locale } from './lib/i18n';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||
|
||||
interface AppProps {
|
||||
locale: Locale;
|
||||
@@ -26,6 +27,7 @@ function App({ locale, messages }: AppProps) {
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryInvalidator />
|
||||
<CliExecutionSync />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
@@ -57,4 +59,19 @@ function QueryInvalidator() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI Execution Sync component
|
||||
* Syncs active CLI executions in the background to keep the count updated in Header
|
||||
*/
|
||||
function CliExecutionSync() {
|
||||
// Always sync active CLI executions with a longer polling interval
|
||||
// This ensures the activeCliCount badge in Header shows correct count on initial load
|
||||
useActiveCliExecutions(
|
||||
true, // enabled: always sync
|
||||
15000 // refetchInterval: 15 seconds (longer than monitor's 5 seconds to reduce load)
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -119,7 +119,7 @@ function CliSettingsCard({
|
||||
)}
|
||||
{cliSettings.settings.includeCoAuthoredBy !== undefined && (
|
||||
<span>
|
||||
Co-authored: {cliSettings.settings.includeCoAuthoredBy ? 'Yes' : 'No'}
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.coAuthoredBy' })}: {formatMessage({ id: cliSettings.settings.includeCoAuthoredBy ? 'common.yes' : 'common.no' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Check, Eye, EyeOff } from 'lucide-react';
|
||||
import { Check, Eye, EyeOff, X, Plus } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -59,12 +59,21 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
|
||||
const [providerId, setProviderId] = useState('');
|
||||
const [model, setModel] = useState('sonnet');
|
||||
const [includeCoAuthoredBy, setIncludeCoAuthoredBy] = useState(false);
|
||||
const [settingsFile, setSettingsFile] = useState('');
|
||||
|
||||
// Direct mode state
|
||||
const [authToken, setAuthToken] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
// Available models state
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [modelInput, setModelInput] = useState('');
|
||||
|
||||
// Tags state
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
|
||||
// Validation errors
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -76,6 +85,9 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
|
||||
setEnabled(cliSettings.enabled);
|
||||
setModel(cliSettings.settings.model || 'sonnet');
|
||||
setIncludeCoAuthoredBy(cliSettings.settings.includeCoAuthoredBy || false);
|
||||
setSettingsFile(cliSettings.settings.settingsFile || '');
|
||||
setAvailableModels(cliSettings.settings.availableModels || []);
|
||||
setTags(cliSettings.settings.tags || []);
|
||||
|
||||
// Determine mode based on settings
|
||||
const hasCustomBaseUrl = Boolean(
|
||||
@@ -104,8 +116,13 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
|
||||
setProviderId('');
|
||||
setModel('sonnet');
|
||||
setIncludeCoAuthoredBy(false);
|
||||
setSettingsFile('');
|
||||
setAuthToken('');
|
||||
setBaseUrl('');
|
||||
setAvailableModels([]);
|
||||
setModelInput('');
|
||||
setTags([]);
|
||||
setTagInput('');
|
||||
setErrors({});
|
||||
}
|
||||
}, [cliSettings, open, providers]);
|
||||
@@ -183,6 +200,9 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
|
||||
env,
|
||||
model,
|
||||
includeCoAuthoredBy,
|
||||
settingsFile: settingsFile.trim() || undefined,
|
||||
availableModels,
|
||||
tags,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -198,6 +218,37 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
|
||||
}
|
||||
};
|
||||
|
||||
// Handle add model
|
||||
const handleAddModel = () => {
|
||||
const newModel = modelInput.trim();
|
||||
if (newModel && !availableModels.includes(newModel)) {
|
||||
setAvailableModels([...availableModels, newModel]);
|
||||
setModelInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle remove model
|
||||
const handleRemoveModel = (modelToRemove: string) => {
|
||||
setAvailableModels(availableModels.filter((m) => m !== modelToRemove));
|
||||
};
|
||||
|
||||
// Handle add tag
|
||||
const handleAddTag = () => {
|
||||
const newTag = tagInput.trim();
|
||||
if (newTag && !tags.includes(newTag)) {
|
||||
setTags([...tags, newTag]);
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle remove tag
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setTags(tags.filter((t) => t !== tagToRemove));
|
||||
};
|
||||
|
||||
// Predefined tags
|
||||
const predefinedTags = ['分析', 'Debug', 'implementation', 'refactoring', 'testing'];
|
||||
|
||||
// Get selected provider info
|
||||
const selectedProvider = providers.find((p) => p.id === providerId);
|
||||
|
||||
@@ -387,15 +438,154 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
|
||||
</Tabs>
|
||||
|
||||
{/* Additional Settings (both modes) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="coAuthored"
|
||||
checked={includeCoAuthoredBy}
|
||||
onCheckedChange={setIncludeCoAuthoredBy}
|
||||
/>
|
||||
<Label htmlFor="coAuthored" className="cursor-pointer">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.includeCoAuthoredBy' })}
|
||||
</Label>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="coAuthored"
|
||||
checked={includeCoAuthoredBy}
|
||||
onCheckedChange={setIncludeCoAuthoredBy}
|
||||
/>
|
||||
<Label htmlFor="coAuthored" className="cursor-pointer">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.includeCoAuthoredBy' })}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settingsFile">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.settingsFile' })}
|
||||
</Label>
|
||||
<Input
|
||||
id="settingsFile"
|
||||
value={settingsFile}
|
||||
onChange={(e) => setSettingsFile(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'apiSettings.cliSettings.settingsFilePlaceholder' })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.settingsFileHint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Available Models Section */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="availableModels">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.availableModels' })}
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded-lg min-h-[60px]">
|
||||
{availableModels.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm"
|
||||
>
|
||||
{model}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveModel(model)}
|
||||
className="hover:text-destructive transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex gap-2 flex-1">
|
||||
<Input
|
||||
id="availableModels"
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddModel();
|
||||
}
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'apiSettings.cliSettings.availableModelsPlaceholder' })}
|
||||
className="flex-1 min-w-[120px]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
variant="outline"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.availableModelsHint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags Section */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tags">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.tags' })}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.tagsDescription' })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded-lg min-h-[60px]">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-primary/10 text-primary rounded-md text-sm"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="hover:text-destructive transition-colors"
|
||||
aria-label={formatMessage({ id: 'apiSettings.cliSettings.removeTag' })}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex gap-2 flex-1">
|
||||
<Input
|
||||
id="tags"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTag();
|
||||
}
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'apiSettings.cliSettings.tagInputPlaceholder' })}
|
||||
className="flex-1 min-w-[120px]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleAddTag}
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Predefined Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.predefinedTags' })}:
|
||||
</span>
|
||||
{predefinedTags.map((predefinedTag) => (
|
||||
<button
|
||||
key={predefinedTag}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!tags.includes(predefinedTag)) {
|
||||
setTags([...tags, predefinedTag]);
|
||||
}
|
||||
}}
|
||||
disabled={tags.includes(predefinedTag)}
|
||||
className="text-xs px-2 py-0.5 rounded border border-border hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{predefinedTag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { CoordinatorInputModal } from './CoordinatorInputModal';
|
||||
|
||||
// Mock zustand stores
|
||||
vi.mock('@/stores/coordinatorStore', () => ({
|
||||
useCoordinatorStore: () => ({
|
||||
startCoordinator: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useNotifications', () => ({
|
||||
useNotifications: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const mockMessages = {
|
||||
'coordinator.modal.title': 'Start Coordinator',
|
||||
'coordinator.modal.description': 'Describe the task',
|
||||
'coordinator.form.taskDescription': 'Task Description',
|
||||
'coordinator.form.taskDescriptionPlaceholder': 'Enter task description',
|
||||
'coordinator.form.parameters': 'Parameters',
|
||||
'coordinator.form.parametersPlaceholder': '{"key": "value"}',
|
||||
'coordinator.form.parametersHelp': 'Optional JSON parameters',
|
||||
'coordinator.form.characterCount': '{current} / {max} characters (min: {min})',
|
||||
'coordinator.form.start': 'Start',
|
||||
'coordinator.form.starting': 'Starting...',
|
||||
'coordinator.validation.taskDescriptionRequired': 'Task description is required',
|
||||
'coordinator.validation.taskDescriptionTooShort': 'Too short',
|
||||
'coordinator.validation.taskDescriptionTooLong': 'Too long',
|
||||
'coordinator.validation.parametersInvalidJson': 'Invalid JSON',
|
||||
'common.actions.cancel': 'Cancel',
|
||||
};
|
||||
|
||||
const renderWithIntl = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<IntlProvider locale="en" messages={mockMessages}>
|
||||
{ui}
|
||||
</IntlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('CoordinatorInputModal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render when open', () => {
|
||||
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText('Start Coordinator')).toBeInTheDocument();
|
||||
expect(screen.getByText('Describe the task')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render when closed', () => {
|
||||
renderWithIntl(<CoordinatorInputModal open={false} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.queryByText('Start Coordinator')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show validation error for empty task description', async () => {
|
||||
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const startButton = screen.getByText('Start');
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Task description is required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show validation error for short task description', async () => {
|
||||
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Enter task description');
|
||||
fireEvent.change(textarea, { target: { value: 'Short' } });
|
||||
|
||||
const startButton = screen.getByText('Start');
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Too short')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show validation error for invalid JSON parameters', async () => {
|
||||
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Enter task description');
|
||||
fireEvent.change(textarea, { target: { value: 'Valid task description here' } });
|
||||
|
||||
const paramsInput = screen.getByPlaceholderText('{"key": "value"}');
|
||||
fireEvent.change(paramsInput, { target: { value: 'invalid json' } });
|
||||
|
||||
const startButton = screen.getByText('Start');
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid JSON')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should submit with valid task description', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Enter task description');
|
||||
fireEvent.change(textarea, { target: { value: 'Valid task description with more than 10 characters' } });
|
||||
|
||||
const startButton = screen.getByText('Start');
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/coordinator/start',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
// ========================================
|
||||
// Coordinator Input Modal Component
|
||||
// ========================================
|
||||
// Modal dialog for starting coordinator execution with task description and parameters
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { useCoordinatorStore } from '@/stores/coordinatorStore';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CoordinatorInputModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
taskDescription?: string;
|
||||
parameters?: string;
|
||||
}
|
||||
|
||||
// ========== Validation Helper ==========
|
||||
|
||||
function validateForm(taskDescription: string, parameters: string): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
|
||||
// Validate task description
|
||||
if (!taskDescription.trim()) {
|
||||
errors.taskDescription = 'coordinator.validation.taskDescriptionRequired';
|
||||
} else {
|
||||
const length = taskDescription.trim().length;
|
||||
if (length < 10) {
|
||||
errors.taskDescription = 'coordinator.validation.taskDescriptionTooShort';
|
||||
} else if (length > 2000) {
|
||||
errors.taskDescription = 'coordinator.validation.taskDescriptionTooLong';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parameters if provided
|
||||
if (parameters.trim()) {
|
||||
try {
|
||||
JSON.parse(parameters.trim());
|
||||
} catch (error) {
|
||||
errors.parameters = 'coordinator.validation.parametersInvalidJson';
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CoordinatorInputModal({ open, onClose }: CoordinatorInputModalProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { success, error: showError } = useNotifications();
|
||||
const { startCoordinator } = useCoordinatorStore();
|
||||
|
||||
// Form state
|
||||
const [taskDescription, setTaskDescription] = useState('');
|
||||
const [parameters, setParameters] = useState('');
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset form when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTaskDescription('');
|
||||
setParameters('');
|
||||
setErrors({});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Handle field change
|
||||
const handleFieldChange = (
|
||||
field: 'taskDescription' | 'parameters',
|
||||
value: string
|
||||
) => {
|
||||
if (field === 'taskDescription') {
|
||||
setTaskDescription(value);
|
||||
} else {
|
||||
setParameters(value);
|
||||
}
|
||||
|
||||
// Clear error for this field when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = async () => {
|
||||
// Validate form
|
||||
const validationErrors = validateForm(taskDescription, parameters);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Parse parameters if provided
|
||||
const parsedParams = parameters.trim() ? JSON.parse(parameters.trim()) : undefined;
|
||||
|
||||
// Generate execution ID
|
||||
const executionId = `exec-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Call API to start coordinator
|
||||
const response = await fetch('/api/coordinator/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
executionId,
|
||||
taskDescription: taskDescription.trim(),
|
||||
parameters: parsedParams,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Unknown error' }));
|
||||
throw new Error(error.message || 'Failed to start coordinator');
|
||||
}
|
||||
|
||||
// Call store to update state
|
||||
await startCoordinator(executionId, taskDescription.trim(), parsedParams);
|
||||
|
||||
success(formatMessage({ id: 'coordinator.success.started' }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
showError('Error', errorMessage);
|
||||
console.error('Failed to start coordinator:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{formatMessage({ id: 'coordinator.modal.title' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'coordinator.modal.description' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Task Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-description" className="text-base font-medium">
|
||||
{formatMessage({ id: 'coordinator.form.taskDescription' })}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="task-description"
|
||||
value={taskDescription}
|
||||
onChange={(e) => handleFieldChange('taskDescription', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'coordinator.form.taskDescriptionPlaceholder' })}
|
||||
rows={6}
|
||||
className={errors.taskDescription ? 'border-destructive' : ''}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatMessage({ id: 'coordinator.form.characterCount' }, {
|
||||
current: taskDescription.length,
|
||||
min: 10,
|
||||
max: 2000,
|
||||
})}
|
||||
</span>
|
||||
{taskDescription.length >= 10 && taskDescription.length <= 2000 && (
|
||||
<span className="text-green-600">Valid</span>
|
||||
)}
|
||||
</div>
|
||||
{errors.taskDescription && (
|
||||
<p className="text-sm text-destructive">
|
||||
{formatMessage({ id: errors.taskDescription })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parameters (Optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parameters" className="text-base font-medium">
|
||||
{formatMessage({ id: 'coordinator.form.parameters' })}
|
||||
</Label>
|
||||
<Input
|
||||
id="parameters"
|
||||
value={parameters}
|
||||
onChange={(e) => handleFieldChange('parameters', e.target.value)}
|
||||
placeholder={formatMessage({ id: 'coordinator.form.parametersPlaceholder' })}
|
||||
className={`font-mono text-sm ${errors.parameters ? 'border-destructive' : ''}`}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'coordinator.form.parametersHelp' })}
|
||||
</p>
|
||||
{errors.parameters && (
|
||||
<p className="text-sm text-destructive">
|
||||
{formatMessage({ id: errors.parameters })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'coordinator.form.starting' })}
|
||||
</>
|
||||
) : (
|
||||
formatMessage({ id: 'coordinator.form.start' })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorInputModal;
|
||||
196
ccw/frontend/src/components/coordinator/CoordinatorLogStream.tsx
Normal file
196
ccw/frontend/src/components/coordinator/CoordinatorLogStream.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
// ========================================
|
||||
// Coordinator Log Stream Component
|
||||
// ========================================
|
||||
// Real-time log display with level filtering and auto-scroll
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FileText } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/Card';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { useCoordinatorStore, type LogLevel, type CoordinatorLog } from '@/stores/coordinatorStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CoordinatorLogStreamProps {
|
||||
maxHeight?: number;
|
||||
autoScroll?: boolean;
|
||||
showFilter?: boolean;
|
||||
}
|
||||
|
||||
type LogLevelFilter = LogLevel | 'all';
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CoordinatorLogStream({
|
||||
maxHeight = 400,
|
||||
autoScroll = true,
|
||||
showFilter = true,
|
||||
}: CoordinatorLogStreamProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { logs } = useCoordinatorStore();
|
||||
const [levelFilter, setLevelFilter] = useState<LogLevelFilter>('all');
|
||||
const logContainerRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Filter logs by level
|
||||
const filteredLogs = useMemo(() => {
|
||||
if (levelFilter === 'all') {
|
||||
return logs;
|
||||
}
|
||||
return logs.filter((log) => log.level === levelFilter);
|
||||
}, [logs, levelFilter]);
|
||||
|
||||
// Auto-scroll to latest log
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [filteredLogs, autoScroll]);
|
||||
|
||||
// Get log level color
|
||||
const getLogLevelColor = (level: LogLevel): string => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'warn':
|
||||
return 'text-yellow-600';
|
||||
case 'success':
|
||||
return 'text-green-600';
|
||||
case 'debug':
|
||||
return 'text-blue-600';
|
||||
case 'info':
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
// Get log level background color
|
||||
const getLogLevelBgColor = (level: LogLevel): string => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'bg-red-50';
|
||||
case 'warn':
|
||||
return 'bg-yellow-50';
|
||||
case 'success':
|
||||
return 'bg-green-50';
|
||||
case 'debug':
|
||||
return 'bg-blue-50';
|
||||
case 'info':
|
||||
default:
|
||||
return 'bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
// Format log entry
|
||||
const formatLogEntry = (log: CoordinatorLog): string => {
|
||||
const timestamp = new Date(log.timestamp).toLocaleTimeString();
|
||||
const levelLabel = `[${log.level.toUpperCase()}]`;
|
||||
const source = log.source ? `[${log.source}]` : '';
|
||||
return `${timestamp} ${levelLabel} ${source} ${log.message}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<CardTitle className="text-base">
|
||||
{formatMessage({ id: 'coordinator.logs' })}
|
||||
</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({filteredLogs.length} {formatMessage({ id: 'coordinator.entries' })})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Level Filter */}
|
||||
{showFilter && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{formatMessage({ id: 'coordinator.logLevel' })}
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={levelFilter}
|
||||
onValueChange={(value) => setLevelFilter(value as LogLevelFilter)}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="level-all" />
|
||||
<Label htmlFor="level-all" className="cursor-pointer">
|
||||
{formatMessage({ id: 'coordinator.level.all' })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="info" id="level-info" />
|
||||
<Label htmlFor="level-info" className="cursor-pointer text-gray-600">
|
||||
{formatMessage({ id: 'coordinator.level.info' })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="warn" id="level-warn" />
|
||||
<Label htmlFor="level-warn" className="cursor-pointer text-yellow-600">
|
||||
{formatMessage({ id: 'coordinator.level.warn' })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="error" id="level-error" />
|
||||
<Label htmlFor="level-error" className="cursor-pointer text-red-600">
|
||||
{formatMessage({ id: 'coordinator.level.error' })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="debug" id="level-debug" />
|
||||
<Label htmlFor="level-debug" className="cursor-pointer text-blue-600">
|
||||
{formatMessage({ id: 'coordinator.level.debug' })}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Display */}
|
||||
<div className="space-y-2">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground text-sm">
|
||||
{formatMessage({ id: 'coordinator.noLogs' })}
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
ref={logContainerRef}
|
||||
className={cn(
|
||||
'w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto whitespace-pre-wrap break-words font-mono'
|
||||
)}
|
||||
style={{ maxHeight: `${maxHeight}px` }}
|
||||
>
|
||||
{filteredLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={cn(
|
||||
'py-1 px-2 mb-1 rounded',
|
||||
getLogLevelBgColor(log.level)
|
||||
)}
|
||||
>
|
||||
<span className={getLogLevelColor(log.level)}>
|
||||
{formatLogEntry(log)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorLogStream;
|
||||
@@ -0,0 +1,289 @@
|
||||
// ========================================
|
||||
// Coordinator Question Modal Component
|
||||
// ========================================
|
||||
// Interactive question dialog for coordinator execution
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
|
||||
import { useCoordinatorStore, type CoordinatorQuestion } from '@/stores/coordinatorStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CoordinatorQuestionModalProps {
|
||||
question: CoordinatorQuestion | null;
|
||||
onSubmit?: (questionId: string, answer: string | string[]) => void;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CoordinatorQuestionModal({
|
||||
question,
|
||||
onSubmit,
|
||||
}: CoordinatorQuestionModalProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { submitAnswer } = useCoordinatorStore();
|
||||
const [answer, setAnswer] = useState<string | string[]>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset state when question changes
|
||||
useEffect(() => {
|
||||
if (question) {
|
||||
setAnswer(question.type === 'multi' ? [] : '');
|
||||
setError(null);
|
||||
// Auto-focus on input when modal opens
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [question]);
|
||||
|
||||
// Validate answer
|
||||
const validateAnswer = (): boolean => {
|
||||
if (!question) return false;
|
||||
|
||||
if (question.required) {
|
||||
if (question.type === 'multi') {
|
||||
if (!Array.isArray(answer) || answer.length === 0) {
|
||||
setError(formatMessage({ id: 'coordinator.validation.answerRequired' }));
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!answer || (typeof answer === 'string' && !answer.trim())) {
|
||||
setError(formatMessage({ id: 'coordinator.validation.answerRequired' }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = async () => {
|
||||
if (!question) return;
|
||||
|
||||
if (!validateAnswer()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const finalAnswer = typeof answer === 'string' ? answer.trim() : answer;
|
||||
|
||||
// Call store action
|
||||
await submitAnswer(question.id, finalAnswer);
|
||||
|
||||
// Call optional callback
|
||||
onSubmit?.(question.id, finalAnswer);
|
||||
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to submit answer:', error);
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: formatMessage({ id: 'coordinator.error.submitFailed' })
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Enter key
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && question?.type === 'text') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle multi-select change
|
||||
const handleMultiSelectChange = (option: string, checked: boolean) => {
|
||||
if (!Array.isArray(answer)) {
|
||||
setAnswer([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
setAnswer([...answer, option]);
|
||||
} else {
|
||||
setAnswer(answer.filter((a) => a !== option));
|
||||
}
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!question} onOpenChange={() => {/* Prevent manual close */}}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[500px]"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{question.title}</DialogTitle>
|
||||
{question.description && (
|
||||
<DialogDescription>{question.description}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Text Input */}
|
||||
{question.type === 'text' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text-answer">
|
||||
{formatMessage({ id: 'coordinator.question.answer' })}
|
||||
{question.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="text-answer"
|
||||
ref={inputRef}
|
||||
value={typeof answer === 'string' ? answer : ''}
|
||||
onChange={(e) => {
|
||||
setAnswer(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={formatMessage({ id: 'coordinator.question.textPlaceholder' })}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single Select (RadioGroup) */}
|
||||
{question.type === 'single' && question.options && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{formatMessage({ id: 'coordinator.question.selectOne' })}
|
||||
{question.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={typeof answer === 'string' ? answer : ''}
|
||||
onValueChange={(value) => {
|
||||
setAnswer(value);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<div key={option} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option} id={`option-${option}`} />
|
||||
<Label htmlFor={`option-${option}`} className="cursor-pointer">
|
||||
{option}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi Select (Checkboxes) */}
|
||||
{question.type === 'multi' && question.options && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{formatMessage({ id: 'coordinator.question.selectMultiple' })}
|
||||
{question.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option) => {
|
||||
const isChecked = Array.isArray(answer) && answer.includes(option);
|
||||
return (
|
||||
<div key={option} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`multi-${option}`}
|
||||
checked={isChecked}
|
||||
onChange={(e) => handleMultiSelectChange(option, e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<Label htmlFor={`multi-${option}`} className="cursor-pointer">
|
||||
{option}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Yes/No Buttons */}
|
||||
{question.type === 'yes_no' && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{formatMessage({ id: 'coordinator.question.confirm' })}
|
||||
{question.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={answer === 'yes' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setAnswer('yes');
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{formatMessage({ id: 'coordinator.question.yes' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant={answer === 'no' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setAnswer('no');
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{formatMessage({ id: 'coordinator.question.no' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'coordinator.question.submitting' })}
|
||||
</>
|
||||
) : (
|
||||
formatMessage({ id: 'coordinator.question.submit' })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorQuestionModal;
|
||||
116
ccw/frontend/src/components/coordinator/CoordinatorTimeline.tsx
Normal file
116
ccw/frontend/src/components/coordinator/CoordinatorTimeline.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
// ========================================
|
||||
// CoordinatorTimeline Component
|
||||
// ========================================
|
||||
// Main horizontal timeline container for coordinator pipeline visualization
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCoordinatorStore, selectCommandChain, selectCurrentNode } from '@/stores/coordinatorStore';
|
||||
import { TimelineNode } from './TimelineNode';
|
||||
import { NodeConnector } from './NodeConnector';
|
||||
|
||||
export interface CoordinatorTimelineProps {
|
||||
className?: string;
|
||||
autoScroll?: boolean;
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal scrolling timeline displaying the coordinator command chain
|
||||
* with connectors between nodes
|
||||
*/
|
||||
export function CoordinatorTimeline({
|
||||
className,
|
||||
autoScroll = true,
|
||||
onNodeClick,
|
||||
}: CoordinatorTimelineProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Store selectors
|
||||
const commandChain = useCoordinatorStore(selectCommandChain);
|
||||
const currentNode = useCoordinatorStore(selectCurrentNode);
|
||||
|
||||
// Auto-scroll to the current/latest node
|
||||
useEffect(() => {
|
||||
if (!autoScroll || !scrollContainerRef.current) return;
|
||||
|
||||
// Find the active or latest node
|
||||
const activeNodeIndex = commandChain.findIndex(
|
||||
(node) => node.status === 'running' || node.id === currentNode?.id
|
||||
);
|
||||
|
||||
// If no active node, scroll to the end
|
||||
if (activeNodeIndex === -1) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: scrollContainerRef.current.scrollWidth,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll the active node into view
|
||||
const nodeElements = scrollContainerRef.current.querySelectorAll('[data-node-id]');
|
||||
const activeElement = nodeElements[activeNodeIndex] as HTMLElement;
|
||||
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
});
|
||||
}
|
||||
}, [commandChain, currentNode?.id, autoScroll]);
|
||||
|
||||
// Handle node click
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
if (onNodeClick) {
|
||||
onNodeClick(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
// Render empty state
|
||||
if (commandChain.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center p-8 text-muted-foreground', className)}>
|
||||
<div className="text-center">
|
||||
<p className="text-sm">No pipeline nodes to display</p>
|
||||
<p className="text-xs mt-1">Start a coordinator execution to see the pipeline</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
'flex items-center gap-0 p-4 overflow-x-auto overflow-y-hidden',
|
||||
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent',
|
||||
className
|
||||
)}
|
||||
role="region"
|
||||
aria-label="Coordinator pipeline timeline"
|
||||
>
|
||||
{commandChain.map((node, index) => (
|
||||
<div key={node.id} className="flex items-center" data-node-id={node.id}>
|
||||
{/* Timeline node */}
|
||||
<TimelineNode
|
||||
node={node}
|
||||
isActive={currentNode?.id === node.id}
|
||||
onClick={() => handleNodeClick(node.id)}
|
||||
/>
|
||||
|
||||
{/* Connector to next node (if not last) */}
|
||||
{index < commandChain.length - 1 && (
|
||||
<NodeConnector
|
||||
status={commandChain[index + 1].status}
|
||||
className="mx-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoordinatorTimeline;
|
||||
49
ccw/frontend/src/components/coordinator/NodeConnector.tsx
Normal file
49
ccw/frontend/src/components/coordinator/NodeConnector.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// ========================================
|
||||
// NodeConnector Component
|
||||
// ========================================
|
||||
// Visual connector line between pipeline nodes with status-based styling
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NodeExecutionStatus } from '@/stores/coordinatorStore';
|
||||
|
||||
export interface NodeConnectorProps {
|
||||
status: NodeExecutionStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connector line between timeline nodes
|
||||
* Changes color based on the status of the connected node
|
||||
*/
|
||||
export function NodeConnector({ status, className }: NodeConnectorProps) {
|
||||
// Determine connector color and animation based on status
|
||||
const getConnectorStyle = () => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-gradient-to-r from-green-500 to-green-400';
|
||||
case 'failed':
|
||||
return 'bg-gradient-to-r from-red-500 to-red-400';
|
||||
case 'running':
|
||||
return 'bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse';
|
||||
case 'pending':
|
||||
return 'bg-gradient-to-r from-gray-300 to-gray-200 dark:from-gray-700 dark:to-gray-600';
|
||||
case 'skipped':
|
||||
return 'bg-gradient-to-r from-yellow-400 to-yellow-300';
|
||||
default:
|
||||
return 'bg-gradient-to-r from-gray-300 to-gray-200 dark:from-gray-700 dark:to-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-16 h-1 shrink-0 transition-all duration-300',
|
||||
getConnectorStyle(),
|
||||
className
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodeConnector;
|
||||
254
ccw/frontend/src/components/coordinator/NodeDetailsPanel.tsx
Normal file
254
ccw/frontend/src/components/coordinator/NodeDetailsPanel.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
// ========================================
|
||||
// Node Details Panel Component
|
||||
// ========================================
|
||||
// Expandable panel showing node logs, error information, and retry/skip actions
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Loader2, RotateCcw, SkipForward, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCoordinatorStore, type CommandNode } from '@/stores/coordinatorStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface NodeDetailsPanelProps {
|
||||
node: CommandNode;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function NodeDetailsPanel({
|
||||
node,
|
||||
isExpanded = true,
|
||||
onToggle,
|
||||
}: NodeDetailsPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { retryNode, skipNode, logs } = useCoordinatorStore();
|
||||
const [expanded, setExpanded] = useState(isExpanded);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const logScrollRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Filter logs for this node
|
||||
const nodeLogs = logs.filter((log) => log.nodeId === node.id);
|
||||
|
||||
// Auto-scroll to latest log
|
||||
useEffect(() => {
|
||||
if (expanded && logScrollRef.current) {
|
||||
logScrollRef.current.scrollTop = logScrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [expanded, nodeLogs]);
|
||||
|
||||
// Handle expand/collapse
|
||||
const handleToggle = () => {
|
||||
const newExpanded = !expanded;
|
||||
setExpanded(newExpanded);
|
||||
onToggle?.(newExpanded);
|
||||
};
|
||||
|
||||
// Handle retry
|
||||
const handleRetry = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await retryNode(node.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to retry node:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle skip
|
||||
const handleSkip = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await skipNode(node.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to skip node:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-green-600';
|
||||
case 'failed':
|
||||
return 'text-red-600';
|
||||
case 'running':
|
||||
return 'text-blue-600';
|
||||
case 'skipped':
|
||||
return 'text-yellow-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
// Get status label
|
||||
const getStatusLabel = (status: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'coordinator.status.pending',
|
||||
running: 'coordinator.status.running',
|
||||
completed: 'coordinator.status.completed',
|
||||
failed: 'coordinator.status.failed',
|
||||
skipped: 'coordinator.status.skipped',
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="cursor-pointer" onClick={handleToggle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<button
|
||||
className="inline-flex items-center justify-center"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<CardTitle className="text-lg">
|
||||
{node.name}
|
||||
</CardTitle>
|
||||
<span className={cn('text-sm font-medium', getStatusColor(node.status))}>
|
||||
{formatMessage({ id: getStatusLabel(node.status) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{node.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2">{node.description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{expanded && (
|
||||
<CardContent className="space-y-4">
|
||||
{/* Logs Section */}
|
||||
{nodeLogs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{formatMessage({ id: 'coordinator.logs' })}
|
||||
</h4>
|
||||
<pre
|
||||
ref={logScrollRef}
|
||||
className="w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto max-h-[200px] whitespace-pre-wrap break-words font-mono"
|
||||
>
|
||||
{nodeLogs
|
||||
.map((log) => {
|
||||
const timestamp = new Date(log.timestamp).toLocaleTimeString();
|
||||
const levelLabel = `[${log.level.toUpperCase()}]`;
|
||||
return `${timestamp} ${levelLabel} ${log.message}`;
|
||||
})
|
||||
.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Information */}
|
||||
{node.error && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-red-600">
|
||||
{formatMessage({ id: 'coordinator.error' })}
|
||||
</h4>
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
|
||||
{node.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Section */}
|
||||
{node.output && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{formatMessage({ id: 'coordinator.output' })}
|
||||
</h4>
|
||||
<pre className="w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto max-h-[150px] whitespace-pre-wrap break-words font-mono">
|
||||
{node.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Information */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{node.startedAt && (
|
||||
<div>
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
{formatMessage({ id: 'coordinator.startedAt' })}:
|
||||
</span>
|
||||
<p>{new Date(node.startedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
{node.completedAt && (
|
||||
<div>
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
{formatMessage({ id: 'coordinator.completedAt' })}:
|
||||
</span>
|
||||
<p>{new Date(node.completedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{node.status === 'failed' && (
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetry}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'coordinator.retrying' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="w-3 h-3 mr-2" />
|
||||
{formatMessage({ id: 'coordinator.retry' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSkip}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'coordinator.skipping' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SkipForward className="w-3 h-3 mr-2" />
|
||||
{formatMessage({ id: 'coordinator.skip' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodeDetailsPanel;
|
||||
279
ccw/frontend/src/components/coordinator/README.md
Normal file
279
ccw/frontend/src/components/coordinator/README.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Coordinator Components
|
||||
|
||||
## CoordinatorInputModal
|
||||
|
||||
Modal dialog for starting coordinator execution with task description and optional JSON parameters.
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import { CoordinatorInputModal } from '@/components/coordinator';
|
||||
import { useState } from 'react';
|
||||
|
||||
function MyComponent() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
Start Coordinator
|
||||
</Button>
|
||||
|
||||
<CoordinatorInputModal
|
||||
open={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Task Description**: Required text area (10-2000 characters)
|
||||
- **Parameters**: Optional JSON input
|
||||
- **Validation**: Real-time validation for description length and JSON format
|
||||
- **Loading State**: Displays loading indicator during submission
|
||||
- **Error Handling**: Shows appropriate error messages
|
||||
- **Internationalization**: Full i18n support (English/Chinese)
|
||||
- **Notifications**: Success/error toasts via useNotifications hook
|
||||
|
||||
### API Integration
|
||||
|
||||
The component integrates with:
|
||||
- **POST /api/coordinator/start**: Starts coordinator execution
|
||||
- **coordinatorStore**: Updates Zustand store state
|
||||
- **notificationStore**: Shows success/error notifications
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `open` | `boolean` | Yes | Controls modal visibility |
|
||||
| `onClose` | `() => void` | Yes | Callback when modal closes |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- **Task Description**:
|
||||
- Minimum length: 10 characters
|
||||
- Maximum length: 2000 characters
|
||||
- Required field
|
||||
|
||||
- **Parameters**:
|
||||
- Optional field
|
||||
- Must be valid JSON if provided
|
||||
|
||||
### Example Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"executionId": "exec-1738477200000-abc123def",
|
||||
"taskDescription": "Implement user authentication with JWT tokens",
|
||||
"parameters": {
|
||||
"timeout": 3600,
|
||||
"priority": "high"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Timeline View Components
|
||||
|
||||
Horizontal scrolling timeline visualization for coordinator command pipeline execution.
|
||||
|
||||
### CoordinatorTimeline
|
||||
|
||||
Main timeline container that displays the command chain with auto-scrolling to active nodes.
|
||||
|
||||
#### Usage
|
||||
|
||||
```tsx
|
||||
import { CoordinatorTimeline } from '@/components/coordinator';
|
||||
|
||||
function MyComponent() {
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
console.log('Node clicked:', nodeId);
|
||||
};
|
||||
|
||||
return (
|
||||
<CoordinatorTimeline
|
||||
autoScroll={true}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `className` | `string` | No | - | Additional CSS classes |
|
||||
| `autoScroll` | `boolean` | No | `true` | Auto-scroll to active/latest node |
|
||||
| `onNodeClick` | `(nodeId: string) => void` | No | - | Callback when node is clicked |
|
||||
|
||||
#### Features
|
||||
|
||||
- **Horizontal Scrolling**: Smooth horizontal scroll with mouse wheel
|
||||
- **Auto-scroll**: Automatically scrolls to the active or latest node
|
||||
- **Empty State**: Shows helpful message when no nodes are present
|
||||
- **Store Integration**: Uses `useCoordinatorStore` for state management
|
||||
|
||||
---
|
||||
|
||||
### TimelineNode
|
||||
|
||||
Individual node card displaying node status, timing, and expandable details.
|
||||
|
||||
#### Usage
|
||||
|
||||
```tsx
|
||||
import { TimelineNode } from '@/components/coordinator';
|
||||
import type { CommandNode } from '@/stores/coordinatorStore';
|
||||
|
||||
function MyComponent({ node }: { node: CommandNode }) {
|
||||
return (
|
||||
<TimelineNode
|
||||
node={node}
|
||||
isActive={true}
|
||||
onClick={() => console.log('Clicked:', node.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `node` | `CommandNode` | Yes | Node data from coordinator store |
|
||||
| `isActive` | `boolean` | No | Whether this node is currently active |
|
||||
| `onClick` | `() => void` | No | Callback when node is clicked |
|
||||
| `className` | `string` | No | Additional CSS classes |
|
||||
|
||||
#### Features
|
||||
|
||||
- **Status Indicators**:
|
||||
- `completed`: Green checkmark icon
|
||||
- `failed`: Red X icon
|
||||
- `running`: Blue spinning loader
|
||||
- `pending`: Gray clock icon
|
||||
- `skipped`: Yellow X icon
|
||||
|
||||
- **Status Badges**:
|
||||
- Success (green)
|
||||
- Failed (red)
|
||||
- Running (blue)
|
||||
- Pending (gray outline)
|
||||
- Skipped (yellow)
|
||||
|
||||
- **Expandable Details**:
|
||||
- Error messages (red background)
|
||||
- Output text (scrollable pre)
|
||||
- Result JSON (formatted and scrollable)
|
||||
|
||||
- **Timing Information**:
|
||||
- Start time
|
||||
- Completion time
|
||||
- Duration calculation
|
||||
|
||||
- **Animations**:
|
||||
- Hover scale effect (scale-105)
|
||||
- Smooth transitions (300ms)
|
||||
- Active ring (ring-2 ring-primary)
|
||||
|
||||
---
|
||||
|
||||
### NodeConnector
|
||||
|
||||
Visual connector line between timeline nodes with status-based styling.
|
||||
|
||||
#### Usage
|
||||
|
||||
```tsx
|
||||
import { NodeConnector } from '@/components/coordinator';
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<TimelineNode node={node1} />
|
||||
<NodeConnector status="completed" />
|
||||
<TimelineNode node={node2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `status` | `NodeExecutionStatus` | Yes | Status of the connected node |
|
||||
| `className` | `string` | No | Additional CSS classes |
|
||||
|
||||
#### Status Colors
|
||||
|
||||
| Status | Color | Animation |
|
||||
|--------|-------|-----------|
|
||||
| `completed` | Green gradient | None |
|
||||
| `failed` | Red gradient | None |
|
||||
| `running` | Blue gradient | Pulse animation |
|
||||
| `pending` | Gray gradient | None |
|
||||
| `skipped` | Yellow gradient | None |
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CoordinatorInputModal,
|
||||
CoordinatorTimeline,
|
||||
} from '@/components/coordinator';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
function CoordinatorDashboard() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
console.log('Node clicked:', nodeId);
|
||||
// Show node details panel, etc.
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border p-4">
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
New Execution
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CoordinatorTimeline
|
||||
autoScroll={true}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input Modal */}
|
||||
<CoordinatorInputModal
|
||||
open={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Responsive**: Works on mobile and desktop
|
||||
- **Dark Mode**: Full dark mode support via Tailwind CSS
|
||||
- **Accessibility**: Proper ARIA labels and keyboard navigation
|
||||
- **Performance**: Smooth 60fps animations
|
||||
- **Mobile-first**: Touch-friendly interactions
|
||||
|
||||
213
ccw/frontend/src/components/coordinator/TimelineNode.tsx
Normal file
213
ccw/frontend/src/components/coordinator/TimelineNode.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
// ========================================
|
||||
// TimelineNode Component
|
||||
// ========================================
|
||||
// Individual node card in the coordinator pipeline timeline
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, XCircle, Loader2, ChevronDown, ChevronUp, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { CommandNode } from '@/stores/coordinatorStore';
|
||||
|
||||
export interface TimelineNodeProps {
|
||||
node: CommandNode;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual timeline node card with status indicator and expandable details
|
||||
*/
|
||||
export function TimelineNode({ node, isActive = false, onClick, className }: TimelineNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Get status icon
|
||||
const getStatusIcon = () => {
|
||||
const iconClassName = 'h-5 w-5 shrink-0';
|
||||
switch (node.status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className={cn(iconClassName, 'text-green-500')} />;
|
||||
case 'failed':
|
||||
return <XCircle className={cn(iconClassName, 'text-red-500')} />;
|
||||
case 'running':
|
||||
return <Loader2 className={cn(iconClassName, 'text-blue-500 animate-spin')} />;
|
||||
case 'skipped':
|
||||
return <XCircle className={cn(iconClassName, 'text-yellow-500')} />;
|
||||
case 'pending':
|
||||
default:
|
||||
return <Clock className={cn(iconClassName, 'text-gray-400')} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get status badge variant
|
||||
const getStatusBadge = () => {
|
||||
switch (node.status) {
|
||||
case 'completed':
|
||||
return <Badge variant="success">Success</Badge>;
|
||||
case 'failed':
|
||||
return <Badge variant="destructive">Failed</Badge>;
|
||||
case 'running':
|
||||
return <Badge variant="info">Running</Badge>;
|
||||
case 'skipped':
|
||||
return <Badge variant="warning">Skipped</Badge>;
|
||||
case 'pending':
|
||||
default:
|
||||
return <Badge variant="outline">Pending</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (timestamp?: string) => {
|
||||
if (!timestamp) return 'N/A';
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
};
|
||||
|
||||
// Calculate duration
|
||||
const getDuration = () => {
|
||||
if (!node.startedAt || !node.completedAt) return null;
|
||||
const start = new Date(node.startedAt).getTime();
|
||||
const end = new Date(node.completedAt).getTime();
|
||||
const durationMs = end - start;
|
||||
|
||||
if (durationMs < 1000) return `${durationMs}ms`;
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
const handleToggleExpand = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const hasDetails = Boolean(node.output || node.error || node.result);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'w-64 shrink-0 cursor-pointer transition-all duration-300',
|
||||
'hover:shadow-lg hover:scale-105',
|
||||
isActive && 'ring-2 ring-primary',
|
||||
isExpanded && 'w-80',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{getStatusIcon()}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-foreground truncate" title={node.name}>
|
||||
{node.name}
|
||||
</h4>
|
||||
{node.description && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5" title={node.description}>
|
||||
{node.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 pt-0">
|
||||
{/* Timing information */}
|
||||
{(node.startedAt || node.completedAt) && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 mb-2">
|
||||
{node.startedAt && (
|
||||
<div>Started: {formatTime(node.startedAt)}</div>
|
||||
)}
|
||||
{node.completedAt && (
|
||||
<div>Completed: {formatTime(node.completedAt)}</div>
|
||||
)}
|
||||
{getDuration() && (
|
||||
<div className="font-medium">Duration: {getDuration()}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expand/collapse toggle for details */}
|
||||
{hasDetails && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleExpand}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors w-full"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
Hide details
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
Show details
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded details panel */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Error message */}
|
||||
{node.error && (
|
||||
<div className="p-2 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800">
|
||||
<div className="text-xs font-semibold text-red-700 dark:text-red-400 mb-1">
|
||||
Error:
|
||||
</div>
|
||||
<div className="text-xs text-red-600 dark:text-red-300 break-words">
|
||||
{node.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output */}
|
||||
{Boolean(node.output) && (
|
||||
<div className="p-2 rounded-md bg-muted/50 border border-border">
|
||||
<div className="text-xs font-semibold text-foreground mb-1">
|
||||
Output:
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
|
||||
{String(node.output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{Boolean(node.result) && (
|
||||
<div className="p-2 rounded-md bg-muted/50 border border-border">
|
||||
<div className="text-xs font-semibold text-foreground mb-1">
|
||||
Result:
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
|
||||
{typeof node.result === 'object' && node.result !== null
|
||||
? JSON.stringify(node.result, null, 2)
|
||||
: String(node.result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Command information */}
|
||||
{node.command && !isExpanded && (
|
||||
<div className="mt-2 text-xs text-muted-foreground truncate" title={node.command}>
|
||||
<span className="font-mono">{node.command}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineNode;
|
||||
23
ccw/frontend/src/components/coordinator/index.ts
Normal file
23
ccw/frontend/src/components/coordinator/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Coordinator components
|
||||
export { CoordinatorInputModal } from './CoordinatorInputModal';
|
||||
export type { CoordinatorInputModalProps } from './CoordinatorInputModal';
|
||||
|
||||
// Timeline visualization components
|
||||
export { CoordinatorTimeline } from './CoordinatorTimeline';
|
||||
export type { CoordinatorTimelineProps } from './CoordinatorTimeline';
|
||||
|
||||
export { TimelineNode } from './TimelineNode';
|
||||
export type { TimelineNodeProps } from './TimelineNode';
|
||||
|
||||
export { NodeConnector } from './NodeConnector';
|
||||
export type { NodeConnectorProps } from './NodeConnector';
|
||||
|
||||
// Node interaction components
|
||||
export { NodeDetailsPanel } from './NodeDetailsPanel';
|
||||
export type { NodeDetailsPanelProps } from './NodeDetailsPanel';
|
||||
|
||||
export { CoordinatorLogStream } from './CoordinatorLogStream';
|
||||
export type { CoordinatorLogStreamProps } from './CoordinatorLogStream';
|
||||
|
||||
export { CoordinatorQuestionModal } from './CoordinatorQuestionModal';
|
||||
export type { CoordinatorQuestionModalProps } from './CoordinatorQuestionModal';
|
||||
@@ -121,7 +121,7 @@ export function Header({
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-destructive text-[10px] text-destructive-foreground flex items-center justify-center font-medium">
|
||||
<span className="absolute -top-1 -right-1 min-h-4 min-w-4 px-1 rounded-full bg-destructive text-[10px] text-destructive-foreground flex items-center justify-center font-medium">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// ========================================
|
||||
// Sidebar Component
|
||||
// ========================================
|
||||
// Collapsible navigation sidebar with route links
|
||||
// Collapsible navigation sidebar with 6-group accordion structure
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Home,
|
||||
@@ -12,8 +11,6 @@ import {
|
||||
Workflow,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
ListTodo,
|
||||
Search,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Brain,
|
||||
@@ -28,9 +25,15 @@ import {
|
||||
Shield,
|
||||
History,
|
||||
Server,
|
||||
Layers,
|
||||
Wrench,
|
||||
Cog,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Accordion } from '@/components/ui/Accordion';
|
||||
import { NavGroup, type NavItem } from '@/components/shared/NavGroup';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
|
||||
export interface SidebarProps {
|
||||
/** Whether sidebar is collapsed */
|
||||
@@ -43,34 +46,82 @@ export interface SidebarProps {
|
||||
onMobileClose?: () => void;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
badge?: number | string;
|
||||
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
|
||||
// Navigation group definitions
|
||||
interface NavGroupDef {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: React.ElementType;
|
||||
items: Array<{
|
||||
path: string;
|
||||
labelKey: string;
|
||||
icon: React.ElementType;
|
||||
badge?: number | string;
|
||||
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
|
||||
}>;
|
||||
}
|
||||
|
||||
// Navigation item definitions (without labels for i18n)
|
||||
const navItemDefinitions: Omit<NavItem, 'label'>[] = [
|
||||
{ path: '/', icon: Home },
|
||||
{ path: '/sessions', icon: FolderKanban },
|
||||
{ path: '/lite-tasks', icon: Zap },
|
||||
{ path: '/project', icon: LayoutDashboard },
|
||||
{ path: '/history', icon: Clock },
|
||||
{ path: '/orchestrator', icon: Workflow },
|
||||
{ path: '/loops', icon: RefreshCw },
|
||||
{ path: '/issues', icon: AlertCircle },
|
||||
{ path: '/skills', icon: Sparkles },
|
||||
{ path: '/commands', icon: Terminal },
|
||||
{ path: '/memory', icon: Brain },
|
||||
{ path: '/prompts', icon: History },
|
||||
{ path: '/hooks', icon: GitFork },
|
||||
{ path: '/settings', icon: Settings },
|
||||
{ path: '/settings/rules', icon: Shield },
|
||||
{ path: '/settings/codexlens', icon: Sparkles },
|
||||
{ path: '/api-settings', icon: Server },
|
||||
{ path: '/help', icon: HelpCircle },
|
||||
// Define the 6 navigation groups with their items
|
||||
const navGroupDefinitions: NavGroupDef[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
titleKey: 'navigation.groups.overview',
|
||||
icon: Layers,
|
||||
items: [
|
||||
{ path: '/', labelKey: 'navigation.main.home', icon: Home },
|
||||
{ path: '/project', labelKey: 'navigation.main.project', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
titleKey: 'navigation.groups.workflow',
|
||||
icon: Workflow,
|
||||
items: [
|
||||
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
|
||||
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
|
||||
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
|
||||
{ path: '/loops', labelKey: 'navigation.main.loops', icon: RefreshCw },
|
||||
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
titleKey: 'navigation.groups.knowledge',
|
||||
icon: Brain,
|
||||
items: [
|
||||
{ path: '/memory', labelKey: 'navigation.main.memory', icon: Brain },
|
||||
{ path: '/prompts', labelKey: 'navigation.main.prompts', icon: History },
|
||||
{ path: '/skills', labelKey: 'navigation.main.skills', icon: Sparkles },
|
||||
{ path: '/commands', labelKey: 'navigation.main.commands', icon: Terminal },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'issues',
|
||||
titleKey: 'navigation.groups.issues',
|
||||
icon: AlertCircle,
|
||||
items: [
|
||||
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
titleKey: 'navigation.groups.tools',
|
||||
icon: Wrench,
|
||||
items: [
|
||||
{ path: '/hooks', labelKey: 'navigation.main.hooks', icon: GitFork },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'configuration',
|
||||
titleKey: 'navigation.groups.configuration',
|
||||
icon: Cog,
|
||||
items: [
|
||||
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings },
|
||||
{ path: '/settings/rules', labelKey: 'navigation.main.rules', icon: Shield },
|
||||
{ path: '/settings/codexlens', labelKey: 'navigation.main.codexlens', icon: Sparkles },
|
||||
{ path: '/api-settings', labelKey: 'navigation.main.apiSettings', icon: Server },
|
||||
{ path: '/help', labelKey: 'navigation.main.help', icon: HelpCircle },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
@@ -80,18 +131,17 @@ export function Sidebar({
|
||||
onMobileClose,
|
||||
}: SidebarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const location = useLocation();
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(collapsed);
|
||||
const { sidebarCollapsed, expandedNavGroups, setExpandedNavGroups } = useAppStore();
|
||||
|
||||
const isCollapsed = onCollapsedChange ? collapsed : internalCollapsed;
|
||||
const isCollapsed = onCollapsedChange ? collapsed : sidebarCollapsed;
|
||||
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
if (onCollapsedChange) {
|
||||
onCollapsedChange(!collapsed);
|
||||
} else {
|
||||
setInternalCollapsed(!internalCollapsed);
|
||||
useAppStore.getState().setSidebarCollapsed(!sidebarCollapsed);
|
||||
}
|
||||
}, [collapsed, internalCollapsed, onCollapsedChange]);
|
||||
}, [collapsed, sidebarCollapsed, onCollapsedChange]);
|
||||
|
||||
const handleNavClick = useCallback(() => {
|
||||
// Close mobile sidebar when navigating
|
||||
@@ -100,31 +150,18 @@ export function Sidebar({
|
||||
}
|
||||
}, [onMobileClose]);
|
||||
|
||||
// Build nav items with translated labels
|
||||
const navItems = useMemo(() => {
|
||||
const keyMap: Record<string, string> = {
|
||||
'/': 'main.home',
|
||||
'/sessions': 'main.sessions',
|
||||
'/lite-tasks': 'main.liteTasks',
|
||||
'/project': 'main.project',
|
||||
'/history': 'main.history',
|
||||
'/orchestrator': 'main.orchestrator',
|
||||
'/loops': 'main.loops',
|
||||
'/issues': 'main.issues',
|
||||
'/skills': 'main.skills',
|
||||
'/commands': 'main.commands',
|
||||
'/memory': 'main.memory',
|
||||
'/prompts': 'main.prompts',
|
||||
'/hooks': 'main.hooks',
|
||||
'/settings': 'main.settings',
|
||||
'/settings/rules': 'main.rules',
|
||||
'/settings/codexlens': 'main.codexlens',
|
||||
'/api-settings': 'main.apiSettings',
|
||||
'/help': 'main.help',
|
||||
};
|
||||
return navItemDefinitions.map((item) => ({
|
||||
...item,
|
||||
label: formatMessage({ id: `navigation.${keyMap[item.path]}` }),
|
||||
const handleAccordionChange = useCallback((value: string[]) => {
|
||||
setExpandedNavGroups(value);
|
||||
}, [setExpandedNavGroups]);
|
||||
|
||||
// Build nav groups with translated labels
|
||||
const navGroups = useMemo(() => {
|
||||
return navGroupDefinitions.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((item) => ({
|
||||
...item,
|
||||
label: formatMessage({ id: item.labelKey }),
|
||||
})) as NavItem[],
|
||||
}));
|
||||
}, [formatMessage]);
|
||||
|
||||
@@ -153,57 +190,42 @@ export function Sidebar({
|
||||
aria-label={formatMessage({ id: 'navigation.header.brand' })}
|
||||
>
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
<ul className="space-y-1 px-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
// Parse item path to extract base path and query params
|
||||
const [basePath, searchParams] = item.path.split('?');
|
||||
const isActive = location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath));
|
||||
// For query param items, also check if search matches
|
||||
const isQueryParamActive = searchParams &&
|
||||
location.search.includes(searchParams);
|
||||
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
onClick={handleNavClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors',
|
||||
'hover:bg-hover hover:text-foreground',
|
||||
(isActive && !searchParams) || isQueryParamActive
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground',
|
||||
isCollapsed && 'justify-center px-2'
|
||||
)}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||
item.badgeVariant === 'success' && 'bg-success-light text-success',
|
||||
item.badgeVariant === 'warning' && 'bg-warning-light text-warning',
|
||||
item.badgeVariant === 'info' && 'bg-info-light text-info',
|
||||
(!item.badgeVariant || item.badgeVariant === 'default') &&
|
||||
'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{isCollapsed ? (
|
||||
// Collapsed view: render flat list of icons
|
||||
<div className="space-y-4 px-2">
|
||||
{navGroups.map((group) => (
|
||||
<NavGroup
|
||||
key={group.id}
|
||||
groupId={group.id}
|
||||
titleKey={group.titleKey}
|
||||
icon={group.icon}
|
||||
items={group.items}
|
||||
collapsed={true}
|
||||
onNavClick={handleNavClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Expanded view: render accordion groups
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={expandedNavGroups}
|
||||
onValueChange={handleAccordionChange}
|
||||
className="space-y-1 px-2"
|
||||
>
|
||||
{navGroups.map((group) => (
|
||||
<NavGroup
|
||||
key={group.id}
|
||||
groupId={group.id}
|
||||
titleKey={group.titleKey}
|
||||
icon={group.icon}
|
||||
items={group.items}
|
||||
collapsed={false}
|
||||
onNavClick={handleNavClick}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar footer - collapse toggle */}
|
||||
|
||||
@@ -481,11 +481,15 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 border-b border-border hover:bg-muted/50 transition-colors',
|
||||
'border-l-4',
|
||||
'border-l-4 relative',
|
||||
getTypeBorder(notification.type),
|
||||
isRead && 'opacity-70'
|
||||
)}
|
||||
>
|
||||
{/* Unread dot indicator */}
|
||||
{!isRead && (
|
||||
<span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-destructive" />
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
{/* Icon */}
|
||||
<div className="mt-0.5">{getNotificationIcon(notification.type)}</div>
|
||||
@@ -512,6 +516,23 @@ function NotificationItem({ notification, onDelete, onToggleRead }: Notification
|
||||
{notification.source}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Read/Unread status badge */}
|
||||
{!isRead && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="h-5 px-1.5 text-[10px] font-medium shrink-0"
|
||||
>
|
||||
{formatMessage({ id: 'notifications.unread' }) || '未读'}
|
||||
</Badge>
|
||||
)}
|
||||
{isRead && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 text-[10px] font-medium shrink-0 opacity-60"
|
||||
>
|
||||
{formatMessage({ id: 'notifications.read' }) || '已读'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp row: absolute + relative */}
|
||||
|
||||
91
ccw/frontend/src/components/shared/BatchOperationToolbar.tsx
Normal file
91
ccw/frontend/src/components/shared/BatchOperationToolbar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// ========================================
|
||||
// BatchOperationToolbar Component
|
||||
// ========================================
|
||||
// Toolbar for batch operations on prompts
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
|
||||
export interface BatchOperationToolbarProps {
|
||||
/** Number of selected items */
|
||||
selectedCount: number;
|
||||
/** Whether all items are selected */
|
||||
allSelected: boolean;
|
||||
/** Called when select all is toggled */
|
||||
onSelectAll: (selected: boolean) => void;
|
||||
/** Called when clear selection is triggered */
|
||||
onClearSelection: () => void;
|
||||
/** Called when batch delete is triggered */
|
||||
onDelete: () => void;
|
||||
/** Whether delete operation is in progress */
|
||||
isDeleting?: boolean;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BatchOperationToolbar component for bulk actions
|
||||
*/
|
||||
export function BatchOperationToolbar({
|
||||
selectedCount,
|
||||
allSelected,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
className,
|
||||
}: BatchOperationToolbarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-4 p-3 bg-primary/10 rounded-lg border border-primary/20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Selection info and select all */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(checked) => onSelectAll(checked === true)}
|
||||
aria-label={formatMessage({ id: 'prompts.batch.selectAll' })}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'prompts.batch.selected' }, { count: selectedCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearSelection}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
{formatMessage({ id: 'prompts.batch.clearSelection' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{formatMessage({ id: 'prompts.batch.deleteSelected' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BatchOperationToolbar;
|
||||
387
ccw/frontend/src/components/shared/InsightDetailPanel.tsx
Normal file
387
ccw/frontend/src/components/shared/InsightDetailPanel.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
// ========================================
|
||||
// InsightDetailPanel Component
|
||||
// ========================================
|
||||
// Display detailed view of a single insight with patterns, suggestions, and metadata
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
X,
|
||||
Sparkles,
|
||||
Bot,
|
||||
Code2,
|
||||
Cpu,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
Clock,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import type { InsightHistory, Pattern, Suggestion } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface InsightDetailPanelProps {
|
||||
/** Insight to display (null = panel hidden) */
|
||||
insight: InsightHistory | null;
|
||||
/** Called when close button clicked */
|
||||
onClose: () => void;
|
||||
/** Called when delete button clicked */
|
||||
onDelete?: (insightId: string) => void;
|
||||
/** Is delete operation in progress */
|
||||
isDeleting?: boolean;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Tool icon mapping
|
||||
const toolConfig = {
|
||||
gemini: {
|
||||
icon: Sparkles,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
label: 'Gemini',
|
||||
},
|
||||
qwen: {
|
||||
icon: Bot,
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
label: 'Qwen',
|
||||
},
|
||||
codex: {
|
||||
icon: Code2,
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-500/10',
|
||||
label: 'Codex',
|
||||
},
|
||||
default: {
|
||||
icon: Cpu,
|
||||
color: 'text-gray-500',
|
||||
bgColor: 'bg-gray-500/10',
|
||||
label: 'CLI',
|
||||
},
|
||||
};
|
||||
|
||||
// Severity configuration
|
||||
const severityConfig = {
|
||||
error: {
|
||||
badge: 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||
border: 'border-l-red-500',
|
||||
dot: 'bg-red-500',
|
||||
},
|
||||
warning: {
|
||||
badge: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20',
|
||||
border: 'border-l-yellow-500',
|
||||
dot: 'bg-yellow-500',
|
||||
},
|
||||
info: {
|
||||
badge: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||
border: 'border-l-blue-500',
|
||||
dot: 'bg-blue-500',
|
||||
},
|
||||
default: {
|
||||
badge: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
|
||||
border: 'border-l-gray-500',
|
||||
dot: 'bg-gray-500',
|
||||
},
|
||||
};
|
||||
|
||||
// Suggestion type configuration
|
||||
const suggestionTypeConfig = {
|
||||
refactor: {
|
||||
badge: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
|
||||
icon: 'refactor',
|
||||
},
|
||||
optimize: {
|
||||
badge: 'bg-green-500/10 text-green-500 border-green-500/20',
|
||||
icon: 'optimize',
|
||||
},
|
||||
fix: {
|
||||
badge: 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||
icon: 'fix',
|
||||
},
|
||||
document: {
|
||||
badge: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||
icon: 'document',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format timestamp to relative time
|
||||
*/
|
||||
function formatRelativeTime(timestamp: string, locale: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (locale === 'zh') {
|
||||
if (diffMins < 1) return '刚刚';
|
||||
if (diffMins < 60) return `${diffMins}分钟前`;
|
||||
if (diffHours < 24) return `${diffHours}小时前`;
|
||||
return `${diffDays}天前`;
|
||||
}
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* PatternItem component for displaying a single pattern
|
||||
*/
|
||||
function PatternItem({ pattern, locale }: { pattern: Pattern; locale: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const severity = pattern.severity ?? 'info';
|
||||
const config = severityConfig[severity] ?? severityConfig.default;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 rounded-md border bg-card',
|
||||
'border-l-4',
|
||||
config.border
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 text-xs font-medium rounded border',
|
||||
config.badge
|
||||
)}
|
||||
>
|
||||
{pattern.name?.split(' ')[0] || 'Pattern'}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 text-xs font-medium rounded border uppercase',
|
||||
config.badge
|
||||
)}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground mb-2">{pattern.description}</p>
|
||||
{pattern.example && (
|
||||
<div className="mt-2 p-2 bg-muted rounded text-xs font-mono overflow-x-auto">
|
||||
<code className="text-muted-foreground">{pattern.example}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SuggestionItem component for displaying a single suggestion
|
||||
*/
|
||||
function SuggestionItem({ suggestion, locale }: { suggestion: Suggestion; locale: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const config = suggestionTypeConfig[suggestion.type] ?? suggestionTypeConfig.refactor;
|
||||
const typeLabel = formatMessage({ id: `prompts.suggestions.types.${suggestion.type}` });
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded-md border border-border bg-card">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 text-xs font-medium rounded border',
|
||||
config.badge
|
||||
)}
|
||||
>
|
||||
{typeLabel}
|
||||
</span>
|
||||
{suggestion.effort && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'prompts.suggestions.effort' })}: {suggestion.effort}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-foreground mb-1">{suggestion.title}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">{suggestion.description}</p>
|
||||
{suggestion.code && (
|
||||
<div className="mt-2 p-2 bg-muted rounded text-xs font-mono overflow-x-auto">
|
||||
<code className="text-muted-foreground">{suggestion.code}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* InsightDetailPanel component - Display full insight details
|
||||
*/
|
||||
export function InsightDetailPanel({
|
||||
insight,
|
||||
onClose,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
className,
|
||||
}: InsightDetailPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const locale = useIntl().locale;
|
||||
|
||||
// Don't render if no insight
|
||||
if (!insight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = toolConfig[insight.tool as keyof typeof toolConfig] ?? toolConfig.default;
|
||||
const ToolIcon = config.icon;
|
||||
const timeAgo = formatRelativeTime(insight.created_at, locale);
|
||||
const patternCount = insight.patterns?.length ?? 0;
|
||||
const suggestionCount = insight.suggestions?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 right-0 w-full max-w-md bg-background border-l border-border shadow-lg',
|
||||
'flex flex-col',
|
||||
'animate-in slide-in-from-right',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolIcon className={cn('h-5 w-5', config.color)} />
|
||||
<h2 className="text-lg font-semibold text-card-foreground">
|
||||
{formatMessage({ id: 'prompts.insightDetail.title' })}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-accent transition-colors"
|
||||
aria-label={formatMessage({ id: 'common.actions.close' })}
|
||||
>
|
||||
<X className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className={cn('flex items-center gap-1.5', config.color)}>
|
||||
<ToolIcon className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{config.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{timeAgo}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{insight.prompt_count} {formatMessage({ id: 'prompts.insightDetail.promptsAnalyzed' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Patterns */}
|
||||
{insight.patterns && insight.patterns.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-card-foreground">
|
||||
{formatMessage({ id: 'prompts.insightDetail.patterns' })} ({patternCount})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{insight.patterns.map((pattern) => (
|
||||
<PatternItem key={pattern.id} pattern={pattern} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{insight.suggestions && insight.suggestions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-card-foreground">
|
||||
{formatMessage({ id: 'prompts.insightDetail.suggestions' })} ({suggestionCount})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{insight.suggestions.map((suggestion) => (
|
||||
<SuggestionItem key={suggestion.id} suggestion={suggestion} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{(!insight.patterns || insight.patterns.length === 0) &&
|
||||
(!insight.suggestions || insight.suggestions.length === 0) && (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
{formatMessage({ id: 'prompts.insightDetail.noContent' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
{onDelete && (
|
||||
<div className="p-4 border-t border-border">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(insight.id)}
|
||||
disabled={isDeleting}
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{isDeleting
|
||||
? formatMessage({ id: 'prompts.insightDetail.deleting' })
|
||||
: formatMessage({ id: 'common.actions.delete' })
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* InsightDetailPanelOverlay - Full screen overlay with panel
|
||||
*/
|
||||
export interface InsightDetailPanelOverlayProps extends InsightDetailPanelProps {
|
||||
/** Show overlay backdrop */
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
export function InsightDetailPanelOverlay({
|
||||
insight,
|
||||
onClose,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
showOverlay = true,
|
||||
className,
|
||||
}: InsightDetailPanelOverlayProps) {
|
||||
if (!insight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showOverlay && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 animate-in fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
<InsightDetailPanel
|
||||
insight={insight}
|
||||
onClose={onClose}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
className={cn('z-50', className)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default InsightDetailPanel;
|
||||
248
ccw/frontend/src/components/shared/InsightsHistoryList.tsx
Normal file
248
ccw/frontend/src/components/shared/InsightsHistoryList.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
// ========================================
|
||||
// InsightsHistoryList Component
|
||||
// ========================================
|
||||
// Display past insight analysis results with tool icon, timestamp, pattern count, and suggestion count
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Sparkles,
|
||||
Bot,
|
||||
Code2,
|
||||
Cpu,
|
||||
Brain,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import type { InsightHistory } from '@/lib/api';
|
||||
|
||||
export interface InsightsHistoryListProps {
|
||||
/** Array of historical insights */
|
||||
insights?: InsightHistory[];
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
/** Called when an insight card is clicked */
|
||||
onInsightSelect?: (insightId: string) => void;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Tool icon mapping
|
||||
const toolConfig = {
|
||||
gemini: {
|
||||
icon: Sparkles,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10',
|
||||
label: 'Gemini',
|
||||
},
|
||||
qwen: {
|
||||
icon: Bot,
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-500/10',
|
||||
label: 'Qwen',
|
||||
},
|
||||
codex: {
|
||||
icon: Code2,
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-500/10',
|
||||
label: 'Codex',
|
||||
},
|
||||
default: {
|
||||
icon: Cpu,
|
||||
color: 'text-gray-500',
|
||||
bgColor: 'bg-gray-500/10',
|
||||
label: 'CLI',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format timestamp to relative time (e.g., "2h ago")
|
||||
*/
|
||||
function formatTimeAgo(timestamp: string, locale: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
const isZh = locale === 'zh';
|
||||
|
||||
if (minutes < 1) return isZh ? '刚刚' : 'Just now';
|
||||
if (minutes < 60) return isZh ? `${minutes} 分钟前` : `${minutes}m ago`;
|
||||
if (hours < 24) return isZh ? `${hours} 小时前` : `${hours}h ago`;
|
||||
if (days < 7) return isZh ? `${days} 天前` : `${days}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity level from patterns
|
||||
* Pattern severity: 'error' | 'warning' | 'info'
|
||||
*/
|
||||
function getSeverityLevel(patterns: InsightHistory['patterns']): 'low' | 'medium' | 'high' {
|
||||
if (!patterns || patterns.length === 0) return 'low';
|
||||
const hasHigh = patterns.some(p => p.severity === 'error');
|
||||
const hasMedium = patterns.some(p => p.severity === 'warning');
|
||||
return hasHigh ? 'high' : hasMedium ? 'medium' : 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity color mapping
|
||||
*/
|
||||
const severityConfig = {
|
||||
low: {
|
||||
border: 'border-l-4 border-l-green-500',
|
||||
},
|
||||
medium: {
|
||||
border: 'border-l-4 border-l-yellow-500',
|
||||
},
|
||||
high: {
|
||||
border: 'border-l-4 border-l-red-500',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* InsightHistoryCard component for displaying a single insight history entry
|
||||
*/
|
||||
function InsightHistoryCard({
|
||||
insight,
|
||||
locale,
|
||||
onClick,
|
||||
}: {
|
||||
insight: InsightHistory;
|
||||
locale: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const severity = getSeverityLevel(insight.patterns);
|
||||
const config = toolConfig[insight.tool as keyof typeof toolConfig] ?? toolConfig.default;
|
||||
const ToolIcon = config.icon;
|
||||
const timeAgo = formatTimeAgo(insight.created_at, locale);
|
||||
const patternCount = insight.patterns?.length ?? 0;
|
||||
const suggestionCount = insight.suggestions?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'p-3 rounded-lg border border-border bg-card hover:bg-muted/50 cursor-pointer transition-colors',
|
||||
'border-l-4',
|
||||
severityConfig[severity].border
|
||||
)}
|
||||
>
|
||||
{/* Header: Tool and timestamp */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className={cn('flex items-center gap-1.5', config.color)}>
|
||||
<ToolIcon className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">{config.label}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{timeAgo}</div>
|
||||
</div>
|
||||
|
||||
{/* Stats: Patterns, Suggestions, Prompts */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-sm font-semibold text-foreground">{patternCount}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'prompts.insightsHistory.patterns' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-sm font-semibold text-foreground">{suggestionCount}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'prompts.insightsHistory.suggestions' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-sm font-semibold text-foreground">{insight.prompt_count}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'prompts.insightsHistory.prompts' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pattern preview (if available) */}
|
||||
{insight.patterns && insight.patterns.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-1.5 text-xs',
|
||||
insight.patterns[0].severity === 'error'
|
||||
? 'text-red-500'
|
||||
: insight.patterns[0].severity === 'warning'
|
||||
? 'text-yellow-600'
|
||||
: 'text-blue-500'
|
||||
)}
|
||||
>
|
||||
<span className="font-medium uppercase">
|
||||
{insight.patterns[0].name?.split(' ')[0] || 'Pattern'}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate flex-1">
|
||||
{insight.patterns[0].description?.slice(0, 50)}
|
||||
{insight.patterns[0].description && insight.patterns[0].description.length > 50 ? '...' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* InsightsHistoryList component - Display past insight analysis results
|
||||
*/
|
||||
export function InsightsHistoryList({
|
||||
insights = [],
|
||||
isLoading = false,
|
||||
onInsightSelect,
|
||||
className,
|
||||
}: InsightsHistoryListProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const locale = useIntl().locale;
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('rounded-lg border border-border bg-card p-4', className)}>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground mr-2" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'prompts.insightsHistory.loading' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (insights.length === 0) {
|
||||
return (
|
||||
<div className={cn('rounded-lg border border-border bg-card p-4', className)}>
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<Brain className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'prompts.insightsHistory.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
{formatMessage({ id: 'prompts.insightsHistory.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// List of insights
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{insights.map((insight) => (
|
||||
<InsightHistoryCard
|
||||
key={insight.id}
|
||||
insight={insight}
|
||||
locale={locale}
|
||||
onClick={() => onInsightSelect?.(insight.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InsightsHistoryList;
|
||||
143
ccw/frontend/src/components/shared/NavGroup.tsx
Normal file
143
ccw/frontend/src/components/shared/NavGroup.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
// ========================================
|
||||
// NavGroup Component
|
||||
// ========================================
|
||||
// Collapsible navigation group using Radix Accordion
|
||||
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from '@/components/ui/Accordion';
|
||||
|
||||
export interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
badge?: number | string;
|
||||
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
export interface NavGroupProps {
|
||||
/** Unique identifier for the group */
|
||||
groupId: string;
|
||||
/** Title i18n key */
|
||||
titleKey: string;
|
||||
/** Optional icon for group header */
|
||||
icon?: React.ElementType;
|
||||
/** Navigation items in this group */
|
||||
items: NavItem[];
|
||||
/** Whether sidebar is collapsed */
|
||||
collapsed?: boolean;
|
||||
/** Callback when nav item is clicked */
|
||||
onNavClick?: () => void;
|
||||
}
|
||||
|
||||
export function NavGroup({
|
||||
groupId,
|
||||
titleKey,
|
||||
icon: Icon,
|
||||
items,
|
||||
collapsed = false,
|
||||
onNavClick,
|
||||
}: NavGroupProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const location = useLocation();
|
||||
const title = formatMessage({ id: titleKey });
|
||||
|
||||
// If collapsed, render items without accordion
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
const [basePath] = item.path.split('?');
|
||||
const isActive =
|
||||
location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath));
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onNavClick}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-3 px-2 py-2.5 rounded-md text-sm transition-colors',
|
||||
'hover:bg-hover hover:text-foreground',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
title={item.label}
|
||||
>
|
||||
<ItemIcon className="w-5 h-5 flex-shrink-0" />
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AccordionItem value={groupId} className="border-none">
|
||||
<AccordionTrigger className="px-3 py-2 hover:no-underline hover:bg-hover/50 rounded-md text-muted-foreground hover:text-foreground">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
{Icon && <Icon className="w-4 h-4" />}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-1">
|
||||
<ul className="space-y-1">
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
const [basePath, searchParams] = item.path.split('?');
|
||||
const isActive =
|
||||
location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath));
|
||||
const isQueryParamActive =
|
||||
searchParams && location.search.includes(searchParams);
|
||||
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
onClick={onNavClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors pl-6',
|
||||
'hover:bg-hover hover:text-foreground',
|
||||
(isActive && !searchParams) || isQueryParamActive
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<ItemIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 text-xs font-semibold rounded-full',
|
||||
item.badgeVariant === 'success' &&
|
||||
'bg-success-light text-success',
|
||||
item.badgeVariant === 'warning' &&
|
||||
'bg-warning-light text-warning',
|
||||
item.badgeVariant === 'info' && 'bg-info-light text-info',
|
||||
(!item.badgeVariant || item.badgeVariant === 'default') &&
|
||||
'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavGroup;
|
||||
@@ -9,6 +9,8 @@ import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { QualityBadge } from '@/components/shared/QualityBadge';
|
||||
import {
|
||||
Copy,
|
||||
Trash2,
|
||||
@@ -31,6 +33,12 @@ export interface PromptCardProps {
|
||||
actionsDisabled?: boolean;
|
||||
/** Default expanded state */
|
||||
defaultExpanded?: boolean;
|
||||
/** Selection state for batch operations */
|
||||
selected?: boolean;
|
||||
/** Called when selection state changes */
|
||||
onSelectChange?: (id: string, selected: boolean) => void;
|
||||
/** Whether selection mode is active */
|
||||
selectionMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +74,9 @@ export function PromptCard({
|
||||
className,
|
||||
actionsDisabled = false,
|
||||
defaultExpanded = false,
|
||||
selected = false,
|
||||
onSelectChange,
|
||||
selectionMode = false,
|
||||
}: PromptCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
||||
@@ -91,12 +102,44 @@ export function PromptCard({
|
||||
setExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleSelectionChange = (checked: boolean) => {
|
||||
onSelectChange?.(prompt.id, checked);
|
||||
};
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (selectionMode && (e.target as HTMLElement).closest('.prompt-card-checkbox')) {
|
||||
return;
|
||||
}
|
||||
if (selectionMode) {
|
||||
handleSelectionChange(!selected);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('transition-all duration-200', className)}>
|
||||
<Card
|
||||
className={cn(
|
||||
'transition-all duration-200',
|
||||
selected && 'ring-2 ring-primary',
|
||||
selectionMode && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Checkbox for selection mode */}
|
||||
{selectionMode && (
|
||||
<div className="prompt-card-checkbox">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={handleSelectionChange}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and metadata */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('flex-1 min-w-0', !selectionMode && 'ml-0')}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium text-foreground truncate">
|
||||
{prompt.title || formatMessage({ id: 'prompts.card.untitled' })}
|
||||
@@ -106,6 +149,7 @@ export function PromptCard({
|
||||
{prompt.category}
|
||||
</Badge>
|
||||
)}
|
||||
<QualityBadge qualityScore={prompt.quality_score} className="text-xs" />
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { StatCard, StatCardSkeleton } from '@/components/shared/StatCard';
|
||||
import { MessageSquare, FileType, Hash } from 'lucide-react';
|
||||
import { MessageSquare, FileType, Hash, Star } from 'lucide-react';
|
||||
|
||||
export interface QualityDistribution {
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
export interface PromptStatsProps {
|
||||
/** Total number of prompts */
|
||||
@@ -15,6 +21,10 @@ export interface PromptStatsProps {
|
||||
avgLength: number;
|
||||
/** Most common intent/category */
|
||||
topIntent: string | null;
|
||||
/** Average quality score (0-100) */
|
||||
avgQualityScore?: number;
|
||||
/** Quality distribution */
|
||||
qualityDistribution?: QualityDistribution;
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
@@ -22,15 +32,18 @@ export interface PromptStatsProps {
|
||||
/**
|
||||
* PromptStats component - displays prompt history statistics
|
||||
*
|
||||
* Shows three key metrics:
|
||||
* Shows four key metrics:
|
||||
* - Total prompts: overall count of stored prompts
|
||||
* - Average length: mean character count across all prompts
|
||||
* - Top intent: most frequently used category
|
||||
* - Quality: average quality score with distribution
|
||||
*/
|
||||
export function PromptStats({
|
||||
totalCount,
|
||||
avgLength,
|
||||
topIntent,
|
||||
avgQualityScore,
|
||||
qualityDistribution,
|
||||
isLoading = false,
|
||||
}: PromptStatsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
@@ -44,7 +57,7 @@ export function PromptStats({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={formatMessage({ id: 'prompts.stats.totalCount' })}
|
||||
value={totalCount}
|
||||
@@ -69,6 +82,17 @@ export function PromptStats({
|
||||
isLoading={isLoading}
|
||||
description={formatMessage({ id: 'prompts.stats.topIntentDesc' })}
|
||||
/>
|
||||
<StatCard
|
||||
title={formatMessage({ id: 'prompts.stats.avgQuality' })}
|
||||
value={avgQualityScore !== undefined ? Math.round(avgQualityScore) : 'N/A'}
|
||||
icon={Star}
|
||||
variant="warning"
|
||||
isLoading={isLoading}
|
||||
description={qualityDistribution
|
||||
? `${formatMessage({ id: 'prompts.quality.high' })}: ${qualityDistribution.high} | ${formatMessage({ id: 'prompts.quality.medium' })}: ${qualityDistribution.medium} | ${formatMessage({ id: 'prompts.quality.low' })}: ${qualityDistribution.low}`
|
||||
: formatMessage({ id: 'prompts.stats.avgQualityDesc' })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +102,8 @@ export function PromptStats({
|
||||
*/
|
||||
export function PromptStatsSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-3">
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-4">
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
<StatCardSkeleton />
|
||||
|
||||
88
ccw/frontend/src/components/shared/QualityBadge.tsx
Normal file
88
ccw/frontend/src/components/shared/QualityBadge.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
// ========================================
|
||||
// QualityBadge Component
|
||||
// ========================================
|
||||
// Badge component for displaying prompt quality score
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { Prompt } from '@/types/store';
|
||||
|
||||
export interface QualityBadgeProps {
|
||||
/** Quality score (0-100) */
|
||||
qualityScore?: number;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality level from score
|
||||
*/
|
||||
function getQualityLevel(score?: number): 'high' | 'medium' | 'low' | 'none' {
|
||||
if (score === undefined || score === null) return 'none';
|
||||
if (score >= 80) return 'high';
|
||||
if (score >= 60) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge variant for quality level
|
||||
*/
|
||||
function getBadgeVariant(level: 'high' | 'medium' | 'low' | 'none'): 'success' | 'warning' | 'secondary' | 'outline' {
|
||||
switch (level) {
|
||||
case 'high':
|
||||
return 'success';
|
||||
case 'medium':
|
||||
return 'warning';
|
||||
case 'low':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* QualityBadge component - displays prompt quality score with color coding
|
||||
*
|
||||
* Quality levels:
|
||||
* - High (>=80): Green badge
|
||||
* - Medium (>=60): Yellow badge
|
||||
* - Low (<60): Gray badge
|
||||
* - No score: Outline badge
|
||||
*/
|
||||
export function QualityBadge({ qualityScore, className }: QualityBadgeProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const level = getQualityLevel(qualityScore);
|
||||
const variant = getBadgeVariant(level);
|
||||
|
||||
const labelKey = `prompts.quality.${level}`;
|
||||
const label = formatMessage({ id: labelKey });
|
||||
|
||||
if (level === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className={className}>
|
||||
{qualityScore !== undefined && `${qualityScore} `}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get quality badge data for a prompt
|
||||
*/
|
||||
export function useQualityBadge(prompt: Prompt) {
|
||||
const qualityScore = prompt.quality_score;
|
||||
const level = getQualityLevel(qualityScore);
|
||||
const variant = getBadgeVariant(level);
|
||||
|
||||
return {
|
||||
qualityScore,
|
||||
level,
|
||||
variant,
|
||||
hasQuality: qualityScore !== undefined && qualityScore !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export default QualityBadge;
|
||||
54
ccw/frontend/src/components/ui/Accordion.tsx
Normal file
54
ccw/frontend/src/components/ui/Accordion.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b border-border/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-2 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-2 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
updateMemory,
|
||||
deleteMemory,
|
||||
type CoreMemory,
|
||||
type MemoryResponse,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { workspaceQueryKeys } from '@/lib/queryKeys';
|
||||
@@ -30,6 +29,8 @@ const STALE_TIME = 60 * 1000;
|
||||
export interface MemoryFilter {
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
favorite?: boolean;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMemoryOptions {
|
||||
@@ -93,6 +94,26 @@ export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn {
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by favorite status (from metadata)
|
||||
if (filter?.favorite === true) {
|
||||
memories = memories.filter((m) => {
|
||||
if (!m.metadata) return false;
|
||||
try {
|
||||
const metadata = typeof m.metadata === 'string' ? JSON.parse(m.metadata) : m.metadata;
|
||||
return metadata.favorite === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by archived status
|
||||
if (filter?.archived === true) {
|
||||
memories = memories.filter((m) => m.archived === true);
|
||||
} else if (filter?.archived === false) {
|
||||
memories = memories.filter((m) => m.archived !== true);
|
||||
}
|
||||
|
||||
return memories;
|
||||
})();
|
||||
|
||||
@@ -202,6 +223,64 @@ export function useDeleteMemory(): UseDeleteMemoryReturn {
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseArchiveMemoryReturn {
|
||||
archiveMemory: (memoryId: string) => Promise<void>;
|
||||
isArchiving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useArchiveMemory(): UseArchiveMemoryReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (memoryId: string) =>
|
||||
fetch(`/api/core-memory/memories/${encodeURIComponent(memoryId)}/archive?path=${encodeURIComponent(projectPath)}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}).then(res => res.json()),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
archiveMemory: mutation.mutateAsync,
|
||||
isArchiving: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseUnarchiveMemoryReturn {
|
||||
unarchiveMemory: (memoryId: string) => Promise<void>;
|
||||
isUnarchiving: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useUnarchiveMemory(): UseUnarchiveMemoryReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (memoryId: string) =>
|
||||
fetch(`/api/core-memory/memories?path=${encodeURIComponent(projectPath)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ id: memoryId, archived: false }),
|
||||
}).then(res => res.json()),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
unarchiveMemory: mutation.mutateAsync,
|
||||
isUnarchiving: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hook for all memory mutations
|
||||
*/
|
||||
@@ -209,14 +288,20 @@ export function useMemoryMutations() {
|
||||
const create = useCreateMemory();
|
||||
const update = useUpdateMemory();
|
||||
const remove = useDeleteMemory();
|
||||
const archive = useArchiveMemory();
|
||||
const unarchive = useUnarchiveMemory();
|
||||
|
||||
return {
|
||||
createMemory: create.createMemory,
|
||||
updateMemory: update.updateMemory,
|
||||
deleteMemory: remove.deleteMemory,
|
||||
archiveMemory: archive.archiveMemory,
|
||||
unarchiveMemory: unarchive.unarchiveMemory,
|
||||
isCreating: create.isCreating,
|
||||
isUpdating: update.isUpdating,
|
||||
isDeleting: remove.isDeleting,
|
||||
isMutating: create.isCreating || update.isUpdating || remove.isDeleting,
|
||||
isArchiving: archive.isArchiving,
|
||||
isUnarchiving: unarchive.isUnarchiving,
|
||||
isMutating: create.isCreating || update.isUpdating || remove.isDeleting || archive.isArchiving || unarchive.isUnarchiving,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,14 +7,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchPrompts,
|
||||
fetchPromptInsights,
|
||||
fetchInsightsHistory,
|
||||
analyzePrompts,
|
||||
deletePrompt,
|
||||
batchDeletePrompts,
|
||||
deleteInsight,
|
||||
type Prompt,
|
||||
type PromptInsight,
|
||||
type Pattern,
|
||||
type Suggestion,
|
||||
type PromptsResponse,
|
||||
type PromptInsightsResponse,
|
||||
type InsightsHistoryResponse,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
@@ -24,6 +25,7 @@ export const promptHistoryKeys = {
|
||||
lists: () => [...promptHistoryKeys.all, 'list'] as const,
|
||||
list: (filters?: PromptHistoryFilter) => [...promptHistoryKeys.lists(), filters] as const,
|
||||
insights: () => [...promptHistoryKeys.all, 'insights'] as const,
|
||||
insightsHistory: () => [...promptHistoryKeys.all, 'insightsHistory'] as const,
|
||||
};
|
||||
|
||||
// Default stale time: 30 seconds (prompts update less frequently)
|
||||
@@ -32,6 +34,7 @@ const STALE_TIME = 30 * 1000;
|
||||
export interface PromptHistoryFilter {
|
||||
search?: string;
|
||||
intent?: string;
|
||||
project?: string;
|
||||
dateRange?: { start: Date | null; end: Date | null };
|
||||
}
|
||||
|
||||
@@ -43,12 +46,19 @@ export interface UsePromptHistoryOptions {
|
||||
|
||||
export interface UsePromptHistoryReturn {
|
||||
prompts: Prompt[];
|
||||
allPrompts: Prompt[];
|
||||
totalPrompts: number;
|
||||
promptsBySession: Record<string, Prompt[]>;
|
||||
stats: {
|
||||
totalCount: number;
|
||||
avgLength: number;
|
||||
topIntent: string | null;
|
||||
avgQualityScore?: number;
|
||||
qualityDistribution?: {
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
};
|
||||
};
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
@@ -96,6 +106,10 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
|
||||
prompts = prompts.filter((p) => p.category === filter.intent);
|
||||
}
|
||||
|
||||
if (filter?.project) {
|
||||
prompts = prompts.filter((p) => p.project === filter.project);
|
||||
}
|
||||
|
||||
if (filter?.dateRange?.start || filter?.dateRange?.end) {
|
||||
prompts = prompts.filter((p) => {
|
||||
const date = new Date(p.createdAt);
|
||||
@@ -132,6 +146,34 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
|
||||
}
|
||||
const topIntent = Object.entries(intentCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
|
||||
|
||||
// Calculate quality distribution
|
||||
const qualityDistribution = {
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
let totalQualityScore = 0;
|
||||
let qualityScoreCount = 0;
|
||||
|
||||
for (const prompt of allPrompts) {
|
||||
if (prompt.quality_score !== undefined && prompt.quality_score !== null) {
|
||||
totalQualityScore += prompt.quality_score;
|
||||
qualityScoreCount++;
|
||||
|
||||
if (prompt.quality_score >= 80) {
|
||||
qualityDistribution.high++;
|
||||
} else if (prompt.quality_score >= 60) {
|
||||
qualityDistribution.medium++;
|
||||
} else {
|
||||
qualityDistribution.low++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const avgQualityScore = qualityScoreCount > 0
|
||||
? totalQualityScore / qualityScoreCount
|
||||
: undefined;
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
@@ -142,12 +184,15 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
|
||||
|
||||
return {
|
||||
prompts: filteredPrompts,
|
||||
allPrompts,
|
||||
totalPrompts: totalCount,
|
||||
promptsBySession,
|
||||
stats: {
|
||||
totalCount: allPrompts.length,
|
||||
avgLength,
|
||||
topIntent,
|
||||
avgQualityScore,
|
||||
qualityDistribution,
|
||||
},
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
@@ -157,6 +202,8 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Hook for fetching prompt insights
|
||||
*/
|
||||
@@ -175,6 +222,28 @@ export function usePromptInsights(options: { enabled?: boolean; staleTime?: numb
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching insights history (past CLI analyses)
|
||||
*/
|
||||
export function useInsightsHistory(options: {
|
||||
limit?: number;
|
||||
enabled?: boolean;
|
||||
staleTime?: number;
|
||||
} = {}) {
|
||||
const { limit = 20, enabled = true, staleTime = STALE_TIME } = options;
|
||||
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const queryEnabled = enabled && !!projectPath;
|
||||
|
||||
return useQuery({
|
||||
queryKey: promptHistoryKeys.insightsHistory(),
|
||||
queryFn: () => fetchInsightsHistory(projectPath, limit),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
retry: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Mutations ==========
|
||||
|
||||
export interface UseAnalyzePromptsReturn {
|
||||
@@ -244,18 +313,120 @@ export function useDeletePrompt(): UseDeletePromptReturn {
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseBatchDeletePromptsReturn {
|
||||
batchDeletePrompts: (promptIds: string[]) => Promise<{ deleted: number }>;
|
||||
isBatchDeleting: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useBatchDeletePrompts(): UseBatchDeletePromptsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: batchDeletePrompts,
|
||||
onMutate: async (promptIds) => {
|
||||
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.all });
|
||||
const previousPrompts = queryClient.getQueryData<PromptsResponse>(promptHistoryKeys.list());
|
||||
|
||||
queryClient.setQueryData<PromptsResponse>(promptHistoryKeys.list(), (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
prompts: old.prompts.filter((p) => !promptIds.includes(p.id)),
|
||||
total: old.total - promptIds.length,
|
||||
};
|
||||
});
|
||||
|
||||
return { previousPrompts };
|
||||
},
|
||||
onError: (_error, _promptIds, context) => {
|
||||
if (context?.previousPrompts) {
|
||||
queryClient.setQueryData(promptHistoryKeys.list(), context.previousPrompts);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
batchDeletePrompts: mutation.mutateAsync,
|
||||
isBatchDeleting: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseDeleteInsightReturn {
|
||||
deleteInsight: (insightId: string) => Promise<{ success: boolean }>;
|
||||
isDeleting: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useDeleteInsight(): UseDeleteInsightReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (insightId: string) => deleteInsight(insightId, projectPath),
|
||||
onMutate: async (insightId) => {
|
||||
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.insightsHistory() });
|
||||
const previousInsights = queryClient.getQueryData<InsightsHistoryResponse>(promptHistoryKeys.insightsHistory());
|
||||
|
||||
queryClient.setQueryData<InsightsHistoryResponse>(promptHistoryKeys.insightsHistory(), (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
insights: old.insights.filter((i) => i.id !== insightId),
|
||||
};
|
||||
});
|
||||
|
||||
return { previousInsights };
|
||||
},
|
||||
onError: (_error, _insightId, context) => {
|
||||
if (context?.previousInsights) {
|
||||
queryClient.setQueryData(promptHistoryKeys.insightsHistory(), context.previousInsights);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.insightsHistory() });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deleteInsight: mutation.mutateAsync,
|
||||
isDeleting: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hook for all prompt history mutations
|
||||
*/
|
||||
export function usePromptHistoryMutations() {
|
||||
const analyze = useAnalyzePrompts();
|
||||
const remove = useDeletePrompt();
|
||||
const batchRemove = useBatchDeletePrompts();
|
||||
|
||||
return {
|
||||
analyzePrompts: analyze.analyzePrompts,
|
||||
deletePrompt: remove.deletePrompt,
|
||||
batchDeletePrompts: batchRemove.batchDeletePrompts,
|
||||
isAnalyzing: analyze.isAnalyzing,
|
||||
isDeleting: remove.isDeleting,
|
||||
isMutating: analyze.isAnalyzing || remove.isDeleting,
|
||||
isBatchDeleting: batchRemove.isBatchDeleting,
|
||||
isMutating: analyze.isAnalyzing || remove.isDeleting || batchRemove.isBatchDeleting,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique projects from prompts list
|
||||
*/
|
||||
export function extractUniqueProjects(prompts: Prompt[]): string[] {
|
||||
const projectsSet = new Set<string>();
|
||||
for (const prompt of prompts) {
|
||||
if (prompt.project) {
|
||||
projectsSet.add(prompt.project);
|
||||
}
|
||||
}
|
||||
return Array.from(projectsSet).sort();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNotificationStore } from '@/stores';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import { useCoordinatorStore } from '@/stores/coordinatorStore';
|
||||
import {
|
||||
OrchestratorMessageSchema,
|
||||
type OrchestratorWebSocketMessage,
|
||||
@@ -60,6 +61,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
// CLI stream store for CLI output handling
|
||||
const addOutput = useCliStreamStore((state) => state.addOutput);
|
||||
|
||||
// Coordinator store for coordinator state updates
|
||||
const updateNodeStatus = useCoordinatorStore((state) => state.updateNodeStatus);
|
||||
const addCoordinatorLog = useCoordinatorStore((state) => state.addLog);
|
||||
const setActiveQuestion = useCoordinatorStore((state) => state.setActiveQuestion);
|
||||
const markExecutionComplete = useCoordinatorStore((state) => state.markExecutionComplete);
|
||||
const coordinatorExecutionId = useCoordinatorStore((state) => state.currentExecutionId);
|
||||
|
||||
// Handle incoming WebSocket messages
|
||||
const handleMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
@@ -143,6 +151,56 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Coordinator messages
|
||||
if (data.type?.startsWith('COORDINATOR_')) {
|
||||
// Only process messages for current coordinator execution
|
||||
if (coordinatorExecutionId && data.executionId !== coordinatorExecutionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch to coordinator store based on message type
|
||||
switch (data.type) {
|
||||
case 'COORDINATOR_STATE_UPDATE':
|
||||
// Check for completion
|
||||
if (data.status === 'completed') {
|
||||
markExecutionComplete(true);
|
||||
} else if (data.status === 'failed') {
|
||||
markExecutionComplete(false);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_COMMAND_STARTED':
|
||||
updateNodeStatus(data.nodeId, 'running');
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_COMMAND_COMPLETED':
|
||||
updateNodeStatus(data.nodeId, 'completed', data.result);
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_COMMAND_FAILED':
|
||||
updateNodeStatus(data.nodeId, 'failed', undefined, data.error);
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_LOG_ENTRY':
|
||||
addCoordinatorLog(
|
||||
data.log.message,
|
||||
data.log.level,
|
||||
data.log.nodeId,
|
||||
data.log.source
|
||||
);
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_QUESTION_ASKED':
|
||||
setActiveQuestion(data.question);
|
||||
break;
|
||||
|
||||
case 'COORDINATOR_ANSWER_RECEIVED':
|
||||
// Answer received - handled by submitAnswer in the store
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an orchestrator message
|
||||
if (!data.type?.startsWith('ORCHESTRATOR_')) {
|
||||
return;
|
||||
@@ -210,6 +268,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
},
|
||||
[
|
||||
currentExecution,
|
||||
coordinatorExecutionId,
|
||||
setWsLastMessage,
|
||||
setExecutionStatus,
|
||||
setNodeStarted,
|
||||
@@ -220,6 +279,10 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
updateNode,
|
||||
addOutput,
|
||||
addA2UINotification,
|
||||
updateNodeStatus,
|
||||
addCoordinatorLog,
|
||||
setActiveQuestion,
|
||||
markExecutionComplete,
|
||||
onMessage,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -996,7 +996,7 @@ export interface CommandsResponse {
|
||||
* @param projectPath - Optional project path to filter data by workspace
|
||||
*/
|
||||
export async function fetchCommands(projectPath?: string): Promise<CommandsResponse> {
|
||||
// Try with project path first, fall back to global on 403/404
|
||||
// Try with project path first, fall back to global on errors
|
||||
if (projectPath) {
|
||||
try {
|
||||
const url = `/api/commands?path=${encodeURIComponent(projectPath)}`;
|
||||
@@ -1017,30 +1017,41 @@ export async function fetchCommands(projectPath?: string): Promise<CommandsRespo
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const apiError = error as ApiError;
|
||||
if (apiError.status === 403 || apiError.status === 404) {
|
||||
// Fall back to global commands list
|
||||
console.warn('[fetchCommands] 403/404 for project path, falling back to global commands');
|
||||
if (apiError.status === 403 || apiError.status === 404 || apiError.status === 400) {
|
||||
// Fall back to global commands list on path validation errors
|
||||
console.warn('[fetchCommands] Path validation failed, falling back to global commands');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: fetch global commands
|
||||
const data = await fetchApi<{
|
||||
commands?: Command[];
|
||||
projectCommands?: Command[];
|
||||
userCommands?: Command[];
|
||||
groups?: string[];
|
||||
projectGroupsConfig?: Record<string, any>;
|
||||
userGroupsConfig?: Record<string, any>;
|
||||
}>('/api/commands');
|
||||
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
|
||||
return {
|
||||
commands: data.commands ?? allCommands,
|
||||
groups: data.groups,
|
||||
projectGroupsConfig: data.projectGroupsConfig,
|
||||
userGroupsConfig: data.userGroupsConfig,
|
||||
};
|
||||
try {
|
||||
const data = await fetchApi<{
|
||||
commands?: Command[];
|
||||
projectCommands?: Command[];
|
||||
userCommands?: Command[];
|
||||
groups?: string[];
|
||||
projectGroupsConfig?: Record<string, any>;
|
||||
userGroupsConfig?: Record<string, any>;
|
||||
}>('/api/commands');
|
||||
const allCommands = [...(data.projectCommands ?? []), ...(data.userCommands ?? [])];
|
||||
return {
|
||||
commands: data.commands ?? allCommands,
|
||||
groups: data.groups,
|
||||
projectGroupsConfig: data.projectGroupsConfig,
|
||||
userGroupsConfig: data.userGroupsConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
// If global fetch also fails, return empty data instead of throwing
|
||||
console.warn('[fetchCommands] Failed to fetch commands, returning empty data:', error);
|
||||
return {
|
||||
commands: [],
|
||||
groups: [],
|
||||
projectGroupsConfig: {},
|
||||
userGroupsConfig: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1095,6 +1106,8 @@ export interface CoreMemory {
|
||||
source?: string;
|
||||
tags?: string[];
|
||||
size?: number;
|
||||
metadata?: string | Record<string, any>;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryResponse {
|
||||
@@ -1111,7 +1124,7 @@ export async function fetchMemories(projectPath?: string): Promise<MemoryRespons
|
||||
// Try with project path first, fall back to global on 403/404
|
||||
if (projectPath) {
|
||||
try {
|
||||
const url = `/api/memory?path=${encodeURIComponent(projectPath)}`;
|
||||
const url = `/api/core-memory/memories?path=${encodeURIComponent(projectPath)}`;
|
||||
const data = await fetchApi<{
|
||||
memories?: CoreMemory[];
|
||||
totalSize?: number;
|
||||
@@ -1137,7 +1150,7 @@ export async function fetchMemories(projectPath?: string): Promise<MemoryRespons
|
||||
memories?: CoreMemory[];
|
||||
totalSize?: number;
|
||||
claudeMdCount?: number;
|
||||
}>('/api/memory');
|
||||
}>('/api/core-memory/memories');
|
||||
return {
|
||||
memories: data.memories ?? [],
|
||||
totalSize: data.totalSize ?? 0,
|
||||
@@ -1153,12 +1166,16 @@ export async function fetchMemories(projectPath?: string): Promise<MemoryRespons
|
||||
export async function createMemory(input: {
|
||||
content: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, any>;
|
||||
}, projectPath?: string): Promise<CoreMemory> {
|
||||
const url = projectPath ? `/api/memory?path=${encodeURIComponent(projectPath)}` : '/api/memory';
|
||||
return fetchApi<CoreMemory>(url, {
|
||||
const url = '/api/core-memory/memories';
|
||||
return fetchApi<{ success: boolean; memory: CoreMemory }>(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
body: JSON.stringify({
|
||||
...input,
|
||||
path: projectPath,
|
||||
}),
|
||||
}).then(data => data.memory);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1172,13 +1189,15 @@ export async function updateMemory(
|
||||
input: Partial<CoreMemory>,
|
||||
projectPath?: string
|
||||
): Promise<CoreMemory> {
|
||||
const url = projectPath
|
||||
? `/api/memory/${encodeURIComponent(memoryId)}?path=${encodeURIComponent(projectPath)}`
|
||||
: `/api/memory/${encodeURIComponent(memoryId)}`;
|
||||
return fetchApi<CoreMemory>(url, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const url = '/api/core-memory/memories';
|
||||
return fetchApi<{ success: boolean; memory: CoreMemory }>(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
id: memoryId,
|
||||
...input,
|
||||
path: projectPath,
|
||||
}),
|
||||
}).then(data => data.memory);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1188,8 +1207,8 @@ export async function updateMemory(
|
||||
*/
|
||||
export async function deleteMemory(memoryId: string, projectPath?: string): Promise<void> {
|
||||
const url = projectPath
|
||||
? `/api/memory/${encodeURIComponent(memoryId)}?path=${encodeURIComponent(projectPath)}`
|
||||
: `/api/memory/${encodeURIComponent(memoryId)}`;
|
||||
? `/api/core-memory/memories/${encodeURIComponent(memoryId)}?path=${encodeURIComponent(projectPath)}`
|
||||
: `/api/core-memory/memories/${encodeURIComponent(memoryId)}`;
|
||||
return fetchApi<void>(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
@@ -2329,6 +2348,35 @@ export interface PromptInsightsResponse {
|
||||
suggestions: Suggestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insight history entry from CLI analysis
|
||||
*/
|
||||
export interface InsightHistory {
|
||||
/** Unique insight identifier */
|
||||
id: string;
|
||||
/** Created timestamp */
|
||||
created_at: string;
|
||||
/** AI tool used for analysis */
|
||||
tool: 'gemini' | 'qwen' | 'codex' | string;
|
||||
/** Number of prompts analyzed */
|
||||
prompt_count: number;
|
||||
/** Detected patterns */
|
||||
patterns: Pattern[];
|
||||
/** AI suggestions */
|
||||
suggestions: Suggestion[];
|
||||
/** Associated execution ID */
|
||||
execution_id: string | null;
|
||||
/** Language preference */
|
||||
lang: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insights history response from backend
|
||||
*/
|
||||
export interface InsightsHistoryResponse {
|
||||
insights: InsightHistory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze prompts request
|
||||
*/
|
||||
@@ -2356,6 +2404,30 @@ export async function fetchPromptInsights(projectPath?: string): Promise<PromptI
|
||||
return fetchApi<PromptInsightsResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch insights history (past CLI analyses) from backend
|
||||
* @param projectPath - Optional project path to filter data by workspace
|
||||
* @param limit - Maximum number of insights to fetch (default: 20)
|
||||
*/
|
||||
export async function fetchInsightsHistory(projectPath?: string, limit: number = 20): Promise<InsightsHistoryResponse> {
|
||||
const url = projectPath
|
||||
? `/api/memory/insights?limit=${limit}&path=${encodeURIComponent(projectPath)}`
|
||||
: `/api/memory/insights?limit=${limit}`;
|
||||
return fetchApi<InsightsHistoryResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single insight detail by ID
|
||||
* @param insightId - Insight ID to fetch
|
||||
* @param projectPath - Optional project path to filter data by workspace
|
||||
*/
|
||||
export async function fetchInsightDetail(insightId: string, projectPath?: string): Promise<InsightHistory> {
|
||||
const url = projectPath
|
||||
? `/api/memory/insights/${encodeURIComponent(insightId)}?path=${encodeURIComponent(projectPath)}`
|
||||
: `/api/memory/insights/${encodeURIComponent(insightId)}`;
|
||||
return fetchApi<InsightHistory>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze prompts using AI tool
|
||||
*/
|
||||
@@ -2375,6 +2447,28 @@ export async function deletePrompt(promptId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an insight from history
|
||||
*/
|
||||
export async function deleteInsight(insightId: string, projectPath?: string): Promise<{ success: boolean }> {
|
||||
const url = projectPath
|
||||
? `/api/memory/insights/${encodeURIComponent(insightId)}?path=${encodeURIComponent(projectPath)}`
|
||||
: `/api/memory/insights/${encodeURIComponent(insightId)}`;
|
||||
return fetchApi<{ success: boolean }>(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch delete prompts from history
|
||||
*/
|
||||
export async function batchDeletePrompts(promptIds: string[]): Promise<{ deleted: number }> {
|
||||
return fetchApi<{ deleted: number }>('/api/memory/prompts/batch-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ promptIds }),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== File Explorer API ==========
|
||||
|
||||
/**
|
||||
@@ -3844,6 +3938,41 @@ export interface ExecutionStateResponse {
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator pipeline details response
|
||||
*/
|
||||
export interface CoordinatorPipelineDetails {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
command: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
output?: string;
|
||||
parentId?: string;
|
||||
children?: Array<any>;
|
||||
}>;
|
||||
totalSteps: number;
|
||||
estimatedDuration?: number;
|
||||
logs?: Array<{
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: 'info' | 'warn' | 'error' | 'debug' | 'success';
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
source?: 'system' | 'node' | 'user';
|
||||
}>;
|
||||
status: 'idle' | 'initializing' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution log entry
|
||||
*/
|
||||
@@ -3874,6 +4003,14 @@ export async function fetchExecutionState(execId: string): Promise<{ success: bo
|
||||
return fetchApi(`/api/orchestrator/executions/${encodeURIComponent(execId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch coordinator pipeline details by execution ID
|
||||
* @param execId - Execution/Pipeline ID
|
||||
*/
|
||||
export async function fetchCoordinatorPipeline(execId: string): Promise<{ success: boolean; data: CoordinatorPipelineDetails }> {
|
||||
return fetchApi(`/api/coordinator/pipeline/${encodeURIComponent(execId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch execution logs with pagination and filtering
|
||||
* @param execId - Execution ID
|
||||
|
||||
@@ -289,8 +289,22 @@
|
||||
"descriptionPlaceholder": "Optional description for this configuration",
|
||||
"selectProvider": "Select a provider",
|
||||
"includeCoAuthoredBy": "Include co-authored-by in commits",
|
||||
"coAuthoredBy": "Co-authored",
|
||||
"availableModels": "Available Models",
|
||||
"availableModelsPlaceholder": "Enter model name and press Enter",
|
||||
"availableModelsHint": "Models shown in CLI dropdown menus. Click × to remove.",
|
||||
"nameFormatHint": "Letters, numbers, hyphens, underscores only. Used as: ccw cli --tool [name]",
|
||||
"nameTooLong": "Name must be {max} characters or less",
|
||||
"settingsFile": "Settings File",
|
||||
"settingsFilePlaceholder": "e.g., /path/to/settings.json",
|
||||
"settingsFileHint": "Path to external Claude CLI settings file (passed via --settings parameter)",
|
||||
"tags": "Tags",
|
||||
"tagsDescription": "Tags for CLI tool routing and auto-selection (e.g., analysis, Debug)",
|
||||
"addTag": "Add tag",
|
||||
"tagInputPlaceholder": "Enter a tag...",
|
||||
"predefinedTags": "Common tags",
|
||||
"removeTag": "Remove tag",
|
||||
"noTags": "No tags added",
|
||||
"validation": {
|
||||
"providerRequired": "Please select a provider",
|
||||
"authOrBaseUrlRequired": "Please enter auth token or base URL"
|
||||
|
||||
@@ -181,6 +181,8 @@
|
||||
"tutorials": "Tutorials"
|
||||
}
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"askQuestion": {
|
||||
"defaultTitle": "Questions",
|
||||
"description": "Please answer the following questions",
|
||||
@@ -188,5 +190,71 @@
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"required": "This question is required"
|
||||
},
|
||||
"coordinator": {
|
||||
"modal": {
|
||||
"title": "Start Coordinator",
|
||||
"description": "Describe the task you want the coordinator to execute"
|
||||
},
|
||||
"form": {
|
||||
"taskDescription": "Task Description",
|
||||
"taskDescriptionPlaceholder": "Describe what you want the coordinator to do (minimum 10 characters)...",
|
||||
"parameters": "Parameters (Optional)",
|
||||
"parametersPlaceholder": "{\"key\": \"value\"}",
|
||||
"parametersHelp": "Optional JSON parameters for the coordinator execution",
|
||||
"characterCount": "{current} / {max} characters (min: {min})",
|
||||
"start": "Start Coordinator",
|
||||
"starting": "Starting..."
|
||||
},
|
||||
"validation": {
|
||||
"taskDescriptionRequired": "Task description is required",
|
||||
"taskDescriptionTooShort": "Task description must be at least 10 characters",
|
||||
"taskDescriptionTooLong": "Task description must not exceed 2000 characters",
|
||||
"parametersInvalidJson": "Parameters must be valid JSON",
|
||||
"answerRequired": "An answer is required"
|
||||
},
|
||||
"success": {
|
||||
"started": "Coordinator started successfully"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped"
|
||||
},
|
||||
"logs": "Logs",
|
||||
"entries": "entries",
|
||||
"error": "Error",
|
||||
"output": "Output",
|
||||
"startedAt": "Started At",
|
||||
"completedAt": "Completed At",
|
||||
"retrying": "Retrying...",
|
||||
"retry": "Retry",
|
||||
"skipping": "Skipping...",
|
||||
"skip": "Skip",
|
||||
"logLevel": "Log Level",
|
||||
"level": {
|
||||
"all": "All",
|
||||
"info": "Info",
|
||||
"warn": "Warning",
|
||||
"error": "Error",
|
||||
"debug": "Debug"
|
||||
},
|
||||
"noLogs": "No logs available",
|
||||
"question": {
|
||||
"answer": "Answer",
|
||||
"textPlaceholder": "Enter your answer...",
|
||||
"selectOne": "Select One",
|
||||
"selectMultiple": "Select Multiple",
|
||||
"confirm": "Confirm",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"submitting": "Submitting...",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"error": {
|
||||
"submitFailed": "Failed to submit answer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,17 @@
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyError": "Failed to copy",
|
||||
"refresh": "Refresh",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse"
|
||||
},
|
||||
"tabs": {
|
||||
"memories": "Memories",
|
||||
"favorites": "Favorites",
|
||||
"archived": "Archived"
|
||||
},
|
||||
"stats": {
|
||||
"totalSize": "Total Size",
|
||||
"count": "Count",
|
||||
@@ -43,7 +50,9 @@
|
||||
"editTitle": "Edit Memory",
|
||||
"labels": {
|
||||
"content": "Content",
|
||||
"tags": "Tags"
|
||||
"tags": "Tags",
|
||||
"favorite": "Favorite",
|
||||
"priority": "Priority"
|
||||
},
|
||||
"placeholders": {
|
||||
"content": "Enter memory content...",
|
||||
@@ -57,6 +66,11 @@
|
||||
"updating": "Updating..."
|
||||
}
|
||||
},
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"types": {
|
||||
"coreMemory": "Core Memory",
|
||||
"workflow": "Workflow",
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"groups": {
|
||||
"overview": "Overview",
|
||||
"workflow": "Workflow & Execution",
|
||||
"knowledge": "Knowledge & Memory",
|
||||
"issues": "Issue Management",
|
||||
"tools": "Tools & Hooks",
|
||||
"configuration": "Configuration & Support"
|
||||
},
|
||||
"main": {
|
||||
"home": "Home",
|
||||
"sessions": "Sessions",
|
||||
|
||||
@@ -48,5 +48,7 @@
|
||||
"atTime": "at {0}"
|
||||
},
|
||||
"markAsRead": "Mark as read",
|
||||
"markAsUnread": "Mark as unread"
|
||||
"markAsUnread": "Mark as unread",
|
||||
"read": "Read",
|
||||
"unread": "Unread"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"description": "View and analyze your prompt history with AI insights",
|
||||
"searchPlaceholder": "Search prompts...",
|
||||
"filterByIntent": "Filter by intent",
|
||||
"filterByProject": "Filter by project",
|
||||
"intents": {
|
||||
"all": "All Intents",
|
||||
"intent": "Intent",
|
||||
@@ -12,6 +13,10 @@
|
||||
"document": "Document",
|
||||
"analyze": "Analyze"
|
||||
},
|
||||
"projects": {
|
||||
"all": "All Projects",
|
||||
"project": "Project"
|
||||
},
|
||||
"stats": {
|
||||
"totalCount": "Total Prompts",
|
||||
"totalCountDesc": "All stored prompts",
|
||||
@@ -19,7 +24,15 @@
|
||||
"avgLengthDesc": "Mean character count",
|
||||
"topIntent": "Top Intent",
|
||||
"topIntentDesc": "Most used category",
|
||||
"noIntent": "N/A"
|
||||
"noIntent": "N/A",
|
||||
"avgQuality": "Avg Quality",
|
||||
"avgQualityDesc": "Quality score distribution"
|
||||
},
|
||||
"quality": {
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low",
|
||||
"none": "N/A"
|
||||
},
|
||||
"card": {
|
||||
"untitled": "Untitled Prompt",
|
||||
@@ -52,6 +65,24 @@
|
||||
"suggestions": "Suggestions"
|
||||
}
|
||||
},
|
||||
"insightsHistory": {
|
||||
"loading": "Loading insights history...",
|
||||
"patterns": "Patterns",
|
||||
"suggestions": "Suggestions",
|
||||
"prompts": "Prompts",
|
||||
"empty": {
|
||||
"title": "No analysis history",
|
||||
"message": "Run an analysis to see historical insights and patterns."
|
||||
}
|
||||
},
|
||||
"insightDetail": {
|
||||
"title": "Insight Detail",
|
||||
"patterns": "Patterns Found",
|
||||
"suggestions": "Suggestions",
|
||||
"promptsAnalyzed": "prompts analyzed",
|
||||
"noContent": "No patterns or suggestions available for this insight.",
|
||||
"deleting": "Deleting..."
|
||||
},
|
||||
"suggestions": {
|
||||
"types": {
|
||||
"refactor": "Refactor",
|
||||
@@ -63,7 +94,15 @@
|
||||
},
|
||||
"dialog": {
|
||||
"deleteTitle": "Delete Prompt",
|
||||
"deleteConfirm": "Are you sure you want to delete this prompt? This action cannot be undone."
|
||||
"deleteConfirm": "Are you sure you want to delete this prompt? This action cannot be undone.",
|
||||
"batchDeleteTitle": "Delete Prompts",
|
||||
"batchDeleteConfirm": "Are you sure you want to delete {count} selected prompt(s)? This action cannot be undone."
|
||||
},
|
||||
"batch": {
|
||||
"selected": "{count} selected",
|
||||
"selectAll": "Select All",
|
||||
"clearSelection": "Clear Selection",
|
||||
"deleteSelected": "Delete Selected"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "No prompts found",
|
||||
|
||||
@@ -289,8 +289,22 @@
|
||||
"descriptionPlaceholder": "此配置的可选描述",
|
||||
"selectProvider": "选择提供商",
|
||||
"includeCoAuthoredBy": "在提交中包含 co-authored-by",
|
||||
"coAuthoredBy": "共同创作",
|
||||
"availableModels": "可用模型",
|
||||
"availableModelsPlaceholder": "输入模型名称并按回车",
|
||||
"availableModelsHint": "显示在 CLI 下拉菜单中的模型。点击 × 删除。",
|
||||
"nameFormatHint": "仅限字母、数字、连字符和下划线。用作:ccw cli --tool [名称]",
|
||||
"nameTooLong": "名称必须在 {max} 个字符以内",
|
||||
"settingsFile": "配置文件路径",
|
||||
"settingsFilePlaceholder": "例如:/path/to/settings.json",
|
||||
"settingsFileHint": "外部 Claude CLI 配置文件路径(通过 --settings 参数传递)",
|
||||
"tags": "标签",
|
||||
"tagsDescription": "CLI 工具路由和自动选择标签(例如:分析、Debug)",
|
||||
"addTag": "添加标签",
|
||||
"tagInputPlaceholder": "输入标签...",
|
||||
"predefinedTags": "常用标签",
|
||||
"removeTag": "删除标签",
|
||||
"noTags": "未添加标签",
|
||||
"validation": {
|
||||
"providerRequired": "请选择提供商",
|
||||
"authOrBaseUrlRequired": "请输入认证令牌或基础 URL"
|
||||
|
||||
@@ -185,6 +185,8 @@
|
||||
"tutorials": "教程"
|
||||
}
|
||||
},
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"askQuestion": {
|
||||
"defaultTitle": "问题",
|
||||
"description": "请回答以下问题",
|
||||
@@ -192,5 +194,71 @@
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"required": "此问题为必填项"
|
||||
},
|
||||
"coordinator": {
|
||||
"modal": {
|
||||
"title": "启动协调器",
|
||||
"description": "描述您希望协调器执行的任务"
|
||||
},
|
||||
"form": {
|
||||
"taskDescription": "任务描述",
|
||||
"taskDescriptionPlaceholder": "描述协调器需要执行的任务(至少10个字符)...",
|
||||
"parameters": "参数(可选)",
|
||||
"parametersPlaceholder": "{\"key\": \"value\"}",
|
||||
"parametersHelp": "协调器执行的可选JSON参数",
|
||||
"characterCount": "{current} / {max} 字符(最少:{min})",
|
||||
"start": "启动协调器",
|
||||
"starting": "启动中..."
|
||||
},
|
||||
"validation": {
|
||||
"taskDescriptionRequired": "任务描述为必填项",
|
||||
"taskDescriptionTooShort": "任务描述至少需要10个字符",
|
||||
"taskDescriptionTooLong": "任务描述不能超过2000个字符",
|
||||
"parametersInvalidJson": "参数必须是有效的JSON格式",
|
||||
"answerRequired": "答案为必填项"
|
||||
},
|
||||
"success": {
|
||||
"started": "协调器启动成功"
|
||||
},
|
||||
"status": {
|
||||
"pending": "待执行",
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败",
|
||||
"skipped": "已跳过"
|
||||
},
|
||||
"logs": "日志",
|
||||
"entries": "条日志",
|
||||
"error": "错误",
|
||||
"output": "输出",
|
||||
"startedAt": "开始时间",
|
||||
"completedAt": "完成时间",
|
||||
"retrying": "重试中...",
|
||||
"retry": "重试",
|
||||
"skipping": "跳过中...",
|
||||
"skip": "跳过",
|
||||
"logLevel": "日志级别",
|
||||
"level": {
|
||||
"all": "全部",
|
||||
"info": "信息",
|
||||
"warn": "警告",
|
||||
"error": "错误",
|
||||
"debug": "调试"
|
||||
},
|
||||
"noLogs": "无可用日志",
|
||||
"question": {
|
||||
"answer": "答案",
|
||||
"textPlaceholder": "输入您的答案...",
|
||||
"selectOne": "选择一个",
|
||||
"selectMultiple": "选择多个",
|
||||
"confirm": "确认",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"submitting": "提交中...",
|
||||
"submit": "提交"
|
||||
},
|
||||
"error": {
|
||||
"submitFailed": "提交答案失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,17 @@
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"copy": "复制",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyError": "复制失败",
|
||||
"refresh": "刷新",
|
||||
"expand": "展开",
|
||||
"collapse": "收起"
|
||||
},
|
||||
"tabs": {
|
||||
"memories": "记忆",
|
||||
"favorites": "收藏",
|
||||
"archived": "归档"
|
||||
},
|
||||
"stats": {
|
||||
"totalSize": "总大小",
|
||||
"count": "数量",
|
||||
@@ -43,7 +50,9 @@
|
||||
"editTitle": "编辑记忆",
|
||||
"labels": {
|
||||
"content": "内容",
|
||||
"tags": "标签"
|
||||
"tags": "标签",
|
||||
"favorite": "收藏",
|
||||
"priority": "优先级"
|
||||
},
|
||||
"placeholders": {
|
||||
"content": "输入记忆内容...",
|
||||
@@ -57,6 +66,11 @@
|
||||
"updating": "更新中..."
|
||||
}
|
||||
},
|
||||
"priority": {
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
},
|
||||
"types": {
|
||||
"coreMemory": "核心记忆",
|
||||
"workflow": "工作流",
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"groups": {
|
||||
"overview": "概览",
|
||||
"workflow": "工作流与执行",
|
||||
"knowledge": "知识与记忆",
|
||||
"issues": "问题管理",
|
||||
"tools": "工具与钩子",
|
||||
"configuration": "配置与支持"
|
||||
},
|
||||
"main": {
|
||||
"home": "首页",
|
||||
"sessions": "会话",
|
||||
|
||||
@@ -48,5 +48,7 @@
|
||||
"atTime": "{0}"
|
||||
},
|
||||
"markAsRead": "标为已读",
|
||||
"markAsUnread": "标为未读"
|
||||
"markAsUnread": "标为未读",
|
||||
"read": "已读",
|
||||
"unread": "未读"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"description": "查看和分析您的提示历史记录,获取 AI 洞察",
|
||||
"searchPlaceholder": "搜索提示...",
|
||||
"filterByIntent": "按意图筛选",
|
||||
"filterByProject": "按项目筛选",
|
||||
"intents": {
|
||||
"all": "所有意图",
|
||||
"intent": "意图",
|
||||
@@ -12,6 +13,10 @@
|
||||
"document": "文档",
|
||||
"analyze": "分析"
|
||||
},
|
||||
"projects": {
|
||||
"all": "所有项目",
|
||||
"project": "项目"
|
||||
},
|
||||
"stats": {
|
||||
"totalCount": "总提示数",
|
||||
"totalCountDesc": "所有存储的提示",
|
||||
@@ -19,7 +24,15 @@
|
||||
"avgLengthDesc": "平均字符数",
|
||||
"topIntent": "主要意图",
|
||||
"topIntentDesc": "最常用的类别",
|
||||
"noIntent": "无"
|
||||
"noIntent": "无",
|
||||
"avgQuality": "平均质量",
|
||||
"avgQualityDesc": "质量分数分布"
|
||||
},
|
||||
"quality": {
|
||||
"high": "高",
|
||||
"medium": "中",
|
||||
"low": "低",
|
||||
"none": "无"
|
||||
},
|
||||
"card": {
|
||||
"untitled": "未命名提示",
|
||||
@@ -52,6 +65,24 @@
|
||||
"suggestions": "建议"
|
||||
}
|
||||
},
|
||||
"insightsHistory": {
|
||||
"loading": "加载洞察历史...",
|
||||
"patterns": "模式",
|
||||
"suggestions": "建议",
|
||||
"prompts": "提示",
|
||||
"empty": {
|
||||
"title": "暂无分析历史",
|
||||
"message": "运行分析以查看历史洞察和模式。"
|
||||
}
|
||||
},
|
||||
"insightDetail": {
|
||||
"title": "洞察详情",
|
||||
"patterns": "发现的模式",
|
||||
"suggestions": "建议",
|
||||
"promptsAnalyzed": "个提示已分析",
|
||||
"noContent": "此洞察暂无模式或建议。",
|
||||
"deleting": "删除中..."
|
||||
},
|
||||
"suggestions": {
|
||||
"types": {
|
||||
"refactor": "重构",
|
||||
@@ -63,7 +94,15 @@
|
||||
},
|
||||
"dialog": {
|
||||
"deleteTitle": "删除提示",
|
||||
"deleteConfirm": "确定要删除此提示吗?此操作无法撤销。"
|
||||
"deleteConfirm": "确定要删除此提示吗?此操作无法撤销。",
|
||||
"batchDeleteTitle": "批量删除提示",
|
||||
"batchDeleteConfirm": "确定要删除 {count} 个选中的提示吗?此操作无法撤销。"
|
||||
},
|
||||
"batch": {
|
||||
"selected": "已选 {count} 个",
|
||||
"selectAll": "全选",
|
||||
"clearSelection": "清除选择",
|
||||
"deleteSelected": "删除选中"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "未找到提示",
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// ========================================
|
||||
// View and manage core memory and context with CRUD operations
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Brain,
|
||||
Search,
|
||||
@@ -19,12 +20,16 @@ import {
|
||||
Copy,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Star,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { useMemory, useMemoryMutations } from '@/hooks';
|
||||
import type { CoreMemory } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -38,10 +43,19 @@ interface MemoryCardProps {
|
||||
onEdit: (memory: CoreMemory) => void;
|
||||
onDelete: (memory: CoreMemory) => void;
|
||||
onCopy: (content: string) => void;
|
||||
onToggleFavorite: (memory: CoreMemory) => void;
|
||||
onArchive: (memory: CoreMemory) => void;
|
||||
onUnarchive: (memory: CoreMemory) => void;
|
||||
}
|
||||
|
||||
function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCopy }: MemoryCardProps) {
|
||||
function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCopy, onToggleFavorite, onArchive, onUnarchive }: MemoryCardProps) {
|
||||
const formattedDate = new Date(memory.createdAt).toLocaleDateString();
|
||||
|
||||
// Parse metadata from memory
|
||||
const metadata = memory.metadata ? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata) : {};
|
||||
const isFavorite = metadata.favorite === true;
|
||||
const priority = metadata.priority || 'medium';
|
||||
const isArchived = memory.archived || false;
|
||||
const formattedSize = memory.size
|
||||
? memory.size < 1024
|
||||
? `${memory.size} B`
|
||||
@@ -70,6 +84,16 @@ function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCo
|
||||
{memory.source}
|
||||
</Badge>
|
||||
)}
|
||||
{priority !== 'medium' && (
|
||||
<Badge variant={priority === 'high' ? 'destructive' : 'secondary'} className="text-xs">
|
||||
{priority}
|
||||
</Badge>
|
||||
)}
|
||||
{isArchived && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Archived
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formattedDate} - {formattedSize}
|
||||
@@ -77,6 +101,17 @@ function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCo
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-8 w-8 p-0", isFavorite && "text-yellow-500")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(memory);
|
||||
}}
|
||||
>
|
||||
<Star className={cn("w-4 h-4", isFavorite && "fill-current")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -99,6 +134,31 @@ function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCo
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{!isArchived ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onArchive(memory);
|
||||
}}
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnarchive(memory);
|
||||
}}
|
||||
>
|
||||
<ArchiveRestore className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -160,7 +220,7 @@ function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCo
|
||||
interface NewMemoryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { content: string; tags?: string[] }) => void;
|
||||
onSubmit: (data: { content: string; tags?: string[]; metadata?: Record<string, any> }) => void;
|
||||
isCreating: boolean;
|
||||
editingMemory?: CoreMemory | null;
|
||||
}
|
||||
@@ -175,6 +235,27 @@ function NewMemoryDialog({
|
||||
const { formatMessage } = useIntl();
|
||||
const [content, setContent] = useState(editingMemory?.content || '');
|
||||
const [tagsInput, setTagsInput] = useState(editingMemory?.tags?.join(', ') || '');
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
|
||||
|
||||
// Initialize from editing memory metadata
|
||||
useEffect(() => {
|
||||
if (editingMemory && editingMemory.metadata) {
|
||||
try {
|
||||
const metadata = typeof editingMemory.metadata === 'string'
|
||||
? JSON.parse(editingMemory.metadata)
|
||||
: editingMemory.metadata;
|
||||
setIsFavorite(metadata.favorite === true);
|
||||
setPriority(metadata.priority || 'medium');
|
||||
} catch {
|
||||
setIsFavorite(false);
|
||||
setPriority('medium');
|
||||
}
|
||||
} else {
|
||||
setIsFavorite(false);
|
||||
setPriority('medium');
|
||||
}
|
||||
}, [editingMemory]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -183,9 +264,21 @@ function NewMemoryDialog({
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
onSubmit({ content: content.trim(), tags: tags.length > 0 ? tags : undefined });
|
||||
|
||||
// Build metadata object
|
||||
const metadata: Record<string, any> = {};
|
||||
if (isFavorite) metadata.favorite = true;
|
||||
if (priority !== 'medium') metadata.priority = priority;
|
||||
|
||||
onSubmit({
|
||||
content: content.trim(),
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
});
|
||||
setContent('');
|
||||
setTagsInput('');
|
||||
setIsFavorite(false);
|
||||
setPriority('medium');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,6 +310,30 @@ function NewMemoryDialog({
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="favorite"
|
||||
checked={isFavorite}
|
||||
onCheckedChange={(checked) => setIsFavorite(checked === true)}
|
||||
/>
|
||||
<label htmlFor="favorite" className="text-sm font-medium cursor-pointer">
|
||||
{formatMessage({ id: 'memory.createDialog.labels.favorite' })}
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">{formatMessage({ id: 'memory.createDialog.labels.priority' })}</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
|
||||
className="mt-1 w-full p-2 bg-background border border-input rounded-md text-sm"
|
||||
>
|
||||
<option value="low">{formatMessage({ id: 'memory.priority.low' })}</option>
|
||||
<option value="medium">{formatMessage({ id: 'memory.priority.medium' })}</option>
|
||||
<option value="high">{formatMessage({ id: 'memory.priority.high' })}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{formatMessage({ id: 'memory.createDialog.buttons.cancel' })}
|
||||
@@ -250,6 +367,11 @@ export function MemoryPage() {
|
||||
const [isNewMemoryOpen, setIsNewMemoryOpen] = useState(false);
|
||||
const [editingMemory, setEditingMemory] = useState<CoreMemory | null>(null);
|
||||
const [expandedMemories, setExpandedMemories] = useState<Set<string>>(new Set());
|
||||
const [currentTab, setCurrentTab] = useState<'memories' | 'favorites' | 'archived'>('memories');
|
||||
|
||||
// Build filter based on current tab
|
||||
const favoriteFilter = currentTab === 'favorites' ? { favorite: true } : undefined;
|
||||
const archivedFilter = currentTab === 'archived' ? { archived: true } : { archived: false };
|
||||
|
||||
const {
|
||||
memories,
|
||||
@@ -263,10 +385,12 @@ export function MemoryPage() {
|
||||
filter: {
|
||||
search: searchQuery || undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
...favoriteFilter,
|
||||
...archivedFilter,
|
||||
},
|
||||
});
|
||||
|
||||
const { createMemory, updateMemory, deleteMemory, isCreating, isUpdating } =
|
||||
const { createMemory, updateMemory, deleteMemory, archiveMemory, unarchiveMemory, isCreating, isUpdating } =
|
||||
useMemoryMutations();
|
||||
|
||||
const toggleExpand = (memoryId: string) => {
|
||||
@@ -281,12 +405,12 @@ export function MemoryPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateMemory = async (data: { content: string; tags?: string[] }) => {
|
||||
const handleCreateMemory = async (data: { content: string; tags?: string[]; metadata?: Record<string, any> }) => {
|
||||
if (editingMemory) {
|
||||
await updateMemory(editingMemory.id, data);
|
||||
setEditingMemory(null);
|
||||
} else {
|
||||
await createMemory(data);
|
||||
await createMemory(data as any); // TODO: update createMemory type to accept metadata
|
||||
}
|
||||
setIsNewMemoryOpen(false);
|
||||
};
|
||||
@@ -302,12 +426,29 @@ export function MemoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async (memory: CoreMemory) => {
|
||||
const currentMetadata = memory.metadata ? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata) : {};
|
||||
const newFavorite = !(currentMetadata.favorite === true);
|
||||
await updateMemory(memory.id, {
|
||||
metadata: JSON.stringify({ ...currentMetadata, favorite: newFavorite }),
|
||||
} as any); // TODO: update updateMemory to accept metadata field
|
||||
};
|
||||
|
||||
const handleArchive = async (memory: CoreMemory) => {
|
||||
await archiveMemory(memory.id);
|
||||
};
|
||||
|
||||
const handleUnarchive = async (memory: CoreMemory) => {
|
||||
await unarchiveMemory(memory.id);
|
||||
};
|
||||
|
||||
const copyToClipboard = async (content: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
// TODO: Show toast notification
|
||||
toast.success(formatMessage({ id: 'memory.actions.copySuccess' }));
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
toast.error(formatMessage({ id: 'memory.actions.copyError' }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -348,6 +489,34 @@ export function MemoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-2 border-b border-border">
|
||||
<Button
|
||||
variant={currentTab === 'memories' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setCurrentTab('memories')}
|
||||
>
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'memory.tabs.memories' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentTab === 'favorites' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setCurrentTab('favorites')}
|
||||
>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'memory.tabs.favorites' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentTab === 'archived' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setCurrentTab('archived')}
|
||||
>
|
||||
<Archive className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'memory.tabs.archived' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="p-4">
|
||||
@@ -429,9 +598,9 @@ export function MemoryPage() {
|
||||
|
||||
{/* Memory List */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="h-64 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : memories.length === 0 ? (
|
||||
@@ -449,7 +618,7 @@ export function MemoryPage() {
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{memories.map((memory) => (
|
||||
<MemoryCard
|
||||
key={memory.id}
|
||||
@@ -459,6 +628,9 @@ export function MemoryPage() {
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCopy={copyToClipboard}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onArchive={handleArchive}
|
||||
onUnarchive={handleUnarchive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -17,12 +17,20 @@ import {
|
||||
import {
|
||||
usePromptHistory,
|
||||
usePromptInsights,
|
||||
useInsightsHistory,
|
||||
usePromptHistoryMutations,
|
||||
useDeleteInsight,
|
||||
extractUniqueProjects,
|
||||
type PromptHistoryFilter,
|
||||
} from '@/hooks/usePromptHistory';
|
||||
import { PromptStats, PromptStatsSkeleton } from '@/components/shared/PromptStats';
|
||||
import { PromptCard } from '@/components/shared/PromptCard';
|
||||
import { BatchOperationToolbar } from '@/components/shared/BatchOperationToolbar';
|
||||
import { InsightsPanel } from '@/components/shared/InsightsPanel';
|
||||
import { InsightsHistoryList } from '@/components/shared/InsightsHistoryList';
|
||||
import { InsightDetailPanelOverlay } from '@/components/shared/InsightDetailPanel';
|
||||
import { fetchInsightDetail } from '@/lib/api';
|
||||
import type { InsightHistory } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -42,7 +50,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type IntentFilter = 'all' | string;
|
||||
@@ -56,24 +63,35 @@ export function PromptHistoryPage() {
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [intentFilter, setIntentFilter] = React.useState<IntentFilter>('all');
|
||||
const [projectFilter, setProjectFilter] = React.useState<string>('all');
|
||||
const [selectedTool, setSelectedTool] = React.useState<'gemini' | 'qwen' | 'codex'>('gemini');
|
||||
|
||||
// Dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||
const [promptToDelete, setPromptToDelete] = React.useState<string | null>(null);
|
||||
|
||||
// Insight detail state
|
||||
const [selectedInsight, setSelectedInsight] = React.useState<InsightHistory | null>(null);
|
||||
const [insightDetailOpen, setInsightDetailOpen] = React.useState(false);
|
||||
|
||||
// Batch operations state
|
||||
const [selectedPromptIds, setSelectedPromptIds] = React.useState<Set<string>>(new Set());
|
||||
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = React.useState(false);
|
||||
|
||||
// Build filter object
|
||||
const filter: PromptHistoryFilter = React.useMemo(
|
||||
() => ({
|
||||
search: searchQuery,
|
||||
intent: intentFilter === 'all' ? undefined : intentFilter,
|
||||
project: projectFilter === 'all' ? undefined : projectFilter,
|
||||
}),
|
||||
[searchQuery, intentFilter]
|
||||
[searchQuery, intentFilter, projectFilter]
|
||||
);
|
||||
|
||||
// Fetch prompts and insights
|
||||
const {
|
||||
prompts,
|
||||
allPrompts,
|
||||
promptsBySession,
|
||||
stats,
|
||||
isLoading,
|
||||
@@ -83,10 +101,18 @@ export function PromptHistoryPage() {
|
||||
} = usePromptHistory({ filter });
|
||||
|
||||
const { data: insightsData, isLoading: insightsLoading } = usePromptInsights();
|
||||
const { data: insightsHistoryData, isLoading: insightsHistoryLoading } = useInsightsHistory({ limit: 20 });
|
||||
|
||||
const { analyzePrompts, deletePrompt, isAnalyzing } = usePromptHistoryMutations();
|
||||
const { analyzePrompts, deletePrompt, batchDeletePrompts, isAnalyzing, isBatchDeleting } = usePromptHistoryMutations();
|
||||
const { deleteInsight: deleteInsightMutation, isDeleting: isDeletingInsight } = useDeleteInsight();
|
||||
|
||||
const isMutating = isAnalyzing;
|
||||
const isMutating = isAnalyzing || isBatchDeleting;
|
||||
|
||||
// Extract unique projects from all prompts
|
||||
const uniqueProjects = React.useMemo(
|
||||
() => extractUniqueProjects(allPrompts),
|
||||
[allPrompts]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
const handleAnalyze = async () => {
|
||||
@@ -118,6 +144,45 @@ export function PromptHistoryPage() {
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleInsightSelect = async (insightId: string) => {
|
||||
try {
|
||||
const insight = await fetchInsightDetail(insightId);
|
||||
setSelectedInsight(insight);
|
||||
setInsightDetailOpen(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch insight detail:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInsight = async (insightId: string) => {
|
||||
const locale = useIntl().locale;
|
||||
const confirmMessage = locale === 'zh'
|
||||
? '确定要删除此分析吗?此操作无法撤销。'
|
||||
: 'Are you sure you want to delete this insight? This action cannot be undone.';
|
||||
|
||||
if (!window.confirm(confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteInsightMutation(insightId);
|
||||
setInsightDetailOpen(false);
|
||||
setSelectedInsight(null);
|
||||
// Show success toast
|
||||
const successMessage = locale === 'zh' ? '洞察已删除' : 'Insight deleted';
|
||||
if (window.showToast) {
|
||||
window.showToast(successMessage, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete insight:', err);
|
||||
// Show error toast
|
||||
const errorMessage = locale === 'zh' ? '删除洞察失败' : 'Failed to delete insight';
|
||||
if (window.showToast) {
|
||||
window.showToast(errorMessage, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleIntentFilter = (intent: string) => {
|
||||
setIntentFilter((prev) => (prev === intent ? 'all' : intent));
|
||||
};
|
||||
@@ -125,9 +190,53 @@ export function PromptHistoryPage() {
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setIntentFilter('all');
|
||||
setProjectFilter('all');
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery.length > 0 || intentFilter !== 'all';
|
||||
// Batch operations handlers
|
||||
const handleSelectPrompt = (promptId: string, selected: boolean) => {
|
||||
setSelectedPromptIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(promptId);
|
||||
} else {
|
||||
next.delete(promptId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = (selected: boolean) => {
|
||||
if (selected) {
|
||||
setSelectedPromptIds(new Set(prompts.map((p) => p.id)));
|
||||
} else {
|
||||
setSelectedPromptIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedPromptIds(new Set());
|
||||
};
|
||||
|
||||
const handleBatchDeleteClick = () => {
|
||||
if (selectedPromptIds.size > 0) {
|
||||
setBatchDeleteDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmBatchDelete = async () => {
|
||||
if (selectedPromptIds.size === 0) return;
|
||||
|
||||
try {
|
||||
await batchDeletePrompts(Array.from(selectedPromptIds));
|
||||
setBatchDeleteDialogOpen(false);
|
||||
setSelectedPromptIds(new Set());
|
||||
} catch (err) {
|
||||
console.error('Failed to batch delete prompts:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery.length > 0 || intentFilter !== 'all' || projectFilter !== 'all';
|
||||
|
||||
// Group prompts for timeline view
|
||||
const timelineGroups = React.useMemo(() => {
|
||||
@@ -247,9 +356,9 @@ export function PromptHistoryPage() {
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
{formatMessage({ id: 'common.actions.filter' })}
|
||||
{intentFilter !== 'all' && (
|
||||
{(intentFilter !== 'all' || projectFilter !== 'all') && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
|
||||
{intentFilter}
|
||||
{(intentFilter !== 'all' ? 1 : 0) + (projectFilter !== 'all' ? 1 : 0)}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
@@ -274,6 +383,26 @@ export function PromptHistoryPage() {
|
||||
{intentFilter === intent && <span className="text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{formatMessage({ id: 'prompts.filterByProject' })}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectFilter('all')}
|
||||
className="justify-between"
|
||||
>
|
||||
<span>{formatMessage({ id: 'prompts.projects.all' })}</span>
|
||||
{projectFilter === 'all' && <span className="text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
{uniqueProjects.map((project) => (
|
||||
<DropdownMenuItem
|
||||
key={project}
|
||||
onClick={() => setProjectFilter(project)}
|
||||
className="justify-between"
|
||||
>
|
||||
<span className="truncate max-w-32" title={project}>{project}</span>
|
||||
{projectFilter === project && <span className="text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -300,6 +429,16 @@ export function PromptHistoryPage() {
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
)}
|
||||
{projectFilter !== 'all' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setProjectFilter('all')}
|
||||
>
|
||||
{formatMessage({ id: 'prompts.projects.project' })}: {projectFilter}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
)}
|
||||
{searchQuery && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -316,6 +455,16 @@ export function PromptHistoryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch operations toolbar */}
|
||||
<BatchOperationToolbar
|
||||
selectedCount={selectedPromptIds.size}
|
||||
allSelected={prompts.length > 0 && selectedPromptIds.size === prompts.length}
|
||||
onSelectAll={handleSelectAll}
|
||||
onClearSelection={handleClearSelection}
|
||||
onDelete={handleBatchDeleteClick}
|
||||
isDeleting={isBatchDeleting}
|
||||
/>
|
||||
|
||||
{/* Timeline */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
@@ -366,6 +515,9 @@ export function PromptHistoryPage() {
|
||||
prompt={prompt}
|
||||
onDelete={handleDeleteClick}
|
||||
actionsDisabled={isMutating}
|
||||
selected={selectedPromptIds.has(prompt.id)}
|
||||
onSelectChange={handleSelectPrompt}
|
||||
selectionMode={selectedPromptIds.size > 0 || prompts.some(p => selectedPromptIds.has(p.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -376,7 +528,7 @@ export function PromptHistoryPage() {
|
||||
</div>
|
||||
|
||||
{/* Insights panel */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
<InsightsPanel
|
||||
insights={insightsData?.insights}
|
||||
patterns={insightsData?.patterns}
|
||||
@@ -387,6 +539,11 @@ export function PromptHistoryPage() {
|
||||
isAnalyzing={isAnalyzing || insightsLoading}
|
||||
className="sticky top-4"
|
||||
/>
|
||||
<InsightsHistoryList
|
||||
insights={insightsHistoryData?.insights}
|
||||
isLoading={insightsHistoryLoading}
|
||||
onInsightSelect={handleInsightSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -419,6 +576,48 @@ export function PromptHistoryPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Batch Delete Confirmation Dialog */}
|
||||
<Dialog open={batchDeleteDialogOpen} onOpenChange={setBatchDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'prompts.dialog.batchDeleteTitle' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'prompts.dialog.batchDeleteConfirm' }, { count: selectedPromptIds.size })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setBatchDeleteDialogOpen(false);
|
||||
}}
|
||||
disabled={isBatchDeleting}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmBatchDelete}
|
||||
disabled={isBatchDeleting}
|
||||
>
|
||||
{isBatchDeleting ? formatMessage({ id: 'common.actions.deleting' }, { defaultValue: 'Deleting...' }) : formatMessage({ id: 'common.actions.delete' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Insight Detail Panel Overlay */}
|
||||
<InsightDetailPanelOverlay
|
||||
insight={selectedInsight}
|
||||
onClose={() => {
|
||||
setInsightDetailOpen(false);
|
||||
setSelectedInsight(null);
|
||||
}}
|
||||
onDelete={handleDeleteInsight}
|
||||
isDeleting={isDeletingInsight}
|
||||
showOverlay={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const initialState = {
|
||||
// Sidebar
|
||||
sidebarOpen: true,
|
||||
sidebarCollapsed: false,
|
||||
expandedNavGroups: ['overview', 'workflow', 'knowledge', 'issues', 'tools', 'configuration'] as string[],
|
||||
|
||||
// View state
|
||||
currentView: 'sessions' as ViewMode,
|
||||
@@ -110,6 +111,10 @@ export const useAppStore = create<AppStore>()(
|
||||
set({ sidebarCollapsed: collapsed }, false, 'setSidebarCollapsed');
|
||||
},
|
||||
|
||||
setExpandedNavGroups: (groups: string[]) => {
|
||||
set({ expandedNavGroups: groups }, false, 'setExpandedNavGroups');
|
||||
},
|
||||
|
||||
// ========== View Actions ==========
|
||||
|
||||
setCurrentView: (view: ViewMode) => {
|
||||
@@ -150,6 +155,7 @@ export const useAppStore = create<AppStore>()(
|
||||
colorScheme: state.colorScheme,
|
||||
locale: state.locale,
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
expandedNavGroups: state.expandedNavGroups,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// Apply theme on rehydration
|
||||
|
||||
772
ccw/frontend/src/stores/coordinatorStore.ts
Normal file
772
ccw/frontend/src/stores/coordinatorStore.ts
Normal file
@@ -0,0 +1,772 @@
|
||||
// ========================================
|
||||
// Coordinator Store
|
||||
// ========================================
|
||||
// Zustand store for managing coordinator execution state and command chains
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/**
|
||||
* Execution status of a coordinator
|
||||
*/
|
||||
export type CoordinatorStatus = 'idle' | 'initializing' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Node execution status within a command chain
|
||||
*/
|
||||
export type NodeExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
|
||||
/**
|
||||
* Log level for coordinator logs
|
||||
*/
|
||||
export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success';
|
||||
|
||||
/**
|
||||
* Command node representing a step in the coordinator pipeline
|
||||
*/
|
||||
export interface CommandNode {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
command: string;
|
||||
status: NodeExecutionStatus;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
output?: string;
|
||||
parentId?: string; // For hierarchical structure
|
||||
children?: CommandNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log entry for coordinator execution
|
||||
*/
|
||||
export interface CoordinatorLog {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
source?: 'system' | 'node' | 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Question to be answered during coordinator execution
|
||||
*/
|
||||
export interface CoordinatorQuestion {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: 'text' | 'single' | 'multi' | 'yes_no';
|
||||
options?: string[];
|
||||
required: boolean;
|
||||
answer?: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline details fetched from backend
|
||||
*/
|
||||
export interface PipelineDetails {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
nodes: CommandNode[];
|
||||
totalSteps: number;
|
||||
estimatedDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator state
|
||||
*/
|
||||
export interface CoordinatorState {
|
||||
// Current execution
|
||||
currentExecutionId: string | null;
|
||||
status: CoordinatorStatus;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
totalElapsedMs: number;
|
||||
|
||||
// Command chain
|
||||
commandChain: CommandNode[];
|
||||
currentNodeIndex: number;
|
||||
currentNode: CommandNode | null;
|
||||
|
||||
// Pipeline details
|
||||
pipelineDetails: PipelineDetails | null;
|
||||
isPipelineLoaded: boolean;
|
||||
|
||||
// Logs
|
||||
logs: CoordinatorLog[];
|
||||
maxLogs: number;
|
||||
|
||||
// Interactive questions
|
||||
activeQuestion: CoordinatorQuestion | null;
|
||||
pendingQuestions: CoordinatorQuestion[];
|
||||
|
||||
// Execution metadata
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
// Error tracking
|
||||
lastError?: string;
|
||||
errorDetails?: unknown;
|
||||
|
||||
// UI state
|
||||
isLogPanelExpanded: boolean;
|
||||
autoScrollLogs: boolean;
|
||||
|
||||
// Actions
|
||||
startCoordinator: (executionId: string, taskDescription: string, parameters?: Record<string, unknown>) => Promise<void>;
|
||||
pauseCoordinator: () => Promise<void>;
|
||||
resumeCoordinator: () => Promise<void>;
|
||||
cancelCoordinator: (reason?: string) => Promise<void>;
|
||||
updateNodeStatus: (nodeId: string, status: NodeExecutionStatus, result?: unknown, error?: string) => void;
|
||||
submitAnswer: (questionId: string, answer: string | string[]) => Promise<void>;
|
||||
retryNode: (nodeId: string) => Promise<void>;
|
||||
skipNode: (nodeId: string) => Promise<void>;
|
||||
fetchPipelineDetails: (executionId: string) => Promise<void>;
|
||||
syncStateFromServer: () => Promise<void>;
|
||||
addLog: (message: string, level?: LogLevel, nodeId?: string, source?: 'system' | 'node' | 'user') => void;
|
||||
clearLogs: () => void;
|
||||
setActiveQuestion: (question: CoordinatorQuestion | null) => void;
|
||||
markExecutionComplete: (success: boolean, finalResult?: unknown) => void;
|
||||
setLogPanelExpanded: (expanded: boolean) => void;
|
||||
setAutoScrollLogs: (autoScroll: boolean) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const MAX_LOGS = 1000;
|
||||
const LOG_STORAGE_KEY = 'coordinator-storage';
|
||||
const COORDINATOR_STORAGE_VERSION = 1;
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Generate unique ID for logs and questions
|
||||
*/
|
||||
const generateId = (): string => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find node by ID in command chain (handles hierarchical structure)
|
||||
*/
|
||||
const findNodeById = (nodes: CommandNode[], nodeId: string): CommandNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nodeId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeById(node.children, nodeId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// ========== Initial State ==========
|
||||
|
||||
const initialState: CoordinatorState = {
|
||||
currentExecutionId: null,
|
||||
status: 'idle',
|
||||
totalElapsedMs: 0,
|
||||
|
||||
commandChain: [],
|
||||
currentNodeIndex: -1,
|
||||
currentNode: null,
|
||||
|
||||
pipelineDetails: null,
|
||||
isPipelineLoaded: false,
|
||||
|
||||
logs: [],
|
||||
maxLogs: MAX_LOGS,
|
||||
|
||||
activeQuestion: null,
|
||||
pendingQuestions: [],
|
||||
|
||||
metadata: {},
|
||||
|
||||
isLogPanelExpanded: true,
|
||||
autoScrollLogs: true,
|
||||
|
||||
// Actions are added in the create callback
|
||||
startCoordinator: async () => {},
|
||||
pauseCoordinator: async () => {},
|
||||
resumeCoordinator: async () => {},
|
||||
cancelCoordinator: async () => {},
|
||||
updateNodeStatus: () => {},
|
||||
submitAnswer: async () => {},
|
||||
retryNode: async () => {},
|
||||
skipNode: async () => {},
|
||||
fetchPipelineDetails: async () => {},
|
||||
syncStateFromServer: async () => {},
|
||||
addLog: () => {},
|
||||
clearLogs: () => {},
|
||||
setActiveQuestion: () => {},
|
||||
markExecutionComplete: () => {},
|
||||
setLogPanelExpanded: () => {},
|
||||
setAutoScrollLogs: () => {},
|
||||
reset: () => {},
|
||||
};
|
||||
|
||||
// ========== Store ==========
|
||||
|
||||
/**
|
||||
* Coordinator store for managing orchestrator execution state
|
||||
*
|
||||
* @remarks
|
||||
* Uses Zustand with persist middleware to save execution metadata to localStorage.
|
||||
* The store manages command chains, logs, interactive questions, and execution status.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { startCoordinator, status, logs } = useCoordinatorStore();
|
||||
* await startCoordinator('exec-123', 'Build and deploy application');
|
||||
* ```
|
||||
*/
|
||||
export const useCoordinatorStore = create<CoordinatorState>()(
|
||||
persist(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Coordinator Lifecycle Actions ==========
|
||||
|
||||
startCoordinator: async (
|
||||
executionId: string,
|
||||
taskDescription: string,
|
||||
parameters?: Record<string, unknown>
|
||||
) => {
|
||||
set({
|
||||
currentExecutionId: executionId,
|
||||
status: 'initializing',
|
||||
startedAt: new Date().toISOString(),
|
||||
totalElapsedMs: 0,
|
||||
lastError: undefined,
|
||||
errorDetails: undefined,
|
||||
metadata: parameters || {},
|
||||
}, false, 'coordinator/startCoordinator');
|
||||
|
||||
get().addLog(`Starting coordinator execution: ${taskDescription}`, 'info', undefined, 'system');
|
||||
|
||||
try {
|
||||
// Fetch pipeline details from backend
|
||||
await get().fetchPipelineDetails(executionId);
|
||||
|
||||
const state = get();
|
||||
set({
|
||||
status: 'running',
|
||||
currentNodeIndex: 0,
|
||||
currentNode: state.commandChain.length > 0 ? state.commandChain[0] : null,
|
||||
}, false, 'coordinator/startCoordinator-running');
|
||||
|
||||
get().addLog('Coordinator running', 'success', undefined, 'system');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
set({
|
||||
status: 'failed',
|
||||
lastError: errorMessage,
|
||||
errorDetails: error,
|
||||
}, false, 'coordinator/startCoordinator-error');
|
||||
|
||||
get().addLog(`Failed to start coordinator: ${errorMessage}`, 'error', undefined, 'system');
|
||||
}
|
||||
},
|
||||
|
||||
pauseCoordinator: async () => {
|
||||
const state = get();
|
||||
if (state.status !== 'running') {
|
||||
get().addLog('Cannot pause - coordinator is not running', 'warn', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
set({ status: 'paused' }, false, 'coordinator/pauseCoordinator');
|
||||
get().addLog('Coordinator paused', 'info', undefined, 'system');
|
||||
},
|
||||
|
||||
resumeCoordinator: async () => {
|
||||
const state = get();
|
||||
if (state.status !== 'paused') {
|
||||
get().addLog('Cannot resume - coordinator is not paused', 'warn', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
set({ status: 'running' }, false, 'coordinator/resumeCoordinator');
|
||||
get().addLog('Coordinator resumed', 'info', undefined, 'system');
|
||||
},
|
||||
|
||||
cancelCoordinator: async (reason?: string) => {
|
||||
set({
|
||||
status: 'cancelled',
|
||||
completedAt: new Date().toISOString(),
|
||||
}, false, 'coordinator/cancelCoordinator');
|
||||
|
||||
const message = reason ? `Coordinator cancelled: ${reason}` : 'Coordinator cancelled';
|
||||
get().addLog(message, 'warn', undefined, 'system');
|
||||
},
|
||||
|
||||
// ========== Node Status Management ==========
|
||||
|
||||
updateNodeStatus: (nodeId: string, status: NodeExecutionStatus, result?: unknown, error?: string) => {
|
||||
const state = get();
|
||||
const node = findNodeById(state.commandChain, nodeId);
|
||||
if (!node) {
|
||||
console.warn(`[CoordinatorStore] Node not found: ${nodeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a deep copy of the command chain with updated node
|
||||
const updateNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.id === nodeId) {
|
||||
const updated: CommandNode = { ...n, status };
|
||||
if (status === 'running') {
|
||||
updated.startedAt = new Date().toISOString();
|
||||
} else if (status === 'completed') {
|
||||
updated.completedAt = new Date().toISOString();
|
||||
updated.result = result;
|
||||
} else if (status === 'failed') {
|
||||
updated.completedAt = new Date().toISOString();
|
||||
updated.error = error;
|
||||
} else if (status === 'skipped') {
|
||||
updated.completedAt = new Date().toISOString();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: updateNodeInTree(n.children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const updatedCommandChain = updateNodeInTree(state.commandChain);
|
||||
set({ commandChain: updatedCommandChain }, false, 'coordinator/updateNodeStatus');
|
||||
|
||||
// Add logs after state update
|
||||
if (status === 'running') {
|
||||
get().addLog(`Node started: ${node.name}`, 'debug', nodeId, 'system');
|
||||
} else if (status === 'completed') {
|
||||
get().addLog(`Node completed: ${node.name}`, 'success', nodeId, 'system');
|
||||
} else if (status === 'failed') {
|
||||
get().addLog(`Node failed: ${node.name} - ${error || 'Unknown error'}`, 'error', nodeId, 'system');
|
||||
} else if (status === 'skipped') {
|
||||
get().addLog(`Node skipped: ${node.name}`, 'info', nodeId, 'system');
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Interactive Question Handling ==========
|
||||
|
||||
submitAnswer: async (questionId: string, answer: string | string[]) => {
|
||||
const state = get();
|
||||
const question = state.activeQuestion || state.pendingQuestions.find((q) => q.id === questionId);
|
||||
|
||||
if (!question) {
|
||||
get().addLog(`Question not found: ${questionId}`, 'warn', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update question with answer
|
||||
const updatedActiveQuestion =
|
||||
state.activeQuestion && state.activeQuestion.id === questionId
|
||||
? { ...state.activeQuestion, answer }
|
||||
: state.activeQuestion;
|
||||
|
||||
const updatedPendingQuestions = state.pendingQuestions.map((q) =>
|
||||
q.id === questionId ? { ...q, answer } : q
|
||||
);
|
||||
|
||||
set(
|
||||
{
|
||||
activeQuestion: updatedActiveQuestion,
|
||||
pendingQuestions: updatedPendingQuestions,
|
||||
},
|
||||
false,
|
||||
'coordinator/submitAnswer'
|
||||
);
|
||||
|
||||
get().addLog(
|
||||
`Answer submitted for question: ${question.title}`,
|
||||
'info',
|
||||
question.nodeId,
|
||||
'user'
|
||||
);
|
||||
|
||||
// Clear active question
|
||||
set({ activeQuestion: null }, false, 'coordinator/submitAnswer-clear');
|
||||
},
|
||||
|
||||
// ========== Node Control Actions ==========
|
||||
|
||||
retryNode: async (nodeId: string) => {
|
||||
const state = get();
|
||||
const node = findNodeById(state.commandChain, nodeId);
|
||||
if (!node) {
|
||||
get().addLog(`Cannot retry - node not found: ${nodeId}`, 'warn', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
get().addLog(`Retrying node: ${node.name}`, 'info', nodeId, 'system');
|
||||
|
||||
// Recursively update node status to pending
|
||||
const resetNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.id === nodeId) {
|
||||
return { ...n, status: 'pending', result: undefined, error: undefined };
|
||||
}
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: resetNodeInTree(n.children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const updatedCommandChain = resetNodeInTree(state.commandChain);
|
||||
set({ commandChain: updatedCommandChain }, false, 'coordinator/retryNode');
|
||||
},
|
||||
|
||||
skipNode: async (nodeId: string) => {
|
||||
const state = get();
|
||||
const node = findNodeById(state.commandChain, nodeId);
|
||||
if (!node) {
|
||||
get().addLog(`Cannot skip - node not found: ${nodeId}`, 'warn', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
get().addLog(`Skipping node: ${node.name}`, 'info', nodeId, 'system');
|
||||
|
||||
// Recursively update node status to skipped
|
||||
const skipNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.id === nodeId) {
|
||||
return { ...n, status: 'skipped', completedAt: new Date().toISOString() };
|
||||
}
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: skipNodeInTree(n.children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const updatedCommandChain = skipNodeInTree(state.commandChain);
|
||||
set({ commandChain: updatedCommandChain }, false, 'coordinator/skipNode');
|
||||
},
|
||||
|
||||
// ========== Pipeline Details ==========
|
||||
|
||||
fetchPipelineDetails: async (executionId: string) => {
|
||||
try {
|
||||
get().addLog('Fetching pipeline details', 'info', undefined, 'system');
|
||||
|
||||
// Import API function dynamically to avoid circular deps
|
||||
const { fetchCoordinatorPipeline } = await import('../lib/api');
|
||||
|
||||
const response = await fetchCoordinatorPipeline(executionId);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error('Failed to fetch pipeline details');
|
||||
}
|
||||
|
||||
const apiData = response.data;
|
||||
|
||||
// Transform API response to PipelineDetails
|
||||
const pipelineDetails: PipelineDetails = {
|
||||
id: apiData.id,
|
||||
name: apiData.name,
|
||||
description: apiData.description,
|
||||
nodes: apiData.nodes,
|
||||
totalSteps: apiData.totalSteps,
|
||||
estimatedDuration: apiData.estimatedDuration,
|
||||
};
|
||||
|
||||
set({
|
||||
pipelineDetails,
|
||||
isPipelineLoaded: true,
|
||||
commandChain: apiData.nodes,
|
||||
status: apiData.status || get().status,
|
||||
}, false, 'coordinator/fetchPipelineDetails');
|
||||
|
||||
// Load logs if available
|
||||
if (apiData.logs && apiData.logs.length > 0) {
|
||||
set({ logs: apiData.logs }, false, 'coordinator/fetchPipelineDetails-logs');
|
||||
}
|
||||
|
||||
get().addLog('Pipeline details loaded', 'success', undefined, 'system');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
set({
|
||||
isPipelineLoaded: false,
|
||||
lastError: errorMessage,
|
||||
}, false, 'coordinator/fetchPipelineDetails-error');
|
||||
|
||||
get().addLog(`Failed to fetch pipeline details: ${errorMessage}`, 'error', undefined, 'system');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// ========== State Synchronization (for WebSocket reconnection) ==========
|
||||
|
||||
syncStateFromServer: async () => {
|
||||
const state = get();
|
||||
|
||||
// Only sync if we have an active execution
|
||||
if (!state.currentExecutionId) {
|
||||
get().addLog('No active execution to sync', 'debug', undefined, 'system');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
get().addLog('Syncing state from server', 'info', undefined, 'system');
|
||||
|
||||
// Fetch current execution state from server
|
||||
const { fetchExecutionState } = await import('../lib/api');
|
||||
const response = await fetchExecutionState(state.currentExecutionId);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error('Failed to sync execution state');
|
||||
}
|
||||
|
||||
const serverState = response.data;
|
||||
|
||||
// Update local state with server state
|
||||
set({
|
||||
status: serverState.status as CoordinatorStatus,
|
||||
totalElapsedMs: serverState.elapsedMs,
|
||||
}, false, 'coordinator/syncStateFromServer');
|
||||
|
||||
// Fetch full pipeline details if status indicates running/paused
|
||||
if (serverState.status === 'running' || serverState.status === 'paused') {
|
||||
await get().fetchPipelineDetails(state.currentExecutionId);
|
||||
}
|
||||
|
||||
get().addLog('State synchronized with server', 'success', undefined, 'system');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[CoordinatorStore] Failed to sync state:', error);
|
||||
get().addLog(`Failed to sync state from server: ${errorMessage}`, 'warn', undefined, 'system');
|
||||
}
|
||||
},
|
||||
|
||||
addLog: (
|
||||
message: string,
|
||||
level: LogLevel = 'info',
|
||||
nodeId?: string,
|
||||
source: 'system' | 'node' | 'user' = 'system'
|
||||
) => {
|
||||
const state = get();
|
||||
const log: CoordinatorLog = {
|
||||
id: generateId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
nodeId,
|
||||
source,
|
||||
};
|
||||
|
||||
let updatedLogs = [...state.logs, log];
|
||||
|
||||
// Keep only the last maxLogs entries
|
||||
if (updatedLogs.length > state.maxLogs) {
|
||||
updatedLogs = updatedLogs.slice(-state.maxLogs);
|
||||
}
|
||||
|
||||
set({ logs: updatedLogs }, false, 'coordinator/addLog');
|
||||
},
|
||||
|
||||
clearLogs: () => {
|
||||
set({ logs: [] }, false, 'coordinator/clearLogs');
|
||||
},
|
||||
|
||||
// ========== Question Management ==========
|
||||
|
||||
setActiveQuestion: (question: CoordinatorQuestion | null) => {
|
||||
const state = get();
|
||||
const updatedPendingQuestions =
|
||||
question && !state.pendingQuestions.find((q) => q.id === question.id)
|
||||
? [...state.pendingQuestions, question]
|
||||
: state.pendingQuestions;
|
||||
|
||||
set({
|
||||
activeQuestion: question,
|
||||
pendingQuestions: updatedPendingQuestions,
|
||||
}, false, 'coordinator/setActiveQuestion');
|
||||
},
|
||||
|
||||
// ========== Execution Completion ==========
|
||||
|
||||
markExecutionComplete: (success: boolean, finalResult?: unknown) => {
|
||||
const state = get();
|
||||
set({
|
||||
status: success ? 'completed' : 'failed',
|
||||
completedAt: new Date().toISOString(),
|
||||
metadata: { ...state.metadata, finalResult },
|
||||
}, false, 'coordinator/markExecutionComplete');
|
||||
|
||||
const message = success
|
||||
? 'Coordinator execution completed successfully'
|
||||
: 'Coordinator execution failed';
|
||||
get().addLog(message, success ? 'success' : 'error', undefined, 'system');
|
||||
},
|
||||
|
||||
// ========== UI State ==========
|
||||
|
||||
setLogPanelExpanded: (expanded: boolean) => {
|
||||
set({ isLogPanelExpanded: expanded }, false, 'coordinator/setLogPanelExpanded');
|
||||
},
|
||||
|
||||
setAutoScrollLogs: (autoScroll: boolean) => {
|
||||
set({ autoScrollLogs: autoScroll }, false, 'coordinator/setAutoScrollLogs');
|
||||
},
|
||||
|
||||
// ========== Reset ==========
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
currentExecutionId: null,
|
||||
status: 'idle',
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
totalElapsedMs: 0,
|
||||
commandChain: [],
|
||||
currentNodeIndex: -1,
|
||||
currentNode: null,
|
||||
pipelineDetails: null,
|
||||
isPipelineLoaded: false,
|
||||
logs: [],
|
||||
activeQuestion: null,
|
||||
pendingQuestions: [],
|
||||
metadata: {},
|
||||
lastError: undefined,
|
||||
errorDetails: undefined,
|
||||
}, false, 'coordinator/reset');
|
||||
|
||||
get().addLog('Coordinator state reset', 'info', undefined, 'system');
|
||||
},
|
||||
}),
|
||||
{ name: 'CoordinatorStore' }
|
||||
),
|
||||
{
|
||||
name: LOG_STORAGE_KEY,
|
||||
version: COORDINATOR_STORAGE_VERSION,
|
||||
// Only persist metadata and basic pipeline info (not full nodes/logs)
|
||||
partialize: (state) => ({
|
||||
currentExecutionId: state.currentExecutionId,
|
||||
status: state.status,
|
||||
startedAt: state.startedAt,
|
||||
completedAt: state.completedAt,
|
||||
totalElapsedMs: state.totalElapsedMs,
|
||||
metadata: state.metadata,
|
||||
isLogPanelExpanded: state.isLogPanelExpanded,
|
||||
autoScrollLogs: state.autoScrollLogs,
|
||||
// Only persist basic pipeline info, not full nodes
|
||||
pipelineDetails: state.pipelineDetails ? {
|
||||
id: state.pipelineDetails.id,
|
||||
name: state.pipelineDetails.name,
|
||||
description: state.pipelineDetails.description,
|
||||
nodes: [], // Don't persist nodes - will be fetched from API
|
||||
totalSteps: state.pipelineDetails.totalSteps,
|
||||
estimatedDuration: state.pipelineDetails.estimatedDuration,
|
||||
} : null,
|
||||
}),
|
||||
// Rehydration callback to restore state on page load
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
|
||||
// Check if we have an active execution that needs hydration
|
||||
const needsHydration =
|
||||
state.currentExecutionId &&
|
||||
(state.status === 'running' || state.status === 'paused' || state.status === 'initializing') &&
|
||||
(!state.pipelineDetails || state.pipelineDetails.nodes.length === 0);
|
||||
|
||||
if (needsHydration && state.currentExecutionId) {
|
||||
// Log restoration
|
||||
state.addLog('Restoring coordinator state from localStorage', 'info', undefined, 'system');
|
||||
|
||||
// Fetch full pipeline details from API
|
||||
state.fetchPipelineDetails(state.currentExecutionId).catch((error) => {
|
||||
console.error('[CoordinatorStore] Failed to hydrate pipeline details:', error);
|
||||
state.addLog('Failed to restore pipeline data - session may be incomplete', 'warn', undefined, 'system');
|
||||
});
|
||||
} else if (state.currentExecutionId) {
|
||||
// Just log that we restored the session
|
||||
state.addLog('Session state restored', 'info', undefined, 'system');
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========== Helper Hooks ==========
|
||||
|
||||
/**
|
||||
* Hook to get coordinator actions
|
||||
* Useful for components that only need actions, not the full state
|
||||
*/
|
||||
export const useCoordinatorActions = () => {
|
||||
return useCoordinatorStore((state) => ({
|
||||
startCoordinator: state.startCoordinator,
|
||||
pauseCoordinator: state.pauseCoordinator,
|
||||
resumeCoordinator: state.resumeCoordinator,
|
||||
cancelCoordinator: state.cancelCoordinator,
|
||||
updateNodeStatus: state.updateNodeStatus,
|
||||
submitAnswer: state.submitAnswer,
|
||||
retryNode: state.retryNode,
|
||||
skipNode: state.skipNode,
|
||||
fetchPipelineDetails: state.fetchPipelineDetails,
|
||||
syncStateFromServer: state.syncStateFromServer,
|
||||
addLog: state.addLog,
|
||||
clearLogs: state.clearLogs,
|
||||
setActiveQuestion: state.setActiveQuestion,
|
||||
markExecutionComplete: state.markExecutionComplete,
|
||||
setLogPanelExpanded: state.setLogPanelExpanded,
|
||||
setAutoScrollLogs: state.setAutoScrollLogs,
|
||||
reset: state.reset,
|
||||
}));
|
||||
};
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/**
|
||||
* Select current execution status
|
||||
*/
|
||||
export const selectCoordinatorStatus = (state: CoordinatorState) => state.status;
|
||||
|
||||
/**
|
||||
* Select current execution ID
|
||||
*/
|
||||
export const selectCurrentExecutionId = (state: CoordinatorState) => state.currentExecutionId;
|
||||
|
||||
/**
|
||||
* Select all logs
|
||||
*/
|
||||
export const selectCoordinatorLogs = (state: CoordinatorState) => state.logs;
|
||||
|
||||
/**
|
||||
* Select active question
|
||||
*/
|
||||
export const selectActiveQuestion = (state: CoordinatorState) => state.activeQuestion;
|
||||
|
||||
/**
|
||||
* Select command chain
|
||||
*/
|
||||
export const selectCommandChain = (state: CoordinatorState) => state.commandChain;
|
||||
|
||||
/**
|
||||
* Select current node
|
||||
*/
|
||||
export const selectCurrentNode = (state: CoordinatorState) => state.currentNode;
|
||||
|
||||
/**
|
||||
* Select pipeline details
|
||||
*/
|
||||
export const selectPipelineDetails = (state: CoordinatorState) => state.pipelineDetails;
|
||||
|
||||
/**
|
||||
* Select is pipeline loaded
|
||||
*/
|
||||
export const selectIsPipelineLoaded = (state: CoordinatorState) => state.isPipelineLoaded;
|
||||
@@ -76,6 +76,20 @@ export {
|
||||
selectNodeStatus,
|
||||
} from './executionStore';
|
||||
|
||||
// Coordinator Store
|
||||
export {
|
||||
useCoordinatorStore,
|
||||
useCoordinatorActions,
|
||||
selectCoordinatorStatus,
|
||||
selectCurrentExecutionId,
|
||||
selectCoordinatorLogs,
|
||||
selectActiveQuestion,
|
||||
selectCommandChain,
|
||||
selectCurrentNode,
|
||||
selectPipelineDetails,
|
||||
selectIsPipelineLoaded,
|
||||
} from './coordinatorStore';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
// App Store Types
|
||||
@@ -119,6 +133,16 @@ export type {
|
||||
AskQuestionPayload,
|
||||
} from '../types/store';
|
||||
|
||||
// Coordinator Store Types
|
||||
export type {
|
||||
CoordinatorState,
|
||||
CoordinatorStatus,
|
||||
CommandNode,
|
||||
CoordinatorLog,
|
||||
CoordinatorQuestion,
|
||||
PipelineDetails,
|
||||
} from './coordinatorStore';
|
||||
|
||||
// Execution Types
|
||||
export type {
|
||||
ExecutionStatus,
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface AppState {
|
||||
// Sidebar
|
||||
sidebarOpen: boolean;
|
||||
sidebarCollapsed: boolean;
|
||||
expandedNavGroups: string[];
|
||||
|
||||
// View state
|
||||
currentView: ViewMode;
|
||||
@@ -50,6 +51,7 @@ export interface AppActions {
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
setExpandedNavGroups: (groups: string[]) => void;
|
||||
|
||||
// View actions
|
||||
setCurrentView: (view: ViewMode) => void;
|
||||
@@ -611,6 +613,8 @@ export interface Prompt {
|
||||
category?: string;
|
||||
/** Search tags */
|
||||
tags?: string[];
|
||||
/** Project path */
|
||||
project?: string;
|
||||
/** Usage count */
|
||||
useCount?: number;
|
||||
/** Last used timestamp */
|
||||
@@ -619,6 +623,8 @@ export interface Prompt {
|
||||
createdAt: string;
|
||||
/** Updated timestamp */
|
||||
updatedAt?: string;
|
||||
/** Quality score (0-100) */
|
||||
quality_score?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -126,11 +126,16 @@ export function saveEndpointSettings(request: SaveEndpointRequest): SettingsOper
|
||||
// Usage: ccw cli -p "..." --tool <name> --mode analysis
|
||||
try {
|
||||
const projectDir = os.homedir(); // Use home dir as base for global config
|
||||
// Merge user-provided tags with cli-wrapper tag for proper type registration
|
||||
const userTags = request.settings.tags || [];
|
||||
const tags = [...new Set([...userTags, 'cli-wrapper'])]; // Dedupe and ensure cli-wrapper tag
|
||||
addClaudeCustomEndpoint(projectDir, {
|
||||
id: endpointId,
|
||||
name: request.name,
|
||||
enabled: request.enabled ?? true,
|
||||
tags: ['cli-wrapper'] // cli-wrapper tag -> registers as type: 'cli-wrapper'
|
||||
tags,
|
||||
availableModels: request.settings.availableModels,
|
||||
settingsFile: request.settings.settingsFile
|
||||
});
|
||||
console.log(`[CliSettings] Synced endpoint ${endpointId} to cli-tools.json tools (cli-wrapper)`);
|
||||
} catch (syncError) {
|
||||
@@ -306,11 +311,17 @@ export function toggleEndpointEnabled(endpointId: string, enabled: boolean): Set
|
||||
// Sync enabled status with cli-tools.json tools (cli-wrapper type)
|
||||
try {
|
||||
const projectDir = os.homedir();
|
||||
// Load full settings to get tags
|
||||
const endpoint = loadEndpointSettings(endpointId);
|
||||
const userTags = endpoint?.settings.tags || [];
|
||||
const tags = [...new Set([...userTags, 'cli-wrapper'])]; // Dedupe and ensure cli-wrapper tag
|
||||
addClaudeCustomEndpoint(projectDir, {
|
||||
id: endpointId,
|
||||
name: metadata.name,
|
||||
enabled: enabled,
|
||||
tags: ['cli-wrapper'] // cli-wrapper tag -> registers as type: 'cli-wrapper'
|
||||
tags,
|
||||
availableModels: endpoint?.settings.availableModels,
|
||||
settingsFile: endpoint?.settings.settingsFile
|
||||
});
|
||||
console.log(`[CliSettings] Synced endpoint ${endpointId} enabled=${enabled} to cli-tools.json tools`);
|
||||
} catch (syncError) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, unlinkSyn
|
||||
import { join, isAbsolute, extname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getMemoryStore } from '../memory-store.js';
|
||||
import { getCoreMemoryStore } from '../core-memory-store.js';
|
||||
import { executeCliTool } from '../../tools/cli-executor.js';
|
||||
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
|
||||
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
|
||||
@@ -87,6 +88,147 @@ function calculateQualityScore(text: string): number {
|
||||
export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
|
||||
|
||||
// API: Memory Module - Get all memories (core memory list)
|
||||
if (pathname === '/api/memory' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
try {
|
||||
const store = getCoreMemoryStore(projectPath);
|
||||
const memories = store.getMemories({ archived: false, limit: 100 });
|
||||
|
||||
// Calculate total size
|
||||
const totalSize = memories.reduce((sum, m) => sum + (m.content?.length || 0), 0);
|
||||
|
||||
// Count CLAUDE.md files (assuming memories with source='CLAUDE.md')
|
||||
const claudeMdCount = memories.filter(m => m.metadata?.includes('CLAUDE.md') || m.content?.includes('# Claude Instructions')).length;
|
||||
|
||||
// Transform to frontend format
|
||||
const formattedMemories = memories.map(m => ({
|
||||
id: m.id,
|
||||
content: m.content,
|
||||
createdAt: m.created_at,
|
||||
updatedAt: m.updated_at,
|
||||
source: m.metadata || undefined,
|
||||
tags: [], // TODO: Extract tags from metadata if available
|
||||
size: m.content?.length || 0
|
||||
}));
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
memories: formattedMemories,
|
||||
totalSize,
|
||||
claudeMdCount
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (error as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Memory Module - Create new memory
|
||||
if (pathname === '/api/memory' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { content, tags, path: projectPath } = body;
|
||||
|
||||
if (!content) {
|
||||
return { error: 'content is required', status: 400 };
|
||||
}
|
||||
|
||||
const basePath = projectPath || initialPath;
|
||||
|
||||
try {
|
||||
const store = getCoreMemoryStore(basePath);
|
||||
const memory = store.upsertMemory({ content });
|
||||
|
||||
// Broadcast update event
|
||||
broadcastToClients({
|
||||
type: 'CORE_MEMORY_CREATED',
|
||||
payload: {
|
||||
memoryId: memory.id,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: memory.id,
|
||||
content: memory.content,
|
||||
createdAt: memory.created_at,
|
||||
updatedAt: memory.updated_at,
|
||||
source: memory.metadata || undefined,
|
||||
tags: tags || [],
|
||||
size: memory.content?.length || 0
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { error: (error as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Memory Module - Update memory
|
||||
if (pathname.match(/^\/api\/memory\/[^\/]+$/) && req.method === 'PATCH') {
|
||||
const memoryId = pathname.replace('/api/memory/', '');
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { content, tags, path: projectPath } = body;
|
||||
const basePath = projectPath || initialPath;
|
||||
|
||||
try {
|
||||
const store = getCoreMemoryStore(basePath);
|
||||
const memory = store.upsertMemory({ id: memoryId, content });
|
||||
|
||||
// Broadcast update event
|
||||
broadcastToClients({
|
||||
type: 'CORE_MEMORY_UPDATED',
|
||||
payload: {
|
||||
memoryId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: memory.id,
|
||||
content: memory.content,
|
||||
createdAt: memory.created_at,
|
||||
updatedAt: memory.updated_at,
|
||||
source: memory.metadata || undefined,
|
||||
tags: tags || [],
|
||||
size: memory.content?.length || 0
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { error: (error as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Memory Module - Delete memory
|
||||
if (pathname.match(/^\/api\/memory\/[^\/]+$/) && req.method === 'DELETE') {
|
||||
const memoryId = pathname.replace('/api/memory/', '');
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
try {
|
||||
const store = getCoreMemoryStore(projectPath);
|
||||
store.deleteMemory(memoryId);
|
||||
|
||||
// Broadcast update event
|
||||
broadcastToClients({
|
||||
type: 'CORE_MEMORY_DELETED',
|
||||
payload: {
|
||||
memoryId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
} catch (error: unknown) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (error as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Memory Module - Track entity access
|
||||
if (pathname === '/api/memory/track' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
|
||||
@@ -562,8 +562,8 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleClaudeRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Memory routes (/api/memory/*)
|
||||
if (pathname.startsWith('/api/memory/')) {
|
||||
// Memory routes (/api/memory and /api/memory/*)
|
||||
if (pathname === '/api/memory' || pathname.startsWith('/api/memory/')) {
|
||||
if (await handleMemoryRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
|
||||
@@ -429,3 +429,170 @@ export function broadcastOrchestratorLog(execId: string, log: Omit<ExecutionLog,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator WebSocket message types
|
||||
*/
|
||||
export type CoordinatorMessageType =
|
||||
| 'COORDINATOR_STATE_UPDATE'
|
||||
| 'COORDINATOR_COMMAND_STARTED'
|
||||
| 'COORDINATOR_COMMAND_COMPLETED'
|
||||
| 'COORDINATOR_COMMAND_FAILED'
|
||||
| 'COORDINATOR_LOG_ENTRY'
|
||||
| 'COORDINATOR_QUESTION_ASKED'
|
||||
| 'COORDINATOR_ANSWER_RECEIVED';
|
||||
|
||||
/**
|
||||
* Coordinator State Update - fired when coordinator execution status changes
|
||||
*/
|
||||
export interface CoordinatorStateUpdateMessage {
|
||||
type: 'COORDINATOR_STATE_UPDATE';
|
||||
executionId: string;
|
||||
status: 'idle' | 'initializing' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
currentNodeId?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Command Started - fired when a command node begins execution
|
||||
*/
|
||||
export interface CoordinatorCommandStartedMessage {
|
||||
type: 'COORDINATOR_COMMAND_STARTED';
|
||||
executionId: string;
|
||||
nodeId: string;
|
||||
commandName: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Command Completed - fired when a command node finishes successfully
|
||||
*/
|
||||
export interface CoordinatorCommandCompletedMessage {
|
||||
type: 'COORDINATOR_COMMAND_COMPLETED';
|
||||
executionId: string;
|
||||
nodeId: string;
|
||||
result?: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Command Failed - fired when a command node encounters an error
|
||||
*/
|
||||
export interface CoordinatorCommandFailedMessage {
|
||||
type: 'COORDINATOR_COMMAND_FAILED';
|
||||
executionId: string;
|
||||
nodeId: string;
|
||||
error: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Log Entry - fired for execution log entries
|
||||
*/
|
||||
export interface CoordinatorLogEntryMessage {
|
||||
type: 'COORDINATOR_LOG_ENTRY';
|
||||
executionId: string;
|
||||
log: {
|
||||
level: 'info' | 'warn' | 'error' | 'debug' | 'success';
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
source?: 'system' | 'node' | 'user';
|
||||
timestamp: string;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Question Asked - fired when coordinator needs user input
|
||||
*/
|
||||
export interface CoordinatorQuestionAskedMessage {
|
||||
type: 'COORDINATOR_QUESTION_ASKED';
|
||||
executionId: string;
|
||||
question: {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: 'text' | 'single' | 'multi' | 'yes_no';
|
||||
options?: string[];
|
||||
required: boolean;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Answer Received - fired when user submits an answer
|
||||
*/
|
||||
export interface CoordinatorAnswerReceivedMessage {
|
||||
type: 'COORDINATOR_ANSWER_RECEIVED';
|
||||
executionId: string;
|
||||
questionId: string;
|
||||
answer: string | string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for Coordinator messages (without timestamp - added automatically)
|
||||
*/
|
||||
export type CoordinatorMessage =
|
||||
| Omit<CoordinatorStateUpdateMessage, 'timestamp'>
|
||||
| Omit<CoordinatorCommandStartedMessage, 'timestamp'>
|
||||
| Omit<CoordinatorCommandCompletedMessage, 'timestamp'>
|
||||
| Omit<CoordinatorCommandFailedMessage, 'timestamp'>
|
||||
| Omit<CoordinatorLogEntryMessage, 'timestamp'>
|
||||
| Omit<CoordinatorQuestionAskedMessage, 'timestamp'>
|
||||
| Omit<CoordinatorAnswerReceivedMessage, 'timestamp'>;
|
||||
|
||||
/**
|
||||
* Coordinator-specific broadcast with throttling
|
||||
* Throttles COORDINATOR_STATE_UPDATE messages to avoid flooding clients
|
||||
*/
|
||||
let lastCoordinatorBroadcast = 0;
|
||||
const COORDINATOR_BROADCAST_THROTTLE = 1000; // 1 second
|
||||
|
||||
/**
|
||||
* Broadcast coordinator update with throttling
|
||||
* STATE_UPDATE messages are throttled to 1 per second
|
||||
* Other message types are sent immediately
|
||||
*/
|
||||
export function broadcastCoordinatorUpdate(message: CoordinatorMessage): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Throttle COORDINATOR_STATE_UPDATE to reduce WebSocket traffic
|
||||
if (message.type === 'COORDINATOR_STATE_UPDATE' && now - lastCoordinatorBroadcast < COORDINATOR_BROADCAST_THROTTLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'COORDINATOR_STATE_UPDATE') {
|
||||
lastCoordinatorBroadcast = now;
|
||||
}
|
||||
|
||||
broadcastToClients({
|
||||
...message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast coordinator log entry (no throttling)
|
||||
* Used for streaming real-time coordinator logs to Dashboard
|
||||
*/
|
||||
export function broadcastCoordinatorLog(
|
||||
executionId: string,
|
||||
log: {
|
||||
level: 'info' | 'warn' | 'error' | 'debug' | 'success';
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
source?: 'system' | 'node' | 'user';
|
||||
}
|
||||
): void {
|
||||
broadcastToClients({
|
||||
type: 'COORDINATOR_LOG_ENTRY',
|
||||
executionId,
|
||||
log: {
|
||||
...log,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -860,7 +860,7 @@ export function removeClaudeApiEndpoint(
|
||||
*/
|
||||
export function addClaudeCustomEndpoint(
|
||||
projectDir: string,
|
||||
endpoint: { id: string; name: string; enabled: boolean; tags?: string[] }
|
||||
endpoint: { id: string; name: string; enabled: boolean; tags?: string[]; availableModels?: string[]; settingsFile?: string }
|
||||
): ClaudeCliToolsConfig {
|
||||
const config = loadClaudeCliTools(projectDir);
|
||||
|
||||
@@ -869,7 +869,9 @@ export function addClaudeCustomEndpoint(
|
||||
config.tools[endpoint.name] = {
|
||||
enabled: endpoint.enabled,
|
||||
tags: endpoint.tags.filter(t => t !== 'cli-wrapper'),
|
||||
type: 'cli-wrapper'
|
||||
type: 'cli-wrapper',
|
||||
...(endpoint.availableModels && { availableModels: endpoint.availableModels }),
|
||||
...(endpoint.settingsFile && { settingsFile: endpoint.settingsFile })
|
||||
};
|
||||
} else {
|
||||
// API endpoint tool
|
||||
@@ -877,7 +879,9 @@ export function addClaudeCustomEndpoint(
|
||||
enabled: endpoint.enabled,
|
||||
tags: [],
|
||||
type: 'api-endpoint',
|
||||
id: endpoint.id
|
||||
id: endpoint.id,
|
||||
...(endpoint.availableModels && { availableModels: endpoint.availableModels }),
|
||||
...(endpoint.settingsFile && { settingsFile: endpoint.settingsFile })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ export interface ClaudeCliSettings {
|
||||
model?: 'opus' | 'sonnet' | 'haiku' | string;
|
||||
/** 是否包含 co-authored-by */
|
||||
includeCoAuthoredBy?: boolean;
|
||||
/** CLI工具标签 (用于标签路由) */
|
||||
tags?: string[];
|
||||
/** 可用模型列表 (显示在下拉菜单中) */
|
||||
availableModels?: string[];
|
||||
/** 外部配置文件路径 (用于 builtin claude 工具) */
|
||||
settingsFile?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +110,9 @@ export function createDefaultSettings(): ClaudeCliSettings {
|
||||
DISABLE_AUTOUPDATER: '1'
|
||||
},
|
||||
model: 'sonnet',
|
||||
includeCoAuthoredBy: false
|
||||
includeCoAuthoredBy: false,
|
||||
tags: [],
|
||||
availableModels: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +131,18 @@ export function validateSettings(settings: unknown): settings is ClaudeCliSettin
|
||||
return false;
|
||||
}
|
||||
|
||||
// 深层验证:env 内部所有值必须是 string 或 undefined
|
||||
const envObj = s.env as Record<string, unknown>;
|
||||
for (const key in envObj) {
|
||||
if (Object.prototype.hasOwnProperty.call(envObj, key)) {
|
||||
const value = envObj[key];
|
||||
// 允许 undefined 或 string,其他类型(包括 null)都拒绝
|
||||
if (value !== undefined && typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// model 可选,但如果存在必须是字符串
|
||||
if (s.model !== undefined && typeof s.model !== 'string') {
|
||||
return false;
|
||||
@@ -133,5 +153,20 @@ export function validateSettings(settings: unknown): settings is ClaudeCliSettin
|
||||
return false;
|
||||
}
|
||||
|
||||
// tags 可选,但如果存在必须是数组
|
||||
if (s.tags !== undefined && !Array.isArray(s.tags)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// availableModels 可选,但如果存在必须是数组
|
||||
if (s.availableModels !== undefined && !Array.isArray(s.availableModels)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// settingsFile 可选,但如果存在必须是字符串
|
||||
if (s.settingsFile !== undefined && typeof s.settingsFile !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
491
ccw/tests/types/cli-settings.test.ts
Normal file
491
ccw/tests/types/cli-settings.test.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* CLI Settings Type Definitions Tests
|
||||
*
|
||||
* Test coverage:
|
||||
* - ClaudeCliSettings interface type safety
|
||||
* - validateSettings function with deep env validation
|
||||
* - mapProviderToClaudeEnv helper function
|
||||
* - createDefaultSettings helper function
|
||||
*/
|
||||
|
||||
import { describe, it, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
validateSettings,
|
||||
mapProviderToClaudeEnv,
|
||||
createDefaultSettings,
|
||||
} from '../../dist/types/cli-settings.js';
|
||||
|
||||
// Type for testing (interfaces are erased in JS)
|
||||
type ClaudeCliSettings = {
|
||||
env: Record<string, string | undefined>;
|
||||
model?: string;
|
||||
includeCoAuthoredBy?: boolean;
|
||||
tags?: string[];
|
||||
availableModels?: string[];
|
||||
settingsFile?: string;
|
||||
};
|
||||
|
||||
describe('cli-settings.ts', () => {
|
||||
describe('validateSettings', () => {
|
||||
describe('should validate valid ClaudeCliSettings objects', () => {
|
||||
it('should accept a complete valid settings object', () => {
|
||||
const validSettings = {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
|
||||
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
},
|
||||
model: 'sonnet',
|
||||
includeCoAuthoredBy: true,
|
||||
tags: ['分析', 'Debug'],
|
||||
availableModels: ['opus', 'sonnet', 'haiku'],
|
||||
settingsFile: '/path/to/settings.json',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(validSettings), true);
|
||||
});
|
||||
|
||||
it('should accept settings with only required env field', () => {
|
||||
const minimalSettings = {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(minimalSettings), true);
|
||||
});
|
||||
|
||||
it('should accept settings with empty env object', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should accept settings with undefined optional properties', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should reject invalid or non-object inputs', () => {
|
||||
it('should reject null', () => {
|
||||
assert.strictEqual(validateSettings(null), false);
|
||||
});
|
||||
|
||||
it('should reject undefined', () => {
|
||||
assert.strictEqual(validateSettings(undefined), false);
|
||||
});
|
||||
|
||||
it('should reject number', () => {
|
||||
assert.strictEqual(validateSettings(123), false);
|
||||
});
|
||||
|
||||
it('should reject string', () => {
|
||||
assert.strictEqual(validateSettings('invalid'), false);
|
||||
});
|
||||
|
||||
it('should reject boolean', () => {
|
||||
assert.strictEqual(validateSettings(true), false);
|
||||
});
|
||||
|
||||
it('should reject array', () => {
|
||||
assert.strictEqual(validateSettings([]), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should validate env field', () => {
|
||||
it('should reject missing env field', () => {
|
||||
const settings = {
|
||||
model: 'sonnet',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
|
||||
it('should reject non-object env', () => {
|
||||
const settings = {
|
||||
env: 'invalid',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
|
||||
it('should reject env with null value', () => {
|
||||
const settings = {
|
||||
env: null,
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
|
||||
describe('deep env validation (optimization)', () => {
|
||||
it('should reject env with null value (DEEP VALIDATION)', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: null,
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
|
||||
it('should reject env with number value (DEEP VALIDATION)', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 12345,
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
|
||||
it('should reject env with boolean value (DEEP VALIDATION)', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
DISABLE_AUTOUPDATER: true,
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
|
||||
it('should reject env with object value (DEEP VALIDATION)', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
CUSTOM: { nested: 'value' },
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
|
||||
it('should reject env with array value (DEEP VALIDATION)', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
TAGS: ['tag1', 'tag2'],
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
|
||||
it('should accept env with string values', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
|
||||
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should accept env with undefined values (optional env vars)', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should accept env with all undefined values', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
OPTIONAL_VAR: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should validate model field', () => {
|
||||
it('should accept predefined model values', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
model: 'opus',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should accept custom model string', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
model: 'custom-model-3.5',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should reject non-string model', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
model: 123,
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should validate includeCoAuthoredBy field', () => {
|
||||
it('should accept boolean includeCoAuthoredBy', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
includeCoAuthoredBy: true,
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should reject non-boolean includeCoAuthoredBy', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
includeCoAuthoredBy: 'true',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should validate tags field', () => {
|
||||
it('should accept valid tags array', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
tags: ['分析', 'Debug', 'testing'],
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should accept empty tags array', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
tags: [],
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should reject non-array tags', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
tags: 'invalid',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should validate availableModels field', () => {
|
||||
it('should accept valid availableModels array', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
availableModels: ['opus', 'sonnet', 'haiku', 'custom-model'],
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should accept empty availableModels array', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
availableModels: [],
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should reject non-array availableModels', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
availableModels: 'invalid',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should validate settingsFile field', () => {
|
||||
it('should accept valid settingsFile string', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
settingsFile: '/path/to/settings.json',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should accept empty settingsFile string', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
settingsFile: '',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should reject non-string settingsFile', () => {
|
||||
const settings = {
|
||||
env: {},
|
||||
settingsFile: 123,
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and boundary conditions', () => {
|
||||
it('should handle settings with many optional fields', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
|
||||
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
CUSTOM_VAR: 'custom-value',
|
||||
},
|
||||
model: 'custom-model',
|
||||
includeCoAuthoredBy: false,
|
||||
tags: [],
|
||||
availableModels: [],
|
||||
settingsFile: '/path/to/settings.json',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should handle settings with only env and one other field', () => {
|
||||
const settings = {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
|
||||
},
|
||||
model: 'sonnet',
|
||||
};
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapProviderToClaudeEnv', () => {
|
||||
it('should map provider with apiKey only', () => {
|
||||
const provider = { apiKey: 'sk-test-key' };
|
||||
const env = mapProviderToClaudeEnv(provider);
|
||||
|
||||
assert.deepStrictEqual(env, {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-test-key',
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should map provider with apiBase only', () => {
|
||||
const provider = { apiBase: 'https://custom.api.com' };
|
||||
const env = mapProviderToClaudeEnv(provider);
|
||||
|
||||
assert.deepStrictEqual(env, {
|
||||
ANTHROPIC_BASE_URL: 'https://custom.api.com',
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should map provider with both apiKey and apiBase', () => {
|
||||
const provider = {
|
||||
apiKey: 'sk-test-key',
|
||||
apiBase: 'https://custom.api.com',
|
||||
};
|
||||
const env = mapProviderToClaudeEnv(provider);
|
||||
|
||||
assert.deepStrictEqual(env, {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-test-key',
|
||||
ANTHROPIC_BASE_URL: 'https://custom.api.com',
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should map empty provider to default DISABLE_AUTOUPDATER', () => {
|
||||
const provider = {};
|
||||
const env = mapProviderToClaudeEnv(provider);
|
||||
|
||||
assert.deepStrictEqual(env, {
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should always set DISABLE_AUTOUPDATER to "1"', () => {
|
||||
const env1 = mapProviderToClaudeEnv({ apiKey: 'key' });
|
||||
const env2 = mapProviderToClaudeEnv({ apiBase: 'url' });
|
||||
const env3 = mapProviderToClaudeEnv({});
|
||||
|
||||
assert.strictEqual(env1.DISABLE_AUTOUPDATER, '1');
|
||||
assert.strictEqual(env2.DISABLE_AUTOUPDATER, '1');
|
||||
assert.strictEqual(env3.DISABLE_AUTOUPDATER, '1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDefaultSettings', () => {
|
||||
it('should create valid default settings', () => {
|
||||
const settings = createDefaultSettings();
|
||||
|
||||
assert.strictEqual(validateSettings(settings), true);
|
||||
});
|
||||
|
||||
it('should include all default fields', () => {
|
||||
const settings = createDefaultSettings();
|
||||
|
||||
assert.ok('env' in settings);
|
||||
assert.ok('model' in settings);
|
||||
assert.ok('includeCoAuthoredBy' in settings);
|
||||
assert.ok('tags' in settings);
|
||||
assert.ok('availableModels' in settings);
|
||||
});
|
||||
|
||||
it('should have correct default values', () => {
|
||||
const settings = createDefaultSettings();
|
||||
|
||||
assert.deepStrictEqual(settings.env, {
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
});
|
||||
assert.strictEqual(settings.model, 'sonnet');
|
||||
assert.strictEqual(settings.includeCoAuthoredBy, false);
|
||||
assert.deepStrictEqual(settings.tags, []);
|
||||
assert.deepStrictEqual(settings.availableModels, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TypeScript type safety', () => {
|
||||
it('should enforce ClaudeCliSettings interface structure', () => {
|
||||
// This test verifies TypeScript compilation catches type errors
|
||||
const validSettings = {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-ant-123',
|
||||
},
|
||||
model: 'opus',
|
||||
includeCoAuthoredBy: true,
|
||||
tags: ['tag1'],
|
||||
availableModels: ['model1'],
|
||||
settingsFile: '/path/to/file',
|
||||
};
|
||||
|
||||
// Type assertion: all fields should be present and of correct type
|
||||
assert.strictEqual(typeof validSettings.env, 'object');
|
||||
assert.strictEqual(typeof validSettings.model, 'string');
|
||||
assert.strictEqual(typeof validSettings.includeCoAuthoredBy, 'boolean');
|
||||
assert.strictEqual(Array.isArray(validSettings.tags), true);
|
||||
assert.strictEqual(Array.isArray(validSettings.availableModels), true);
|
||||
assert.strictEqual(typeof validSettings.settingsFile, 'string');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user