From d0523684e5aa158b2073dda59c0068701b8018ea Mon Sep 17 00:00:00 2001 From: catlog22 Date: Thu, 8 Jan 2026 17:26:40 +0800 Subject: [PATCH] feat: Enhance CLI output handling with structured Intermediate Representation (IR) - Introduced `CliOutputUnit` and `IOutputParser` interfaces for unified output processing. - Implemented `PlainTextParser` and `JsonLinesParser` for parsing raw CLI output into structured units. - Updated `executeCliTool` to utilize output parsers and handle structured output. - Added `flattenOutputUnits` utility for extracting clean output from structured data. - Enhanced `ConversationTurn` and `ExecutionRecord` interfaces to include structured output. - Created comprehensive documentation for CLI Output Converter usage and integration. - Improved error handling and type mapping for various output formats. --- ccw/docs/cli-output-converter-usage.md | 338 +++++++++++++ ccw/src/commands/cli.ts | 44 +- ccw/src/core/auth/csrf-middleware.ts | 3 +- ccw/src/core/routes/claude-routes.ts | 13 +- ccw/src/core/routes/cli-routes.ts | 17 +- ccw/src/core/routes/memory-routes.ts | 13 +- ccw/src/core/routes/rules-routes.ts | 16 +- ccw/src/core/routes/skills-routes.ts | 8 +- ccw/src/core/server.ts | 2 +- .../templates/dashboard-css/10-cli-status.css | 162 ++++++ .../dashboard-css/33-cli-stream-viewer.css | 71 +++ .../components/cli-stream-viewer.js | 50 +- ccw/src/templates/dashboard-js/i18n.js | 45 ++ .../dashboard-js/views/api-settings.js | 163 ++++-- .../dashboard-js/views/cli-manager.js | 120 ++++- ccw/src/tools/cli-config-manager.ts | 4 +- ccw/src/tools/cli-executor-core.ts | 79 ++- ccw/src/tools/cli-executor-state.ts | 6 + ccw/src/tools/cli-history-store.ts | 2 + ccw/src/tools/cli-output-converter.ts | 474 ++++++++++++++++++ ccw/src/tools/cli-prompt-builder.ts | 55 +- ccw/src/tools/litellm-executor.ts | 44 +- 22 files changed, 1618 insertions(+), 111 deletions(-) create mode 100644 ccw/docs/cli-output-converter-usage.md create mode 100644 ccw/src/tools/cli-output-converter.ts diff --git a/ccw/docs/cli-output-converter-usage.md b/ccw/docs/cli-output-converter-usage.md new file mode 100644 index 00000000..dcbf0c91 --- /dev/null +++ b/ccw/docs/cli-output-converter-usage.md @@ -0,0 +1,338 @@ +# CLI Output Converter Usage Guide + +## Overview + +The CLI Output Converter provides a unified Intermediate Representation (IR) layer for CLI tool output, enabling clean separation between output parsing and consumption scenarios. + +## Architecture + +``` +┌─────────────────┐ +│ CLI Tool │ +│ (stdout/stderr)│ +└────────┬────────┘ + │ Buffer chunks + ▼ +┌─────────────────┐ +│ Output Parser │ +│ (text/json-lines)│ +└────────┬────────┘ + │ CliOutputUnit[] + ▼ +┌─────────────────────────────────────┐ +│ Intermediate Representation (IR) │ +│ - type: stdout|stderr|thought|... │ +│ - content: string | object │ +│ - timestamp: ISO 8601 │ +└────┬────────────────────────────┬───┘ + │ │ + ▼ ▼ +┌─────────────┐ ┌─────────────┐ +│ Storage │ │ View │ +│ (SQLite) │ │ (Dashboard)│ +└─────────────┘ └─────────────┘ + │ + ▼ + ┌─────────────┐ + │ Resume │ + │ (Flatten) │ + └─────────────┘ +``` + +## Basic Usage + +### 1. Creating Parsers + +```typescript +import { createOutputParser } from './cli-output-converter.js'; + +// For plain text output (e.g., Gemini/Qwen plain mode) +const textParser = createOutputParser('text'); + +// For JSON Lines output (e.g., Codex JSONL format) +const jsonParser = createOutputParser('json-lines'); +``` + +### 2. Parsing Stream Chunks + +```typescript +import { spawn } from 'child_process'; +import { createOutputParser } from './cli-output-converter.js'; + +const parser = createOutputParser('json-lines'); +const allUnits: CliOutputUnit[] = []; + +const child = spawn('codex', ['run', 'task']); + +child.stdout.on('data', (chunk: Buffer) => { + const units = parser.parse(chunk, 'stdout'); + allUnits.push(...units); + + // Real-time processing + for (const unit of units) { + console.log(`[${unit.type}] ${unit.content}`); + } +}); + +child.stderr.on('data', (chunk: Buffer) => { + const units = parser.parse(chunk, 'stderr'); + allUnits.push(...units); +}); + +child.on('close', () => { + // Flush remaining buffer + const remaining = parser.flush(); + allUnits.push(...remaining); + + // Save to storage + saveToDatabase(allUnits); +}); +``` + +### 3. Integration with CLI Executor + +```typescript +// In cli-executor-core.ts +import { createOutputParser, type CliOutputUnit } from './cli-output-converter.js'; + +async function executeCliTool(params: CliParams) { + const parser = createOutputParser(params.format === 'json-lines' ? 'json-lines' : 'text'); + const structuredOutput: CliOutputUnit[] = []; + + child.stdout.on('data', (data) => { + const text = data.toString(); + stdout += text; + + // Parse into IR + const units = parser.parse(data, 'stdout'); + structuredOutput.push(...units); + + // Existing streaming logic + if (onOutput) { + onOutput({ type: 'stdout', data: text }); + } + }); + + child.on('close', () => { + // Flush parser + structuredOutput.push(...parser.flush()); + + // Create turn with structured output + const newTurn: ConversationTurn = { + turn: turnNumber, + timestamp: new Date().toISOString(), + prompt, + duration_ms, + status, + exit_code: code, + output: { + stdout: stdout.substring(0, 10240), + stderr: stderr.substring(0, 2048), + truncated: stdout.length > 10240 || stderr.length > 2048, + cached: shouldCache, + stdout_full: shouldCache ? stdout : undefined, + stderr_full: shouldCache ? stderr : undefined, + structured: structuredOutput.length > 0 ? structuredOutput : undefined + } + }; + }); +} +``` + +## Scenario-Specific Usage + +### Scenario 1: View (Dashboard Display) + +```typescript +// In dashboard rendering logic +import { type CliOutputUnit } from '../tools/cli-output-converter.js'; + +function renderOutputUnits(units: CliOutputUnit[]) { + return units.map(unit => { + switch (unit.type) { + case 'thought': + return `
${unit.content}
`; + case 'code': + return `
${unit.content}
`; + case 'file_diff': + return renderDiff(unit.content); + case 'progress': + return renderProgress(unit.content); + default: + return `
${unit.content}
`; + } + }).join('\n'); +} +``` + +### Scenario 2: Storage (Backward Compatible) + +```typescript +// In cli-history-store.ts +function saveConversation(conversation: ConversationRecord) { + for (const turn of conversation.turns) { + // Save traditional fields + db.run(` + INSERT INTO turns (stdout, stderr, truncated, ...) + VALUES (?, ?, ?, ...) + `, turn.output.stdout, turn.output.stderr, turn.output.truncated); + + // Optionally save structured output + if (turn.output.structured) { + db.run(` + INSERT INTO turn_structured_output (turn_id, units) + VALUES (?, ?) + `, turnId, JSON.stringify(turn.output.structured)); + } + } +} +``` + +### Scenario 3: Resume (Context Concatenation) + +```typescript +// In resume-strategy.ts or cli-prompt-builder.ts +import { flattenOutputUnits } from './cli-output-converter.js'; + +function buildContextFromTurns(turns: ConversationTurn[]): string { + const lines: string[] = []; + + for (const turn of turns) { + lines.push(`USER: ${turn.prompt}`); + + // Use structured output if available + if (turn.output.structured) { + const assistantText = flattenOutputUnits(turn.output.structured, { + excludeTypes: ['metadata', 'system'], // Skip noise + includeTypes: ['stdout', 'thought', 'code'] // Keep meaningful content + }); + lines.push(`ASSISTANT: ${assistantText}`); + } else { + // Fallback to plain stdout + lines.push(`ASSISTANT: ${turn.output.stdout}`); + } + } + + return lines.join('\n\n'); +} +``` + +## Advanced Features + +### Filtering by Type + +```typescript +import { flattenOutputUnits, extractContent } from './cli-output-converter.js'; + +// Extract only AI thoughts for analysis +const thoughts = extractContent(units, 'thought'); + +// Get only code blocks +const codeBlocks = extractContent(units, 'code'); + +// Create clean context (exclude system noise) +const cleanContext = flattenOutputUnits(units, { + excludeTypes: ['metadata', 'system', 'progress'] +}); +``` + +### Analytics + +```typescript +import { getOutputStats } from './cli-output-converter.js'; + +const stats = getOutputStats(units); +console.log(`Total units: ${stats.total}`); +console.log(`Thoughts: ${stats.byType.thought || 0}`); +console.log(`Code blocks: ${stats.byType.code || 0}`); +console.log(`Duration: ${stats.firstTimestamp} - ${stats.lastTimestamp}`); +``` + +### Custom Processing + +```typescript +function processUnits(units: CliOutputUnit[]) { + for (const unit of units) { + switch (unit.type) { + case 'file_diff': + // Apply diff to file system + applyDiff(unit.content.path, unit.content.diff); + break; + case 'progress': + // Update progress bar + updateProgress(unit.content.progress, unit.content.total); + break; + case 'thought': + // Log reasoning + logThought(unit.content); + break; + } + } +} +``` + +## Type Reference + +### CliOutputUnitType + +```typescript +type CliOutputUnitType = + | 'stdout' // Standard output text + | 'stderr' // Standard error text + | 'thought' // AI reasoning/thinking + | 'code' // Code block content + | 'file_diff' // File modification diff + | 'progress' // Progress updates + | 'metadata' // Session/execution metadata + | 'system'; // System events/messages +``` + +### CliOutputUnit + +```typescript +interface CliOutputUnit { + type: CliOutputUnitType; + content: T; // string for text types, object for structured types + timestamp: string; // ISO 8601 format +} +``` + +## Migration Path + +### Phase 1: Optional Enhancement (Current) +- Add `structured` field to `ConversationTurn.output` +- Populate during parsing (optional) +- Existing code ignores it (backward compatible) + +### Phase 2: View Integration +- Dashboard uses `structured` when available +- Falls back to plain `stdout` when not present +- Better rendering for thoughts, code, diffs + +### Phase 3: Resume Optimization +- Resume logic prefers `structured` for cleaner context +- Filters out noise (metadata, system events) +- Reduces token usage while preserving semantics + +### Phase 4: Full Adoption +- All CLI tools use converters +- Storage optimized for structured data +- Analytics and insights from IR layer + +## Best Practices + +1. **Always flush the parser** when stream ends to capture incomplete lines +2. **Filter by type** for Resume scenarios to reduce token usage +3. **Use structured content** when available for better display +4. **Keep backward compatibility** by making `structured` optional +5. **Handle missing fields** gracefully (not all tools output JSON) +6. **Validate JSON** before parsing to avoid crashes + +## Future Enhancements + +1. **Streaming transformers** - Apply transformations during parsing +2. **Custom type mappers** - Register custom JSON-to-IR mappings +3. **Compression** - Store structured output more efficiently +4. **Semantic search** - Index thoughts and code separately +5. **Diff viewer** - Interactive file_diff rendering +6. **Progress tracking** - Aggregate progress events across tools diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index 55d749e1..25d3bf13 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -6,6 +6,7 @@ import chalk from 'chalk'; import http from 'http'; import inquirer from 'inquirer'; +import type { CliOutputUnit } from '../tools/cli-output-converter.js'; import { cliExecutorTool, getCliToolsStatus, @@ -725,7 +726,8 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec // Generate execution ID for streaming (use custom ID or timestamp-based) const executionId = id || `${Date.now()}-${tool}`; const startTime = Date.now(); - const spinnerBaseText = `Executing ${tool} (${mode} mode${resumeInfo}${nativeMode})${idInfo}...`; + const modelInfo = model ? ` @${model}` : ''; + const spinnerBaseText = `Executing ${tool}${modelInfo} (${mode} mode${resumeInfo}${nativeMode})${idInfo}...`; console.log(); const spinner = stream ? null : createSpinner(` ${spinnerBaseText}`).start(); @@ -787,20 +789,49 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec }); // Streaming output handler - broadcasts to dashboard AND writes to stdout - const onOutput = (chunk: any) => { + const onOutput = (unit: CliOutputUnit) => { // Always broadcast to dashboard for real-time viewing // Note: /api/hook wraps extraData into payload, so send fields directly + // Maintain backward compatibility with frontend expecting { chunkType, data } + const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); broadcastStreamEvent('CLI_OUTPUT', { executionId, - chunkType: chunk.type, - data: chunk.data + chunkType: unit.type, // For backward compatibility + data: content, // For backward compatibility + unit // New structured format }); + // Write to terminal only when --stream flag is passed if (stream) { - process.stdout.write(chunk.data); + switch (unit.type) { + case 'stdout': + case 'code': + process.stdout.write(typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content)); + break; + case 'stderr': + process.stderr.write(typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content)); + break; + case 'thought': + // Optional: display thinking process with different color + // For now, skip to reduce noise + break; + case 'progress': + // Optional: update progress bar + // For now, skip + break; + default: + // Other types: output content if available + if (unit.content) { + process.stdout.write(typeof unit.content === 'string' ? unit.content : ''); + } + } } }; + // Use JSON-lines parsing by default to enable type badges (thought, code, file_diff, etc.) + // All CLI tools may output structured JSON that can be parsed for richer UI + const outputFormat = 'json-lines'; + try { const result = await cliExecutorTool.execute({ tool, @@ -813,7 +844,8 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec resume, id, // custom execution ID noNative, - stream: !!stream // stream=true → streaming enabled (no cache), stream=false → cache output (default) + stream: !!stream, // stream=true → streaming enabled (no cache), stream=false → cache output (default) + outputFormat // Enable JSONL parsing for tools that support it }, onOutput); // Always pass onOutput for real-time dashboard streaming if (elapsedInterval) clearInterval(elapsedInterval); diff --git a/ccw/src/core/auth/csrf-middleware.ts b/ccw/src/core/auth/csrf-middleware.ts index d0c85d32..119d1fba 100644 --- a/ccw/src/core/auth/csrf-middleware.ts +++ b/ccw/src/core/auth/csrf-middleware.ts @@ -118,8 +118,9 @@ export async function csrfValidation(ctx: CsrfMiddlewareContext): Promise { stream: false, category: 'internal', id: syncId - }, onOutput); + }, (unit) => { + // CliOutputUnit handler: convert to string content for broadcast + const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); + broadcastToClients({ + type: 'CLI_OUTPUT', + payload: { + executionId: syncId, + chunkType: unit.type, + data: content + } + }); + }); // Broadcast CLI_EXECUTION_COMPLETED event broadcastToClients({ diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index 01f1506f..c6b4e618 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -195,7 +195,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { } // API: Get/Update Tool Config - const configMatch = pathname.match(/^\/api\/cli\/config\/(gemini|qwen|codex)$/); + const configMatch = pathname.match(/^\/api\/cli\/config\/(gemini|qwen|codex|claude|opencode)$/); if (configMatch) { const tool = configMatch[1]; @@ -216,7 +216,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { if (req.method === 'PUT') { handlePostRequest(req, res, async (body: unknown) => { try { - const updates = body as { enabled?: boolean; primaryModel?: string; secondaryModel?: string }; + const updates = body as { enabled?: boolean; primaryModel?: string; secondaryModel?: string; tags?: string[] }; const updated = updateToolConfig(initialPath, tool, updates); // Broadcast config updated event @@ -559,19 +559,22 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { category: category || 'user', parentExecutionId, stream: true - }, (chunk) => { - // Append chunk to active execution buffer + }, (unit) => { + // CliOutputUnit handler: convert to string content + const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); + + // Append to active execution buffer const activeExec = activeExecutions.get(executionId); if (activeExec) { - activeExec.output += chunk.data || ''; + activeExec.output += content || ''; } broadcastToClients({ type: 'CLI_OUTPUT', payload: { executionId, - chunkType: chunk.type, - data: chunk.data + chunkType: unit.type, + data: content } }); }); diff --git a/ccw/src/core/routes/memory-routes.ts b/ccw/src/core/routes/memory-routes.ts index cf8c9a45..24191289 100644 --- a/ccw/src/core/routes/memory-routes.ts +++ b/ccw/src/core/routes/memory-routes.ts @@ -1007,7 +1007,18 @@ RULES: Be concise. Focus on practical understanding. Include function signatures stream: false, category: 'internal', id: syncId - }, onOutput); + }, (unit) => { + // CliOutputUnit handler: convert to string content for broadcast + const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); + broadcastToClients({ + type: 'CLI_OUTPUT', + payload: { + executionId: syncId, + chunkType: unit.type, + data: content + } + }); + }); // Broadcast CLI_EXECUTION_COMPLETED event broadcastToClients({ diff --git a/ccw/src/core/routes/rules-routes.ts b/ccw/src/core/routes/rules-routes.ts index f794e443..1e3003fd 100644 --- a/ccw/src/core/routes/rules-routes.ts +++ b/ccw/src/core/routes/rules-routes.ts @@ -661,13 +661,15 @@ FILE NAME: ${fileName}`; // Create onOutput callback for real-time streaming const onOutput = broadcastToClients - ? (chunk: { type: string; data: string }) => { + ? (unit: import('../../tools/cli-output-converter.js').CliOutputUnit) => { + // CliOutputUnit handler: convert to string content for broadcast + const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); broadcastToClients({ type: 'CLI_OUTPUT', payload: { executionId, - chunkType: chunk.type, - data: chunk.data + chunkType: unit.type, + data: content } }); } @@ -746,13 +748,15 @@ FILE NAME: ${fileName}`; // Create onOutput callback for review step const reviewOnOutput = broadcastToClients - ? (chunk: { type: string; data: string }) => { + ? (unit: import('../../tools/cli-output-converter.js').CliOutputUnit) => { + // CliOutputUnit handler: convert to string content for broadcast + const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); broadcastToClients({ type: 'CLI_OUTPUT', payload: { executionId: reviewExecutionId, - chunkType: chunk.type, - data: chunk.data + chunkType: unit.type, + data: content } }); } diff --git a/ccw/src/core/routes/skills-routes.ts b/ccw/src/core/routes/skills-routes.ts index 0354fb7d..89a702ce 100644 --- a/ccw/src/core/routes/skills-routes.ts +++ b/ccw/src/core/routes/skills-routes.ts @@ -579,13 +579,15 @@ Create a new Claude Code skill with the following specifications: // Create onOutput callback for real-time streaming const onOutput = broadcastToClients - ? (chunk: { type: string; data: string }) => { + ? (unit: import('../../tools/cli-output-converter.js').CliOutputUnit) => { + // CliOutputUnit handler: convert to string content for broadcast + const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); broadcastToClients({ type: 'CLI_OUTPUT', payload: { executionId, - chunkType: chunk.type, - data: chunk.data + chunkType: unit.type, + data: content } }); } diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index 2580af8a..6d8a8b8c 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -357,7 +357,7 @@ export async function startServer(options: ServerOptions = {}): Promise(['/api/auth/token', '/api/csrf-token']); + const unauthenticatedPaths = new Set(['/api/auth/token', '/api/csrf-token', '/api/hook']); const server = http.createServer(async (req, res) => { const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`); diff --git a/ccw/src/templates/dashboard-css/10-cli-status.css b/ccw/src/templates/dashboard-css/10-cli-status.css index 46bb22c4..8522a709 100644 --- a/ccw/src/templates/dashboard-css/10-cli-status.css +++ b/ccw/src/templates/dashboard-css/10-cli-status.css @@ -203,6 +203,168 @@ color: hsl(142 76% 36%); } +/* Tool Tags - displayed in tool cards */ +.tool-tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.tool-tag { + font-size: 0.5625rem; + font-weight: 500; + padding: 0.125rem 0.375rem; + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); + border-radius: 0.25rem; +} + +/* Tags Input - used in config modal */ +.tags-input-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Unified tag input - tags and input in one container */ +.tags-unified-input { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + min-height: 2.5rem; + padding: 0.375rem 0.5rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + cursor: text; + transition: border-color 0.15s ease; +} + +.tags-unified-input:focus-within { + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1); +} + +.tag-inline-input { + flex: 1; + min-width: 120px; + border: none; + background: transparent; + outline: none; + font-size: 0.8125rem; + color: hsl(var(--foreground)); + padding: 0.25rem 0; +} + +.tag-inline-input::placeholder { + color: hsl(var(--muted-foreground) / 0.6); +} + +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + min-height: 1.75rem; + padding: 0.25rem; + background: hsl(var(--muted) / 0.3); + border-radius: 0.375rem; +} + +.tag-item { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + background: hsl(var(--primary) / 0.15); + color: hsl(var(--primary)); + border-radius: 0.25rem; +} + +.tag-remove { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + padding: 0; + border: none; + background: transparent; + color: hsl(var(--primary) / 0.6); + cursor: pointer; + font-size: 0.875rem; + line-height: 1; + border-radius: 0.125rem; +} + +.tag-remove:hover { + background: hsl(var(--destructive) / 0.2); + color: hsl(var(--destructive)); +} + +/* Predefined Tags Row - prominent quick add buttons */ +.predefined-tags-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.predefined-tag-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + padding: 0.375rem 0.625rem; + background: hsl(var(--muted)); + color: hsl(var(--foreground)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.predefined-tag-btn:hover { + background: hsl(var(--primary) / 0.15); + color: hsl(var(--primary)); + border-color: hsl(var(--primary) / 0.3); +} + +.predefined-tag-btn i { + opacity: 0.7; +} + +/* Legacy predefined tags */ +.predefined-tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + margin-top: 0.5rem; +} + +.predefined-tag { + font-size: 0.6875rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + background: hsl(var(--muted) / 0.5); + color: hsl(var(--muted-foreground)); + border: 1px dashed hsl(var(--border)); + border-radius: 0.25rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.predefined-tag:hover { + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); + border-color: hsl(var(--primary) / 0.3); +} + .tool-item-right { display: flex; align-items: center; diff --git a/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css b/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css index cf94f041..b309caf2 100644 --- a/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css +++ b/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css @@ -549,6 +549,77 @@ color: hsl(200 70% 70%); } +/* ===== Backend ChunkType Badges (CliOutputUnit.type) ===== */ + +/* Thought/Thinking Message (from JSONL parser) */ +.cli-stream-line.formatted.thought { + background: hsl(280 50% 20% / 0.3); + border-left: 3px solid hsl(280 70% 65%); + font-style: italic; +} + +.cli-msg-badge.cli-msg-thought { + background: hsl(280 70% 65% / 0.2); + color: hsl(280 70% 75%); +} + +/* Code Block Message */ +.cli-stream-line.formatted.code { + background: hsl(220 40% 18% / 0.4); + border-left: 3px solid hsl(220 60% 55%); + font-family: var(--font-mono, 'Consolas', 'Monaco', 'Courier New', monospace); +} + +.cli-msg-badge.cli-msg-code { + background: hsl(220 60% 55% / 0.25); + color: hsl(220 60% 70%); +} + +/* File Diff Message */ +.cli-stream-line.formatted.file_diff { + background: hsl(35 50% 18% / 0.4); + border-left: 3px solid hsl(35 80% 55%); +} + +.cli-msg-badge.cli-msg-file_diff { + background: hsl(35 80% 55% / 0.25); + color: hsl(35 80% 65%); +} + +/* Progress Message */ +.cli-stream-line.formatted.progress { + background: hsl(190 40% 18% / 0.3); + border-left: 3px solid hsl(190 70% 50%); +} + +.cli-msg-badge.cli-msg-progress { + background: hsl(190 70% 50% / 0.2); + color: hsl(190 70% 65%); +} + +/* Metadata Message */ +.cli-stream-line.formatted.metadata { + background: hsl(250 30% 18% / 0.3); + border-left: 3px solid hsl(250 50% 60%); + font-size: 0.85em; +} + +.cli-msg-badge.cli-msg-metadata { + background: hsl(250 50% 60% / 0.2); + color: hsl(250 50% 75%); +} + +/* Stderr Message (Error) */ +.cli-stream-line.formatted.stderr { + background: hsl(0 50% 20% / 0.4); + border-left: 3px solid hsl(0 70% 55%); +} + +.cli-msg-badge.cli-msg-stderr { + background: hsl(0 70% 55% / 0.25); + color: hsl(0 70% 70%); +} + /* Inline Code */ .cli-inline-code { padding: 1px 5px; diff --git a/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js b/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js index 9c87efc4..aa967fa4 100644 --- a/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js +++ b/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js @@ -346,16 +346,50 @@ function renderFormattedLine(line, searchFilter) { // Format inline code content = content.replace(/`([^`]+)`/g, '$1'); - // Build type badge if has prefix - const typeBadge = parsed.hasPrefix ? - ` + // Type badge icons for backend chunkType (CliOutputUnit.type) + const CHUNK_TYPE_ICONS = { + thought: 'brain', + code: 'code', + file_diff: 'git-compare', + progress: 'loader', + system: 'settings', + stderr: 'alert-circle', + metadata: 'info' + }; + + // Type badge labels for backend chunkType + const CHUNK_TYPE_LABELS = { + thought: 'Thinking', + code: 'Code', + file_diff: 'Diff', + progress: 'Progress', + system: 'System', + stderr: 'Error', + metadata: 'Info' + }; + + // Build type badge - prioritize content prefix, then fall back to chunkType + let typeBadge = ''; + let lineClass = ''; + + if (parsed.hasPrefix) { + // Content has Chinese prefix like [系统], [思考], etc. + typeBadge = ` ${parsed.label} - ` : ''; - - // Determine line class based on original type and parsed type - const lineClass = parsed.hasPrefix ? `cli-stream-line formatted ${parsed.type}` : - `cli-stream-line ${line.type}`; + `; + lineClass = `cli-stream-line formatted ${parsed.type}`; + } else if (line.type && line.type !== 'stdout' && CHUNK_TYPE_LABELS[line.type]) { + // No content prefix, but backend sent a meaningful chunkType + typeBadge = ` + + ${CHUNK_TYPE_LABELS[line.type]} + `; + lineClass = `cli-stream-line formatted ${line.type}`; + } else { + // Plain stdout, no badge + lineClass = `cli-stream-line ${line.type || 'stdout'}`; + } return `
${typeBadge}${content}
`; } diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 6fd91450..bdc3a6ee 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -1721,6 +1721,28 @@ const i18n = { 'apiSettings.modelIdExists': 'Model ID already exists', 'apiSettings.useModelTreeToManage': 'Use the model tree to manage individual models', + // CLI Settings + 'apiSettings.cliSettings': 'CLI Settings', + 'apiSettings.addCliSettings': 'Add CLI Settings', + 'apiSettings.editCliSettings': 'Edit CLI Settings', + 'apiSettings.noCliSettings': 'No CLI settings configured', + 'apiSettings.noCliSettingsSelected': 'No CLI Settings Selected', + 'apiSettings.cliSettingsHint': 'Select a CLI settings endpoint or create a new one', + 'apiSettings.cliProviderHint': 'Select an Anthropic provider to use its API key and base URL', + 'apiSettings.noAnthropicProviders': 'No Anthropic providers configured. Please add one in the Providers tab first.', + 'apiSettings.selectProviderFirst': 'Select a provider first', + 'apiSettings.providerRequired': 'Provider is required', + 'apiSettings.modelRequired': 'Model is required', + 'apiSettings.providerNotFound': 'Provider not found', + 'apiSettings.settingsSaved': 'Settings saved successfully', + 'apiSettings.settingsDeleted': 'Settings deleted successfully', + 'apiSettings.confirmDeleteSettings': 'Are you sure you want to delete this CLI settings?', + 'apiSettings.endpointName': 'Endpoint Name', + 'apiSettings.envSettings': 'Environment Settings', + 'apiSettings.settingsFilePath': 'Settings File Path', + 'apiSettings.nameRequired': 'Name is required', + 'apiSettings.status': 'Status', + // Common 'common.cancel': 'Cancel', 'common.optional': '(Optional)', @@ -3777,6 +3799,29 @@ const i18n = { 'apiSettings.modelIdExists': '模型 ID 已存在', 'apiSettings.useModelTreeToManage': '使用模型树管理各个模型', + // CLI Settings + 'apiSettings.cliSettings': 'CLI 配置', + 'apiSettings.addCliSettings': '添加 CLI 配置', + 'apiSettings.editCliSettings': '编辑 CLI 配置', + 'apiSettings.noCliSettings': '未配置 CLI 设置', + 'apiSettings.noCliSettingsSelected': '未选择 CLI 配置', + 'apiSettings.cliSettingsHint': '选择一个 CLI 配置端点或创建新的', + 'apiSettings.cliProviderHint': '选择一个 Anthropic 供应商以使用其 API 密钥和基础 URL', + 'apiSettings.noAnthropicProviders': '未配置 Anthropic 供应商。请先在供应商标签页中添加。', + 'apiSettings.selectProviderFirst': '请先选择供应商', + 'apiSettings.providerRequired': '供应商为必填项', + 'apiSettings.modelRequired': '模型为必填项', + 'apiSettings.providerNotFound': '未找到供应商', + 'apiSettings.settingsSaved': '设置保存成功', + 'apiSettings.settingsDeleted': '设置删除成功', + 'apiSettings.confirmDeleteSettings': '确定要删除此 CLI 配置吗?', + 'apiSettings.endpointName': '端点名称', + 'apiSettings.envSettings': '环境变量设置', + 'apiSettings.settingsFilePath': '配置文件路径', + 'apiSettings.nameRequired': '名称为必填项', + 'apiSettings.tokenRequired': 'API 令牌为必填项', + 'apiSettings.status': '状态', + // Common 'common.cancel': '取消', 'common.optional': '(可选)', diff --git a/ccw/src/templates/dashboard-js/views/api-settings.js b/ccw/src/templates/dashboard-js/views/api-settings.js index 2bc5a266..10895641 100644 --- a/ccw/src/templates/dashboard-js/views/api-settings.js +++ b/ccw/src/templates/dashboard-js/views/api-settings.js @@ -3737,23 +3737,94 @@ function renderCliSettingsEmptyState() { if (window.lucide) lucide.createIcons(); } +/** + * Get available Anthropic providers + */ +function getAvailableAnthropicProviders() { + if (!apiSettingsData || !apiSettingsData.providers) { + return []; + } + + return apiSettingsData.providers.filter(function(p) { + return p.type === 'anthropic' && p.enabled; + }); +} + +/** + * Build provider options HTML for CLI Settings + */ +function buildCliProviderOptions(selectedProviderId) { + var providers = getAvailableAnthropicProviders(); + var optionsHtml = ''; + + providers.forEach(function(provider) { + var isSelected = provider.id === selectedProviderId ? ' selected' : ''; + optionsHtml += ''; + }); + + return optionsHtml; +} + +/** + * Build model options HTML for CLI Settings based on selected provider + */ +function buildCliModelOptions(providerId, selectedModel) { + var providers = getAvailableAnthropicProviders(); + var provider = providers.find(function(p) { return p.id === providerId; }); + + if (!provider || !provider.llmModels || provider.llmModels.length === 0) { + return ''; + } + + var optionsHtml = ''; + provider.llmModels.forEach(function(model) { + var isSelected = model.id === selectedModel ? ' selected' : ''; + optionsHtml += ''; + }); + + return optionsHtml; +} + +/** + * Update CLI Settings model dropdown when provider changes + */ +function onCliProviderChange() { + var providerId = document.getElementById('cli-settings-provider').value; + var modelSelect = document.getElementById('cli-settings-model'); + + if (modelSelect) { + modelSelect.innerHTML = buildCliModelOptions(providerId, ''); + } +} + /** * Show Add CLI Settings Modal */ function showAddCliSettingsModal(existingEndpoint) { var isEdit = !!existingEndpoint; - var settings = existingEndpoint ? existingEndpoint.settings : { env: {}, model: 'sonnet' }; - var env = settings.env || {}; + var settings = existingEndpoint ? existingEndpoint.settings : { env: {}, model: '' }; + var selectedProviderId = settings.providerId || ''; + var providerOptionsHtml = buildCliProviderOptions(selectedProviderId); + var modelOptionsHtml = buildCliModelOptions(selectedProviderId, settings.model); + + // Check if any Anthropic providers are configured + var hasProviders = getAvailableAnthropicProviders().length > 0; + var noProvidersWarning = !hasProviders ? + '
' + + '' + + '' + t('apiSettings.noAnthropicProviders') + '' + + '
' : ''; var modalHtml = - '