| |
| |
| |
|
|
| import { ToolCall, UsageInfo, ReasoningDetail } from './types'; |
| import { ProviderId } from './providers/types'; |
| import { logger } from '../utils'; |
| import { VirtualFile } from '@/lib/vfs'; |
| import { resolveWireFormat } from './providers/wire-format'; |
|
|
| |
| export type { ReasoningDetail }; |
|
|
| export interface StreamResponse { |
| content?: string; |
| reasoning?: string; |
| toolCalls?: ToolCall[]; |
| usage?: UsageInfo; |
| wasTruncated?: boolean; |
| finishReason?: string; |
| |
| |
| invalidToolName?: string; |
| reasoningDetails?: ReasoningDetail[]; |
| |
| midstreamError?: { code?: number | string; message: string }; |
| } |
|
|
| export interface StreamParserOptions { |
| provider: string; |
| model: string; |
| suppressAssistantDelta?: boolean; |
| |
| debugStream?: boolean; |
| |
| |
| allowedToolNames?: ReadonlySet<string>; |
| onProgress?: (event: string, data?: any) => void; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function parseStreamingResponse( |
| response: Response, |
| options: StreamParserOptions |
| ): Promise<StreamResponse> { |
| const { provider, suppressAssistantDelta = false, onProgress } = options; |
| const isAnthropic = resolveWireFormat(provider as ProviderId, options.model) === 'anthropic'; |
| const reader = response.body?.getReader(); |
| if (!reader) throw new Error('No response stream'); |
|
|
| const decoder = new TextDecoder(); |
| let buffer = ''; |
| let content = ''; |
| let reasoning = ''; |
| const toolCallsById: Record<string, ToolCall> = {}; |
| let currentToolCall: Partial<ToolCall> | null = null; |
| let toolCallBuffer = ''; |
| let usageInfo: UsageInfo | undefined; |
|
|
| |
| |
| |
| |
| const allowedToolNames = options.allowedToolNames; |
| let invalidToolName: string | undefined; |
| function shouldAbortOnToolName(name: string | undefined | null): boolean { |
| if (invalidToolName) return true; |
| if (!name || !allowedToolNames || allowedToolNames.size === 0) return false; |
| if (allowedToolNames.has(name)) return false; |
| for (const allowed of allowedToolNames) { |
| if (allowed.startsWith(name)) return false; |
| } |
| invalidToolName = name; |
| reader?.cancel().catch(() => {}); |
| return true; |
| } |
| let wasTruncated = false; |
| let lastFinishReason: string | undefined; |
| const reasoningDetails: ReasoningDetail[] = []; |
|
|
| |
| let inThinkBlock = false; |
| let thinkTagBuffer = ''; |
| |
| |
| let reasoningCompleteEmitted = false; |
|
|
| |
| |
| |
| |
| function splitThinkTags(piece: string): { content: string; reasoning: string } { |
| const text = thinkTagBuffer + piece; |
| thinkTagBuffer = ''; |
| let contentOut = ''; |
| let reasoningOut = ''; |
| let pos = 0; |
|
|
| while (pos < text.length) { |
| if (!inThinkBlock) { |
| const idx = text.indexOf('<think>', pos); |
| if (idx === -1) { |
| |
| for (let k = Math.min(6, text.length - pos); k >= 1; k--) { |
| if ('<think>'.startsWith(text.slice(text.length - k))) { |
| contentOut += text.slice(pos, text.length - k); |
| thinkTagBuffer = text.slice(text.length - k); |
| return { content: contentOut, reasoning: reasoningOut }; |
| } |
| } |
| contentOut += text.slice(pos); |
| return { content: contentOut, reasoning: reasoningOut }; |
| } |
| contentOut += text.slice(pos, idx); |
| inThinkBlock = true; |
| pos = idx + 7; |
| if (pos < text.length && text[pos] === '\n') pos++; |
| } else { |
| const idx = text.indexOf('</think>', pos); |
| if (idx === -1) { |
| |
| for (let k = Math.min(8, text.length - pos); k >= 1; k--) { |
| if ('</think>'.startsWith(text.slice(text.length - k))) { |
| reasoningOut += text.slice(pos, text.length - k); |
| thinkTagBuffer = text.slice(text.length - k); |
| return { content: contentOut, reasoning: reasoningOut }; |
| } |
| } |
| reasoningOut += text.slice(pos); |
| return { content: contentOut, reasoning: reasoningOut }; |
| } |
| reasoningOut += text.slice(pos, idx); |
| inThinkBlock = false; |
| pos = idx + 8; |
| while (pos < text.length && text[pos] === '\n') pos++; |
| } |
| } |
|
|
| return { content: contentOut, reasoning: reasoningOut }; |
| } |
|
|
| |
| const anthropicToolBuffers: Record<string, string> = {}; |
| const contentBlockIndexToToolId: Record<number, string> = {}; |
| let anthropicThinkingBlockIndex: number | null = null; |
|
|
| |
| let lastIndexedToolKey: string | null = null; |
|
|
| |
| |
| |
| const STREAM_READ_TIMEOUT_MS = 45_000; |
| let streamTimedOut = false; |
| let readTimer: ReturnType<typeof setTimeout> | null = null; |
| let midstreamError: { code?: number | string; message: string } | undefined; |
|
|
| try { |
| while (true) { |
| if (invalidToolName) break; |
| const readResult = await Promise.race([ |
| reader.read(), |
| new Promise<{ done: true; value: undefined }>(resolve => { |
| readTimer = setTimeout(() => resolve({ done: true, value: undefined }), STREAM_READ_TIMEOUT_MS); |
| }) |
| ]); |
| if (readTimer) { clearTimeout(readTimer); readTimer = null; } |
| const { done, value } = readResult; |
| if (done) { |
| if (!value && !lastFinishReason) { |
| streamTimedOut = true; |
| logger.warn('[StreamParser] Stream read timeout or premature close (no finish_reason received)'); |
| reader.cancel().catch(() => {}); |
| } |
| break; |
| } |
|
|
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop() || ''; |
|
|
| for (const line of lines) { |
| if (line.length > 0 && options.debugStream) { |
| onProgress?.('stream_raw_chunk', { line }); |
| } |
| |
| if (line.startsWith(':')) { |
| continue; |
| } |
|
|
| if (line.startsWith('data: ')) { |
| const data = line.slice(6); |
| if (data === '[DONE]') { |
| if (currentToolCall && toolCallBuffer && currentToolCall.function && currentToolCall.id) { |
| currentToolCall.function.arguments = toolCallBuffer; |
| toolCallsById[currentToolCall.id] = currentToolCall as ToolCall; |
| } |
| break; |
| } |
|
|
| try { |
| const json = JSON.parse(data); |
|
|
| |
| |
| |
| |
| if (json.error && (!json.choices || json.choices.length === 0)) { |
| const err = json.error; |
| const message = typeof err === 'string' |
| ? err |
| : (err.message || JSON.stringify(err)); |
| midstreamError = { code: err.code, message }; |
| logger.warn(`[StreamParser] Midstream error: ${message}`); |
| onProgress?.('stream_error', { code: err.code, message }); |
| continue; |
| } |
|
|
| if (isAnthropic) { |
| |
| |
| if (json.type === 'message_start' && json.message?.usage) { |
| const u = json.message.usage; |
| usageInfo = { |
| promptTokens: u.input_tokens || 0, |
| completionTokens: u.output_tokens || 0, |
| totalTokens: (u.input_tokens || 0) + (u.output_tokens || 0), |
| cachedTokens: u.cache_read_input_tokens, |
| reasoningTokens: 0, |
| model: options.model, |
| provider |
| }; |
| } else if (json.type === 'message_delta' && json.usage) { |
| const outputTokens = json.usage.output_tokens || 0; |
| if (usageInfo) { |
| usageInfo.completionTokens = outputTokens; |
| usageInfo.totalTokens = usageInfo.promptTokens + outputTokens; |
| } else { |
| usageInfo = { |
| promptTokens: 0, |
| completionTokens: outputTokens, |
| totalTokens: outputTokens, |
| reasoningTokens: 0, |
| model: options.model, |
| provider |
| }; |
| } |
| } |
|
|
| |
| if (json.type === 'message_delta' && json.delta?.stop_reason) { |
| lastFinishReason = json.delta.stop_reason; |
| |
| if (json.delta.stop_reason === 'max_tokens') { |
| wasTruncated = true; |
| logger.warn('[StreamParser] Response truncated due to max_tokens limit (Anthropic)'); |
| } |
| } |
|
|
| |
| if (json.type === 'content_block_start' && json.content_block?.type === 'thinking') { |
| anthropicThinkingBlockIndex = json.index; |
| if (!suppressAssistantDelta) { |
| onProgress?.('reasoning_start', {}); |
| } |
| } else if (json.type === 'content_block_delta' && json.delta?.type === 'thinking_delta') { |
| const piece = json.delta.thinking as string; |
| reasoning += piece; |
| if (!suppressAssistantDelta) { |
|
|
| onProgress?.('reasoning_delta', { text: piece }); |
| } |
| } else if (json.type === 'content_block_stop' && json.index === anthropicThinkingBlockIndex) { |
| anthropicThinkingBlockIndex = null; |
| if (!suppressAssistantDelta) { |
| onProgress?.('reasoning_complete', { reasoning }); |
| reasoningCompleteEmitted = true; |
| } |
| } else if (json.type === 'content_block_delta' && json.delta?.text_delta?.text) { |
| const piece = json.delta.text_delta.text as string; |
| content += piece; |
|
|
| if (!suppressAssistantDelta) onProgress?.('assistant_delta', { text: piece }); |
| } else if (json.type === 'content_block_start' && json.content_block?.type === 'tool_use') { |
| const toolCall = { |
| id: json.content_block.id, |
| type: 'function' as const, |
| function: { |
| name: json.content_block.name, |
| arguments: '' |
| } |
| }; |
| toolCallsById[json.content_block.id] = toolCall; |
| anthropicToolBuffers[json.content_block.id] = ''; |
| contentBlockIndexToToolId[json.index] = json.content_block.id; |
|
|
| |
| if (shouldAbortOnToolName(json.content_block.name)) break; |
|
|
| if (!suppressAssistantDelta) { |
| onProgress?.('toolCalls', { toolCalls: [toolCall] }); |
| } |
| } else if (json.type === 'content_block_delta' && json.delta?.type === 'input_json_delta') { |
| const contentBlockIndex = json.index; |
| const toolId = contentBlockIndexToToolId[contentBlockIndex]; |
|
|
| if (toolId && json.delta.partial_json) { |
| anthropicToolBuffers[toolId] += json.delta.partial_json; |
|
|
| if (!suppressAssistantDelta && toolCallsById[toolId]) { |
| toolCallsById[toolId].function.arguments = anthropicToolBuffers[toolId]; |
| onProgress?.('tool_param_delta', { |
| toolId, |
| fragment: json.delta.partial_json |
| }); |
| } |
| } |
| } else if (json.type === 'content_block_stop') { |
| const contentBlockIndex = json.index; |
| const toolId = contentBlockIndexToToolId[contentBlockIndex]; |
|
|
| if (toolId && anthropicToolBuffers[toolId]) { |
| try { |
| const completeJson = anthropicToolBuffers[toolId]; |
| JSON.parse(completeJson); |
| toolCallsById[toolId].function.arguments = completeJson; |
| } catch (error) { |
| logger.error('Invalid JSON for tool parameters:', anthropicToolBuffers[toolId], error); |
| toolCallsById[toolId].function.arguments = '{}'; |
| } |
| } |
| } |
| } else { |
| |
| const delta = json.choices?.[0]?.delta; |
| const finishReason = json.choices?.[0]?.finish_reason; |
|
|
| |
| if (finishReason) { |
| lastFinishReason = finishReason; |
| |
| if (finishReason === 'length') { |
| wasTruncated = true; |
| logger.warn('[StreamParser] Response truncated due to max_tokens limit'); |
| } |
| } |
|
|
| if (finishReason === 'stop' || finishReason === 'tool_calls' || finishReason === 'length') { |
| if (currentToolCall && toolCallBuffer && currentToolCall.function && currentToolCall.id) { |
| currentToolCall.function.arguments = toolCallBuffer; |
| toolCallsById[currentToolCall.id] = currentToolCall as ToolCall; |
| currentToolCall = null; |
| toolCallBuffer = ''; |
| } |
| } |
|
|
| |
| |
| |
| let handledReasoningDelta = false; |
| if (delta?.reasoning && !delta?.content && !delta?.tool_calls) { |
| const reasoningPiece = String(delta.reasoning); |
| reasoning += reasoningPiece; |
| if (!suppressAssistantDelta) { |
|
|
| onProgress?.('reasoning_delta', { text: reasoningPiece }); |
| } |
| handledReasoningDelta = true; |
| } |
|
|
| |
| if (delta?.reasoning_content && !delta?.content && !delta?.tool_calls) { |
| const reasoningPiece = String(delta.reasoning_content); |
| reasoning += reasoningPiece; |
| if (!suppressAssistantDelta) { |
| onProgress?.('reasoning_delta', { text: reasoningPiece }); |
| } |
| handledReasoningDelta = true; |
| } |
|
|
| if (delta?.content) { |
| const piece = String(delta.content); |
| |
| const { content: contentPiece, reasoning: reasoningPiece } = splitThinkTags(piece); |
| if (contentPiece) { |
| content += contentPiece; |
| if (!suppressAssistantDelta) onProgress?.('assistant_delta', { text: contentPiece }); |
| } |
| if (reasoningPiece) { |
| reasoning += reasoningPiece; |
| if (!suppressAssistantDelta) onProgress?.('reasoning_delta', { text: reasoningPiece }); |
| handledReasoningDelta = true; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (delta?.reasoning_details && Array.isArray(delta.reasoning_details)) { |
| for (const rd of delta.reasoning_details) { |
| |
| const existingIdx = reasoningDetails.findIndex( |
| (existing) => existing.id && existing.id === rd.id |
| ); |
| if (existingIdx >= 0) { |
| |
| if (rd.text) { |
| const previousText = reasoningDetails[existingIdx].text || ''; |
| |
| if (rd.text !== previousText) { |
| |
| const deltaText = rd.text.startsWith(previousText) |
| ? rd.text.slice(previousText.length) |
| : rd.text; |
|
|
| |
| reasoningDetails[existingIdx].text = rd.text; |
|
|
| |
| |
| if (deltaText && !suppressAssistantDelta && !handledReasoningDelta) { |
| onProgress?.('reasoning_delta', { text: deltaText }); |
| } |
| } |
| } |
| if (rd.signature) { |
| reasoningDetails[existingIdx].signature = rd.signature; |
| } |
| } else if (!rd.id && rd.text && !rd.signature && reasoningDetails.length > 0) { |
| |
| |
| |
| const last = reasoningDetails[reasoningDetails.length - 1]; |
| if (last.type === rd.type && !last.signature && !last.id) { |
| last.text = (last.text || '') + rd.text; |
| } else { |
| reasoningDetails.push(rd as ReasoningDetail); |
| } |
| if (rd.text && !suppressAssistantDelta && !handledReasoningDelta) { |
| onProgress?.('reasoning_delta', { text: rd.text }); |
| } |
| } else { |
| reasoningDetails.push(rd as ReasoningDetail); |
| if (rd.text && !suppressAssistantDelta && !handledReasoningDelta) { |
| onProgress?.('reasoning_delta', { text: rd.text }); |
| } |
| } |
| } |
| |
| |
| |
| |
| |
| if (!handledReasoningDelta) { |
| const latestText = reasoningDetails |
| .filter(rd => rd.text) |
| .map(rd => rd.text) |
| .join(''); |
| if (latestText) { |
| reasoning = latestText; |
| } |
| } |
| } |
|
|
| if (delta?.tool_calls) { |
| |
| |
| if (inThinkBlock) { |
| if (thinkTagBuffer) { |
| reasoning += thinkTagBuffer; |
| if (!suppressAssistantDelta) onProgress?.('reasoning_delta', { text: thinkTagBuffer }); |
| thinkTagBuffer = ''; |
| } |
| inThinkBlock = false; |
| if (!suppressAssistantDelta) { |
| onProgress?.('reasoning_complete', { reasoning }); |
| reasoningCompleteEmitted = true; |
| } |
| } |
|
|
| for (const tc of delta.tool_calls) { |
| if (tc.index !== undefined) { |
| const key = `idx_${tc.index}`; |
| lastIndexedToolKey = key; |
| const isNewTool = !toolCallsById[key]; |
|
|
| if (isNewTool) { |
| toolCallsById[key] = { |
| id: tc.id || `tool_${tc.index}`, |
| type: 'function' as const, |
| function: { name: '', arguments: '' } |
| }; |
| } |
|
|
| if (tc.function?.name) { |
| toolCallsById[key].function.name = tc.function.name; |
|
|
| if (shouldAbortOnToolName(toolCallsById[key].function.name)) break; |
|
|
| if (isNewTool && !suppressAssistantDelta) { |
| onProgress?.('toolCalls', { toolCalls: [toolCallsById[key]] }); |
| } |
| } |
|
|
| if (tc.function?.arguments) { |
| const argFragment = tc.function.arguments; |
| toolCallsById[key].function.arguments += argFragment; |
|
|
| if (!suppressAssistantDelta) { |
| onProgress?.('tool_param_delta', { |
| toolId: toolCallsById[key].id, |
| fragment: argFragment |
| }); |
| } |
| } |
| } else if (tc.id) { |
| if (currentToolCall && toolCallBuffer && currentToolCall.function && currentToolCall.id) { |
| currentToolCall.function.arguments = toolCallBuffer; |
| toolCallsById[currentToolCall.id] = currentToolCall as ToolCall; |
| } |
| currentToolCall = { |
| id: tc.id, |
| type: 'function' as const, |
| function: { |
| name: tc.function?.name || '', |
| arguments: '' |
| } |
| }; |
| toolCallBuffer = tc.function?.arguments || ''; |
|
|
| if (shouldAbortOnToolName(currentToolCall.function?.name)) break; |
|
|
| if (!suppressAssistantDelta && currentToolCall.function?.name) { |
| onProgress?.('toolCalls', { toolCalls: [currentToolCall as ToolCall] }); |
| } |
| } else if (tc.function?.arguments) { |
| const argFragment = tc.function.arguments; |
|
|
| |
| |
| |
| if (!currentToolCall && lastIndexedToolKey && toolCallsById[lastIndexedToolKey]) { |
| const indexed = toolCallsById[lastIndexedToolKey]; |
| indexed.function.arguments += argFragment; |
|
|
| if (!suppressAssistantDelta) { |
| onProgress?.('tool_param_delta', { |
| toolId: indexed.id, |
| fragment: argFragment |
| }); |
| } |
| } else { |
| toolCallBuffer += argFragment; |
|
|
| if (!suppressAssistantDelta && currentToolCall) { |
| onProgress?.('tool_param_delta', { |
| toolId: currentToolCall.id, |
| fragment: argFragment |
| }); |
| } |
| } |
| } |
|
|
| if (tc.function?.name && currentToolCall && currentToolCall.function) { |
| currentToolCall.function.name = tc.function.name; |
| } |
| } |
| } |
| } |
|
|
| |
| if (json.usage && !isAnthropic) { |
| const reportedCost = typeof json.usage.cost === 'number' && json.usage.cost > 0 |
| ? json.usage.cost : undefined; |
| usageInfo = { |
| promptTokens: json.usage.prompt_tokens || 0, |
| completionTokens: json.usage.completion_tokens || 0, |
| totalTokens: json.usage.total_tokens || 0, |
| cachedTokens: json.usage.cached_tokens ?? json.usage.prompt_tokens_details?.cached_tokens, |
| reasoningTokens: json.usage.reasoning_tokens || json.usage.completion_tokens_details?.reasoning_tokens || 0, |
| cost: reportedCost, |
| isEstimated: reportedCost === undefined, |
| model: options.model, |
| provider |
| }; |
| } |
|
|
| if (json.x_groq?.usage) { |
| usageInfo = { |
| promptTokens: json.x_groq.usage.prompt_tokens || 0, |
| completionTokens: json.x_groq.usage.completion_tokens || 0, |
| totalTokens: json.x_groq.usage.total_tokens || 0, |
| reasoningTokens: json.x_groq.usage.reasoning_tokens || 0, |
| model: options.model, |
| provider |
| }; |
| } |
| } catch (error) { |
| if (data && data.length > 10 && !data.includes('[DONE]')) { |
| logger.warn('[StreamParser] Parse error:', error, 'Data:', data.substring(0, 200)); |
| } |
| } |
| } |
| } |
| } |
| } catch (error) { |
| logger.error('Error reading stream:', error); |
| if (currentToolCall && toolCallBuffer && currentToolCall.function && currentToolCall.id) { |
| currentToolCall.function.arguments = toolCallBuffer; |
| toolCallsById[currentToolCall.id] = currentToolCall as ToolCall; |
| } |
| } |
|
|
| |
| if (thinkTagBuffer) { |
| if (inThinkBlock) { |
| reasoning += thinkTagBuffer; |
| } else { |
| content += thinkTagBuffer; |
| } |
| thinkTagBuffer = ''; |
| } |
|
|
| |
| |
| |
| if (reasoning && !reasoningCompleteEmitted && !suppressAssistantDelta) { |
| onProgress?.('reasoning_complete', { reasoning }); |
| } |
|
|
| |
| if (streamTimedOut) { |
| if (Object.keys(toolCallsById).length > 0) { |
| wasTruncated = true; |
| } |
| onProgress?.('stream_timeout', {}); |
| } |
|
|
| |
| |
| |
| |
| if (reasoning) { |
| const hasTextEntry = reasoningDetails.some(rd => rd.text && rd.text.length > 0); |
| if (!hasTextEntry) { |
| reasoningDetails.unshift({ type: 'thinking', text: reasoning }); |
| } |
| } |
|
|
| |
| const toolCallsArray = Object.values(toolCallsById); |
|
|
| return { |
| content, |
| reasoning: reasoning || undefined, |
| toolCalls: toolCallsArray, |
| usage: usageInfo, |
| wasTruncated, |
| finishReason: lastFinishReason, |
| reasoningDetails: reasoningDetails.length > 0 ? reasoningDetails : undefined, |
| midstreamError, |
| invalidToolName, |
| }; |
| } |
|
|
| |
| |
| |
| export function buildFileTree(files: VirtualFile[]): string { |
| if (files.length === 0) return ''; |
|
|
| const tree = new Map<string, { |
| isDirectory: boolean; |
| size?: number; |
| children: Set<string>; |
| }>(); |
|
|
| |
| for (const file of files) { |
| const pathParts = file.path.split('/').filter(Boolean); |
|
|
| |
| |
| |
| for (let i = 0; i < pathParts.length - 1; i++) { |
| const dirPath = '/' + pathParts.slice(0, i + 1).join('/'); |
| if (!tree.has(dirPath)) { |
| tree.set(dirPath, { isDirectory: true, children: new Set() }); |
| } |
| const dirParent = i === 0 ? '/' : '/' + pathParts.slice(0, i).join('/'); |
| if (!tree.has(dirParent)) { |
| tree.set(dirParent, { isDirectory: true, children: new Set() }); |
| } |
| tree.get(dirParent)!.children.add(dirPath); |
| } |
|
|
| |
| tree.set(file.path, { |
| isDirectory: false, |
| size: file.size, |
| children: new Set() |
| }); |
|
|
| |
| const parentPath = '/' + pathParts.slice(0, -1).join('/'); |
| if (parentPath !== '/' && tree.has(parentPath)) { |
| tree.get(parentPath)!.children.add(file.path); |
| } else if (parentPath === '/') { |
| if (!tree.has('/')) { |
| tree.set('/', { isDirectory: true, children: new Set() }); |
| } |
| tree.get('/')!.children.add(file.path); |
| } |
| } |
|
|
| |
| const formatSize = (bytes: number): string => { |
| if (bytes === 0) return '0B'; |
| const k = 1024; |
| const sizes = ['B', 'KB', 'MB']; |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); |
| const size = (bytes / Math.pow(k, i)); |
| const formatted = i === 0 ? size.toString() : size.toFixed(1); |
| return formatted + sizes[i]; |
| }; |
|
|
| |
| const buildTreeString = (path: string, prefix: string = '', isLast: boolean = true): string[] => { |
| const entry = tree.get(path); |
| if (!entry) return []; |
|
|
| const lines: string[] = []; |
| const name = path === '/' ? '' : path.split('/').pop() || ''; |
|
|
| if (path !== '/') { |
| const connector = isLast ? 'βββ ' : 'βββ '; |
| const displayName = entry.isDirectory ? name + '/' : name; |
| const sizeInfo = entry.isDirectory ? '' : ` (${formatSize(entry.size || 0)})`; |
| lines.push(prefix + connector + displayName + sizeInfo); |
| } |
|
|
| |
| const children = Array.from(entry.children).sort((a, b) => { |
| const aEntry = tree.get(a); |
| const bEntry = tree.get(b); |
| if (aEntry?.isDirectory !== bEntry?.isDirectory) { |
| return aEntry?.isDirectory ? -1 : 1; |
| } |
| return a.localeCompare(b); |
| }); |
|
|
| children.forEach((childPath, index) => { |
| const isLastChild = index === children.length - 1; |
| const childPrefix = path === '/' ? '' : prefix + (isLast ? ' ' : 'β '); |
| lines.push(...buildTreeString(childPath, childPrefix, isLastChild)); |
| }); |
|
|
| return lines; |
| }; |
|
|
| const treeLines = buildTreeString('/'); |
| return treeLines.length > 0 ? 'Project Structure:\n' + treeLines.join('\n') : ''; |
| } |
|
|