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:
catlog22
2026-02-03 10:02:40 +08:00
parent bcb4af3ba0
commit 5483a72e9f
82 changed files with 6156 additions and 7605 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
```

View File

@@ -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}"`

View File

@@ -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}` 重新打开会话

View File

@@ -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` (返回菜单)

View File

@@ -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/stoppedSkill 应立即退出
*/
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` (返回菜单)

View File

@@ -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` (直接开始开发)
- 失败: 报错退出

View 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
根据用户选择动态决定下一个动作。

View File

@@ -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` (返回菜单)

View File

@@ -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
```

View File

@@ -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 }
}
```

View File

@@ -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
```

View File

@@ -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

View File

@@ -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}}
```

View File

@@ -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?
```

View File

@@ -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}}
```

View File

@@ -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

View File

@@ -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 模板 |

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
此为最终阶段,格式化流程完成。

View File

@@ -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>
```

View File

@@ -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)` |
| 图片 | `/!\[([^\]]*)\]\(([^)]+)\)/` | `![alt](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** | `![alt](url)` | `[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 格式

View File

@@ -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]` |
| `![alt](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 使用 #2196F3H3 使用 #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 标签 |

View File

@@ -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 |

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' },
})
);
});
});
});

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View 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;

View 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;

View 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

View 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;

View 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';

View File

@@ -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>
)}

View File

@@ -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 */}

View File

@@ -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 */}

View 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;

View 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;

View 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;

View 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;

View File

@@ -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 */}

View File

@@ -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 />

View 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;

View 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 };

View File

@@ -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,
};
}

View File

@@ -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();
}

View File

@@ -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,
]
);

View File

@@ -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

View File

@@ -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"

View File

@@ -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"
}
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -48,5 +48,7 @@
"atTime": "at {0}"
},
"markAsRead": "Mark as read",
"markAsUnread": "Mark as unread"
"markAsUnread": "Mark as unread",
"read": "Read",
"unread": "Unread"
}

View File

@@ -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",

View File

@@ -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"

View File

@@ -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": "提交答案失败"
}
}
}

View File

@@ -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": "工作流",

View File

@@ -1,4 +1,12 @@
{
"groups": {
"overview": "概览",
"workflow": "工作流与执行",
"knowledge": "知识与记忆",
"issues": "问题管理",
"tools": "工具与钩子",
"configuration": "配置与支持"
},
"main": {
"home": "首页",
"sessions": "会话",

View File

@@ -48,5 +48,7 @@
"atTime": "{0}"
},
"markAsRead": "标为已读",
"markAsUnread": "标为未读"
"markAsUnread": "标为未读",
"read": "已读",
"unread": "未读"
}

View File

@@ -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": "未找到提示",

View File

@@ -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>

View File

@@ -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">&#10003;</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">&#10003;</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">&#10003;</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>
);
}

View File

@@ -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

View 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;

View File

@@ -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,

View File

@@ -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;
}
/**

View File

@@ -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) {

View File

@@ -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) => {

View File

@@ -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;
}

View File

@@ -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()
});
}

View File

@@ -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 })
};
}

View File

@@ -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;
}

View 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');
});
});
});