|
const express = require('express'); |
|
const fs = require('fs'); |
|
const path = require('path'); |
|
const os = require('os'); |
|
|
|
const app = express(); |
|
const PORT = process.env.PORT || 3000; |
|
|
|
class ChatLogParser { |
|
constructor() { |
|
this.claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects'); |
|
} |
|
|
|
parseJsonlLine(line) { |
|
try { |
|
return JSON.parse(line.trim()); |
|
} catch (error) { |
|
return null; |
|
} |
|
} |
|
|
|
parseJsonlFile(filePath) { |
|
try { |
|
const content = fs.readFileSync(filePath, 'utf8'); |
|
const lines = content.split('\n').filter(line => line.trim()); |
|
const entries = []; |
|
|
|
for (const line of lines) { |
|
const entry = this.parseJsonlLine(line); |
|
if (!entry || entry.type === 'summary') continue; |
|
|
|
const processedEntry = { |
|
uuid: entry.uuid, |
|
timestamp: entry.timestamp ? new Date(entry.timestamp) : null, |
|
sessionId: entry.sessionId, |
|
type: entry.type, |
|
content: '', |
|
toolInfo: [] |
|
}; |
|
|
|
if (entry.type === 'user') { |
|
if (entry.message?.content) { |
|
if (typeof entry.message.content === 'string') { |
|
processedEntry.content = entry.message.content; |
|
} else if (Array.isArray(entry.message.content)) { |
|
processedEntry.content = entry.message.content |
|
.filter(item => typeof item === 'string') |
|
.join(' ') |
|
.trim(); |
|
} |
|
} |
|
} else if (entry.type === 'assistant') { |
|
if (entry.message?.content && Array.isArray(entry.message.content)) { |
|
for (const item of entry.message.content) { |
|
if (item.type === 'text') { |
|
processedEntry.content += item.text + ' '; |
|
} else if (item.type === 'tool_use') { |
|
processedEntry.toolInfo.push({ |
|
type: 'tool_use', |
|
name: item.name, |
|
input: item.input |
|
}); |
|
} |
|
} |
|
processedEntry.content = processedEntry.content.trim(); |
|
} |
|
} |
|
|
|
if (processedEntry.content || processedEntry.toolInfo.length > 0) { |
|
if (entry.type === 'assistant' && !processedEntry.content && processedEntry.toolInfo.length > 0) { |
|
processedEntry.isToolOnly = true; |
|
} |
|
entries.push(processedEntry); |
|
} |
|
} |
|
|
|
return entries; |
|
} catch (error) { |
|
console.error(`Error parsing file ${filePath}:`, error); |
|
return []; |
|
} |
|
} |
|
|
|
getProjectConversations(projectPath) { |
|
const fullProjectPath = path.join(this.claudeProjectsPath, projectPath); |
|
|
|
if (!fs.existsSync(fullProjectPath)) { |
|
throw new Error(`Project path not found: ${projectPath}`); |
|
} |
|
|
|
const files = fs.readdirSync(fullProjectPath) |
|
.filter(file => file.endsWith('.jsonl')) |
|
.map(file => path.join(fullProjectPath, file)); |
|
|
|
let allEntries = []; |
|
for (const file of files) { |
|
const entries = this.parseJsonlFile(file); |
|
allEntries = allEntries.concat(entries); |
|
} |
|
|
|
allEntries.sort((a, b) => { |
|
if (!a.timestamp || !b.timestamp) return 0; |
|
return a.timestamp - b.timestamp; |
|
}); |
|
|
|
const groupedBySessions = {}; |
|
for (const entry of allEntries) { |
|
if (!groupedBySessions[entry.sessionId]) { |
|
groupedBySessions[entry.sessionId] = []; |
|
} |
|
groupedBySessions[entry.sessionId].push(entry); |
|
} |
|
|
|
return { |
|
entries: allEntries, |
|
sessions: groupedBySessions, |
|
projectPath: projectPath |
|
}; |
|
} |
|
|
|
getAvailableProjects() { |
|
try { |
|
return fs.readdirSync(this.claudeProjectsPath) |
|
.filter(dir => { |
|
const fullPath = path.join(this.claudeProjectsPath, dir); |
|
return fs.statSync(fullPath).isDirectory(); |
|
}); |
|
} catch (error) { |
|
return []; |
|
} |
|
} |
|
} |
|
|
|
const parser = new ChatLogParser(); |
|
|
|
app.get('/', (req, res) => { |
|
const projects = parser.getAvailableProjects(); |
|
|
|
const html = ` |
|
<!DOCTYPE html> |
|
<html lang="ja"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Claude Code Chat Log Reader</title> |
|
<style> |
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; background: #f5f5f5; } |
|
.container { max-width: 800px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } |
|
h1 { color: #333; border-bottom: 2px solid #e1e1e1; padding-bottom: 20px; } |
|
.project-list { list-style: none; padding: 0; } |
|
.project-list li { margin: 10px 0; } |
|
.project-list a { color: #0066cc; text-decoration: none; padding: 10px; display: block; border: 1px solid #e1e1e1; border-radius: 4px; transition: background 0.2s; } |
|
.project-list a:hover { background: #f0f8ff; } |
|
.usage { background: #f9f9f9; padding: 20px; border-radius: 4px; margin-top: 20px; } |
|
code { background: #e1e1e1; padding: 2px 4px; border-radius: 2px; } |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<h1>Claude Code Chat Log Reader</h1> |
|
<p>各プロジェクトのClaude Codeとの対話履歴を閲覧できます。</p> |
|
|
|
<h2>利用可能なプロジェクト:</h2> |
|
<ul class="project-list"> |
|
${projects.map(project => |
|
`<li><a href="/project/${encodeURIComponent(project)}">${project}</a></li>` |
|
).join('')} |
|
</ul> |
|
|
|
<div class="usage"> |
|
<h3>使い方:</h3> |
|
<p>上記のプロジェクト名をクリックするか、URLに直接プロジェクトパスを指定してください:</p> |
|
<code>http://localhost:${PORT}/project/[プロジェクトパス]</code> |
|
</div> |
|
</div> |
|
</body> |
|
</html>`; |
|
|
|
res.send(html); |
|
}); |
|
|
|
app.get('/project/:projectPath(*)', (req, res) => { |
|
try { |
|
const projectPath = req.params.projectPath; |
|
const conversations = parser.getProjectConversations(projectPath); |
|
|
|
res.send(generateConversationHtml(conversations)); |
|
} catch (error) { |
|
res.status(404).send(` |
|
<!DOCTYPE html> |
|
<html lang="ja"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Error - Claude Code Chat Log Reader</title> |
|
<style> |
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; background: #f5f5f5; } |
|
.container { max-width: 800px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } |
|
.error { color: #d73a49; background: #ffeef0; padding: 20px; border-radius: 4px; border-left: 4px solid #d73a49; } |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<h1>Error</h1> |
|
<div class="error"> |
|
<strong>プロジェクトが見つかりません:</strong> ${error.message} |
|
</div> |
|
<p><a href="/">← プロジェクト一覧に戻る</a></p> |
|
</div> |
|
</body> |
|
</html>`); |
|
} |
|
}); |
|
|
|
function generateConversationHtml(conversations) { |
|
const { entries, sessions, projectPath } = conversations; |
|
|
|
return ` |
|
<!DOCTYPE html> |
|
<html lang="ja"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Chat Log - ${projectPath}</title> |
|
<style> |
|
body { |
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
margin: 0; |
|
padding: 20px; |
|
background: #f5f5f5; |
|
line-height: 1.6; |
|
} |
|
.container { |
|
max-width: 1000px; |
|
margin: 0 auto; |
|
background: white; |
|
border-radius: 8px; |
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
|
overflow: hidden; |
|
} |
|
.header { |
|
background: #2d3748; |
|
color: white; |
|
padding: 20px 30px; |
|
} |
|
.header h1 { margin: 0; font-size: 24px; } |
|
.header .project-path { opacity: 0.8; font-size: 14px; margin-top: 5px; } |
|
.content { padding: 30px; } |
|
.controls { |
|
margin-bottom: 30px; |
|
padding: 15px; |
|
background: #f8f9fa; |
|
border-radius: 4px; |
|
border: 1px solid #e9ecef; |
|
} |
|
.toggle-button { |
|
background: #007bff; |
|
color: white; |
|
border: none; |
|
padding: 8px 16px; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
margin-right: 10px; |
|
font-size: 14px; |
|
} |
|
.toggle-button:hover { background: #0056b3; } |
|
.toggle-button.active { background: #28a745; } |
|
.session-separator { |
|
margin: 25px 0; |
|
border-top: 2px solid #e1e1e1; |
|
padding-top: 15px; |
|
position: relative; |
|
} |
|
.session-separator::before { |
|
content: "New Session"; |
|
position: absolute; |
|
top: -12px; |
|
left: 20px; |
|
background: white; |
|
padding: 0 10px; |
|
color: #666; |
|
font-size: 12px; |
|
font-weight: bold; |
|
} |
|
.message { |
|
margin: 12px 0; |
|
padding: 12px 16px; |
|
border-radius: 6px; |
|
} |
|
.message.user { |
|
background: #e3f2fd; |
|
border-left: 4px solid #2196f3; |
|
margin-right: 60px; |
|
} |
|
.message.assistant { |
|
background: #f1f8e9; |
|
border-left: 4px solid #4caf50; |
|
margin-left: 60px; |
|
} |
|
.message-header { |
|
font-size: 12px; |
|
color: #666; |
|
margin-bottom: 6px; |
|
font-weight: bold; |
|
} |
|
.message-content { |
|
white-space: pre-wrap; |
|
word-wrap: break-word; |
|
} |
|
.timestamp { |
|
font-size: 11px; |
|
color: #999; |
|
margin-top: 6px; |
|
} |
|
.tool-use { |
|
background: #f8f9fa; |
|
border: 1px solid #dee2e6; |
|
border-left: 4px solid #6c757d; |
|
border-radius: 4px; |
|
padding: 12px; |
|
margin: 12px 0; |
|
margin-left: 30%; |
|
margin-right: 20px; |
|
font-size: 13px; |
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1); |
|
} |
|
.tool-use.todo { |
|
border-left-color: #28a745; |
|
background: #f8fff9; |
|
} |
|
.tool-name { |
|
font-weight: bold; |
|
color: #495057; |
|
margin-bottom: 8px; |
|
font-size: 14px; |
|
} |
|
.tool-params { |
|
list-style: none; |
|
padding: 0; |
|
margin: 0; |
|
} |
|
.tool-param { |
|
margin: 1px 0; |
|
padding: 1px 0; |
|
line-height: 1.2; |
|
} |
|
.param-key { |
|
font-weight: 600; |
|
color: #6c757d; |
|
display: inline-block; |
|
min-width: 120px; |
|
} |
|
.param-value { |
|
color: #212529; |
|
font-family: 'Monaco', 'Menlo', monospace; |
|
font-size: 12px; |
|
line-height: 1.2; |
|
white-space: nowrap; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
max-width: 450px; |
|
display: inline-block; |
|
} |
|
.todo-item { |
|
background: #fff; |
|
border: 1px solid #e9ecef; |
|
border-radius: 4px; |
|
padding: 8px; |
|
margin: 4px 0; |
|
} |
|
.todo-content { |
|
font-weight: 500; |
|
margin-bottom: 4px; |
|
} |
|
.todo-meta { |
|
font-size: 11px; |
|
color: #6c757d; |
|
} |
|
.todo-status { |
|
display: inline-block; |
|
padding: 2px 6px; |
|
border-radius: 3px; |
|
font-size: 10px; |
|
font-weight: bold; |
|
text-transform: uppercase; |
|
} |
|
.todo-status.pending { background: #ffeaa7; color: #856404; } |
|
.todo-status.in_progress { background: #a8e6cf; color: #155724; } |
|
.todo-status.completed { background: #c3e6cb; color: #155724; } |
|
.todo-priority.high { color: #dc3545; } |
|
.todo-priority.medium { color: #fd7e14; } |
|
.todo-priority.low { color: #6c757d; } |
|
.back-link { |
|
color: #007bff; |
|
text-decoration: none; |
|
margin-bottom: 20px; |
|
display: inline-block; |
|
} |
|
.back-link:hover { text-decoration: underline; } |
|
.stats { |
|
background: #f8f9fa; |
|
padding: 15px; |
|
border-radius: 4px; |
|
margin-bottom: 20px; |
|
font-size: 14px; |
|
color: #666; |
|
} |
|
.hidden { display: none !important; } |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<div class="header"> |
|
<h1>Claude Code Chat Log</h1> |
|
<div class="project-path">${projectPath}</div> |
|
</div> |
|
<div class="content"> |
|
<a href="/" class="back-link">← プロジェクト一覧に戻る</a> |
|
|
|
<div class="stats"> |
|
<strong>統計:</strong> |
|
${Object.keys(sessions).length} セッション, |
|
${entries.length} メッセージ |
|
</div> |
|
|
|
<div class="controls"> |
|
<button class="toggle-button" id="toggle-tools" onclick="toggleTools()"> |
|
ツール使用を表示 |
|
</button> |
|
<span style="color: #666; font-size: 12px;"> |
|
ツール使用の表示/非表示を切り替えます |
|
</span> |
|
</div> |
|
|
|
<div class="conversation"> |
|
${generateMessagesHtml(entries)} |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
let showTools = false; |
|
|
|
function toggleTools() { |
|
showTools = !showTools; |
|
const button = document.getElementById('toggle-tools'); |
|
const toolElements = document.querySelectorAll('.tool-use'); |
|
|
|
if (showTools) { |
|
button.textContent = 'ツール使用を非表示'; |
|
button.classList.add('active'); |
|
toolElements.forEach(el => el.classList.remove('hidden')); |
|
} else { |
|
button.textContent = 'ツール使用を表示'; |
|
button.classList.remove('active'); |
|
toolElements.forEach(el => el.classList.add('hidden')); |
|
} |
|
} |
|
</script> |
|
</body> |
|
</html>`; |
|
} |
|
|
|
function generateMessagesHtml(entries) { |
|
let html = ''; |
|
let currentSessionId = null; |
|
|
|
for (const entry of entries) { |
|
if (currentSessionId && currentSessionId !== entry.sessionId) { |
|
html += '<div class="session-separator"></div>'; |
|
} |
|
currentSessionId = entry.sessionId; |
|
|
|
if (!entry.isToolOnly) { |
|
const timestamp = entry.timestamp ? entry.timestamp.toLocaleString('ja-JP') : 'Unknown time'; |
|
const messageClass = entry.type === 'user' ? 'user' : 'assistant'; |
|
const messageHeader = entry.type === 'user' ? 'User' : 'Claude'; |
|
|
|
html += ` |
|
<div class="message ${messageClass}"> |
|
<div class="message-header">${messageHeader}</div> |
|
<div class="message-content">${escapeHtml(entry.content)}</div> |
|
<div class="timestamp">${timestamp}</div> |
|
</div> |
|
`; |
|
} |
|
|
|
html += generateToolInfoHtml(entry.toolInfo); |
|
} |
|
|
|
return html; |
|
} |
|
|
|
function generateToolInfoHtml(toolInfo) { |
|
if (!toolInfo || toolInfo.length === 0) return ''; |
|
|
|
return toolInfo.map(tool => { |
|
if (tool.type === 'tool_use') { |
|
if (tool.name === 'TodoWrite') { |
|
return generateTodoHtml(tool); |
|
} |
|
|
|
const params = Object.entries(tool.input).map(([key, value]) => { |
|
let displayValue; |
|
if (typeof value === 'object') { |
|
displayValue = JSON.stringify(value, null, 2); |
|
} else { |
|
displayValue = String(value); |
|
} |
|
|
|
return ` |
|
<li class="tool-param"> |
|
<span class="param-key">${escapeHtml(key)}:</span> |
|
<span class="param-value">${escapeHtml(displayValue)}</span> |
|
</li> |
|
`; |
|
}).join(''); |
|
|
|
return ` |
|
<div class="tool-use hidden"> |
|
<div class="tool-name">🔧 ${tool.name}</div> |
|
<ul class="tool-params"> |
|
${params} |
|
</ul> |
|
</div> |
|
`; |
|
} |
|
return ''; |
|
}).join(''); |
|
} |
|
|
|
function generateTodoHtml(tool) { |
|
try { |
|
const todos = tool.input.todos; |
|
if (!todos || !Array.isArray(todos)) { |
|
return generateRegularToolHtml(tool); |
|
} |
|
|
|
const todoItems = todos.map(todo => ` |
|
<div class="todo-item"> |
|
<div class="todo-content">${escapeHtml(todo.content)}</div> |
|
<div class="todo-meta"> |
|
<span class="todo-status ${todo.status}">${todo.status}</span> |
|
<span class="todo-priority ${todo.priority}">${todo.priority}</span> |
|
</div> |
|
</div> |
|
`).join(''); |
|
|
|
return ` |
|
<div class="tool-use todo hidden"> |
|
<div class="tool-name">✓ Todo List (${todos.length} items)</div> |
|
<div> |
|
${todoItems} |
|
</div> |
|
</div> |
|
`; |
|
} catch (error) { |
|
return generateRegularToolHtml(tool); |
|
} |
|
} |
|
|
|
function generateRegularToolHtml(tool) { |
|
const params = Object.entries(tool.input).map(([key, value]) => { |
|
let displayValue; |
|
if (typeof value === 'object') { |
|
displayValue = JSON.stringify(value, null, 2); |
|
} else { |
|
displayValue = String(value); |
|
} |
|
|
|
return ` |
|
<li class="tool-param"> |
|
<span class="param-key">${escapeHtml(key)}:</span> |
|
<span class="param-value">${escapeHtml(displayValue)}</span> |
|
</li> |
|
`; |
|
}).join(''); |
|
|
|
return ` |
|
<div class="tool-use hidden"> |
|
<div class="tool-name">🔧 ${tool.name}</div> |
|
<ul class="tool-params"> |
|
${params} |
|
</ul> |
|
</div> |
|
`; |
|
} |
|
|
|
function escapeHtml(text) { |
|
const map = { |
|
'&': '&', |
|
'<': '<', |
|
'>': '>', |
|
'"': '"', |
|
"'": ''' |
|
}; |
|
return text.replace(/[&<>"']/g, m => map[m]); |
|
} |
|
|
|
app.listen(PORT, () => { |
|
console.log(`Claude Code Chat Log Reader running at http://localhost:${PORT}`); |
|
console.log(`Available projects: ${parser.getAvailableProjects().join(', ')}`); |
|
}); |