From 7387a25d65c8ba1975ed0db430abe7c0f7703a0d Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 11 Jan 2026 20:57:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=99=A8=E4=BB=A5?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20CLI=20=E8=BE=93=E5=87=BA=E7=9A=84=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ccw/src/core/routes/claude-routes.ts | 7 +- ccw/src/core/routes/cli-routes.ts | 5 +- ccw/src/core/routes/memory-routes.ts | 5 +- ccw/src/core/routes/rules-routes.ts | 9 +- ccw/src/core/routes/skills-routes.ts | 5 +- ccw/src/tools/cli-output-converter.ts | 338 +++++++++++++++++++++++++- 6 files changed, 351 insertions(+), 18 deletions(-) diff --git a/ccw/src/core/routes/claude-routes.ts b/ccw/src/core/routes/claude-routes.ts index 80e3f05a..668c7d81 100644 --- a/ccw/src/core/routes/claude-routes.ts +++ b/ccw/src/core/routes/claude-routes.ts @@ -556,8 +556,9 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise { } try { - // Import CLI executor + // Import CLI executor and content formatter const { executeCliTool } = await import('../../tools/cli-executor.js'); + const { SmartContentFormatter } = await import('../../tools/cli-output-converter.js'); // Determine file path based on level let filePath: string; @@ -628,8 +629,8 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise { category: 'internal', id: syncId }, (unit) => { - // CliOutputUnit handler: convert to string content for broadcast - const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); + // CliOutputUnit handler: use SmartContentFormatter for intelligent formatting + const content = SmartContentFormatter.format(unit.content, unit.type) || JSON.stringify(unit.content); broadcastToClients({ type: 'CLI_OUTPUT', payload: { diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts index a85ce97a..4b3e8b20 100644 --- a/ccw/src/core/routes/cli-routes.ts +++ b/ccw/src/core/routes/cli-routes.ts @@ -23,6 +23,7 @@ import { getEnrichedConversation, getHistoryWithNativeInfo } from '../../tools/cli-executor.js'; +import { SmartContentFormatter } from '../../tools/cli-output-converter.js'; import { generateSmartContext, formatSmartContext } from '../../tools/smart-context.js'; import { loadCliConfig, @@ -564,8 +565,8 @@ export async function handleCliRoutes(ctx: RouteContext): Promise { parentExecutionId, stream: true }, (unit) => { - // CliOutputUnit handler: convert to string content - const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); + // CliOutputUnit handler: use SmartContentFormatter for intelligent formatting + const content = SmartContentFormatter.format(unit.content, unit.type) || JSON.stringify(unit.content); // Append to active execution buffer const activeExec = activeExecutions.get(executionId); diff --git a/ccw/src/core/routes/memory-routes.ts b/ccw/src/core/routes/memory-routes.ts index 24191289..f8a5e54b 100644 --- a/ccw/src/core/routes/memory-routes.ts +++ b/ccw/src/core/routes/memory-routes.ts @@ -5,6 +5,7 @@ import { join, isAbsolute, extname } from 'path'; import { homedir } from 'os'; import { getMemoryStore } from '../memory-store.js'; import { executeCliTool } from '../../tools/cli-executor.js'; +import { SmartContentFormatter } from '../../tools/cli-output-converter.js'; /** * Route context interface @@ -1008,8 +1009,8 @@ RULES: Be concise. Focus on practical understanding. Include function signatures category: 'internal', id: syncId }, (unit) => { - // CliOutputUnit handler: convert to string content for broadcast - const content = typeof unit.content === 'string' ? unit.content : JSON.stringify(unit.content); + // CliOutputUnit handler: use SmartContentFormatter for intelligent formatting + const content = SmartContentFormatter.format(unit.content, unit.type) || JSON.stringify(unit.content); broadcastToClients({ type: 'CLI_OUTPUT', payload: { diff --git a/ccw/src/core/routes/rules-routes.ts b/ccw/src/core/routes/rules-routes.ts index 1e3003fd..c3dc2d16 100644 --- a/ccw/src/core/routes/rules-routes.ts +++ b/ccw/src/core/routes/rules-routes.ts @@ -6,6 +6,7 @@ import { readFileSync, existsSync, readdirSync, unlinkSync, promises as fsPromis import { join } from 'path'; import { homedir } from 'os'; import { executeCliTool } from '../../tools/cli-executor.js'; +import { SmartContentFormatter } from '../../tools/cli-output-converter.js'; import type { RouteContext } from './types.js'; interface ParsedRuleFrontmatter { @@ -662,8 +663,8 @@ FILE NAME: ${fileName}`; // Create onOutput callback for real-time streaming const onOutput = broadcastToClients ? (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); + // CliOutputUnit handler: use SmartContentFormatter for intelligent formatting + const content = SmartContentFormatter.format(unit.content, unit.type) || JSON.stringify(unit.content); broadcastToClients({ type: 'CLI_OUTPUT', payload: { @@ -749,8 +750,8 @@ FILE NAME: ${fileName}`; // Create onOutput callback for review step const reviewOnOutput = broadcastToClients ? (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); + // CliOutputUnit handler: use SmartContentFormatter for intelligent formatting + const content = SmartContentFormatter.format(unit.content, unit.type) || JSON.stringify(unit.content); broadcastToClients({ type: 'CLI_OUTPUT', payload: { diff --git a/ccw/src/core/routes/skills-routes.ts b/ccw/src/core/routes/skills-routes.ts index 89a702ce..c34b62c1 100644 --- a/ccw/src/core/routes/skills-routes.ts +++ b/ccw/src/core/routes/skills-routes.ts @@ -6,6 +6,7 @@ import { readFileSync, existsSync, readdirSync, statSync, unlinkSync, promises a import { join } from 'path'; import { homedir } from 'os'; import { executeCliTool } from '../../tools/cli-executor.js'; +import { SmartContentFormatter } from '../../tools/cli-output-converter.js'; import { validatePath as validateAllowedPath } from '../../utils/path-validator.js'; import type { RouteContext } from './types.js'; @@ -580,8 +581,8 @@ Create a new Claude Code skill with the following specifications: // Create onOutput callback for real-time streaming const onOutput = broadcastToClients ? (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); + // CliOutputUnit handler: use SmartContentFormatter for intelligent formatting + const content = SmartContentFormatter.format(unit.content, unit.type) || JSON.stringify(unit.content); broadcastToClients({ type: 'CLI_OUTPUT', payload: { diff --git a/ccw/src/tools/cli-output-converter.ts b/ccw/src/tools/cli-output-converter.ts index 5bf0c4c3..6c2d7309 100644 --- a/ccw/src/tools/cli-output-converter.ts +++ b/ccw/src/tools/cli-output-converter.ts @@ -498,6 +498,7 @@ export class JsonLinesParser implements IOutputParser { // ========== OpenCode CLI --format json ========== // {"type":"step_start","timestamp":...,"sessionID":"...","part":{...}} // {"type":"text","timestamp":...,"sessionID":"...","part":{"type":"text","text":"..."}} + // {"type":"tool_use","timestamp":...,"sessionID":"...","part":{"type":"tool","tool":"glob","input":{...},"state":{...}}} // {"type":"step_finish","timestamp":...,"part":{"tokens":{...}}} if (json.type === 'step_start' && json.sessionID) { return { @@ -505,8 +506,7 @@ export class JsonLinesParser implements IOutputParser { content: { message: 'Step started', tool: 'opencode', - sessionId: json.sessionID, - raw: json.part + sessionId: json.sessionID }, timestamp }; @@ -520,15 +520,40 @@ export class JsonLinesParser implements IOutputParser { }; } + // OpenCode tool_use: {"type":"tool_use","part":{"type":"tool","tool":"glob","input":{...},"state":{"status":"..."}}} + if (json.type === 'tool_use' && json.part) { + const part = json.part; + const toolName = part.tool || 'unknown'; + const status = part.state?.status || 'in_progress'; + const input = part.input || {}; + + return { + type: 'tool_call', + content: { + tool: 'opencode', + action: status === 'completed' ? 'result' : 'invoke', + toolName: toolName, + toolId: part.callID || part.id, + parameters: input, + status: status, + output: part.output + }, + timestamp + }; + } + if (json.type === 'step_finish' && json.part) { + const tokens = json.part.tokens || {}; + const inputTokens = tokens.input || 0; + const outputTokens = tokens.output || 0; + return { type: 'metadata', content: { tool: 'opencode', reason: json.part.reason, - tokens: json.part.tokens, - cost: json.part.cost, - raw: json.part + tokens: { input: inputTokens, output: outputTokens }, + cost: json.part.cost }, timestamp }; @@ -671,6 +696,309 @@ export class JsonLinesParser implements IOutputParser { } } +// ========== Smart Content Formatter ========== + +/** + * Intelligent content formatter that detects and formats JSON content + * based on structural patterns rather than hardcoded tool-specific formats. + * + * Key detection patterns: + * - Session/Metadata: session_id, sessionID, thread_id, model, stats + * - Tool Calls: tool_name, tool, function_name, parameters + * - Progress: status, progress, state, reason + * - Tokens: tokens, usage, input_tokens, output_tokens + * - Text Content: content, text, message + */ +export class SmartContentFormatter { + /** + * Format structured content into human-readable text + * Returns formatted string or null if should use original content + */ + static format(content: any, type: CliOutputUnitType): string | null { + if (typeof content === 'string') { + return content; + } + + if (typeof content !== 'object' || content === null) { + return String(content); + } + + // Type-specific formatting + switch (type) { + case 'metadata': + return this.formatMetadata(content); + case 'progress': + return this.formatProgress(content); + case 'tool_call': + return this.formatToolCall(content); + case 'code': + return this.formatCode(content); + case 'file_diff': + return this.formatFileDiff(content); + case 'thought': + return this.formatThought(content); + case 'system': + return this.formatSystem(content); + default: + // Try to extract text content from common fields + return this.extractTextContent(content); + } + } + + /** + * Format metadata (session info, stats, etc.) + */ + private static formatMetadata(content: any): string { + const parts: string[] = []; + + // Tool identifier + if (content.tool) { + parts.push(`[${content.tool.toUpperCase()}]`); + } + + // Session ID + const sessionId = content.sessionId || content.session_id || content.threadId || content.thread_id; + if (sessionId) { + parts.push(`Session: ${this.truncate(sessionId, 20)}`); + } + + // Model info + if (content.model) { + parts.push(`Model: ${content.model}`); + } + + // Status + if (content.status) { + parts.push(`Status: ${content.status}`); + } + + // Duration + if (content.durationMs || content.duration_ms) { + const ms = content.durationMs || content.duration_ms; + parts.push(`Duration: ${this.formatDuration(ms)}`); + } + + // Token usage + const tokens = this.extractTokens(content); + if (tokens) { + parts.push(`Tokens: ${tokens}`); + } + + // Cost + if (content.totalCostUsd || content.total_cost_usd || content.cost) { + const cost = content.totalCostUsd || content.total_cost_usd || content.cost; + parts.push(`Cost: $${typeof cost === 'number' ? cost.toFixed(6) : cost}`); + } + + // Result + if (content.result && typeof content.result === 'string') { + parts.push(`Result: ${this.truncate(content.result, 100)}`); + } + + return parts.length > 0 ? parts.join(' | ') : JSON.stringify(content); + } + + /** + * Format progress updates + */ + private static formatProgress(content: any): string { + const parts: string[] = []; + + // Tool identifier + if (content.tool) { + parts.push(`[${content.tool.toUpperCase()}]`); + } + + // Message + if (content.message) { + parts.push(content.message); + } + + // Status + if (content.status) { + parts.push(`(${content.status})`); + } + + // Progress indicator + if (content.progress !== undefined && content.total !== undefined) { + parts.push(`[${content.progress}/${content.total}]`); + } + + // Session ID (brief) + const sessionId = content.sessionId || content.session_id; + if (sessionId && !content.message) { + parts.push(`Session: ${this.truncate(sessionId, 12)}`); + } + + return parts.length > 0 ? parts.join(' ') : JSON.stringify(content); + } + + /** + * Format tool call (invoke/result) + */ + private static formatToolCall(content: any): string { + const toolName = content.toolName || content.tool_name || content.name || 'unknown'; + const action = content.action || 'invoke'; + const status = content.status; + + if (action === 'result') { + const statusText = status || 'completed'; + let result = `[Tool Result] ${toolName}: ${statusText}`; + if (content.output) { + const outputStr = typeof content.output === 'string' ? content.output : JSON.stringify(content.output); + result += ` → ${this.truncate(outputStr, 150)}`; + } + return result; + } else { + // invoke + let params = ''; + if (content.parameters) { + const paramStr = typeof content.parameters === 'string' + ? content.parameters + : JSON.stringify(content.parameters); + params = this.truncate(paramStr, 100); + } + return `[Tool] ${toolName}(${params})`; + } + } + + /** + * Format code block + */ + private static formatCode(content: any): string { + if (typeof content === 'string') { + return `\`\`\`\n${content}\n\`\`\``; + } + + const lang = content.language || ''; + const code = content.code || content.output || content.content || ''; + const command = content.command; + + let result = ''; + if (command) { + result += `$ ${command}\n`; + } + result += `\`\`\`${lang}\n${code}\n\`\`\``; + + if (content.exitCode !== undefined) { + result += `\n(exit: ${content.exitCode})`; + } + + return result; + } + + /** + * Format file diff + */ + private static formatFileDiff(content: any): string { + const path = content.path || content.file || 'unknown'; + const action = content.action || 'modify'; + const diff = content.diff || content.content || ''; + + return `[${action.toUpperCase()}] ${path}\n\`\`\`diff\n${diff}\n\`\`\``; + } + + /** + * Format thought/reasoning + */ + private static formatThought(content: any): string { + if (typeof content === 'string') { + return `💭 ${content}`; + } + const text = content.text || content.summary || content.content; + return text ? `💭 ${text}` : JSON.stringify(content); + } + + /** + * Format system message + */ + private static formatSystem(content: any): string { + if (typeof content === 'string') { + return `⚙️ ${content}`; + } + const message = content.message || content.content || content.event; + return message ? `⚙️ ${message}` : JSON.stringify(content); + } + + /** + * Extract text content from common fields + */ + private static extractTextContent(content: any): string | null { + // Priority order for text extraction + const textFields = ['text', 'content', 'message', 'output', 'data']; + + for (const field of textFields) { + if (content[field] && typeof content[field] === 'string') { + return content[field]; + } + } + + // Check for nested content + if (content.part && typeof content.part === 'object') { + const nested = this.extractTextContent(content.part); + if (nested) return nested; + } + + // Check for item content (Codex format) + if (content.item && typeof content.item === 'object') { + const nested = this.extractTextContent(content.item); + if (nested) return nested; + } + + return null; + } + + /** + * Extract token usage from various formats + */ + private static extractTokens(content: any): string | null { + // Direct tokens object + if (content.tokens && typeof content.tokens === 'object') { + const input = content.tokens.input || content.tokens.input_tokens || 0; + const output = content.tokens.output || content.tokens.output_tokens || 0; + return `${input}↓ ${output}↑`; + } + + // Usage object + if (content.usage && typeof content.usage === 'object') { + const input = content.usage.input_tokens || content.usage.inputTokens || 0; + const output = content.usage.output_tokens || content.usage.outputTokens || 0; + return `${input}↓ ${output}↑`; + } + + // Stats object + if (content.stats && typeof content.stats === 'object') { + const input = content.stats.input_tokens || content.stats.inputTokens || 0; + const output = content.stats.output_tokens || content.stats.outputTokens || 0; + if (input || output) { + return `${input}↓ ${output}↑`; + } + } + + return null; + } + + /** + * Truncate string to max length + */ + private static truncate(str: string, maxLen: number): string { + if (!str || str.length <= maxLen) return str; + return str.substring(0, maxLen) + '...'; + } + + /** + * Format duration from milliseconds + */ + private static formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + return `${m}m ${rs}s`; + } +} + // ========== Factory Function ========== /**