feat: 引入智能内容格式化器以优化 CLI 输出的格式化处理

This commit is contained in:
catlog22
2026-01-11 20:57:32 +08:00
parent e1eafede65
commit 7387a25d65
6 changed files with 351 additions and 18 deletions

View File

@@ -556,8 +556,9 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
}
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<boolean> {
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: {

View File

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

View File

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

View File

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

View File

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

View File

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