Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions electron/agentSessions/claude/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import os from "node:os";
import path from "node:path";
import { parseClaudeSessionFile } from "./parser";

const CLAUDE_TEST_CWD = "D:\\workspace\\sample-project";
const CLAUDE_TEST_PROJECT_ROOT = "D:\\workspace\\codexflow-fixture";

/**
* 写入临时 Claude JSONL 文件并返回其路径。
*/
Expand All @@ -17,7 +20,7 @@ async function writeTempJsonl(lines: unknown[], filename = `claude-${Date.now()}

describe("parseClaudeSessionFile(本地命令 transcript 分类)", () => {
it("将 Caveat/<command-*>/<local-command-*> 归为 local_command,且不影响 preview 捕获真实首条用户输入", async () => {
const cwd = "G:\\\\Unity\\\\UnityProject\\\\ALP_Dev";
const cwd = CLAUDE_TEST_CWD;
const sessionId = "d9b2555d-bf6e-4a26-b78c-a30174e454c1";
const lines = [
{ type: "file-history-snapshot", snapshot: { timestamp: "2025-12-22T03:22:48.185Z" } },
Expand Down Expand Up @@ -72,7 +75,7 @@ describe("parseClaudeSessionFile(本地命令 transcript 分类)", () => {
});

it("summaryOnly 模式下同样应跳过 transcript,并提取后续真实 prompt 作为 preview", async () => {
const cwd = "G:\\\\Unity\\\\UnityProject\\\\ALP_Dev";
const cwd = CLAUDE_TEST_CWD;
const sessionId = "d9b2555d-bf6e-4a26-b78c-a30174e454c1";
const lines = [
{ type: "file-history-snapshot", snapshot: { timestamp: "2025-12-22T03:22:48.185Z" } },
Expand Down Expand Up @@ -101,16 +104,17 @@ describe("parseClaudeSessionFile(本地命令 transcript 分类)", () => {
});

it("纯路径输入也应回退为文件名预览,避免会话被误判为仅助手输出", async () => {
const cwd = "G:\\\\Projects\\\\CodexFlow";
const cwd = CLAUDE_TEST_PROJECT_ROOT;
const sessionId = "61d0bdd2-f31f-4c77-a148-bf0470f4fffa";
const imagePath = path.win32.join(CLAUDE_TEST_PROJECT_ROOT, "assets", "image-20260317-115705-67p2.png");
const lines = [
{
cwd,
sessionId,
type: "user",
message: {
role: "user",
content: "`C:\\\\Users\\\\52628\\\\AppData\\\\Roaming\\\\codexflow\\\\assets\\\\CodexFlow\\\\image-20260317-115705-67p2.png`",
content: `\`${imagePath}\``,
},
},
{
Expand All @@ -131,5 +135,53 @@ describe("parseClaudeSessionFile(本地命令 transcript 分类)", () => {
expect(details.preview).toBe("图片:image-20260317-115705-67p2.png");
expect(details.title).toBe("图片:image-20260317-115705-67p2.png");
});

it("会将 Claude Read 工具返回的图片恢复为 image 内容,并在本地路径失效时回退到 base64", async () => {
const sessionId = "734c12d2-f31f-4c77-a148-bf0470f4ffaa";
const missingImagePath = path.join(os.tmpdir(), `missing-${Date.now()}.png`);
const lines = [
{
cwd: CLAUDE_TEST_PROJECT_ROOT,
sessionId,
type: "user",
message: { role: "user", content: `\`${missingImagePath}\`` },
},
{
cwd: CLAUDE_TEST_PROJECT_ROOT,
sessionId,
type: "assistant",
message: {
role: "assistant",
content: [{ type: "tool_use", id: "toolu_read", name: "Read", input: { file_path: missingImagePath } }],
},
},
{
cwd: CLAUDE_TEST_PROJECT_ROOT,
sessionId,
type: "user",
message: {
role: "user",
content: [{
type: "tool_result",
tool_use_id: "toolu_read",
content: [{
type: "image",
source: { type: "base64", data: "aGVsbG8=", media_type: "image/png" },
}],
}],
},
},
];

const fp = await writeTempJsonl(lines, `${sessionId}.jsonl`);
const stat = await fs.promises.stat(fp);
const details = await parseClaudeSessionFile(fp, stat, { summaryOnly: false, maxLines: 2000 });

const imageItem = details.messages.flatMap((message) => message.content || []).find((item) => item.type === "image");
expect(imageItem).toBeTruthy();
expect(imageItem?.localPath).toBe(missingImagePath);
expect(String(imageItem?.src || "")).toBe("data:image/png;base64,aGVsbG8=");
expect(imageItem?.fallbackSrc).toBeUndefined();
});
});

