feat(history): full conversation trajectory — GetCascadeTrajectory RPC, all step types rendered

This commit is contained in:
2026-03-08 00:25:33 +09:00
parent 215409535b
commit 410d77537e
3 changed files with 109 additions and 35 deletions

View File

@@ -142,43 +142,49 @@
sessionPanel.setActive(sessionId); sessionPanel.setActive(sessionId);
const session = bridgeSessions.find(s => s.id === sessionId); const session = bridgeSessions.find(s => s.id === sessionId);
if (session) { if (session) {
chatPanel.showSession({ name: session.name, status: 'connected' }); chatPanel.showSession({ name: session.name, status: session.isRunning ? 'running' : 'connected' });
} }
// cascades에서 대화 내용 가져오기 // trajectory에서 전체 대화 내용 가져오기
try { try {
const res = await fetch('/api/bridge/cascades'); const res = await fetch(`/api/bridge/trajectory/${sessionId}`);
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const cascadeData = data.cascades || data; if (data.trajectory?.steps) {
// 선택된 세션의 데이터 찾기 const messages = parseTrajectoryToMessages(data.trajectory.steps);
const cascade = cascadeData[sessionId];
if (cascade) {
const messages = parseCascadeToMessages(cascade);
chatPanel.updateChat(messages); chatPanel.updateChat(messages);
} }
} catch (e) { } catch (e) {
console.warn('[Bridge] cascade 로드 실패:', e); console.warn('[Bridge] trajectory 로드 실패:', e);
} }
} }
/** /**
* Cascade 데이터를 ChatPanel이 이해하는 message[] 형식으로 변환 * Trajectory 스텝 배열을 ChatPanel message[] 형식으로 변환
*/ */
function parseCascadeToMessages(cascade) { function parseTrajectoryToMessages(steps) {
const msgs = []; const msgs = [];
// latestNotifyUserStep — 마지막 AI 응답 for (const step of steps) {
if (cascade.latestNotifyUserStep?.step?.notifyUser?.notificationContent) { switch (step.type) {
case 'CORTEX_STEP_TYPE_USER_INPUT': {
const text = step.userInput?.items?.[0]?.chunk?.value || '';
if (text) {
msgs.push({ type: 'user', text });
}
break;
}
case 'CORTEX_STEP_TYPE_NOTIFY_USER': {
const content = step.notifyUser?.notificationContent || '';
if (content) {
msgs.push({ msgs.push({
type: 'text', type: 'text',
html: cascade.latestNotifyUserStep.step.notifyUser.notificationContent html: simpleMarkdown(content),
.replace(/\n/g, '<br>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>'),
}); });
} }
// latestTaskBoundaryStep — 현재 작업 상태 break;
if (cascade.latestTaskBoundaryStep?.step?.taskBoundary) { }
const tb = cascade.latestTaskBoundaryStep.step.taskBoundary; case 'CORTEX_STEP_TYPE_TASK_BOUNDARY': {
const tb = step.taskBoundary || {};
if (tb.taskName) {
msgs.push({ msgs.push({
type: 'task', type: 'task',
title: tb.taskName || '', title: tb.taskName || '',
@@ -187,13 +193,62 @@
mode: tb.mode || '', mode: tb.mode || '',
}); });
} }
// summary break;
if (cascade.summary && msgs.length === 0) { }
msgs.push({ type: 'text', html: `<p>${cascade.summary}</p>` }); case 'CORTEX_STEP_TYPE_CODE_ACTION': {
const ca = step.codeAction || {};
const file = ca.filePath || ca.targetFile || '';
const desc = ca.description || '';
if (file || desc) {
msgs.push({
type: 'code',
language: '',
code: '',
description: `📝 ${file ? file.split(/[\\/]/).pop() : '파일'}: ${desc || '코드 수정'}`,
});
}
break;
}
case 'CORTEX_STEP_TYPE_RUN_COMMAND': {
const cmd = step.runCommand?.commandLine || step.runCommand?.command || '';
if (cmd) {
msgs.push({
type: 'code',
language: 'bash',
code: cmd,
description: `⚡ 명령 실행 (${step.status === 'CORTEX_STEP_STATUS_DONE' ? '완료' : '실행 중'})`,
});
}
break;
}
case 'CORTEX_STEP_TYPE_PLANNER_RESPONSE': {
// 에이전트 사고 과정 — 접기 가능
const text = step.plannerResponse?.text || step.plannerResponse?.rawText || '';
if (text && text.length > 10) {
msgs.push({
type: 'thought',
text: text.substring(0, 300) + (text.length > 300 ? '...' : ''),
});
}
break;
}
// 덜 중요한 스텝은 생략 (VIEW_FILE, LIST_DIRECTORY, EPHEMERAL 등)
}
} }
return msgs; return msgs;
} }
function simpleMarkdown(text) {
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^## (.*)/gm, '<h3>$1</h3>')
.replace(/^### (.*)/gm, '<h4>$1</h4>')
.replace(/\| (.+?) \|/g, (m) => `<span style="font-family:monospace">${m}</span>`);
}
// ─── 서버 메시지 핸들러 ─────────────────────────────── // ─── 서버 메시지 핸들러 ───────────────────────────────
function handleMessage(msg) { function handleMessage(msg) {
switch (msg.type) { switch (msg.type) {

View File

@@ -46,6 +46,16 @@ class BridgeClient {
return this._get('/api/cascades'); return this._get('/api/cascades');
} }
/**
* 개별 대화의 전체 스텝/메시지 (GetCascadeTrajectory RPC)
*/
async getTrajectory(cascadeId) {
return this._post('/api/ls/rpc', {
method: 'GetCascadeTrajectory',
payload: { cascadeId },
});
}
/** /**
* 메시지 전송 * 메시지 전송
*/ */

View File

@@ -321,6 +321,15 @@ app.post('/api/bridge/accept-terminal', async (req, res) => {
} }
}); });
app.get('/api/bridge/trajectory/:id', async (req, res) => {
try {
const data = await bridge.getTrajectory(req.params.id);
res.json(data);
} catch (e) {
res.status(502).json({ error: e.message });
}
});
// Bridge WS 이벤트 → 프론트엔드 포워딩 // Bridge WS 이벤트 → 프론트엔드 포워딩
bridge.connectWs((msg) => { bridge.connectWs((msg) => {
broadcastToAll({ type: 'bridge_event', ...msg }); broadcastToAll({ type: 'bridge_event', ...msg });