155 changes: 152 additions & 3 deletions electron/agentSessions/claude/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Stats } from "node:fs";
import { detectRuntimeShell } from "../../history";
import type { Message, RuntimeShell } from "../../history";
import { sha256Hex } from "../shared/crypto";
import { createHistoryImageContent } from "../shared/historyImage";
import { dirKeyFromCwd, dirKeyOfFilePath, tidyPathCandidate } from "../shared/path";
import { isWinOrWslPathLineForPreview } from "../shared/preview";

Expand Down Expand Up @@ -52,6 +53,7 @@ export async function parseClaudeSessionFile(filePath: string, stat: Stats, opts
let resumeId: string | undefined = undefined;
let runtimeShell: RuntimeShell = "unknown";
const messages: Message[] = [];
const readToolPathById = new Map<string, string>();

const pushMessage = (msg: Message) => {
if (summaryOnly) return;
Expand Down Expand Up @@ -143,6 +145,7 @@ export async function parseClaudeSessionFile(filePath: string, stat: Stats, opts
const role = normalizeClaudeRole(roleRaw);

const { primaryText, toolBlocks, thinkingText } = extractClaudeContent(messageObj ?? obj);
const structuredToolMessages = extractClaudeStructuredToolMessages(messageObj ?? obj, readToolPathById);

if (role === "user" && primaryText) {
const { promptText, transcriptText } = splitClaudeUserTextForLocalCommandTranscript(primaryText);
Expand All @@ -158,9 +161,13 @@ export async function parseClaudeSessionFile(filePath: string, stat: Stats, opts
if (thinkingText) {
pushMessage({ role: "assistant", content: [{ type: "meta", text: thinkingText }] });
}
for (const tb of toolBlocks) {
if (tb.kind === "tool_call") pushMessage({ role: "assistant", content: [{ type: "tool_call", text: tb.text }] });
else if (tb.kind === "tool_result") pushMessage({ role: "tool", content: [{ type: "tool_result", text: tb.text }] });
if (structuredToolMessages.length > 0) {
for (const toolMessage of structuredToolMessages) pushMessage(toolMessage);
} else {
for (const tb of toolBlocks) {
if (tb.kind === "tool_call") pushMessage({ role: "assistant", content: [{ type: "tool_call", text: tb.text }] });
else if (tb.kind === "tool_result") pushMessage({ role: "tool", content: [{ type: "tool_result", text: tb.text }] });
}
}

if (summaryOnly && preview && cwd) {
Expand Down Expand Up @@ -246,6 +253,148 @@ export async function parseClaudeSessionFile(filePath: string, stat: Stats, opts
}

type ClaudeToolBlock = { kind: "tool_call" | "tool_result"; text: string };
type ClaudeStructuredToolMessage = { role: "assistant" | "tool"; content: Message["content"] };

/**
* 中文说明:从 Claude 内容块中提取结构化的工具消息,并为 `Read -> tool_result.image` 建立路径关联。
*/
function extractClaudeStructuredToolMessages(source: any, readToolPathById: Map<string, string>): ClaudeStructuredToolMessage[] {
try {
const content = source?.content;
if (!Array.isArray(content)) return [];

const out: ClaudeStructuredToolMessage[] = [];
for (const block of content) {
const type = String(block?.type || "").toLowerCase();
if (type === "tool_use" || type === "tool-use" || type === "tool_call" || type === "tool-call") {
const toolCallText = buildClaudeToolUseText(block);
const toolUseId = typeof block?.id === "string" ? block.id.trim() : "";
const filePath = typeof block?.input?.file_path === "string" ? tidyPathCandidate(block.input.file_path) : "";
if (toolUseId && filePath) readToolPathById.set(toolUseId, filePath);
if (toolCallText) out.push({ role: "assistant", content: [{ type: "tool_call", text: toolCallText }] });
continue;
}
if (type === "tool_result" || type === "tool-result") {
const toolResultContent = buildClaudeToolResultContents(block, readToolPathById);
if (toolResultContent.length > 0) out.push({ role: "tool", content: toolResultContent });
}
}
return out;
} catch {
return [];
}
}

/**
* 中文说明:格式化 Claude 的工具调用文本,并保留 `Read.input.file_path` 等关键信息。
*/
function buildClaudeToolUseText(block: any): string {
try {
const name = typeof block?.name === "string" ? block.name : (typeof block?.tool === "string" ? block.tool : "");
const input = block && Object.prototype.hasOwnProperty.call(block, "input") ? safeJsonStringify(block.input) : "";
return name ? `${name}${input ? `\n${input}` : ""}`.trim() : input.trim();
} catch {
return "";
}
}

/**
* 中文说明:构造 Claude `tool_result` 的统一内容项。
* - 文本结果仍保留为 `tool_result`;
* - 图片结果优先尝试本地路径,不存在时回退到会话内 Base64。
*/
function buildClaudeToolResultContents(block: any, readToolPathById: Map<string, string>): Message["content"] {
try {
const rawContent = Array.isArray(block?.content)
? block.content
: (typeof block?.content === "string"
? [block.content]
: (Object.prototype.hasOwnProperty.call(block ?? {}, "output") ? [block.output] : []));
if (!Array.isArray(rawContent) || rawContent.length === 0) return [];

const localPath = resolveClaudeToolResultPath(block, readToolPathById);
const textParts: string[] = [];
const out: Message["content"] = [];

for (const part of rawContent) {
if (typeof part === "string") {
const text = part.trim();
if (text) textParts.push(text);
continue;
}
if (!part || typeof part !== "object") continue;

const type = String(part?.type || "").toLowerCase();
if (type === "text") {
const text = typeof part?.text === "string" ? part.text.trim() : "";
if (text) textParts.push(text);
continue;
}
if (type === "image") {
const source = part?.source;
const imageItem = createHistoryImageContent({
localPath,
mimeType: resolveClaudeImageMimeType(part),
dataUrl: typeof source?.data === "string" && /^data:image\//i.test(source.data) ? source.data : undefined,
base64Data: typeof source?.data === "string" && !/^data:image\//i.test(source.data) ? source.data : undefined,
});
if (imageItem) out.push(imageItem);
continue;
}

const text = safeJsonStringify(part).trim();
if (text) textParts.push(text);
}

if (textParts.length > 0) out.unshift({ type: "tool_result", text: textParts.join("\n\n") });
return out;
} catch {
return [];
}
}

/**
* 中文说明:按 `tool_use_id -> file_path` 映射解析 Claude 图片对应的原始路径。
*/
function resolveClaudeToolResultPath(block: any, readToolPathById: Map<string, string>): string | undefined {
try {
const toolUseId = typeof block?.tool_use_id === "string"
? block.tool_use_id.trim()
: (typeof block?.toolUseId === "string" ? block.toolUseId.trim() : "");
if (toolUseId && readToolPathById.has(toolUseId)) return readToolPathById.get(toolUseId);
const direct = typeof block?.file_path === "string" ? block.file_path : (typeof block?.path === "string" ? block.path : "");
const normalized = tidyPathCandidate(direct);
return normalized || undefined;
} catch {
return undefined;
}
}

/**
* 中文说明:从 Claude 图片块中尽力提取 MIME。
*/
function resolveClaudeImageMimeType(part: any): string | undefined {
try {
const candidates = [
part?.mimeType,
part?.mime_type,
part?.mediaType,
part?.media_type,
part?.source?.mimeType,
part?.source?.mime_type,
part?.source?.mediaType,
part?.source?.media_type,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim().toLowerCase().startsWith("image/")) {
return candidate.trim();
}
}
return undefined;
} catch {
return undefined;
}
}

/**
* 判断一行文本是否属于 Claude Code 的“本地命令 transcript”噪声片段。
Expand Down
46 changes: 45 additions & 1 deletion electron/agentSessions/gemini/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import os from "node:os";
import path from "node:path";
import { parseGeminiSessionFile } from "./parser";

const GEMINI_PREVIEW_TEST_PATH = "/mnt/c/codexflow-fixture/assets/CodexFlow/image-20260131-003734-k8xy.png";

/**
* 写入临时 Gemini session JSON 文件并返回其路径。
*
Expand Down Expand Up @@ -33,6 +35,19 @@ async function writeTempJsonAtRelPath(obj: unknown, relPath: string): Promise<st
return fp;
}

/**
* 创建临时图片文件占位,供 Gemini 图片路径优先逻辑测试使用。
*
* @param fileName 文件名
* @returns 临时图片文件路径
*/
async function writeTempImageFile(fileName = "gemini-inline-image.png"): Promise<string> {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "codexflow-gemini-image-"));
const fp = path.join(dir, fileName);
await fs.promises.writeFile(fp, "fake-image", "utf8");
return fp;
}

describe("parseGeminiSessionFile(超大文件 summaryOnly 预览兜底)", () => {
it("当文件超过 maxBytes 时仍能从前缀提取 preview/sessionId/rawDate", async () => {
const sessionId = "d3862d4d-7d74-46c4-9858-45cf754919ca";
Expand All @@ -49,7 +64,7 @@ describe("parseGeminiSessionFile(超大文件 summaryOnly 预览兜底)", ()
id: "m1",
timestamp: startTime,
type: "user",
content: "`/mnt/c/Users/example-user/AppData/Roaming/codexflow/assets/CodexFlow/image-20260131-003734-k8xy.png`\n\n真实首条:你好",
content: `\`${GEMINI_PREVIEW_TEST_PATH}\`\n\n真实首条:你好`,
},
{
id: "m2",
Expand Down Expand Up @@ -100,5 +115,34 @@ describe("parseGeminiSessionFile(超大文件 summaryOnly 预览兜底)", ()
expect(details.preview).toBe("hello gemini");
expect(details.resumeId).toBe("cc28c19a-73b0-470a-b8cf-738ec6a547a8");
});

it("会优先使用 Gemini 会话中的 inlineData 预览,并保留本地图片路径回退", async () => {
const localImagePath = await writeTempImageFile();
const fp = await writeTempJson(
{
sessionId: "gemini-image-session",
startTime: "2026-03-10T13:15:53.072Z",
lastUpdated: "2026-03-10T13:22:34.015Z",
messages: [
{
type: "user",
content: [
{ text: `@${localImagePath} 请优化这个界面` },
{ inlineData: { data: "aGVsbG8=", mimeType: "image/png" } },
],
},
],
},
`session-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
const stat = await fs.promises.stat(fp);
const details = await parseGeminiSessionFile(fp, stat, { summaryOnly: false, maxBytes: 128 * 1024 });

const imageItem = details.messages.flatMap((message) => message.content || []).find((item) => item.type === "image");
expect(imageItem).toBeTruthy();
expect(imageItem?.localPath).toBe(localImagePath);
expect(String(imageItem?.src || "")).toBe("data:image/png;base64,aGVsbG8=");
expect(String(imageItem?.fallbackSrc || "")).toMatch(/^file:\/\//);
});
});

Loading
Loading