diff --git a/electron/agentSessions/claude/parser.test.ts b/electron/agentSessions/claude/parser.test.ts index 7a9a132..a4d9ca3 100644 --- a/electron/agentSessions/claude/parser.test.ts +++ b/electron/agentSessions/claude/parser.test.ts @@ -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 文件并返回其路径。 */ @@ -17,7 +20,7 @@ async function writeTempJsonl(lines: unknown[], filename = `claude-${Date.now()} describe("parseClaudeSessionFile(本地命令 transcript 分类)", () => { it("将 Caveat// 归为 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" } }, @@ -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" } }, @@ -101,8 +104,9 @@ 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, @@ -110,7 +114,7 @@ describe("parseClaudeSessionFile(本地命令 transcript 分类)", () => { type: "user", message: { role: "user", - content: "`C:\\\\Users\\\\52628\\\\AppData\\\\Roaming\\\\codexflow\\\\assets\\\\CodexFlow\\\\image-20260317-115705-67p2.png`", + content: `\`${imagePath}\``, }, }, { @@ -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(); + }); }); diff --git a/electron/agentSessions/claude/parser.ts b/electron/agentSessions/claude/parser.ts index 9175ee2..7fa1310 100644 --- a/electron/agentSessions/claude/parser.ts +++ b/electron/agentSessions/claude/parser.ts @@ -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"; @@ -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(); const pushMessage = (msg: Message) => { if (summaryOnly) return; @@ -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); @@ -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) { @@ -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): 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): 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 | 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”噪声片段。 diff --git a/electron/agentSessions/gemini/parser.test.ts b/electron/agentSessions/gemini/parser.test.ts index 72bb212..b097215 100644 --- a/electron/agentSessions/gemini/parser.test.ts +++ b/electron/agentSessions/gemini/parser.test.ts @@ -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 文件并返回其路径。 * @@ -33,6 +35,19 @@ async function writeTempJsonAtRelPath(obj: unknown, relPath: string): Promise { + 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"; @@ -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", @@ -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:\/\//); + }); }); diff --git a/electron/agentSessions/gemini/parser.ts b/electron/agentSessions/gemini/parser.ts index fe8adf9..c10e1bd 100644 --- a/electron/agentSessions/gemini/parser.ts +++ b/electron/agentSessions/gemini/parser.ts @@ -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, extractImagePathCandidatesFromText } from "../shared/historyImage"; import { dirKeyFromCwd, dirKeyOfFilePath, tidyPathCandidate } from "../shared/path"; import { filterHistoryPreviewText } from "../shared/preview"; @@ -246,20 +247,36 @@ export async function parseGeminiSessionFile(filePath: string, stat: Stats, opts for (const it of items) { const role = normalizeGeminiRole(String(it?.role ?? it?.type ?? it?.actor ?? "")); const text = extractGeminiText(it); + const imageItems = extractGeminiImageContents(it); if (role === "user") { if (!preview && text) { const filtered = filterHistoryPreviewText(text); if (filtered) preview = clampPreview(filtered); } - if (text) pushMessage({ role: "user", content: [{ type: "input_text", text }] }); + const content: Message["content"] = []; + if (text) content.push({ type: "input_text", text }); + if (imageItems.length > 0) content.push(...imageItems); + if (content.length > 0) pushMessage({ role: "user", content }); } else if (role === "assistant") { - if (text) pushMessage({ role: "assistant", content: [{ type: "output_text", text }] }); + const content: Message["content"] = []; + if (text) content.push({ type: "output_text", text }); + if (imageItems.length > 0) content.push(...imageItems); + if (content.length > 0) pushMessage({ role: "assistant", content }); } else if (role === "system") { - if (text) pushMessage({ role: "system", content: [{ type: "meta", text }] }); + const content: Message["content"] = []; + if (text) content.push({ type: "meta", text }); + if (imageItems.length > 0) content.push(...imageItems); + if (content.length > 0) pushMessage({ role: "system", content }); } else if (role === "tool") { - if (text) pushMessage({ role: "tool", content: [{ type: "tool_result", text }] }); + const content: Message["content"] = []; + if (text) content.push({ type: "tool_result", text }); + if (imageItems.length > 0) content.push(...imageItems); + if (content.length > 0) pushMessage({ role: "tool", content }); } else { - if (text) pushMessage({ role: role || "assistant", content: [{ type: "text", text }] }); + const content: Message["content"] = []; + if (text) content.push({ type: "text", text }); + if (imageItems.length > 0) content.push(...imageItems); + if (content.length > 0) pushMessage({ role: role || "assistant", content }); } if (summaryOnly && preview && cwd) break; } @@ -583,6 +600,41 @@ function extractGeminiText(item: any): string { } } +/** + * 从 Gemini 单条消息中提取图片内容项。 + * - 优先使用 `inlineData` 作为主预览源,避免 `.gemini/tmp` 下的临时文件在部分环境中裂图; + * - 同时保留消息文本里携带的本地路径,供元信息展示、路径复制与主进程原生复制图片复用。 + */ +function extractGeminiImageContents(item: any): Message["content"] { + try { + const content = Array.isArray(item?.content) ? item.content : []; + if (content.length === 0) return []; + + const pathCandidates = extractImagePathCandidatesFromText(extractGeminiText(item)); + const out: Message["content"] = []; + let imageIndex = 0; + + for (const part of content) { + const inlineData = part && typeof part === "object" ? (part as any).inlineData : null; + if (!inlineData || typeof inlineData !== "object") continue; + const imageItem = createHistoryImageContent({ + localPath: pathCandidates[imageIndex] || pathCandidates[pathCandidates.length - 1] || undefined, + mimeType: typeof inlineData.mimeType === "string" ? inlineData.mimeType : undefined, + base64Data: typeof inlineData.data === "string" ? inlineData.data : undefined, + preferDataUrl: true, + }); + if (imageItem) { + out.push(imageItem); + imageIndex += 1; + } + } + + return out; + } catch { + return []; + } +} + /** * 尝试从整体对象或 items 中推断工作目录(Gemini 会使用 projectHash,因此这里返回的路径需要后续 hash 校验)。 */ diff --git a/electron/agentSessions/shared/historyImage.ts b/electron/agentSessions/shared/historyImage.ts new file mode 100644 index 0000000..b21e3a6 --- /dev/null +++ b/electron/agentSessions/shared/historyImage.ts @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) + +import fs from "node:fs"; +import path from "node:path"; +import type { MessageContent } from "../../history"; +import { isUNCPath, normalizeWinPath, uncToWsl } from "../../wsl"; + +export type HistoryImageContentOptions = { + localPath?: string; + mimeType?: string; + dataUrl?: string; + base64Data?: string; + text?: string; + tags?: string[]; + preferDataUrl?: boolean; +}; + +const IMAGE_PATH_PATTERN = /@?((?:[A-Za-z]:\\|\/mnt\/[A-Za-z]\/|\/(?:home|root|Users)\/|\\\\[^\\\/\r\n]+\\[^\\\/\r\n]+\\)[^\r\n]*?\.(?:png|jpe?g|webp|gif|bmp|svg))/gi; + +/** + * 中文说明:从文本中提取图片绝对路径候选。 + * - 仅匹配常见绝对路径前缀,避免把普通文案误判成路径; + * - 以图片扩展名为终点,兼容 Gemini `@path.png 提示词` 这类格式。 + */ +export function extractImagePathCandidatesFromText(text?: string): string[] { + try { + const source = String(text || ""); + if (!source) return []; + const out: string[] = []; + let match: RegExpExecArray | null; + IMAGE_PATH_PATTERN.lastIndex = 0; + while ((match = IMAGE_PATH_PATTERN.exec(source)) !== null) { + const normalized = normalizeImagePathCandidate(match[1]); + if (normalized && !out.includes(normalized)) out.push(normalized); + } + return out; + } catch { + return []; + } +} + +/** + * 中文说明:构造历史图片内容项。 + * - 路径有效时优先返回本地 `file:///` 预览地址; + * - 路径失效但存在会话内 Base64/Data URL 时,回退为 data URL; + * - 同时保留 `fallbackSrc`,供前端在本地文件失效时无状态回退。 + */ +export function createHistoryImageContent(options: HistoryImageContentOptions): MessageContent | null { + const localPath = normalizeImagePathCandidate(options.localPath); + const mimeType = normalizeMimeType(options.mimeType) || inferMimeTypeFromPath(localPath) || inferMimeTypeFromDataUrl(options.dataUrl); + const dataUrl = buildImageDataUrl(options.dataUrl, options.base64Data, mimeType); + const hasLocalFile = !!localPath && historyImagePathExists(localPath); + const preferDataUrl = !!options.preferDataUrl && !!dataUrl; + const primarySrc = preferDataUrl + ? dataUrl + : (hasLocalFile ? toHistoryImagePreviewUrl(localPath) : dataUrl); + const fallbackSrc = preferDataUrl + ? (hasLocalFile ? toHistoryImagePreviewUrl(localPath) : "") + : (hasLocalFile ? dataUrl : ""); + if (!primarySrc) return null; + + const content: MessageContent = { + type: "image", + text: buildHistoryImageText({ + localPath, + mimeType, + hasSessionFallback: !!fallbackSrc, + explicitText: options.text, + }), + src: primarySrc, + localPath: localPath || undefined, + mimeType: mimeType || undefined, + }; + const tags = Array.isArray(options.tags) ? options.tags.filter((tag) => String(tag || "").trim().length > 0) : []; + if (tags.length > 0) content.tags = Array.from(new Set(tags)); + if (fallbackSrc && fallbackSrc !== primarySrc) content.fallbackSrc = fallbackSrc; + return content; +} + +/** + * 中文说明:将本地图片路径转为可直接用于渲染层的 `file:///` 地址。 + */ +export function toHistoryImagePreviewUrl(localPath?: string): string { + const raw = normalizeImagePathCandidate(localPath); + if (!raw) return ""; + if (/^file:\/\//i.test(raw)) return raw; + const winPath = normalizeWinPath(raw); + if (isUNCPath(winPath)) { + const normalized = encodeFileUrlSegment(winPath.replace(/\\/g, "/")); + return normalized.startsWith("//") ? `file:${normalized}` : `file://${normalized.replace(/^\/+/, "")}`; + } + if (/^[a-zA-Z]:[\\/]/.test(winPath)) { + return `file:///${encodeFileUrlSegment(winPath.replace(/\\/g, "/"))}`; + } + if (raw.startsWith("/")) { + return `file://${encodeFileUrlSegment(raw)}`; + } + return ""; +} + +/** + * 中文说明:判断图片路径当前是否仍可从文件系统读取。 + * - 先尝试原始路径; + * - 再尝试 WSL 可访问的等价路径,兼容在 WSL 测试环境中读取 Windows 路径。 + */ +export function historyImagePathExists(localPath?: string): boolean { + const candidates = buildFsPathCandidates(localPath); + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) return true; + } catch {} + } + return false; +} + +/** + * 中文说明:规范化图片路径候选,移除常见包裹符号与 Gemini `@` 前缀。 + */ +function normalizeImagePathCandidate(value?: string): string { + try { + let raw = String(value || "").trim(); + if (!raw) return ""; + raw = raw.replace(/^@+/, "").trim(); + raw = raw.replace(/^`+|`+$/g, "").trim(); + raw = raw.replace(/^"+|"+$/g, "").trim(); + raw = raw.replace(/^'+|'+$/g, "").trim(); + return raw; + } catch { + return String(value || "").trim(); + } +} + +/** + * 中文说明:为文件存在性检测生成一组可访问路径候选。 + */ +function buildFsPathCandidates(localPath?: string): string[] { + const raw = normalizeImagePathCandidate(localPath); + if (!raw) return []; + + const out = new Set(); + const push = (value?: string) => { + const next = String(value || "").trim(); + if (next) out.add(next); + }; + + push(raw); + const winPath = normalizeWinPath(raw); + push(winPath); + + if (isUNCPath(winPath)) { + const unc = uncToWsl(winPath); + if (unc?.wslPath) push(unc.wslPath); + } + + const driveMatch = winPath.match(/^([a-zA-Z]):\\(.*)$/); + if (driveMatch?.[1]) { + const drive = driveMatch[1].toLowerCase(); + const rest = String(driveMatch[2] || "").replace(/\\/g, "/"); + push(`/mnt/${drive}/${rest}`); + } + + return Array.from(out); +} + +/** + * 中文说明:构造图片项的搜索/导出文案,避免把 Base64 正文写入历史文本。 + */ +function buildHistoryImageText(args: { localPath?: string; mimeType?: string; hasSessionFallback: boolean; explicitText?: string }): string { + const explicit = String(args.explicitText || "").trim(); + if (explicit) return explicit; + + const lines = ["图片"]; + if (args.localPath) lines.push(`路径: ${args.localPath}`); + if (args.mimeType) lines.push(`类型: ${args.mimeType}`); + if (args.hasSessionFallback) lines.push("回退: 会话内图片数据"); + return lines.join("\n"); +} + +/** + * 中文说明:构造 data URL;若已传入完整 data URL,则直接复用。 + */ +function buildImageDataUrl(dataUrl?: string, base64Data?: string, mimeType?: string): string { + const direct = String(dataUrl || "").trim(); + if (/^data:image\//i.test(direct)) return direct; + const base64 = String(base64Data || "").trim(); + if (!base64) return ""; + const normalizedMime = normalizeMimeType(mimeType) || "image/png"; + return `data:${normalizedMime};base64,${base64}`; +} + +/** + * 中文说明:根据路径扩展名推断图片 MIME。 + */ +function inferMimeTypeFromPath(localPath?: string): string { + const ext = path.extname(String(localPath || "")).toLowerCase(); + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".webp") return "image/webp"; + if (ext === ".gif") return "image/gif"; + if (ext === ".bmp") return "image/bmp"; + if (ext === ".svg") return "image/svg+xml"; + if (ext === ".png") return "image/png"; + return ""; +} + +/** + * 中文说明:从 data URL 头部推断 MIME。 + */ +function inferMimeTypeFromDataUrl(dataUrl?: string): string { + const raw = String(dataUrl || "").trim(); + const match = raw.match(/^data:([^;,]+)[;,]/i); + return normalizeMimeType(match?.[1]) || ""; +} + +/** + * 中文说明:规范化 MIME,确保只接受图片类型。 + */ +function normalizeMimeType(value?: string): string { + const raw = String(value || "").trim().toLowerCase(); + if (!raw.startsWith("image/")) return ""; + return raw; +} + +/** + * 中文说明:对文件 URL 路径段做安全编码,避免空格、`#`、`?` 破坏地址语义。 + */ +function encodeFileUrlSegment(value: string): string { + return encodeURI(String(value || "")).replace(/#/g, "%23").replace(/\?/g, "%3F"); +} diff --git a/electron/history.test.ts b/electron/history.test.ts index 87bc482..a314054 100644 --- a/electron/history.test.ts +++ b/electron/history.test.ts @@ -27,6 +27,17 @@ async function createHistoryJsonlFile(lines: unknown[]): Promise { return filePath; } +/** + * 中文说明:在临时目录中创建一个图片文件占位,用于验证本地路径优先策略。 + */ +async function createTempImageFile(fileName = "history-image.png"): Promise { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "codexflow-history-image-")); + tempDirs.push(dir); + const filePath = path.join(dir, fileName); + await fsp.writeFile(filePath, "fake-image", "utf8"); + return filePath; +} + /** * 中文说明:提取解析结果中的全部文本片段,便于断言尾部消息是否被完整保留。 */ @@ -141,4 +152,142 @@ describe("electron/history.readHistoryFile", () => { const full = await readHistoryFile(filePath, { maxLines: 0 }); expect(collectTexts(full.messages).some((text) => text.includes("尾部消息必须可见"))).toBe(true); }); + + it("会优先恢复 Codex 历史中的本地图片路径,并保留 data URL 回退", async () => { + const localImagePath = await createTempImageFile(); + const dataUrl = "data:image/png;base64,aGVsbG8="; + const filePath = await createHistoryJsonlFile([ + { + timestamp: "2026-03-17T03:57:01.021Z", + type: "session_meta", + payload: { + id: "session-image", + cwd: "/tmp/demo", + }, + }, + { + timestamp: "2026-03-17T03:57:02.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: `请查看这张图片:${localImagePath}` }], + }, + }, + { + timestamp: "2026-03-17T03:57:03.000Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call-image", + output: [{ type: "input_image", image_url: dataUrl }], + }, + }, + ]); + + const parsed = await readHistoryFile(filePath, { maxLines: 0 }); + const imageItem = parsed.messages + .flatMap((message) => message.content || []) + .find((item) => item.type === "image"); + + expect(imageItem).toBeTruthy(); + expect(imageItem?.localPath).toBe(localImagePath); + expect(String(imageItem?.src || "")).toMatch(/^file:\/\//); + expect(imageItem?.fallbackSrc).toBe(dataUrl); + expect(String(imageItem?.text || "")).toContain(localImagePath); + }); + + it("连续多个 view_image 输出会按各自 call_id 恢复对应的本地图片路径", async () => { + const localImagePathA = await createTempImageFile("history-image-a.png"); + const localImagePathB = await createTempImageFile("history-image-b.png"); + const localImagePathC = await createTempImageFile("history-image-c.png"); + const dataUrlA = "data:image/png;base64,QUFB"; + const dataUrlB = "data:image/png;base64,QkJC"; + const dataUrlC = "data:image/png;base64,Q0ND"; + const filePath = await createHistoryJsonlFile([ + { + timestamp: "2026-03-17T04:10:00.000Z", + type: "session_meta", + payload: { + id: "session-image-multi", + cwd: "/tmp/demo", + }, + }, + { + timestamp: "2026-03-17T04:10:01.000Z", + type: "response_item", + payload: { + type: "function_call", + name: "view_image", + arguments: JSON.stringify({ path: localImagePathA }), + call_id: "call-image-a", + }, + }, + { + timestamp: "2026-03-17T04:10:02.000Z", + type: "response_item", + payload: { + type: "function_call", + name: "view_image", + arguments: JSON.stringify({ path: localImagePathB }), + call_id: "call-image-b", + }, + }, + { + timestamp: "2026-03-17T04:10:03.000Z", + type: "response_item", + payload: { + type: "function_call", + name: "view_image", + arguments: JSON.stringify({ path: localImagePathC }), + call_id: "call-image-c", + }, + }, + { + timestamp: "2026-03-17T04:10:04.000Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call-image-a", + output: [{ type: "input_image", image_url: dataUrlA }], + }, + }, + { + timestamp: "2026-03-17T04:10:05.000Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call-image-b", + output: [{ type: "input_image", image_url: dataUrlB }], + }, + }, + { + timestamp: "2026-03-17T04:10:06.000Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call-image-c", + output: [{ type: "input_image", image_url: dataUrlC }], + }, + }, + ]); + + const parsed = await readHistoryFile(filePath, { maxLines: 0 }); + const recoveredImageItems = parsed.messages + .filter((message) => message.role === "tool") + .flatMap((message) => message.content || []) + .filter((item) => item.type === "image" && String(item.fallbackSrc || "").startsWith("data:image/")); + + expect(recoveredImageItems).toHaveLength(3); + expect(recoveredImageItems.map((item) => item.localPath)).toEqual([ + localImagePathA, + localImagePathB, + localImagePathC, + ]); + expect(recoveredImageItems.map((item) => item.fallbackSrc)).toEqual([ + dataUrlA, + dataUrlB, + dataUrlC, + ]); + }); }); diff --git a/electron/history.ts b/electron/history.ts index 0f845ad..98ae432 100644 --- a/electron/history.ts +++ b/electron/history.ts @@ -13,6 +13,7 @@ import { isUNCPath, uncToWsl, getSessionsRootsFastAsync } from './wsl'; import { perfLogger } from './log'; import settings from './settings'; import { extractTaggedPrefix } from './agentSessions/shared/taggedPrefix'; +import { createHistoryImageContent, extractImagePathCandidatesFromText } from './agentSessions/shared/historyImage'; import { deriveGeminiProjectHashCandidatesFromPath, extractGeminiProjectHashFromPath } from './agentSessions/gemini/parser'; import { pathMatchesDirKeyScope, tidyPathCandidate } from "./agentSessions/shared/path"; @@ -38,8 +39,16 @@ export type HistorySummary = { resumeId?: string; runtimeShell?: RuntimeShell; }; -// 消息内容:支持可选 tags(用于嵌套类型筛选,例如 message.input_text) -export type MessageContent = { type: string; text: string; tags?: string[] }; +// 消息内容:支持可选 tags(用于嵌套类型筛选,例如 message.input_text) +export type MessageContent = { + type: string; + text: string; + tags?: string[]; + src?: string; + fallbackSrc?: string; + localPath?: string; + mimeType?: string; +}; export type Message = { role: string; content: MessageContent[] }; // 历史摘要前缀与缓存上限(控制内存占用) @@ -1347,11 +1356,13 @@ export async function readHistoryFile(filePath: string, opts?: { chunkSize?: num const messages: Message[] = []; let skipped = 0; let id = path.basename(filePath).replace(/\.jsonl$/, ''); - let title = id; - let date = 0; - // 说明类去重:会话头 instructions 与用户 可能内容相同 - const __seenInstructions = new Set(); - const __normInstr = (s: string) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); + let title = id; + let date = 0; + // 说明类去重:会话头 instructions 与用户 可能内容相同 + const __seenInstructions = new Set(); + const __normInstr = (s: string) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); + const recentImagePathCandidates: string[] = []; + const imagePathCandidatesByCallId = new Map(); // 读取 stat 并检查缓存 let stat: fs.Stats | null = null; @@ -1365,11 +1376,224 @@ export async function readHistoryFile(filePath: string, opts?: { chunkSize?: num } // 统一的行解析函数,便于 UNC/WSL 与本地读取共享逻辑 - function pretty(v: any): string { - try { return JSON.stringify(v, null, 2); } catch { return String(v); } - } - - function parseLine(line: string, lineIndex: number) { + function pretty(v: any): string { + try { return JSON.stringify(v, null, 2); } catch { return String(v); } + } + + /** + * 中文说明:记录最近在历史文本中出现的图片路径,供后续图片结果块做就近关联。 + */ + function rememberImagePathsFromText(text?: string): string[] { + const extracted = extractImagePathCandidatesFromText(text); + try { + for (const candidate of extracted) { + if (!candidate || recentImagePathCandidates.includes(candidate)) continue; + recentImagePathCandidates.push(candidate); + while (recentImagePathCandidates.length > 8) recentImagePathCandidates.shift(); + } + } catch {} + return extracted; + } + + /** + * 中文说明:返回最近一次出现的图片路径候选。 + */ + function latestImagePathCandidate(): string { + return recentImagePathCandidates.length > 0 ? String(recentImagePathCandidates[recentImagePathCandidates.length - 1] || '') : ''; + } + + /** + * 中文说明:把函数调用中的图片路径候选按 `call_id` 绑定,供对应输出块精确回填本地路径。 + * - 优先使用函数参数里直接出现的路径,避免多个 `view_image` 连续调用时都误绑到“最近一张图”; + * - 同一 `call_id` 仅保留去重后的顺序列表,兼容单次调用携带多张图片的情况。 + */ + function rememberImagePathsForCallId(callId?: string, candidates?: string[]): void { + const normalizedCallId = String(callId || '').trim(); + if (!normalizedCallId) return; + const next = Array.isArray(candidates) + ? candidates.filter((candidate) => String(candidate || '').trim().length > 0) + : []; + if (next.length === 0) return; + + const uniqueCandidates = Array.from(new Set(next)); + imagePathCandidatesByCallId.set(normalizedCallId, uniqueCandidates); + } + + /** + * 中文说明:为函数输出里的第 N 个图片块选择最合适的本地路径提示。 + * - 先按 `call_id` 命中对应函数调用时记录的路径; + * - 若该调用只记录到一张路径,则复用于该输出里的所有图片块; + * - 最后再回退到全局最近图片路径,兼容旧日志或缺失 `call_id` 的场景。 + */ + function resolveImagePathHintForCallOutput(callId: string, imageIndex: number): string { + const normalizedCallId = String(callId || '').trim(); + const candidates = normalizedCallId ? (imagePathCandidatesByCallId.get(normalizedCallId) || []) : []; + if (candidates[imageIndex]) return String(candidates[imageIndex] || ''); + if (candidates.length > 0) return String(candidates[candidates.length - 1] || ''); + return latestImagePathCandidate(); + } + + /** + * 中文说明:从函数调用参数中提取图片路径候选,并兼容 JSON 字符串里的转义反斜杠。 + * - 优先遍历反序列化后的参数对象,拿到真实路径值; + * - 反序列化失败时退回原始字符串/格式化文本,兼容历史日志里的自由文本参数。 + */ + function extractImagePathCandidatesFromFunctionCallArguments(args: unknown, fallbackText?: string): string[] { + const out = new Set(); + + /** + * 中文说明:把一段文本里的图片路径候选加入结果集,保持原始顺序并自动去重。 + */ + function rememberCandidatesFromText(text?: string): void { + for (const candidate of extractImagePathCandidatesFromText(text)) { + if (!candidate) continue; + out.add(candidate); + } + } + + /** + * 中文说明:深度遍历函数参数值,收集其中所有字符串型图片路径。 + */ + function visitArgumentValue(value: unknown): void { + if (typeof value === 'string') { + rememberCandidatesFromText(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) { + visitArgumentValue(item); + } + return; + } + if (!value || typeof value !== 'object') return; + for (const item of Object.values(value as Record)) { + visitArgumentValue(item); + } + } + + if (typeof args === 'string') { + let parsed = false; + try { + visitArgumentValue(JSON.parse(args)); + parsed = true; + } catch {} + if (!parsed || out.size === 0) rememberCandidatesFromText(args); + } else { + visitArgumentValue(args); + } + + if (out.size === 0) rememberCandidatesFromText(fallbackText); + return Array.from(out); + } + + /** + * 中文说明:按既有标签前缀规则拆分文本,并写入统一消息内容数组。 + * - 复用 `extractTaggedPrefix`,保持 `instructions/environment_context` 的展示与筛选语义一致; + * - 仅在需要补造旧格式 `event_msg.user_message` 时使用,避免扩大既有分支改动面。 + */ + function appendTaggedTextContent( + contentArr: MessageContent[], + text: string, + options?: { itemType?: string; containerTag?: string }, + ): void { + const source = String(text || ''); + if (!source.trim()) return; + + const itemType = String(options?.itemType || 'text'); + const containerTag = String(options?.containerTag || `message.${itemType.toLowerCase()}`); + const { rest, picked } = extractTaggedPrefix(source); + if (picked.length > 0) { + for (const item of picked) { + if (String(item.type).toLowerCase() === 'instructions') { + const key = __normInstr(String(item.text || '')); + if (key && __seenInstructions.has(key)) continue; + if (key) __seenInstructions.add(key); + } + const innerTag = 'message.' + String(item.type).toLowerCase(); + contentArr.push({ ...item, tags: Array.from(new Set([...(item.tags || []), innerTag, containerTag])) }); + } + if (rest.trim().length > 0) contentArr.push({ type: itemType, text: rest, tags: [containerTag] }); + return; + } + + contentArr.push({ type: itemType, text: source, tags: [containerTag] }); + } + + /** + * 中文说明:把仅包含图片绝对路径的 `event_msg.user_message` 恢复为“文本 + 多张图片”内容。 + * - 仅当 `images/local_images/text_elements` 都为空时才介入,避免与现代 `input_image` 记录重复; + * - 保留原始输入文本,便于搜索与上下文回看; + * - 为每个路径生成独立图片内容项,解决一条消息多图只挂上一张的问题。 + */ + function buildPathOnlyCodexUserMessageEventContent(payload: any): MessageContent[] | null { + try { + if (!payload || typeof payload !== 'object') return null; + const hasInlineImages = Array.isArray(payload.images) + && payload.images.some((item: any) => String(item || '').trim().length > 0); + const hasLocalImages = Array.isArray(payload.local_images) + && payload.local_images.some((item: any) => typeof item === 'string' && String(item).trim().length > 0); + const hasTextElements = Array.isArray(payload.text_elements) && payload.text_elements.length > 0; + if (hasInlineImages || hasLocalImages || hasTextElements) return null; + + const messageText = typeof payload.message === 'string' ? payload.message : ''; + const pathCandidates = extractImagePathCandidatesFromText(messageText); + if (pathCandidates.length === 0) return null; + + const contentArr: MessageContent[] = []; + appendTaggedTextContent(contentArr, messageText, { itemType: 'input_text', containerTag: 'message.input_text' }); + for (const localPath of pathCandidates) { + const imageItem = createHistoryImageContent({ + localPath, + tags: ['message.image', 'event_msg.user_message.image'], + }); + if (imageItem) contentArr.push(imageItem); + } + return contentArr.length > 0 ? contentArr : null; + } catch { + return null; + } + } + + /** + * 中文说明:将 Codex 历史中的图片块转换为统一图片内容项。 + */ + function createCodexImageContent(block: any, localPathHint?: string): MessageContent | null { + try { + if (!block || typeof block !== 'object') return null; + const type = String(block.type || '').toLowerCase(); + const imageUrl = typeof block.image_url === 'string' ? block.image_url : (typeof block.url === 'string' ? block.url : ''); + if (type !== 'input_image' && !/^data:image\//i.test(imageUrl)) return null; + const localPath = typeof block.local_path === 'string' + ? block.local_path + : (typeof block.file_path === 'string' + ? block.file_path + : (typeof block.path === 'string' ? block.path : localPathHint)); + return createHistoryImageContent({ + localPath, + dataUrl: imageUrl, + mimeType: typeof block.mime_type === 'string' ? block.mime_type : (typeof block.mimeType === 'string' ? block.mimeType : undefined), + }); + } catch { + return null; + } + } + + /** + * 中文说明:剥离 `function_call_output` 中已单独抽取的图片块,避免把大段 Base64 再写入文本。 + */ + function stripCodexImageBlocks(output: any): any { + try { + if (Array.isArray(output)) { + const rest = output.filter((item) => !createCodexImageContent(item)); + return rest.length > 0 ? rest : ''; + } + return output; + } catch { + return output; + } + } + + function parseLine(line: string, lineIndex: number) { if (!line.trim()) return lineIndex; try { const obj = JSON.parse(line); @@ -1423,21 +1647,39 @@ export async function readHistoryFile(filePath: string, opts?: { chunkSize?: num Object.prototype.hasOwnProperty.call(obj, 'id') && Object.prototype.hasOwnProperty.call(obj, 'timestamp') ); - if (obj.type === 'message' || obj.record_type === 'message' || (obj.type === 'response_item' && obj.payload && (obj.payload.type === 'message' || obj.payload.record_type === 'message'))) { + const pathOnlyUserMessageContent = obj.type === 'event_msg' && obj.payload && obj.payload.type === 'user_message' + ? buildPathOnlyCodexUserMessageEventContent(obj.payload) + : null; + + if (obj.type === 'message' || obj.record_type === 'message' || (obj.type === 'response_item' && obj.payload && (obj.payload.type === 'message' || obj.payload.record_type === 'message'))) { const src: any = (obj.type === 'response_item' && obj.payload) ? obj.payload : obj; const role = src.role || src.actor || src.from || 'user'; const contentArr: MessageContent[] = []; + const localImages = Array.isArray((src as any).local_images) + ? (src as any).local_images.filter((item: any) => typeof item === 'string' && String(item).trim().length > 0) + : []; + let localImageCursor = 0; // 仅“前缀”匹配提取 // 等,不做全文搜索(性能考虑) if (Array.isArray(src.content)) { for (const c of src.content) { if (!c) continue; + if (typeof c === 'object') { + const localPathHint = String(localImages[localImageCursor] || latestImagePathCandidate() || ''); + const imageItem = createCodexImageContent(c, localPathHint); + if (imageItem) { + if (localImages[localImageCursor]) localImageCursor += 1; + contentArr.push(imageItem); + continue; + } + } if (c.type && (c.text || c.code || c.payload)) { - const text = c.text ?? c.code ?? pretty(c.payload ?? ''); - const s = String(text ?? ''); - if (s.trim().length === 0) continue; - // 将 input_text/text 中的标签内容单独分离分类 - if (String(c.type).toLowerCase() === 'input_text' || String(c.type).toLowerCase() === 'text') { - const { rest, picked } = extractTaggedPrefix(s); + const text = c.text ?? c.code ?? pretty(c.payload ?? ''); + const s = String(text ?? ''); + if (s.trim().length === 0) continue; + rememberImagePathsFromText(s); + // 将 input_text/text 中的标签内容单独分离分类 + if (String(c.type).toLowerCase() === 'input_text' || String(c.type).toLowerCase() === 'text') { + const { rest, picked } = extractTaggedPrefix(s); if (picked.length > 0) { for (const it of picked) { if (String(it.type).toLowerCase() === 'instructions') { @@ -1456,12 +1698,13 @@ export async function readHistoryFile(filePath: string, opts?: { chunkSize?: num } else { contentArr.push({ type: String(c.type), text: s, tags: ['message.' + String(c.type).toLowerCase()] }); } - } else if (typeof c === 'string') { - const s = String(c); - if (s.trim().length === 0) continue; - const { rest, picked } = extractTaggedPrefix(s); - if (picked.length > 0) { - for (const it of picked) { + } else if (typeof c === 'string') { + const s = String(c); + if (s.trim().length === 0) continue; + rememberImagePathsFromText(s); + const { rest, picked } = extractTaggedPrefix(s); + if (picked.length > 0) { + for (const it of picked) { if (String(it.type).toLowerCase() === 'instructions') { const k = __normInstr(String(it.text || '')); if (k && __seenInstructions.has(k)) continue; @@ -1475,12 +1718,13 @@ export async function readHistoryFile(filePath: string, opts?: { chunkSize?: num } } } - } else if (typeof src.content === 'string') { - const s = String(src.content); - if (s.trim().length > 0) { - const { rest, picked } = extractTaggedPrefix(s); - if (picked.length > 0) { - for (const it of picked) { + } else if (typeof src.content === 'string') { + const s = String(src.content); + if (s.trim().length > 0) { + rememberImagePathsFromText(s); + const { rest, picked } = extractTaggedPrefix(s); + if (picked.length > 0) { + for (const it of picked) { if (String(it.type).toLowerCase() === 'instructions') { const k = __normInstr(String(it.text || '')); if (k && __seenInstructions.has(k)) continue; @@ -1500,12 +1744,13 @@ export async function readHistoryFile(filePath: string, opts?: { chunkSize?: num try { const hasInput = typeof (src as any).input_text === 'string' && String((src as any).input_text).length > 0; const hasOutput = !hasInput && typeof (src as any).output_text === 'string' && String((src as any).output_text).length > 0; - const it = String(hasInput ? (src as any).input_text : (hasOutput ? (src as any).output_text : '')); - const containerTag = hasInput ? 'message.input_text' : (hasOutput ? 'message.output_text' : 'message.text'); - if (it.trim().length > 0) { - const { rest, picked } = extractTaggedPrefix(it); - if (picked.length > 0) { - for (const it2 of picked) { + const it = String(hasInput ? (src as any).input_text : (hasOutput ? (src as any).output_text : '')); + const containerTag = hasInput ? 'message.input_text' : (hasOutput ? 'message.output_text' : 'message.text'); + if (it.trim().length > 0) { + rememberImagePathsFromText(it); + const { rest, picked } = extractTaggedPrefix(it); + if (picked.length > 0) { + for (const it2 of picked) { if (String(it2.type).toLowerCase() === 'instructions') { const k = __normInstr(String(it2.text || '')); if (k && __seenInstructions.has(k)) continue; @@ -1532,38 +1777,61 @@ export async function readHistoryFile(filePath: string, opts?: { chunkSize?: num } } catch {} if (contentArr.length > 0) messages.push({ role, content: contentArr }); + } else if (pathOnlyUserMessageContent) { + try { + rememberImagePathsFromText(typeof obj.payload?.message === 'string' ? obj.payload.message : ''); + } catch {} + messages.push({ role: 'user', content: pathOnlyUserMessageContent }); } else if (obj.type === 'function_call' || (obj.type === 'response_item' && obj.payload && obj.payload.type === 'function_call')) { // 工具/函数调用 const src: any = (obj.type === 'response_item' && obj.payload) ? obj.payload : obj; const name = src.name || src.tool || src.function || 'function'; - let argsPretty = ''; - try { - if (typeof src.arguments === 'string') { - try { argsPretty = JSON.stringify(JSON.parse(src.arguments), null, 2); } - catch { argsPretty = src.arguments; } - } else if (src.arguments) { - argsPretty = JSON.stringify(src.arguments, null, 2); - } - } catch {} - const text = `name: ${name}\n${argsPretty ? 'arguments:\n' + argsPretty : ''}${(src as any).call_id ? `\ncall_id: ${(src as any).call_id}` : ''}`.trim(); - messages.push({ role: 'tool', content: [{ type: 'function_call', text, tags: ['function_call'] }] }); - } else if (obj.type === 'function_call_output' || (obj.type === 'response_item' && obj.payload && obj.payload.type === 'function_call_output')) { - // 工具输出 - const src: any = (obj.type === 'response_item' && obj.payload) ? obj.payload : obj; - let out = ''; - try { - if (typeof src.output === 'string') { - // 某些日志将整个对象 JSON 作为字符串包裹 - try { out = JSON.stringify(JSON.parse(src.output), null, 2); } - catch { out = src.output; } - } else if (src.output) { - out = JSON.stringify(src.output, null, 2); - } - } catch {} - const meta = (src as any).metadata ? `\nmetadata:\n${pretty((src as any).metadata)}` : ''; - const text = `${out}${meta}`.trim(); - messages.push({ role: 'tool', content: [{ type: 'function_output', text, tags: ['function_output'] }] }); - } else if (obj.type === 'reasoning' || (obj.type === 'response_item' && obj.payload && obj.payload.type === 'reasoning')) { + let argsPretty = ''; + try { + if (typeof src.arguments === 'string') { + try { argsPretty = JSON.stringify(JSON.parse(src.arguments), null, 2); } + catch { argsPretty = src.arguments; } + } else if (src.arguments) { + argsPretty = JSON.stringify(src.arguments, null, 2); + } + } catch {} + const text = `name: ${name}\n${argsPretty ? 'arguments:\n' + argsPretty : ''}${(src as any).call_id ? `\ncall_id: ${(src as any).call_id}` : ''}`.trim(); + rememberImagePathsFromText(text); + const imagePathCandidates = extractImagePathCandidatesFromFunctionCallArguments((src as any).arguments, text); + rememberImagePathsForCallId((src as any).call_id, imagePathCandidates); + messages.push({ role: 'tool', content: [{ type: 'function_call', text, tags: ['function_call'] }] }); + } else if (obj.type === 'function_call_output' || (obj.type === 'response_item' && obj.payload && obj.payload.type === 'function_call_output')) { + // 工具输出 + const src: any = (obj.type === 'response_item' && obj.payload) ? obj.payload : obj; + const contentArr: MessageContent[] = []; + try { + const outputItems = Array.isArray(src.output) ? src.output : []; + const callId = String((src as any).call_id || ''); + for (let index = 0; index < outputItems.length; index += 1) { + const item = outputItems[index]; + const imageItem = createCodexImageContent(item, resolveImagePathHintForCallOutput(callId, index)); + if (imageItem) contentArr.push(imageItem); + } + } catch {} + let out = ''; + try { + const sanitizedOutput = stripCodexImageBlocks(src.output); + if (typeof sanitizedOutput === 'string') { + // 某些日志将整个对象 JSON 作为字符串包裹 + try { out = JSON.stringify(JSON.parse(sanitizedOutput), null, 2); } + catch { out = sanitizedOutput; } + } else if (sanitizedOutput) { + out = JSON.stringify(sanitizedOutput, null, 2); + } + } catch {} + const meta = (src as any).metadata ? `\nmetadata:\n${pretty((src as any).metadata)}` : ''; + const text = `${out}${meta}`.trim(); + if (text) { + rememberImagePathsFromText(text); + contentArr.unshift({ type: 'function_output', text, tags: ['function_output'] }); + } + if (contentArr.length > 0) messages.push({ role: 'tool', content: contentArr }); + } else if (obj.type === 'reasoning' || (obj.type === 'response_item' && obj.payload && obj.payload.type === 'reasoning')) { // 仅展示公开的 summary,忽略/标注加密内容 const src: any = (obj.type === 'response_item' && obj.payload) ? obj.payload : obj; const items: MessageContent[] = []; diff --git a/electron/images.ts b/electron/images.ts index f92dc9d..71cf055 100644 --- a/electron/images.ts +++ b/electron/images.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { URL } from "node:url"; import { nativeImage, clipboard, app } from "electron"; import wsl from "./wsl"; import { resolveGeminiImageDirWinPath, type GeminiRuntimeEnv } from "./gemini/projectTemp"; @@ -172,6 +173,87 @@ export function clipboardHasImage(): boolean { try { return !clipboard.readImage().isEmpty(); } catch { return false; } } +/** + * 中文说明:把图片来源字符串规范化为主进程可读取的本地文件路径。 + * - 兼容 `file:///`、Windows 盘符、UNC、`/mnt//...` 与 WSL 绝对路径; + * - 返回空串表示该来源不是本地文件路径或无法可靠映射。 + */ +function normalizeClipboardImageFilePath(value?: string): string { + try { + let raw = String(value || "").trim(); + if (!raw || /^data:image\//i.test(raw)) return ""; + + if (/^file:\/\//i.test(raw)) { + try { + const parsed = new URL(raw); + const decodedPath = decodeURIComponent(parsed.pathname || ""); + if (parsed.host) { + raw = `\\\\${parsed.host}${decodedPath.replace(/\//g, "\\")}`; + } else if (/^\/[A-Za-z]:\//.test(decodedPath)) { + raw = decodedPath.slice(1).replace(/\//g, "\\"); + } else { + raw = decodedPath; + } + } catch { + raw = raw.replace(/^file:\/\//i, ""); + } + } + + const mntMatch = raw.match(/^\/mnt\/([A-Za-z])\/(.*)$/); + if (mntMatch?.[1]) { + const drive = mntMatch[1].toUpperCase(); + const rest = String(mntMatch[2] || "").replace(/\//g, "\\"); + return `${drive}:\\${rest}`; + } + + if (raw.startsWith("/")) { + try { return wsl.wslToUNC(raw, resolveDefaultDistro()); } catch {} + return ""; + } + + return wsl.normalizeWinPath(raw); + } catch { + return ""; + } +} + +/** + * 中文说明:从多种图片来源中构造 Electron `nativeImage`,供剪贴板复制等原生能力复用。 + */ +function createNativeImageFromSources(args: { localPath?: string; src?: string; fallbackSrc?: string }): Electron.NativeImage | null { + const sources = [args.localPath, args.src, args.fallbackSrc]; + for (const source of sources) { + const raw = String(source || "").trim(); + if (!raw) continue; + try { + if (/^data:image\//i.test(raw)) { + const image = nativeImage.createFromDataURL(raw); + if (image && !image.isEmpty()) return image; + continue; + } + const filePath = normalizeClipboardImageFilePath(raw); + if (!filePath) continue; + const image = nativeImage.createFromPath(filePath); + if (image && !image.isEmpty()) return image; + } catch {} + } + return null; +} + +/** + * 中文说明:将指定图片写入系统剪贴板,兼容本地路径与 data URL。 + */ +export async function copyImageToClipboard(args: { localPath?: string; src?: string; fallbackSrc?: string }): Promise<{ ok: true } | { ok: false; error: string }> { + try { + const image = createNativeImageFromSources(args); + if (!image || image.isEmpty()) return { ok: false, error: "image source unavailable" }; + clipboard.writeImage(image); + return { ok: true }; + } catch (e: any) { + return { ok: false, error: String(e) }; + } +} + export async function readClipboardAsPNGAndSave(opts: SaveOpts = {}) { try { const img = clipboard.readImage(); @@ -188,5 +270,6 @@ export default { saveFromBuffer, saveFromDataURL, clipboardHasImage, + copyImageToClipboard, readClipboardAsPNGAndSave, }; diff --git a/electron/main.ts b/electron/main.ts index bd2a729..9a5366e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3848,11 +3848,27 @@ ipcMain.handle('images.saveFromClipboard', async (_e, { return res; } catch (e: any) { return { ok: false, error: String(e) } as any; - } -}); - + } +}); + +ipcMain.handle('images.copyToClipboard', async (_e, { + localPath, + src, + fallbackSrc, +}: { + localPath?: string; + src?: string; + fallbackSrc?: string; +}) => { + try { + return await images.copyImageToClipboard({ localPath, src, fallbackSrc }); + } catch (e: any) { + return { ok: false, error: String(e) } as any; + } +}); + ipcMain.handle('images.trash', async (_e, { winPath }: { winPath: string }) => { - try { + try { if (!winPath || typeof winPath !== 'string') throw new Error('invalid path'); try { await fsp.rm(winPath, { force: true }); diff --git a/electron/preload.ts b/electron/preload.ts index 52d50a8..3c51c2f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -619,10 +619,13 @@ contextBridge.exposeInMainWorld('host', { saveFromClipboard: async (args: { projectWinRoot?: string; projectWslRoot?: string; projectName?: string; prefix?: string; providerId?: string; runtimeEnv?: 'wsl' | 'windows' | 'pwsh'; distro?: string }) => { return await ipcRenderer.invoke('images.saveFromClipboard', args); }, - trash: async (args: { winPath: string }) => { - return await ipcRenderer.invoke('images.trash', args); - } - } + copyToClipboard: async (args: { localPath?: string; src?: string; fallbackSrc?: string }) => { + return await ipcRenderer.invoke('images.copyToClipboard', args); + }, + trash: async (args: { winPath: string }) => { + return await ipcRenderer.invoke('images.trash', args); + } + } }); // 确保预加载脚本以 CommonJS 导出,避免打包后在非模块环境使用 import 失败 diff --git a/web/src/App.tsx b/web/src/App.tsx index 9b00890..c7763c9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Combobox } from "@/components/ui/combobox"; import PathChipsInput, { type PathChip } from "@/components/ui/path-chips-input"; +import InteractiveImagePreview from "@/components/ui/interactive-image-preview"; import { retainPreviewUrl, releasePreviewUrl } from "@/lib/previewUrlRegistry"; import { retainPastedImage, releasePastedImage, requestTrashWinPath } from "@/lib/imageResourceRegistry"; import { setActiveFileIndexRoot } from "@/lib/atSearch"; @@ -64,13 +65,20 @@ import { isGeminiProvider, writeBracketedPaste, writeBracketedPasteAndEnter } fr import { oscBufferDefaults, trimOscBuffer } from "@/lib/oscNotificationBuffer"; import { resolveDirRowDropPosition } from "@/lib/dir-tree-dnd"; import HistoryCopyButton from "@/components/history/history-copy-button"; +import { useHistoryImageContextMenu } from "@/components/history/history-image-context-menu"; import HistoryPanelToggleButton from "@/components/history/history-panel-toggle-button"; import { resolveHistoryDetailSearchMode, shouldEnableHistoryDetailDomHighlights, shouldUseVirtualizedHistoryDetail, } from "@/features/history/detail-virtualization"; -import { HistoryMarkdown } from "@/features/history/renderers/history-markdown"; +import { + buildHistoryContentItemKey, + hasVisibleHistoryMessageContent, + HistoryTextWithInlineImages, + resolveHistoryInlineImageRenderState, + type HistoryInlineImageRenderState, +} from "@/features/history/history-inline-images"; import { VirtualizedList, type VirtualizedListHandle } from "@/features/history/virtualized-list"; import { applyHistoryFindHighlights, clearHistoryFindHighlights, setActiveHistoryFindMatch } from "@/features/history/find/history-find"; import { toWSLForInsert } from "@/lib/wsl"; @@ -11966,13 +11974,129 @@ const HISTORY_DETAIL_VIRTUAL_OVERSCAN = 1600; const HISTORY_DETAIL_SEARCH_DEBOUNCE_MS = 120; const HISTORY_DETAIL_PRE_CLASS_NAME = "mt-2 max-w-full overflow-x-auto whitespace-pre-wrap font-apple-regular"; -function ContentRenderer({ items, kprefix, projectWinPath }: { items: MessageContent[]; kprefix?: string; projectWinPath?: string }) { +/** + * 中文说明:渲染历史详情中的图片块。 + * - 主视图改为“路径 + 小图”紧凑排布,避免历史详情被大图撑开; + * - 悬停与点击交互统一复用输入框 chips 的图片预览行为。 + */ +function HistoryImageContent({ item, itemKey }: { item: MessageContent; itemKey: string }) { + const primarySrc = String(item?.src || "").trim(); + const fallbackSrc = String(item?.fallbackSrc || "").trim(); + const localPath = String(item?.localPath || "").trim(); + const metaLines = [ + localPath ? `路径: ${localPath}` : "", + item.mimeType ? `类型: ${item.mimeType}` : "", + ].filter((line) => String(line || "").trim().length > 0); + const fallbackText = String(item?.text || "").trim(); + const labelText = localPath || fallbackText.split(/\r?\n/)[0] || "图片"; + const { openContextMenu: openImageCtxMenu, contextMenuNode } = useHistoryImageContextMenu({ + src: primarySrc, + fallbackSrc, + localPath, + }); + const dialogMeta = metaLines.length > 0 || fallbackSrc + ? ( +
+ {metaLines.map((line, index) => ( +
{line}
+ ))} + {fallbackSrc ?
回退: 会话内图片数据
: null} +
+ ) + : undefined; + + return ( + <> +
+
+ image + +
+ + {({ hasPreview, hoverTriggerProps, openDialog, imageProps, isUsingFallback }) => ( +
+ {hasPreview ? ( + + ) : ( +
+ N/A +
+ )} +
+
+ {labelText} +
+ {metaLines.length > 0 ? ( +
+ {metaLines.map((line, index) => ( +
{line}
+ ))} +
+ ) : null} + {isUsingFallback ? ( +
当前显示: 会话内图片数据
+ ) : null} + {!hasPreview && fallbackText ? ( +
{fallbackText}
+ ) : null} +
+
+ )} +
+
+ {contextMenuNode} + + ); +} + +function ContentRenderer({ + items, + kprefix, + projectWinPath, + inlineImageState, +}: { + items: MessageContent[]; + kprefix?: string; + projectWinPath?: string; + inlineImageState?: HistoryInlineImageRenderState; +}) { if (!Array.isArray(items) || items.length === 0) return null; return (
{items.map((c, i) => { const ty = (c?.type || '').toLowerCase(); const text = String(c?.text ?? ''); + const contentItemKey = buildHistoryContentItemKey(kprefix || "itm", i); + if (ty === 'image') { + if (inlineImageState?.hiddenImageItemKeys?.has(contentItemKey)) return null; + return ; + } + if (inlineImageState?.hiddenTextItemKeys?.has(contentItemKey)) return null; if (ty === 'user_instructions') { // 展开显示 user_instructions(移除折叠) return ( @@ -12058,7 +12182,12 @@ function ContentRenderer({ items, kprefix, projectWinPath }: { items: MessageCon return (
- +
); } @@ -12083,7 +12212,12 @@ function ContentRenderer({ items, kprefix, projectWinPath }: { items: MessageCon input
- + ); } @@ -12094,7 +12228,12 @@ function ContentRenderer({ items, kprefix, projectWinPath }: { items: MessageCon output - + ); } @@ -12130,7 +12269,12 @@ function ContentRenderer({ items, kprefix, projectWinPath }: { items: MessageCon return (
- +
); })} @@ -12164,6 +12308,7 @@ type HistoryRenderOptions = { activeMessageKey?: string; registerMessageRef?: (key: string, node: HTMLDivElement | null) => void; projectWinPath?: string; + inlineImageState?: HistoryInlineImageRenderState; }; /** @@ -12214,6 +12359,7 @@ function renderHistoryHeader(session: HistorySession) { function HistoryMessageCard({ view, options }: { view: HistoryMessageView; options?: HistoryRenderOptions }) { const message = view.message; const messageKey = view.messageKey; + if (!hasVisibleHistoryMessageContent(message, messageKey, options?.inlineImageState)) return null; const isActive = options?.activeMessageKey === messageKey; return (
{message.role}
- +
); } @@ -12248,11 +12399,15 @@ function renderHistoryMessageList(messages: HistoryMessageView[], options?: Hist function estimateHistoryMessageHeight(view: HistoryMessageView): number { const items = Array.isArray(view?.message?.content) ? view.message.content : []; let textLength = Math.max(24, String(view?.message?.role || "").length); + let imageBonus = 0; for (const item of items) textLength += Math.min(12000, String((item as any)?.text || "").length); + for (const item of items) { + if (String((item as any)?.type || "").toLowerCase() === "image") imageBonus += 260; + } const lineEstimate = Math.min(56, Math.ceil(textLength / 110)); const itemBonus = Math.max(0, items.length - 1) * 28; - return Math.min(2200, Math.max(148, 96 + lineEstimate * 18 + itemBonus)); + return Math.min(2800, Math.max(148, 96 + lineEstimate * 18 + itemBonus + imageBonus)); } /** @@ -12467,7 +12622,26 @@ function HistoryDetail({ sessions, selectedHistoryId, projectWinPath, onBack, on ); }, [selectedHistoryId, detailSession, typeFilter, normalizedDetailSearch, detailSearchMode]); - const filteredMessages = filteredHistory.messages; + const inlineImageState = useMemo(() => resolveHistoryInlineImageRenderState( + filteredHistory.messages.map((view) => ({ + messageKey: view.messageKey, + message: view.message, + })), + ), [filteredHistory.messages]); + const visibleFilteredHistory = useMemo(() => { + const messages = filteredHistory.messages.filter((view) => hasVisibleHistoryMessageContent( + view.message, + view.messageKey, + inlineImageState, + )); + const visibleMessageKeys = new Set(messages.map((view) => view.messageKey)); + return { + messages, + matches: filteredHistory.matches.filter((match) => visibleMessageKeys.has(match.messageKey)), + }; + }, [filteredHistory.matches, filteredHistory.messages, inlineImageState]); + + const filteredMessages = visibleFilteredHistory.messages; const showNoMatch = detailSearchActive && filteredMessages.length === 0; const detailScrollAreaRef = useRef(null); const historyScrollToTopTitle = t("common:scrollToTopWithShortcut") as string; @@ -12488,8 +12662,8 @@ function HistoryDetail({ sessions, selectedHistoryId, projectWinPath, onBack, on const historyVirtualListRef = useRef(null); const matches = useMemo(() => { if (!detailSearchActive || !detailSession) return []; - return filteredHistory.matches; - }, [detailSearchActive, detailSession, filteredHistory.matches]); + return visibleFilteredHistory.matches; + }, [detailSearchActive, detailSession, visibleFilteredHistory.matches]); const messageIndexByKey = useMemo(() => { const out = new Map(); for (let index = 0; index < filteredMessages.length; index += 1) { @@ -12649,7 +12823,8 @@ function HistoryDetail({ sessions, selectedHistoryId, projectWinPath, onBack, on activeMessageKey: detailSearchActive ? activeMatch?.messageKey : undefined, registerMessageRef, projectWinPath, - }), [detailSearchActive, activeMatch?.messageKey, registerMessageRef, projectWinPath]); + inlineImageState, + }), [detailSearchActive, activeMatch?.messageKey, registerMessageRef, projectWinPath, inlineImageState]); /** * 中文说明:将历史详情滚动区滚动到顶部。 @@ -12781,7 +12956,7 @@ function HistoryDetail({ sessions, selectedHistoryId, projectWinPath, onBack, on filtered.sort(); // 默认仅勾选 input_text 与 output_text,以突出用户与助手的主要对话内容 const next: Record = {}; - for (const k of filtered) next[k] = (k === 'input_text' || k === 'output_text'); + for (const k of filtered) next[k] = (k === 'input_text' || k === 'output_text' || k === 'image'); if (seq === reqSeq.current) setTypeFilter(next); } catch {} } catch (e) { diff --git a/web/src/app/app-shared.tsx b/web/src/app/app-shared.tsx index 2bdb2f7..3188a47 100644 --- a/web/src/app/app-shared.tsx +++ b/web/src/app/app-shared.tsx @@ -406,7 +406,15 @@ function getProviderIconSrc(providerId: string, providerItemById: Record ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +/** + * 中文说明:启用 React 18 的 act 环境标记,避免测试输出告警。 + */ +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +/** + * 中文说明:渲染一个最小缩略图触发器,用于验证右键菜单与复制图片链路。 + */ +function HistoryImageContextMenuHarness(props: { payload: HistoryImageContextMenuPayload }): React.ReactElement { + const { openContextMenu, contextMenuNode } = useHistoryImageContextMenu(props.payload); + return ( + <> + + {contextMenuNode} + + ); +} + +/** + * 中文说明:卸载并清理 React Root,避免不同用例之间相互污染。 + */ +function safeUnmountRoot(root: Root, host: HTMLElement): void { + try { + act(() => { + try { root.unmount(); } catch {} + }); + } catch { + try { root.unmount(); } catch {} + } + try { host.remove(); } catch {} +} + +/** + * 中文说明:创建并挂载一个独立的 React Root。 + */ +function createMountedRoot(): { host: HTMLDivElement; root: Root; unmount: () => void } { + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + return { + host, + root, + unmount: () => { + safeUnmountRoot(root, host); + }, + }; +} + +/** + * 中文说明:查找菜单里的目标按钮,便于断言右键菜单是否已正确渲染。 + */ +function findButtonByText(text: string): HTMLButtonElement | null { + const buttons = Array.from(document.querySelectorAll("button")) as HTMLButtonElement[]; + return buttons.find((button) => (button.textContent || "").includes(text)) || null; +} + +/** + * 中文说明:派发一次右键事件,模拟用户在缩略图上打开上下文菜单。 + */ +async function dispatchContextMenu(target: HTMLElement, init?: MouseEventInit): Promise { + await act(async () => { + target.dispatchEvent(new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + button: 2, + clientX: 160, + clientY: 180, + ...init, + })); + }); +} + +/** + * 中文说明:派发一次点击事件,用于触发菜单项动作。 + */ +async function dispatchClick(target: HTMLElement): Promise { + await act(async () => { + target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + delete (window as any).host; + document.body.innerHTML = ""; +}); + +describe("useHistoryImageContextMenu", () => { + it("右键缩略图后应显示复制图片菜单并调用宿主剪贴板接口", async () => { + const copyToClipboard = vi.fn().mockResolvedValue({ ok: true }); + (window as any).host = { + images: { + copyToClipboard, + }, + utils: { + copyText: vi.fn().mockResolvedValue({ ok: true }), + }, + }; + + const mounted = createMountedRoot(); + try { + await act(async () => { + mounted.root.render( + , + ); + }); + + const thumbImage = document.querySelector("[data-testid=\"history-image-thumb-img\"]") as HTMLImageElement | null; + if (!thumbImage) throw new Error("missing history image thumb"); + + await dispatchContextMenu(thumbImage); + + const copyImageButton = findButtonByText("history:copyImage"); + expect(copyImageButton).toBeTruthy(); + + if (!copyImageButton) throw new Error("missing copy image button"); + await dispatchClick(copyImageButton); + + expect(copyToClipboard).toHaveBeenCalledTimes(1); + expect(copyToClipboard).toHaveBeenCalledWith({ + localPath: "C:\\repo\\image.png", + src: "file:///C:/repo/image.png", + fallbackSrc: "data:image/png;base64,AAAA", + }); + expect(findButtonByText("history:copyImage")).toBeNull(); + } finally { + mounted.unmount(); + } + }); +}); diff --git a/web/src/components/history/history-image-context-menu.tsx b/web/src/components/history/history-image-context-menu.tsx new file mode 100644 index 0000000..cb8b9c2 --- /dev/null +++ b/web/src/components/history/history-image-context-menu.tsx @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Copy as CopyIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +export type HistoryImageContextMenuPayload = { + src?: string; + fallbackSrc?: string; + localPath?: string; +}; + +type HistoryImageContextMenuState = { + show: boolean; + x: number; + y: number; +}; + +type HistoryImageContextMenuResult = { + openContextMenu: (event: React.MouseEvent) => void; + contextMenuNode: React.ReactNode; +}; + +const HISTORY_IMAGE_CONTEXT_MENU_MARGIN = 8; +const HISTORY_IMAGE_CONTEXT_MENU_WIDTH = 188; +const HISTORY_IMAGE_CONTEXT_MENU_HEIGHT = 96; + +/** + * 中文说明:将历史图片右键菜单位置限制在视口内,避免菜单贴边被裁切。 + */ +function clampHistoryImageContextMenuPosition(event: React.MouseEvent): { x: number; y: number } { + const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0; + const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0; + const nextX = viewportWidth > 0 + ? Math.min( + Math.max(event.clientX, HISTORY_IMAGE_CONTEXT_MENU_MARGIN), + Math.max(HISTORY_IMAGE_CONTEXT_MENU_MARGIN, viewportWidth - HISTORY_IMAGE_CONTEXT_MENU_WIDTH), + ) + : event.clientX; + const nextY = viewportHeight > 0 + ? Math.min( + Math.max(event.clientY, HISTORY_IMAGE_CONTEXT_MENU_MARGIN), + Math.max(HISTORY_IMAGE_CONTEXT_MENU_MARGIN, viewportHeight - HISTORY_IMAGE_CONTEXT_MENU_HEIGHT), + ) + : event.clientY; + return { x: nextX, y: nextY }; +} + +/** + * 中文说明:复制历史图片到系统剪贴板,优先复用主进程暴露的原生图片能力。 + */ +async function copyHistoryImageToClipboard(args: HistoryImageContextMenuPayload): Promise { + const hostImages = window.host?.images; + if (!hostImages?.copyToClipboard) return; + await hostImages.copyToClipboard({ + localPath: String(args.localPath || "").trim(), + src: String(args.src || "").trim(), + fallbackSrc: String(args.fallbackSrc || "").trim(), + }); +} + +/** + * 中文说明:复制历史图片的本地路径文本,便于继续排查或复用原文件。 + */ +async function copyHistoryImagePathToClipboard(localPath?: string): Promise { + const normalizedPath = String(localPath || "").trim(); + if (!normalizedPath) return; + + try { + const copyText = window.host?.utils?.copyText; + if (copyText) { + await copyText(normalizedPath); + return; + } + } catch {} + + try { + await navigator.clipboard.writeText(normalizedPath); + } catch {} +} + +/** + * 中文说明:为历史详情中的图片缩略图提供统一右键菜单能力,复用在独立图片块与行内缩略图。 + */ +export function useHistoryImageContextMenu(payload: HistoryImageContextMenuPayload): HistoryImageContextMenuResult { + const { t } = useTranslation(["history"]); + const primarySrc = String(payload.src || "").trim(); + const fallbackSrc = String(payload.fallbackSrc || "").trim(); + const localPath = String(payload.localPath || "").trim(); + const [menuState, setMenuState] = useState({ show: false, x: 0, y: 0 }); + const menuRef = useRef(null); + + /** + * 中文说明:关闭历史图片右键菜单,避免滚动、外部点击后残留浮层。 + */ + const closeContextMenu = useCallback(() => { + setMenuState((previous) => (previous.show ? { ...previous, show: false } : previous)); + }, []); + + /** + * 中文说明:按鼠标位置打开历史图片右键菜单,并限制菜单不会溢出视口。 + */ + const openContextMenu = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const nextPosition = clampHistoryImageContextMenuPosition(event); + setMenuState({ show: true, ...nextPosition }); + }, []); + + /** + * 中文说明:执行“复制图片”菜单动作,统一复用主进程剪贴板桥接。 + */ + const handleCopyImage = useCallback(async () => { + try { + await copyHistoryImageToClipboard({ localPath, src: primarySrc, fallbackSrc }); + } catch {} + closeContextMenu(); + }, [closeContextMenu, fallbackSrc, localPath, primarySrc]); + + /** + * 中文说明:执行“复制路径”菜单动作,仅在存在本地路径时启用。 + */ + const handleCopyPath = useCallback(async () => { + await copyHistoryImagePathToClipboard(localPath); + closeContextMenu(); + }, [closeContextMenu, localPath]); + + useEffect(() => { + if (!menuState.show) return; + + /** + * 中文说明:点击菜单外部区域时关闭右键菜单,避免悬浮菜单滞留。 + */ + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (menuRef.current && target && menuRef.current.contains(target)) return; + closeContextMenu(); + }; + + /** + * 中文说明:按下 Escape 时关闭右键菜单,补齐键盘退出路径。 + */ + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") closeContextMenu(); + }; + + window.addEventListener("mousedown", handlePointerDown, true); + window.addEventListener("scroll", closeContextMenu, true); + window.addEventListener("resize", closeContextMenu); + window.addEventListener("keydown", handleEscape); + return () => { + window.removeEventListener("mousedown", handlePointerDown, true); + window.removeEventListener("scroll", closeContextMenu, true); + window.removeEventListener("resize", closeContextMenu); + window.removeEventListener("keydown", handleEscape); + }; + }, [closeContextMenu, menuState.show]); + + const contextMenuNode = menuState.show && typeof document !== "undefined" + ? createPortal( +
event.preventDefault()} + > +
+ + +
+
, + document.body, + ) + : null; + + return { + openContextMenu, + contextMenuNode, + }; +} diff --git a/web/src/components/ui/interactive-image-preview.tsx b/web/src/components/ui/interactive-image-preview.tsx new file mode 100644 index 0000000..1c77840 --- /dev/null +++ b/web/src/components/ui/interactive-image-preview.tsx @@ -0,0 +1,527 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +type InteractiveImagePreviewNaturalSize = { + width: number; + height: number; +}; + +type InteractiveImagePreviewPanOffset = { + x: number; + y: number; +}; + +type InteractiveImagePreviewDragState = { + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; +}; + +const INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM = 1; +const INTERACTIVE_IMAGE_PREVIEW_MAX_ZOOM = 6; +const INTERACTIVE_IMAGE_PREVIEW_WHEEL_ZOOM_SPEED = 0.0016; + +/** + * 中文说明:将缩放或位移数值限制在指定区间内,避免拖拽/滚轮缩放越界。 + */ +function clampInteractiveImagePreviewValue(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + if (min > max) return value; + return Math.min(Math.max(value, min), max); +} + +type InteractiveImagePreviewRenderArgs = { + hasPreview: boolean; + resolvedSrc: string; + isUsingFallback: boolean; + hoverTriggerProps: { + onMouseEnter: (event: React.MouseEvent) => void; + onMouseLeave: () => void; + }; + openDialog: () => void; + imageProps: React.ImgHTMLAttributes; +}; + +type InteractiveImagePreviewProps = { + src?: string; + fallbackSrc?: string; + alt: string; + dialogTitle: string; + dialogDescription?: string; + dialogMeta?: React.ReactNode; + hoverImageClassName?: string; + dialogImageClassName?: string; + children: (args: InteractiveImagePreviewRenderArgs) => React.ReactNode; +}; + +/** + * 中文说明:为任意图片缩略图提供统一的悬停预览与点击弹窗能力。 + * - 悬停:展示和 chips 一致的浮层预览; + * - 点击:打开适合查看大图的 Dialog; + * - 失效:优先尝试回退到后端提供的 `fallbackSrc`。 + */ +export default function InteractiveImagePreview({ + src, + fallbackSrc, + alt, + dialogTitle, + dialogDescription, + dialogMeta, + hoverImageClassName, + dialogImageClassName, + children, +}: InteractiveImagePreviewProps) { + const primarySrc = String(src || "").trim(); + const stableFallbackSrc = String(fallbackSrc || "").trim(); + const [resolvedSrc, setResolvedSrc] = useState(primarySrc || stableFallbackSrc); + const [hoverRect, setHoverRect] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [naturalSize, setNaturalSize] = useState(null); + const [dialogZoom, setDialogZoom] = useState(INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM); + const [dialogPan, setDialogPan] = useState({ x: 0, y: 0 }); + const [dialogDragging, setDialogDragging] = useState(false); + const hoverAnchorRef = useRef(null); + const dialogViewportRef = useRef(null); + const dialogImageRef = useRef(null); + const dialogZoomRef = useRef(INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM); + const dialogPanRef = useRef({ x: 0, y: 0 }); + const dialogDragStateRef = useRef(null); + + useEffect(() => { + setResolvedSrc(primarySrc || stableFallbackSrc); + }, [primarySrc, stableFallbackSrc]); + + useEffect(() => { + setNaturalSize(null); + }, [resolvedSrc]); + + useEffect(() => { + dialogZoomRef.current = dialogZoom; + }, [dialogZoom]); + + useEffect(() => { + dialogPanRef.current = dialogPan; + }, [dialogPan]); + + /** + * 中文说明:当主图失效时自动切到回退图;若已无可用回退,则标记为损坏。 + */ + const handleImageError = useCallback((event: React.SyntheticEvent) => { + if (stableFallbackSrc && resolvedSrc !== stableFallbackSrc) { + setResolvedSrc(stableFallbackSrc); + return; + } + try { + event.currentTarget.dataset.cfPreviewBroken = "1"; + } catch {} + }, [resolvedSrc, stableFallbackSrc]); + + /** + * 中文说明:记录触发元素位置,用于在视口中计算悬停预览浮层位置。 + */ + const handleMouseEnter = useCallback((event: React.MouseEvent) => { + if (!resolvedSrc) return; + hoverAnchorRef.current = event.currentTarget; + setHoverRect(event.currentTarget.getBoundingClientRect()); + }, [resolvedSrc]); + + /** + * 中文说明:关闭悬停预览浮层。 + */ + const handleMouseLeave = useCallback(() => { + hoverAnchorRef.current = null; + setHoverRect(null); + }, []); + + /** + * 中文说明:打开大图查看弹窗。 + */ + const openDialog = useCallback(() => { + if (!resolvedSrc) return; + hoverAnchorRef.current = null; + setHoverRect(null); + setDialogOpen(true); + }, [resolvedSrc]); + + /** + * 中文说明:重置大图弹窗的缩放与位移状态,确保每次打开都从“适配视口”的初始状态开始。 + */ + const resetDialogTransform = useCallback(() => { + dialogDragStateRef.current = null; + dialogZoomRef.current = INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM; + dialogPanRef.current = { x: 0, y: 0 }; + setDialogDragging(false); + setDialogZoom(INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM); + setDialogPan({ x: 0, y: 0 }); + }, []); + + useEffect(() => { + if (!dialogOpen) { + resetDialogTransform(); + return; + } + resetDialogTransform(); + }, [dialogOpen, resolvedSrc, resetDialogTransform]); + + /** + * 中文说明:记录图片原始分辨率,供大图弹窗优先按自身尺寸展示。 + */ + const handleImageLoad = useCallback((event: React.SyntheticEvent) => { + const nextWidth = Number(event.currentTarget.naturalWidth || 0); + const nextHeight = Number(event.currentTarget.naturalHeight || 0); + if (nextWidth <= 0 || nextHeight <= 0) return; + setNaturalSize((previous) => { + if (previous?.width === nextWidth && previous.height === nextHeight) return previous; + return { width: nextWidth, height: nextHeight }; + }); + }, []); + + /** + * 中文说明:在滚动或窗口尺寸变化后刷新浮层锚点位置,避免共享预览组件引入交互回退。 + */ + useEffect(() => { + if (!hoverRect) return; + + const refreshHoverRect = () => { + const anchor = hoverAnchorRef.current; + if (!anchor || !anchor.isConnected) { + hoverAnchorRef.current = null; + setHoverRect(null); + return; + } + setHoverRect((previous) => { + const nextRect = anchor.getBoundingClientRect(); + if ( + previous && + previous.left === nextRect.left && + previous.top === nextRect.top && + previous.width === nextRect.width && + previous.height === nextRect.height + ) { + return previous; + } + return nextRect; + }); + }; + + refreshHoverRect(); + window.addEventListener("scroll", refreshHoverRect, true); + window.addEventListener("resize", refreshHoverRect); + return () => { + window.removeEventListener("scroll", refreshHoverRect, true); + window.removeEventListener("resize", refreshHoverRect); + }; + }, [hoverRect]); + + /** + * 中文说明:按当前视口和基础图片尺寸约束拖拽位移,避免大图被拖出可视范围太远。 + */ + const clampDialogPanOffset = useCallback((nextPan: InteractiveImagePreviewPanOffset, zoomValue: number): InteractiveImagePreviewPanOffset => { + if (zoomValue <= INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM + 0.001) return { x: 0, y: 0 }; + + const viewport = dialogViewportRef.current; + const image = dialogImageRef.current; + if (!viewport || !image) return nextPan; + + const viewportWidth = Math.max(0, viewport.clientWidth); + const viewportHeight = Math.max(0, viewport.clientHeight); + const imageWidth = Math.max(0, image.clientWidth); + const imageHeight = Math.max(0, image.clientHeight); + const maxX = Math.max(0, ((imageWidth * zoomValue) - viewportWidth) / 2); + const maxY = Math.max(0, ((imageHeight * zoomValue) - viewportHeight) / 2); + + return { + x: clampInteractiveImagePreviewValue(nextPan.x, -maxX, maxX), + y: clampInteractiveImagePreviewValue(nextPan.y, -maxY, maxY), + }; + }, []); + + /** + * 中文说明:统一提交大图查看器的缩放与位移状态,确保 state 与 ref 始终一致。 + */ + const commitDialogTransform = useCallback(( + nextZoom: number, + nextPan: InteractiveImagePreviewPanOffset, + ) => { + const clampedZoom = clampInteractiveImagePreviewValue( + nextZoom, + INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM, + INTERACTIVE_IMAGE_PREVIEW_MAX_ZOOM, + ); + const clampedPan = clampDialogPanOffset(nextPan, clampedZoom); + dialogZoomRef.current = clampedZoom; + dialogPanRef.current = clampedPan; + setDialogZoom(clampedZoom); + setDialogPan(clampedPan); + }, [clampDialogPanOffset]); + + /** + * 中文说明:结束当前拖拽手势,恢复查看器的非拖拽状态。 + */ + const stopDialogDragging = useCallback(() => { + dialogDragStateRef.current = null; + setDialogDragging(false); + }, []); + + /** + * 中文说明:处理滚轮缩放,优先保持鼠标所在区域尽量稳定,提升查看大图时的定位感。 + */ + const handleDialogWheel = useCallback((event: React.WheelEvent) => { + if (!dialogOpen || !resolvedSrc) return; + event.preventDefault(); + + const viewport = dialogViewportRef.current; + const currentZoom = dialogZoomRef.current; + const delta = -event.deltaY * INTERACTIVE_IMAGE_PREVIEW_WHEEL_ZOOM_SPEED; + const nextZoom = clampInteractiveImagePreviewValue( + currentZoom * (1 + delta), + INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM, + INTERACTIVE_IMAGE_PREVIEW_MAX_ZOOM, + ); + if (Math.abs(nextZoom - currentZoom) < 0.001) return; + + if (!viewport) { + commitDialogTransform(nextZoom, dialogPanRef.current); + return; + } + + const rect = viewport.getBoundingClientRect(); + const cursorX = event.clientX - rect.left - (rect.width / 2); + const cursorY = event.clientY - rect.top - (rect.height / 2); + const currentPan = dialogPanRef.current; + const nextPan = nextZoom <= INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM + 0.001 + ? { x: 0, y: 0 } + : { + x: cursorX - (((cursorX - currentPan.x) / currentZoom) * nextZoom), + y: cursorY - (((cursorY - currentPan.y) / currentZoom) * nextZoom), + }; + + commitDialogTransform(nextZoom, nextPan); + }, [commitDialogTransform, dialogOpen, resolvedSrc]); + + /** + * 中文说明:在放大状态下开始拖拽平移图片。 + */ + const handleDialogPointerDown = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return; + if (dialogZoomRef.current <= INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM + 0.001) return; + + dialogDragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: dialogPanRef.current.x, + originY: dialogPanRef.current.y, + }; + setDialogDragging(true); + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch {} + }, []); + + /** + * 中文说明:根据拖拽位移实时平移放大后的图片。 + */ + const handleDialogPointerMove = useCallback((event: React.PointerEvent) => { + const dragState = dialogDragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + + const nextPan = clampDialogPanOffset({ + x: dragState.originX + (event.clientX - dragState.startX), + y: dragState.originY + (event.clientY - dragState.startY), + }, dialogZoomRef.current); + dialogPanRef.current = nextPan; + setDialogPan(nextPan); + }, [clampDialogPanOffset]); + + /** + * 中文说明:在拖拽结束或指针捕获丢失后清理拖拽状态。 + */ + const handleDialogPointerUp = useCallback((event: React.PointerEvent) => { + const dragState = dialogDragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch {} + stopDialogDragging(); + }, [stopDialogDragging]); + + /** + * 中文说明:响应指针取消或捕获丢失,避免查看器卡在“拖拽中”状态。 + */ + const handleDialogPointerCancel = useCallback(() => { + stopDialogDragging(); + }, [stopDialogDragging]); + + useEffect(() => { + if (!dialogOpen) return; + if (dialogZoomRef.current <= INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM + 0.001) return; + const clampedPan = clampDialogPanOffset(dialogPanRef.current, dialogZoomRef.current); + if (clampedPan.x === dialogPanRef.current.x && clampedPan.y === dialogPanRef.current.y) return; + dialogPanRef.current = clampedPan; + setDialogPan(clampedPan); + }, [clampDialogPanOffset, dialogOpen, dialogZoom, naturalSize]); + + useEffect(() => { + if (!dialogOpen) return; + const handleResize = () => { + const clampedPan = clampDialogPanOffset(dialogPanRef.current, dialogZoomRef.current); + if (clampedPan.x === dialogPanRef.current.x && clampedPan.y === dialogPanRef.current.y) return; + dialogPanRef.current = clampedPan; + setDialogPan(clampedPan); + }; + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [clampDialogPanOffset, dialogOpen]); + + const hasPreview = !!resolvedSrc; + const isUsingFallback = !!stableFallbackSrc && resolvedSrc === stableFallbackSrc && resolvedSrc !== primarySrc; + const hoverTriggerProps = { + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + }; + const compactMetaBadges = [ + naturalSize ? `${naturalSize.width} × ${naturalSize.height}` : "", + dialogDescription ? dialogDescription : "", + isUsingFallback ? "会话内图片数据" : "", + ].filter((item) => String(item || "").trim().length > 0); + const imageProps: React.ImgHTMLAttributes = { + src: resolvedSrc, + alt, + loading: "lazy", + decoding: "async", + onLoad: handleImageLoad, + onError: handleImageError, + }; + + return ( + <> + {children({ + hasPreview, + resolvedSrc, + isUsingFallback, + hoverTriggerProps, + openDialog, + imageProps, + })} + {hoverRect && resolvedSrc && typeof document !== "undefined" + ? createPortal( + (() => { + const centerX = hoverRect.left + hoverRect.width / 2; + const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0; + const anchorCenterY = hoverRect.top + hoverRect.height / 2; + const preferBelow = !viewportHeight || anchorCenterY < viewportHeight * 0.5; + const baseTop = preferBelow ? hoverRect.bottom + 8 : hoverRect.top - 8; + const clampedTop = viewportHeight + ? Math.min(Math.max(baseTop, 24), viewportHeight - 24) + : baseTop; + return ( +
+
+ {alt} +
+
+ ); + })(), + document.body, + ) + : null} + + + + {dialogTitle} + {dialogDescription ? {dialogDescription} : null} + +
+
INTERACTIVE_IMAGE_PREVIEW_MIN_ZOOM + 0.001 + ? (dialogDragging ? "cursor-grabbing" : "cursor-grab") + : "cursor-zoom-in", + )} + onWheel={handleDialogWheel} + onPointerDown={handleDialogPointerDown} + onPointerMove={handleDialogPointerMove} + onPointerUp={handleDialogPointerUp} + onPointerCancel={handleDialogPointerCancel} + onLostPointerCapture={handleDialogPointerCancel} + > +
+
+ {resolvedSrc ? ( + {alt} + ) : null} +
+
+
+ {(dialogTitle || compactMetaBadges.length > 0 || dialogMeta) ? ( +
+
+
+ {dialogTitle ? ( +
+ {dialogTitle} +
+ ) : null} + {compactMetaBadges.map((item) => ( + + {item} + + ))} +
+ {dialogMeta ? ( +
+ {dialogMeta} +
+ ) : null} +
+
+ ) : null} +
+
+
+ + ); +} diff --git a/web/src/components/ui/path-chips-input.test.tsx b/web/src/components/ui/path-chips-input.test.tsx index 226b383..c60f720 100644 --- a/web/src/components/ui/path-chips-input.test.tsx +++ b/web/src/components/ui/path-chips-input.test.tsx @@ -24,6 +24,44 @@ vi.mock("@/components/ui/dialog", () => ({ DialogTitle: ({ children }: { children?: React.ReactNode }) =>
{children}
, })); +vi.mock("@/components/ui/interactive-image-preview", () => ({ + __esModule: true, + default: ({ src, fallbackSrc, alt, dialogTitle, dialogDescription, dialogMeta, children }: any) => { + const primarySrc = String(src || ""); + const stableFallbackSrc = String(fallbackSrc || ""); + const [resolvedSrc, setResolvedSrc] = React.useState(primarySrc || stableFallbackSrc); + React.useEffect(() => { + setResolvedSrc(primarySrc || stableFallbackSrc); + }, [primarySrc, stableFallbackSrc]); + return ( +
+
{dialogMeta}
+ {children({ + hasPreview: !!resolvedSrc, + resolvedSrc, + isUsingFallback: !!stableFallbackSrc && resolvedSrc === stableFallbackSrc && resolvedSrc !== primarySrc, + hoverTriggerProps: { + onMouseEnter: () => {}, + onMouseLeave: () => {}, + }, + openDialog: () => {}, + imageProps: { + src: resolvedSrc, + alt: String(alt || ""), + onError: () => { + if (stableFallbackSrc && resolvedSrc !== stableFallbackSrc) setResolvedSrc(stableFallbackSrc); + }, + }, + })} +
+ ); + }, +})); + /** * 中文说明:启用 React 18 的 act 环境标记,避免测试输出告警。 */ @@ -81,7 +119,7 @@ function createMountedRoot(): { host: HTMLDivElement; root: Root; unmount: () => /** * 中文说明:渲染最小化的 `PathChipsInput` 场景,只保留复制文件名验证所需的受控属性。 */ -async function renderPathChipsInput(chips: PathChip[]): Promise<() => void> { +async function renderPathChipsInput(chips: PathChip[], props?: Partial>): Promise<() => void> { const mounted = createMountedRoot(); await act(async () => { mounted.root.render( @@ -90,6 +128,7 @@ async function renderPathChipsInput(chips: PathChip[]): Promise<() => void> { onChipsChange={() => {}} draft="" onDraftChange={() => {}} + {...props} /> ); }); @@ -266,6 +305,28 @@ describe("PathChipsInput(复制文件名按钮)", () => { expect(document.querySelector("img")).toBeNull(); }); + + it("图片 Chip 弹窗元信息只显示一次 Windows 路径", async () => { + cleanup = await renderPathChipsInput([ + createPathChip({ + id: "image-chip", + chipKind: "image", + fileName: "image.png", + previewUrl: "blob:test-image", + type: "image/png", + winPath: "C:\\repo\\image.png", + wslPath: "/mnt/c/repo/image.png", + }), + ], { + runEnv: "wsl", + }); + + const previewHost = document.querySelector('[data-testid="interactive-image-preview"]') as HTMLElement | null; + const previewMeta = document.querySelector('[data-testid="interactive-image-preview-meta"]') as HTMLElement | null; + expect(previewHost?.dataset.dialogDescription || "").toBe(""); + expect(previewMeta?.textContent || "").toContain("C:\\repo\\image.png"); + expect(previewMeta?.textContent || "").not.toContain("/mnt/c/repo/image.png"); + }); }); describe("PathChipsInput 撤回历史", () => { diff --git a/web/src/components/ui/path-chips-input.tsx b/web/src/components/ui/path-chips-input.tsx index 53441f3..a28c7bc 100644 --- a/web/src/components/ui/path-chips-input.tsx +++ b/web/src/components/ui/path-chips-input.tsx @@ -14,6 +14,7 @@ import { toWSLForInsert, joinWinAbs, toWslRelOrAbsForProject, isWinPathUnderRoot import { extractWinPathsFromDataTransfer, probeWinPathKind, preferExistingWinPathCandidate, type WinPathProbeResult } from "@/lib/dragDrop"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import InteractiveImagePreview from "@/components/ui/interactive-image-preview"; import { extractImagesFromPasteEvent, isImageFileName, @@ -528,51 +529,6 @@ export default function PathChipsInput({ return () => cancelAnimationFrame(raf); }, [ctxMenu.show, ctxMenu.x, ctxMenu.y]); - const [hoverPreview, setHoverPreview] = useState<{ chip: PathChip; rect: DOMRect; key: string } | null>(null); - const previewAnchorRef = useRef(null); - const hidePreview = useCallback(() => { - previewAnchorRef.current = null; - setHoverPreview(null); - }, []); - const showPreview = useCallback((chip: PathChip, key: string, target: HTMLElement | null) => { - if (!target) return; - previewAnchorRef.current = target; - setHoverPreview({ chip, rect: target.getBoundingClientRect(), key }); - }, []); - useEffect(() => { - if (!hoverPreview) return; - const refresh = () => { - const anchor = previewAnchorRef.current; - if (!anchor || !anchor.isConnected) { - hidePreview(); - return; - } - setHoverPreview((prev) => { - if (!prev) return prev; - const rect = anchor.getBoundingClientRect(); - const same = - prev.rect.left === rect.left && - prev.rect.top === rect.top && - prev.rect.width === rect.width && - prev.rect.height === rect.height; - if (same) return prev; - return { ...prev, rect }; - }); - }; - refresh(); - window.addEventListener('scroll', refresh, true); - window.addEventListener('resize', refresh); - return () => { - window.removeEventListener('scroll', refresh, true); - window.removeEventListener('resize', refresh); - }; - }, [hoverPreview, hidePreview]); - useEffect(() => { - if (!hoverPreview) return; - const exists = chips.some((chip, idx) => buildChipStableKey(chip, String(idx)) === hoverPreview.key); - if (!exists) hidePreview(); - }, [chips, hoverPreview, hidePreview]); - /** * 中文说明:读取当前受控值的最新镜像,避免异步流程拿到过期的 draft / chips。 */ @@ -994,25 +950,6 @@ export default function PathChipsInput({ } catch { return ""; } }, [projectWslRoot, winRoot]); - const handleChipMouseEnter = useCallback((chip: PathChip, key: string, target: HTMLElement) => { - if (!resolveChipPreviewSrc(chip)) return; - showPreview(chip, key, target); - }, [showPreview]); - - /** - * 中文说明:当图片 Chip 的 `blob:` 预览失效时,自动回退到磁盘文件路径预览。 - */ - const handleChipImageError = useCallback((event: React.SyntheticEvent, chip: PathChip) => { - const target = event.currentTarget; - const fallbackUrl = resolveChipImageFallbackUrl(chip); - if (fallbackUrl && target.dataset.cfPreviewFallback !== "1") { - target.dataset.cfPreviewFallback = "1"; - target.src = fallbackUrl; - return; - } - target.dataset.cfPreviewBroken = "1"; - }, []); - // 判定 Chip 是否目录:优先使用 isDir 标记;若无则根据路径尾部斜杠推断 const isChipDir = useCallback((chip?: any): boolean => { try { @@ -1333,45 +1270,69 @@ export default function PathChipsInput({ const chipKey = buildChipStableKey(chip, `${idx}`); const chipAny = chip as PathChip; const isRule = chipAny.chipKind === "rule"; - const tooltip = isRule + const preferredPathText = isRule ? chipAny.rulePath || chipAny.winPath || chipAny.wslPath || "" - : (runEnv === 'wsl' - ? String((chipAny as any)?.wslPath || (chipAny as any)?.winPath || "") - : (resolveChipWindowsFullPath(chipAny) || String((chipAny as any)?.winPath || (chipAny as any)?.wslPath || ""))); + : (resolveChipWindowsFullPath(chipAny) || String((chipAny as any)?.winPath || (chipAny as any)?.wslPath || "")); + const tooltip = preferredPathText; const ruleLabel = chipAny.rulePath?.split(/[/\\]/).pop() || chipAny.rulePath || chipAny.fileName || t('common:files.rule'); const labelText = isRule ? ruleLabel : chip.fileName || (chip as any)?.wslPath || t('common:files.image'); const isDir = !!(chipAny as any).isDir || (/\/$/.test(String(chip.wslPath || ''))); const previewSrc = resolveChipPreviewSrc(chip); - const iconNode = (() => { - if (previewSrc) { - return ( - {chip.fileName handleChipImageError(event, chip)} - /> - ); - } - if (isRule) return ; - if (isDir) return ; - return ; - })(); - return ( + /** + * 中文说明:统一渲染单个 Chip;若存在图片预览能力,则由共享组件注入悬停预览与点击弹窗交互。 + */ + const chipContent = (hoverTriggerProps?: { onMouseEnter: (event: React.MouseEvent) => void; onMouseLeave: () => void }, openDialog?: () => void, imageProps?: React.ImgHTMLAttributes) => { + /** + * 中文说明:图片 Chip 支持整块左键打开大图;复制/删除等操作按钮通过 data 标记排除。 + */ + const handleChipPrimaryClick = (ev: React.MouseEvent) => { + if (!previewSrc || !openDialog) return; + const target = ev.target as HTMLElement | null; + if (target?.closest("[data-chip-action='true']")) return; + ev.preventDefault(); + ev.stopPropagation(); + openDialog(); + }; + + return (
handleChipMouseEnter(chip, chipKey, ev.currentTarget)} - onMouseLeave={hidePreview} + onMouseEnter={hoverTriggerProps?.onMouseEnter} + onMouseLeave={hoverTriggerProps?.onMouseLeave} + onClick={handleChipPrimaryClick} onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation(); setCtxMenu({ show: true, x: ev.clientX, y: ev.clientY, chip }); }} > - {iconNode} + {previewSrc && imageProps ? ( + + ) : ( + <> + {isRule ? : null} + {!isRule && isDir ? : null} + {!isRule && !isDir ? : null} + + )} { ev.preventDefault(); @@ -1415,48 +1378,27 @@ export default function PathChipsInput({ ×
- ); + ); + }; + if (previewSrc) { + return ( + {preferredPathText} : null} + > + {({ hoverTriggerProps, openDialog, imageProps }) => chipContent(hoverTriggerProps, openDialog, imageProps)} + + ); + } + return chipContent(); })} - {hoverPreview && resolveChipPreviewSrc(hoverPreview.chip) && typeof document !== "undefined" - ? createPortal( - (() => { - const { rect, chip } = hoverPreview; - const previewSrc = resolveChipPreviewSrc(chip); - const centerX = rect.left + rect.width / 2; - const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0; - const anchorCenterY = rect.top + rect.height / 2; - // 规则: - // - 输入区在视口上半部分:优先向下弹出,避免被顶部吃掉(全屏模式下尤为明显) - // - 输入区在视口下半部分:优先向上弹出,减少被底部遮挡的概率 - const preferBelow = !viewportHeight || anchorCenterY < viewportHeight * 0.5; - const baseTop = preferBelow ? rect.bottom + 8 : rect.top - 8; - const clampedTop = viewportHeight - ? Math.min(Math.max(baseTop, 24), viewportHeight - 24) - : baseTop; - const top = clampedTop; - const translateYClass = preferBelow ? "translate-y-0" : "-translate-y-full"; - return ( -
-
- {chip.fileName handleChipImageError(event, chip)} - /> -
-
- ); - })(), - document.body - ) - : null} - {/* 内部输入:multiline 时使用 textarea 以获得自动换行;避免长文本被截断 同时增加 pb-10 给右下角发送按钮让位,避免遮挡最后一行。 */} {multiline ? ( diff --git a/web/src/features/history/history-inline-images.tsx b/web/src/features/history/history-inline-images.tsx new file mode 100644 index 0000000..73c1c1c --- /dev/null +++ b/web/src/features/history/history-inline-images.tsx @@ -0,0 +1,1209 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) + +import React from "react"; +import type { HistoryMessage, MessageContent } from "@/app/app-shared"; +import { useHistoryImageContextMenu } from "@/components/history/history-image-context-menu"; +import InteractiveImagePreview from "@/components/ui/interactive-image-preview"; +import { HistoryMarkdown } from "@/features/history/renderers/history-markdown"; + +const IMAGE_PATH_PATTERN = /@?((?:[A-Za-z]:\\|\/mnt\/[A-Za-z]\/|\/(?:home|root|Users)\/|\\\\[^\\\/\r\n]+\\[^\\\/\r\n]+\\)[^\r\n]*?\.(?:png|jpe?g|webp|gif|bmp|svg))/gi; +const IMAGE_OPEN_TAG_PATTERN = //gi; +const IMAGE_CLOSE_TAG_PATTERN = /<\/image>/gi; +const IMAGE_PLACEHOLDER_PATTERN = /\[(image\s+\d+[^\]]*)\]/gi; +const IMAGE_LABEL_PATTERN = /\[(Image\s*#\d+)\]/gi; + +export type HistoryInlineImageMessageInput = { + messageKey: string; + message: HistoryMessage; +}; + +type HistoryInlineImageCandidate = { + imageItemKey: string; + image: MessageContent; + pathKeys: string[]; +}; + +type HistoryInlineImageTokenKind = "path" | "image_open" | "image_close" | "image_placeholder" | "image_label"; + +type HistoryInlineImageTextToken = { + kind: HistoryInlineImageTokenKind; + rawText: string; + displayText: string; + start: number; + end: number; + path?: string; + label?: string; +}; + +type HistoryInlineImageMatch = { + kind: Exclude; + tokenText: string; + displayText: string; + imageItemKey: string; + image: MessageContent; + path?: string; + label?: string; +}; + +type HistoryInlineImageTextEntry = { + textItemKey: string; + text: string; + tokens: HistoryInlineImageTextToken[]; +}; + +type HistoryInlineImageItemEntry = { + imageItemKey: string; + image: MessageContent; +}; + +type HistoryInlineImageResolverRuntime = { + pathQueues: Map; + groupedImageItemKeysByPathKey: Map>; + sequentialCandidates: HistoryInlineImageCandidate[]; + consumedImageItemKeys: Set; + hiddenImageItemKeys: Set; + boundCandidateByPathKey: Map; + sequentialCursor: { value: number }; +}; + +export type HistoryInlineImageRenderState = { + hiddenImageItemKeys: Set; + hiddenTextItemKeys: Set; + matchesByTextItemKey: Map; +}; + +type HistoryInlineImageTextProps = { + text: string; + textItemKey: string; + projectRootPath?: string; + inlineImageState?: HistoryInlineImageRenderState; +}; + +/** + * 中文说明:构造历史消息内单个内容项的稳定键。 + */ +export function buildHistoryContentItemKey(messageKey: string, itemIndex: number): string { + return `${String(messageKey || "").trim()}:content:${Math.max(0, Number(itemIndex) || 0)}`; +} + +/** + * 中文说明:判断某类内容是否适合做“路径旁内联图片”渲染。 + * - 仅对原本按 Markdown/普通文本渲染的内容启用; + * - `code`、`state`、`session_meta` 等结构化块维持原展示方式。 + */ +export function shouldRenderHistoryTextWithInlineImages(type?: string): boolean { + const ty = String(type || "").trim().toLowerCase(); + if (ty === "image") return false; + if (ty === "code") return false; + if (ty === "function_call") return false; + if (ty === "function_output") return false; + if (ty === "user_instructions") return false; + if (ty === "environment_context") return false; + if (ty === "instructions") return false; + if (ty === "git") return false; + if (ty === "state") return false; + if (ty === "session_meta") return false; + return true; +} + +/** + * 中文说明:判断一条历史消息在应用“图片回挂”后是否仍有可见内容。 + */ +export function hasVisibleHistoryMessageContent( + message: HistoryMessage | null | undefined, + messageKey: string, + inlineImageState?: HistoryInlineImageRenderState, +): boolean { + const items = Array.isArray(message?.content) ? message!.content : []; + if (items.length === 0) return false; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const itemKey = buildHistoryContentItemKey(messageKey, index); + const type = String(item?.type || "").trim().toLowerCase(); + if (type === "image") { + if (!inlineImageState?.hiddenImageItemKeys?.has(itemKey)) return true; + continue; + } + if (inlineImageState?.hiddenTextItemKeys?.has(itemKey)) continue; + if (String(item?.text ?? "").trim().length > 0) return true; + } + return false; +} + +/** + * 中文说明:基于“文本 token”和“会话中恢复出的图片项”建立只读配对关系。 + * - 路径型 token 会优先复用同一路径代表图,并隐藏该路径对应的全部独立图片块; + * - 占位符型 token 会按出现顺序绑定图片,兼容旧版 Codex/Claude/Gemini 历史结构; + * - 纯 `` 文本项会被标记为隐藏,避免详情页残留噪音行。 + */ +export function resolveHistoryInlineImageRenderState( + messages: HistoryInlineImageMessageInput[], +): HistoryInlineImageRenderState { + const runtime = collectHistoryInlineImageCandidates(messages); + const hiddenTextItemKeys = new Set(); + const matchesByTextItemKey = new Map(); + + for (const view of Array.isArray(messages) ? messages : []) { + const items = Array.isArray(view?.message?.content) ? view.message.content : []; + const messageTextEntries: HistoryInlineImageTextEntry[] = []; + const messageImageEntries: HistoryInlineImageItemEntry[] = []; + const boundCandidateByLabelKey = new Map(); + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const type = String(item?.type || "").trim().toLowerCase(); + if (type === "image") { + messageImageEntries.push({ + imageItemKey: buildHistoryContentItemKey(view.messageKey, index), + image: item, + }); + } + if (!shouldRenderHistoryTextWithInlineImages(type)) continue; + + const text = String(item?.text ?? ""); + if (!text) continue; + const textItemKey = buildHistoryContentItemKey(view.messageKey, index); + const tokens = extractHistoryInlineImageTokens(text); + if (tokens.length === 0) continue; + + const matches: HistoryInlineImageMatch[] = []; + for (const token of tokens) { + if (token.kind === "image_close") continue; + const candidate = resolveHistoryInlineImageTokenMatch(token, runtime, boundCandidateByLabelKey); + if (!candidate) continue; + matches.push({ + kind: token.kind, + tokenText: token.rawText, + displayText: token.displayText, + imageItemKey: candidate.imageItemKey, + image: candidate.image, + path: token.path, + label: token.label, + }); + } + + if (matches.length > 0) matchesByTextItemKey.set(textItemKey, matches); + if (shouldHideHistoryInlineTextItem(text, tokens)) hiddenTextItemKeys.add(textItemKey); + messageTextEntries.push({ textItemKey, text, tokens }); + } + + for (const textItemKey of collectCollapsibleHistoryInlineTextItemKeys(messageTextEntries)) { + hiddenTextItemKeys.add(textItemKey); + } + for (const imageItemKey of collectCollapsibleHistoryInlineImageItemKeys(messageImageEntries, messageTextEntries)) { + runtime.hiddenImageItemKeys.add(imageItemKey); + } + } + + return { + hiddenImageItemKeys: runtime.hiddenImageItemKeys, + hiddenTextItemKeys, + matchesByTextItemKey, + }; +} + +/** + * 中文说明:将包含路径或图片占位符的文本渲染为“原文本 + 行内缩略图”。 + * - 仅在存在已解析 token 时启用自定义渲染; + * - 普通文本仍回退到 `HistoryMarkdown`,尽量保持原有展示能力。 + */ +export function HistoryTextWithInlineImages({ + text, + textItemKey, + projectRootPath, + inlineImageState, +}: HistoryInlineImageTextProps) { + if (inlineImageState?.hiddenTextItemKeys?.has(textItemKey)) return null; + + const matches = inlineImageState?.matchesByTextItemKey?.get(textItemKey) || []; + const tokens = extractHistoryInlineImageTokens(text); + const hasPathPreviewFallback = tokens.some((token) => token.kind === "path" && !!buildHistoryInlineImageContentFromPath(token.path || token.rawText)); + const shouldUseCustomRender = tokens.length > 0 && ( + matches.length > 0 || hasPathPreviewFallback || tokens.some((token) => token.kind !== "path") + ); + if (!shouldUseCustomRender) return ; + + const lines = String(text || "").split(/\r?\n/); + let matchCursor = 0; + + /** + * 中文说明:按文本出现顺序取出当前 token 对应的下一张图片,避免跨 token 串配。 + */ + const takeNextMatch = (token: HistoryInlineImageTextToken): HistoryInlineImageMatch | null => { + for (let index = matchCursor; index < matches.length; index += 1) { + const candidate = matches[index]; + if (!doesHistoryInlineImageMatchToken(candidate, token)) continue; + matchCursor = index + 1; + return candidate; + } + return null; + }; + + return ( +
+ {lines.map((line, lineIndex) => { + const lineTokens = extractHistoryInlineImageTokens(line); + if (lineTokens.length === 0) { + return ( +
+ {line || "\u00a0"} +
+ ); + } + + const nodes: React.ReactNode[] = []; + let lastIndex = 0; + for (let tokenIndex = 0; tokenIndex < lineTokens.length; tokenIndex += 1) { + const token = lineTokens[tokenIndex]; + if (token.start > lastIndex) { + nodes.push( + + {line.slice(lastIndex, token.start)} + , + ); + } + + if (token.kind !== "image_close") { + const match = takeNextMatch(token); + if (match) { + const mergedImage = token.kind === "path" + ? mergeHistoryInlineImageWithPathFallback(match.image, token.path || token.rawText) + : match.image; + nodes.push( + , + ); + } else if (token.kind === "path") { + const fallbackImage = buildHistoryInlineImageContentFromPath(token.path || token.rawText); + if (fallbackImage) { + nodes.push( + , + ); + } else { + nodes.push( + + {token.displayText} + , + ); + } + } else { + nodes.push( + + {token.displayText} + , + ); + } + } + + lastIndex = token.end; + } + + if (lastIndex < line.length) { + nodes.push( + + {line.slice(lastIndex)} + , + ); + } + + if (nodes.length === 0 && stripHistoryInlineImageCloseTags(line).trim().length === 0) return null; + + return ( +
+ {nodes.length > 0 ? nodes : (stripHistoryInlineImageCloseTags(line) || "\u00a0")} +
+ ); + })} +
+ ); +} + +/** + * 中文说明:收集会话中的图片项,并建立路径索引与顺序索引。 + */ +function collectHistoryInlineImageCandidates( + messages: HistoryInlineImageMessageInput[], +): HistoryInlineImageResolverRuntime { + const pathQueues = new Map(); + const groupedImageItemKeysByPathKey = new Map>(); + const sequentialCandidates: HistoryInlineImageCandidate[] = []; + + for (const view of Array.isArray(messages) ? messages : []) { + const items = Array.isArray(view?.message?.content) ? view.message.content : []; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + if (String(item?.type || "").trim().toLowerCase() !== "image") continue; + + const pathKeys = buildHistoryImagePathKeys(String(item?.localPath || "")); + const candidate: HistoryInlineImageCandidate = { + imageItemKey: buildHistoryContentItemKey(view.messageKey, index), + image: item, + pathKeys, + }; + sequentialCandidates.push(candidate); + + for (const pathKey of pathKeys) { + const queue = pathQueues.get(pathKey) || []; + if (!queue.some((entry) => entry.imageItemKey === candidate.imageItemKey)) queue.push(candidate); + pathQueues.set(pathKey, queue); + + const groupedItemKeys = groupedImageItemKeysByPathKey.get(pathKey) || new Set(); + groupedItemKeys.add(candidate.imageItemKey); + groupedImageItemKeysByPathKey.set(pathKey, groupedItemKeys); + } + } + } + + return { + pathQueues, + groupedImageItemKeysByPathKey, + sequentialCandidates, + consumedImageItemKeys: new Set(), + hiddenImageItemKeys: new Set(), + boundCandidateByPathKey: new Map(), + sequentialCursor: { value: 0 }, + }; +} + +/** + * 中文说明:为单个文本 token 解析最合适的图片候选。 + */ +function resolveHistoryInlineImageTokenMatch( + token: HistoryInlineImageTextToken, + runtime: HistoryInlineImageResolverRuntime, + boundCandidateByLabelKey: Map, +): HistoryInlineImageCandidate | null { + if (token.kind === "path") { + const pathKeys = buildHistoryImagePathKeys(token.path || token.rawText); + const boundCandidate = findBoundHistoryInlineImageCandidateByPath(pathKeys, runtime.boundCandidateByPathKey); + if (boundCandidate) return boundCandidate; + + const candidate = findNextHistoryInlineImageCandidateByPath(pathKeys, runtime); + if (!candidate) return null; + bindHistoryInlineImageCandidateToPathKeys(candidate, pathKeys, runtime.boundCandidateByPathKey); + hideHistoryInlineImageCandidateGroup(candidate, runtime); + return candidate; + } + + if (token.kind === "image_open" || token.kind === "image_label") { + const labelKey = toHistoryInlineImageLabelKey(token.label || token.displayText); + const boundCandidate = labelKey ? boundCandidateByLabelKey.get(labelKey) || null : null; + if (boundCandidate) return boundCandidate; + + const candidate = takeNextHistoryInlineImageSequentialCandidate(runtime); + if (!candidate) return null; + if (labelKey) boundCandidateByLabelKey.set(labelKey, candidate); + hideHistoryInlineImageCandidateGroup(candidate, runtime); + return candidate; + } + + if (token.kind === "image_placeholder") { + const candidate = takeNextHistoryInlineImageSequentialCandidate(runtime); + if (!candidate) return null; + hideHistoryInlineImageCandidateGroup(candidate, runtime); + return candidate; + } + + return null; +} + +/** + * 中文说明:从路径绑定表中查找已绑定的代表图,保证同一路径反复出现时只展示一组图片。 + */ +function findBoundHistoryInlineImageCandidateByPath( + pathKeys: string[], + boundCandidateByPathKey: Map, +): HistoryInlineImageCandidate | null { + for (const pathKey of pathKeys) { + const candidate = boundCandidateByPathKey.get(pathKey); + if (candidate) return candidate; + } + return null; +} + +/** + * 中文说明:按路径索引查找下一张尚未被消费的图片候选。 + */ +function findNextHistoryInlineImageCandidateByPath( + pathKeys: string[], + runtime: HistoryInlineImageResolverRuntime, +): HistoryInlineImageCandidate | null { + for (const pathKey of pathKeys) { + const queue = runtime.pathQueues.get(pathKey) || []; + for (const candidate of queue) { + if (runtime.consumedImageItemKeys.has(candidate.imageItemKey)) continue; + return candidate; + } + } + return null; +} + +/** + * 中文说明:从全局顺序队列中取下一张尚未消费的图片,兼容旧版 `` 与 `[image ...]` 占位符。 + */ +function takeNextHistoryInlineImageSequentialCandidate( + runtime: HistoryInlineImageResolverRuntime, +): HistoryInlineImageCandidate | null { + while (runtime.sequentialCursor.value < runtime.sequentialCandidates.length) { + const candidate = runtime.sequentialCandidates[runtime.sequentialCursor.value]; + runtime.sequentialCursor.value += 1; + if (runtime.consumedImageItemKeys.has(candidate.imageItemKey)) continue; + return candidate; + } + return null; +} + +/** + * 中文说明:把候选图与路径键建立绑定,后续同路径 token 会复用该候选。 + */ +function bindHistoryInlineImageCandidateToPathKeys( + candidate: HistoryInlineImageCandidate, + tokenPathKeys: string[], + boundCandidateByPathKey: Map, +): void { + for (const pathKey of [...candidate.pathKeys, ...tokenPathKeys]) { + if (!pathKey) continue; + boundCandidateByPathKey.set(pathKey, candidate); + } +} + +/** + * 中文说明:隐藏候选图所属的整组图片项,并同步标记为已消费。 + * - 路径型图片会把相同归一化路径的全部独立 `IMAGE` 块一起隐藏; + * - 无路径图片则至少隐藏自身,避免再次作为独立块出现。 + */ +function hideHistoryInlineImageCandidateGroup( + candidate: HistoryInlineImageCandidate, + runtime: HistoryInlineImageResolverRuntime, +): void { + runtime.hiddenImageItemKeys.add(candidate.imageItemKey); + runtime.consumedImageItemKeys.add(candidate.imageItemKey); + + if (candidate.pathKeys.length === 0) return; + for (const pathKey of candidate.pathKeys) { + const groupedItemKeys = runtime.groupedImageItemKeysByPathKey.get(pathKey); + if (!groupedItemKeys) continue; + for (const imageItemKey of groupedItemKeys) { + runtime.hiddenImageItemKeys.add(imageItemKey); + runtime.consumedImageItemKeys.add(imageItemKey); + } + } +} + +/** + * 中文说明:判断单个文本项是否应整体隐藏。 + * - 目前仅在文本只剩 `` 这类关闭占位符时隐藏; + * - 其他 token 即使暂未匹配图片,也保留原文字,避免误伤正文。 + */ +function shouldHideHistoryInlineTextItem( + text: string, + tokens: HistoryInlineImageTextToken[], +): boolean { + if (tokens.length === 0) return false; + let cursor = 0; + for (const token of tokens) { + const before = text.slice(cursor, token.start); + if (before.trim().length > 0) return false; + if (token.kind !== "image_close") return false; + cursor = token.end; + } + return text.slice(cursor).trim().length === 0; +} + +/** + * 中文说明:在同一条消息内折叠“被后续更完整内容覆盖”的旧版图片占位符文本项。 + * - 仅折叠纯占位符文本,不隐藏带正文的真实输入内容; + * - 若后续文本覆盖了当前图片标识(同标签/同路径/同占位符),则优先展示后面的聚合内容。 + */ +function collectCollapsibleHistoryInlineTextItemKeys( + entries: HistoryInlineImageTextEntry[], +): Set { + const out = new Set(); + const descriptors = entries.map((entry) => describeHistoryInlineImageTextEntry(entry)); + + for (let index = 0; index < descriptors.length; index += 1) { + const current = descriptors[index]; + if (!current.isPlaceholderOnly) continue; + if (current.signatures.size === 0) continue; + + for (let nextIndex = index + 1; nextIndex < descriptors.length; nextIndex += 1) { + const next = descriptors[nextIndex]; + if (!doesHistoryInlineImageSignatureSetCover(next.signatures, current.signatures)) continue; + if (!shouldCollapseHistoryInlineTextEntry(current, next)) continue; + out.add(current.textItemKey); + break; + } + } + + return out; +} + +/** + * 中文说明:在同一条消息内兜底隐藏旧格式图片卡片,避免 `` 与 `[Image #n]` 同时重复展示。 + * - 仅在消息里确实存在旧式图片 token 时触发; + * - 优先隐藏无本地路径、纯会话内恢复出来的图片块,减少误伤独立图片消息。 + */ +function collectCollapsibleHistoryInlineImageItemKeys( + imageEntries: HistoryInlineImageItemEntry[], + textEntries: HistoryInlineImageTextEntry[], +): Set { + const out = new Set(); + if (imageEntries.length === 0 || textEntries.length === 0) return out; + + const tokenStats = collectHistoryInlineImageMessageTokenStats(textEntries); + if (!tokenStats.hasLegacyToken) return out; + if (tokenStats.signatureCount === 0) return out; + + const preferredEntries = imageEntries.filter((entry) => !String(entry.image?.localPath || "").trim()); + const fallbackEntries = preferredEntries.length > 0 ? preferredEntries : imageEntries; + const hideCount = Math.min(fallbackEntries.length, tokenStats.signatureCount); + for (let index = 0; index < hideCount; index += 1) { + const itemKey = fallbackEntries[index]?.imageItemKey; + if (itemKey) out.add(itemKey); + } + return out; +} + +type HistoryInlineImageTextEntryDescriptor = { + textItemKey: string; + normalizedText: string; + signatures: Set; + visibleTextLength: number; + isPlaceholderOnly: boolean; +}; + +/** + * 中文说明:提炼文本项的可折叠特征,便于后续做轻量级覆盖判断。 + */ +function describeHistoryInlineImageTextEntry( + entry: HistoryInlineImageTextEntry, +): HistoryInlineImageTextEntryDescriptor { + const visibleText = stripHistoryInlineImageTokenTexts(entry.text, entry.tokens).trim(); + return { + textItemKey: entry.textItemKey, + normalizedText: normalizeHistoryInlineImageTextForComparison(entry.text), + signatures: collectHistoryInlineImageTokenSignatures(entry.tokens), + visibleTextLength: visibleText.length, + isPlaceholderOnly: visibleText.length === 0 && hasHistoryInlineImageRenderableToken(entry.tokens), + }; +} + +type HistoryInlineImageMessageTokenStats = { + hasLegacyToken: boolean; + signatureCount: number; +}; + +/** + * 中文说明:汇总单条消息中的图片 token 统计,用于决定是否隐藏旧格式独立图片卡片。 + */ +function collectHistoryInlineImageMessageTokenStats( + entries: HistoryInlineImageTextEntry[], +): HistoryInlineImageMessageTokenStats { + const signatures = new Set(); + let hasLegacyToken = false; + + for (const entry of entries) { + for (const token of entry.tokens) { + if (token.kind === "image_open" || token.kind === "image_label" || token.kind === "image_placeholder") { + hasLegacyToken = true; + } + const signature = toHistoryInlineImageTokenSignature(token); + if (signature) signatures.add(signature); + } + } + + return { + hasLegacyToken, + signatureCount: signatures.size, + }; +} + +/** + * 中文说明:判断后续文本项是否足以替代当前纯占位符项。 + * - 后续项只要覆盖当前全部图片标识,并且信息量不更少,即可隐藏当前占位符项; + * - 这样可消除旧 Codex/Claude/Gemini 历史里被拆散的 `[Image #n]` 小框。 + */ +function shouldCollapseHistoryInlineTextEntry( + current: HistoryInlineImageTextEntryDescriptor, + next: HistoryInlineImageTextEntryDescriptor, +): boolean { + if (next.visibleTextLength > current.visibleTextLength) return true; + if (next.signatures.size > current.signatures.size) return true; + if (next.normalizedText === current.normalizedText) return true; + return next.visibleTextLength === 0; +} + +/** + * 中文说明:判断文本项是否至少包含一个可展示的图片 token。 + */ +function hasHistoryInlineImageRenderableToken(tokens: HistoryInlineImageTextToken[]): boolean { + return tokens.some((token) => token.kind !== "image_close"); +} + +/** + * 中文说明:提取文本项中的图片标识集合,用于比较“后续内容是否覆盖当前占位符”。 + */ +function collectHistoryInlineImageTokenSignatures( + tokens: HistoryInlineImageTextToken[], +): Set { + const out = new Set(); + for (const token of tokens) { + const signature = toHistoryInlineImageTokenSignature(token); + if (signature) out.add(signature); + } + return out; +} + +/** + * 中文说明:为单个图片 token 生成稳定比较键,兼容路径、标签与旧版占位符。 + */ +function toHistoryInlineImageTokenSignature(token: HistoryInlineImageTextToken): string { + if (token.kind === "image_close") return ""; + if (token.kind === "path") { + const pathKeys = buildHistoryImagePathKeys(token.path || token.rawText).sort(); + return pathKeys.length > 0 ? `path:${pathKeys[0]}` : ""; + } + if (token.kind === "image_open" || token.kind === "image_label") { + const labelKey = toHistoryInlineImageLabelKey(token.label || token.displayText || token.rawText); + return labelKey ? `label:${labelKey}` : ""; + } + if (token.kind === "image_placeholder") { + const placeholderKey = toHistoryInlineImagePlaceholderKey(token.rawText); + return placeholderKey ? `placeholder:${placeholderKey}` : ""; + } + return ""; +} + +/** + * 中文说明:判断后者的图片标识集合是否完整覆盖前者。 + */ +function doesHistoryInlineImageSignatureSetCover( + next: Set, + current: Set, +): boolean { + if (current.size === 0) return false; + for (const signature of current) { + if (!next.has(signature)) return false; + } + return true; +} + +/** + * 中文说明:移除文本中的图片 token 文本,仅保留真正可见的正文部分。 + */ +function stripHistoryInlineImageTokenTexts( + text: string, + tokens: HistoryInlineImageTextToken[], +): string { + if (tokens.length === 0) return String(text || ""); + + let cursor = 0; + let out = ""; + for (const token of tokens) { + if (token.start > cursor) out += text.slice(cursor, token.start); + cursor = Math.max(cursor, token.end); + } + if (cursor < text.length) out += text.slice(cursor); + return out; +} + +/** + * 中文说明:将文本压平成稳定的比较值,避免空白差异影响重复项折叠。 + */ +function normalizeHistoryInlineImageTextForComparison(value?: string): string { + return String(value || "").replace(/\s+/g, " ").trim(); +} + +/** + * 中文说明:提取文本中的所有图片相关 token,并做重叠去重。 + */ +function extractHistoryInlineImageTokens(text?: string): HistoryInlineImageTextToken[] { + try { + const source = String(text || ""); + if (!source) return []; + + const candidates: HistoryInlineImageTextToken[] = []; + pushHistoryInlineImagePathTokens(source, candidates); + pushHistoryInlineImageOpenTokens(source, candidates); + pushHistoryInlineImageCloseTokens(source, candidates); + pushHistoryInlineImagePlaceholderTokens(source, candidates); + pushHistoryInlineImageLabelTokens(source, candidates); + + return filterOverlappingHistoryInlineImageTokens(candidates); + } catch { + return []; + } +} + +/** + * 中文说明:提取文本内真实图片路径 token。 + */ +function pushHistoryInlineImagePathTokens( + source: string, + out: HistoryInlineImageTextToken[], +): void { + let match: RegExpExecArray | null; + IMAGE_PATH_PATTERN.lastIndex = 0; + while ((match = IMAGE_PATH_PATTERN.exec(source)) !== null) { + const captured = String(match[1] || ""); + const normalizedPath = normalizeHistoryImagePathCandidate(captured); + if (!normalizedPath) continue; + + const fullMatch = String(match[0] || ""); + const capturedOffset = fullMatch.indexOf(captured); + const start = match.index + Math.max(0, capturedOffset); + const end = start + captured.length; + out.push({ + kind: "path", + rawText: captured, + displayText: captured, + start, + end, + path: normalizedPath, + }); + } +} + +/** + * 中文说明:提取旧版 `` 打开占位符。 + */ +function pushHistoryInlineImageOpenTokens( + source: string, + out: HistoryInlineImageTextToken[], +): void { + let match: RegExpExecArray | null; + IMAGE_OPEN_TAG_PATTERN.lastIndex = 0; + while ((match = IMAGE_OPEN_TAG_PATTERN.exec(source)) !== null) { + const label = String(match[1] || "").trim(); + const rawText = String(match[0] || ""); + out.push({ + kind: "image_open", + rawText, + displayText: label ? `[${label}]` : rawText, + start: match.index, + end: match.index + rawText.length, + label: label ? `[${label}]` : "", + }); + } +} + +/** + * 中文说明:提取旧版 `` 关闭占位符。 + */ +function pushHistoryInlineImageCloseTokens( + source: string, + out: HistoryInlineImageTextToken[], +): void { + let match: RegExpExecArray | null; + IMAGE_CLOSE_TAG_PATTERN.lastIndex = 0; + while ((match = IMAGE_CLOSE_TAG_PATTERN.exec(source)) !== null) { + const rawText = String(match[0] || ""); + out.push({ + kind: "image_close", + rawText, + displayText: "", + start: match.index, + end: match.index + rawText.length, + }); + } +} + +/** + * 中文说明:提取 `[image 965x458 PNG]` 这类旧版尺寸占位符。 + */ +function pushHistoryInlineImagePlaceholderTokens( + source: string, + out: HistoryInlineImageTextToken[], +): void { + let match: RegExpExecArray | null; + IMAGE_PLACEHOLDER_PATTERN.lastIndex = 0; + while ((match = IMAGE_PLACEHOLDER_PATTERN.exec(source)) !== null) { + const rawText = String(match[0] || ""); + out.push({ + kind: "image_placeholder", + rawText, + displayText: rawText, + start: match.index, + end: match.index + rawText.length, + }); + } +} + +/** + * 中文说明:提取 `[Image #1]` 这类旧版标签占位符。 + */ +function pushHistoryInlineImageLabelTokens( + source: string, + out: HistoryInlineImageTextToken[], +): void { + let match: RegExpExecArray | null; + IMAGE_LABEL_PATTERN.lastIndex = 0; + while ((match = IMAGE_LABEL_PATTERN.exec(source)) !== null) { + const label = String(match[1] || "").trim(); + const rawText = String(match[0] || ""); + out.push({ + kind: "image_label", + rawText, + displayText: rawText, + start: match.index, + end: match.index + rawText.length, + label: label ? `[${label}]` : rawText, + }); + } +} + +/** + * 中文说明:过滤重叠 token,优先保留范围更大的高优先级 token。 + */ +function filterOverlappingHistoryInlineImageTokens( + tokens: HistoryInlineImageTextToken[], +): HistoryInlineImageTextToken[] { + const sorted = [...tokens].sort((left, right) => { + if (left.start !== right.start) return left.start - right.start; + const lengthDiff = (right.end - right.start) - (left.end - left.start); + if (lengthDiff !== 0) return lengthDiff; + return getHistoryInlineImageTokenPriority(right.kind) - getHistoryInlineImageTokenPriority(left.kind); + }); + + const out: HistoryInlineImageTextToken[] = []; + for (const token of sorted) { + const hasOverlap = out.some((existing) => token.start < existing.end && token.end > existing.start); + if (hasOverlap) continue; + out.push(token); + } + return out.sort((left, right) => left.start - right.start); +} + +/** + * 中文说明:返回 token 冲突时的优先级。 + */ +function getHistoryInlineImageTokenPriority(kind: HistoryInlineImageTokenKind): number { + switch (kind) { + case "image_open": + return 5; + case "image_close": + return 4; + case "path": + return 3; + case "image_placeholder": + return 2; + case "image_label": + return 1; + default: + return 0; + } +} + +/** + * 中文说明:判断某条解析结果是否对应当前渲染 token。 + */ +function doesHistoryInlineImageMatchToken( + match: HistoryInlineImageMatch, + token: HistoryInlineImageTextToken, +): boolean { + if (match.kind !== token.kind) return false; + + if (token.kind === "path") { + return pathKeysIntersect( + buildHistoryImagePathKeys(match.path || match.tokenText), + buildHistoryImagePathKeys(token.path || token.rawText), + ); + } + + if (token.kind === "image_open" || token.kind === "image_label") { + return toHistoryInlineImageLabelKey(match.label || match.displayText || match.tokenText) + === toHistoryInlineImageLabelKey(token.label || token.displayText || token.rawText); + } + + if (token.kind === "image_placeholder") { + return toHistoryInlineImagePlaceholderKey(match.tokenText) === toHistoryInlineImagePlaceholderKey(token.rawText); + } + + return false; +} + +/** + * 中文说明:渲染单个“文本 token + 小图”行内片段。 + */ +function HistoryInlineImageToken({ displayText, image }: { displayText: string; image: MessageContent }) { + const primarySrc = String(image?.src || "").trim(); + const fallbackSrc = String(image?.fallbackSrc || "").trim(); + const { openContextMenu: openImageContextMenu, contextMenuNode } = useHistoryImageContextMenu({ + src: primarySrc, + fallbackSrc, + localPath: image.localPath, + }); + const dialogMetaLines = [ + image.localPath ? `路径: ${image.localPath}` : "", + image.mimeType ? `类型: ${image.mimeType}` : "", + fallbackSrc ? "回退: 会话内图片数据" : "", + ].filter((line) => String(line || "").trim().length > 0); + + return ( + + {displayText} + 0 ? ( +
+ {dialogMetaLines.map((line, index) => ( +
{line}
+ ))} +
+ ) : undefined} + > + {({ hasPreview, hoverTriggerProps, openDialog, imageProps }) => ( + hasPreview ? ( + + ) : null + )} +
+ {contextMenuNode} +
+ ); +} + +/** + * 中文说明:规范化历史中的图片路径文本,去除 Gemini `@` 前缀与常见包裹符号。 + */ +function normalizeHistoryImagePathCandidate(value?: string): string { + let raw = String(value || "").trim(); + if (!raw) return ""; + raw = raw.replace(/^@+/, "").trim(); + raw = raw.replace(/^`+|`+$/g, "").trim(); + raw = raw.replace(/^"+|"+$/g, "").trim(); + raw = raw.replace(/^'+|'+$/g, "").trim(); + return raw; +} + +/** + * 中文说明:对历史图片 `file:///` 地址做轻量编码,避免空格与保留字符破坏 URL 语义。 + */ +function encodeHistoryInlineImageFileUrlPath(value?: string): string { + const raw = String(value || "").trim(); + if (!raw) return ""; + return encodeURI(raw).replace(/#/g, "%23").replace(/\?/g, "%3F"); +} + +/** + * 中文说明:将历史里的本地路径转为可用于浏览器预览的主地址。 + * - Windows 盘符优先转为 `file:///C:/...`; + * - `/mnt//...` 优先映射为 Windows 盘符地址,兼容 Windows 侧 Electron; + * - 其他 POSIX 绝对路径保持原样,兼容 WSL/Linux 侧运行。 + */ +function toHistoryInlineImagePreviewSrc(value?: string): string { + const raw = normalizeHistoryImagePathCandidate(value); + if (!raw) return ""; + if (/^(?:data:image\/|blob:|file:\/\/)/i.test(raw)) return raw; + + const windowsMatch = raw.match(/^([A-Za-z]):[\\/](.*)$/); + if (windowsMatch?.[1]) { + const drive = windowsMatch[1].toUpperCase(); + const rest = String(windowsMatch[2] || "").replace(/\\/g, "/"); + return `file:///${encodeHistoryInlineImageFileUrlPath(`${drive}:/${rest}`)}`; + } + + if (raw.startsWith("//")) return `file:${encodeHistoryInlineImageFileUrlPath(raw)}`; + if (raw.startsWith("/")) return `file://${encodeHistoryInlineImageFileUrlPath(raw)}`; + return ""; +} + +/** + * 中文说明:为 `/mnt//...` 这类路径提供备用预览地址,兼容不同运行环境。 + */ +function toHistoryInlineImageFallbackSrc(value?: string): string { + const raw = normalizeHistoryImagePathCandidate(value); + if (!raw) return ""; + const mntMatch = raw.match(/^\/mnt\/([A-Za-z])\/(.*)$/); + if (mntMatch?.[1]) { + const drive = mntMatch[1].toUpperCase(); + const rest = String(mntMatch[2] || ""); + return `file:///${encodeHistoryInlineImageFileUrlPath(`${drive}:/${rest}`)}`; + } + + const windowsMatch = raw.match(/^([A-Za-z]):[\\/](.*)$/); + if (windowsMatch?.[1]) { + const drive = windowsMatch[1].toLowerCase(); + const rest = String(windowsMatch[2] || "").replace(/\\/g, "/"); + return `file://${encodeHistoryInlineImageFileUrlPath(`/mnt/${drive}/${rest}`)}`; + } + + return ""; +} + +/** + * 中文说明:根据扩展名推断历史图片 MIME,用于大图弹窗显示紧凑元信息。 + */ +function inferHistoryInlineImageMimeType(value?: string): string { + const raw = normalizeHistoryImagePathCandidate(value).toLowerCase(); + if (raw.endsWith(".jpg") || raw.endsWith(".jpeg")) return "image/jpeg"; + if (raw.endsWith(".webp")) return "image/webp"; + if (raw.endsWith(".gif")) return "image/gif"; + if (raw.endsWith(".bmp")) return "image/bmp"; + if (raw.endsWith(".svg")) return "image/svg+xml"; + if (raw.endsWith(".png")) return "image/png"; + return ""; +} + +/** + * 中文说明:在缺少后端图片内容项时,直接根据路径 token 构造只读预览对象。 + * - 仅服务于历史详情渲染,不参与消息归档; + * - 优先使用适合当前平台的 `file:///` 地址,并为 `/mnt//...` 提供兼容回退。 + */ +function buildHistoryInlineImageContentFromPath(value?: string): MessageContent | null { + const localPath = normalizeHistoryImagePathCandidate(value); + if (!localPath) return null; + + const primarySrc = toHistoryInlineImagePreviewSrc(localPath); + if (!primarySrc) return null; + + const fallbackSrc = toHistoryInlineImageFallbackSrc(localPath); + const mimeType = inferHistoryInlineImageMimeType(localPath); + const textLines = ["图片", `路径: ${localPath}`]; + if (mimeType) textLines.push(`类型: ${mimeType}`); + if (fallbackSrc && fallbackSrc !== primarySrc) textLines.push("回退: 路径兼容预览"); + + return { + type: "image", + text: textLines.join("\n"), + src: primarySrc, + fallbackSrc: fallbackSrc && fallbackSrc !== primarySrc ? fallbackSrc : undefined, + localPath, + mimeType: mimeType || undefined, + }; +} + +/** + * 中文说明:为路径型 token 合并“后端图片项 + 路径直连预览”的双保险结果。 + * - 若已命中旧缓存中的图片项,优先保留其主图地址; + * - 同时把当前路径生成的本地预览地址挂成回退,避免旧缓存中的坏图地址导致裂图; + * - 文本里的真实路径优先作为 `localPath` 展示,保证详情信息稳定。 + */ +function mergeHistoryInlineImageWithPathFallback( + image: MessageContent, + pathValue?: string, +): MessageContent { + const pathImage = buildHistoryInlineImageContentFromPath(pathValue); + if (!pathImage) return image; + + const primarySrc = String(image?.src || "").trim() || String(pathImage.src || "").trim(); + const fallbackCandidates = [ + String(pathImage.src || "").trim(), + String(pathImage.fallbackSrc || "").trim(), + String(image?.fallbackSrc || "").trim(), + ].filter((candidate, index, list) => candidate.length > 0 && candidate !== primarySrc && list.indexOf(candidate) === index); + + return { + ...pathImage, + ...image, + src: primarySrc, + fallbackSrc: fallbackCandidates[0] || undefined, + localPath: pathImage.localPath || image.localPath, + mimeType: image.mimeType || pathImage.mimeType, + }; +} + +/** + * 中文说明:为路径生成一组可互相映射的归一化键,兼容 Windows 与 `/mnt/x/` 互转场景。 + */ +function buildHistoryImagePathKeys(value?: string): string[] { + const raw = normalizeHistoryImagePathCandidate(value); + if (!raw) return []; + + const out = new Set(); + const push = (candidate?: string) => { + const key = toHistoryImagePathKey(candidate); + if (key) out.add(key); + }; + + push(raw); + + const windowsMatch = raw.match(/^([A-Za-z]):[\\/](.*)$/); + if (windowsMatch?.[1]) { + const drive = windowsMatch[1].toLowerCase(); + const rest = String(windowsMatch[2] || "").replace(/\\/g, "/"); + push(`/mnt/${drive}/${rest}`); + } + + const mntMatch = raw.match(/^\/mnt\/([A-Za-z])\/(.*)$/); + if (mntMatch?.[1]) { + const drive = mntMatch[1].toUpperCase(); + const rest = String(mntMatch[2] || "").replace(/\//g, "\\"); + push(`${drive}:\\${rest}`); + } + + return Array.from(out); +} + +/** + * 中文说明:将路径候选归一化为可比较的键。 + */ +function toHistoryImagePathKey(value?: string): string { + const raw = normalizeHistoryImagePathCandidate(value); + if (!raw) return ""; + return raw.replace(/\\/g, "/").replace(/\/+/g, "/").toLowerCase(); +} + +/** + * 中文说明:将旧版图片标签归一化为稳定键。 + */ +function toHistoryInlineImageLabelKey(value?: string): string { + return String(value || "").replace(/\s+/g, " ").trim().toLowerCase(); +} + +/** + * 中文说明:将旧版尺寸占位符归一化为稳定键。 + */ +function toHistoryInlineImagePlaceholderKey(value?: string): string { + return String(value || "").replace(/\s+/g, " ").trim().toLowerCase(); +} + +/** + * 中文说明:删除文本中的 `` 关闭占位符。 + */ +function stripHistoryInlineImageCloseTags(value?: string): string { + return String(value || "").replace(IMAGE_CLOSE_TAG_PATTERN, ""); +} + +/** + * 中文说明:判断两组路径键是否存在交集。 + */ +function pathKeysIntersect(left: string[], right: string[]): boolean { + if (left.length === 0 || right.length === 0) return false; + const rightSet = new Set(right); + for (const item of left) { + if (rightSet.has(item)) return true; + } + return false; +} diff --git a/web/src/locales/en/history.json b/web/src/locales/en/history.json index 1cd0253..cf916f2 100644 --- a/web/src/locales/en/history.json +++ b/web/src/locales/en/history.json @@ -14,8 +14,9 @@ "selectRightToView": "Select a history item on the right to view details.", "showing": "… {total} total, showing first {count}", "empty": "Choose a project to view history, or switch to all sessions.", - "noMatch": "No matching history", - "copyPath": "Copy Path", + "noMatch": "No matching history", + "copyImage": "Copy Image", + "copyPath": "Copy Path", "linkMenuSetProjectIde": "Set IDE for This Project", "linkMenuUseVsCode": "Use VS Code", "linkMenuUseCursor": "Use Cursor", diff --git a/web/src/locales/zh/history.json b/web/src/locales/zh/history.json index d78dc9f..0654e49 100644 --- a/web/src/locales/zh/history.json +++ b/web/src/locales/zh/history.json @@ -14,8 +14,9 @@ "selectRightToView": "请选择右侧一条历史以查看详情。", "showing": "… 共 {total} 条,展示前 {count} 条", "empty": "选择项目以查看记录,或筛选全部会话", - "noMatch": "无匹配历史", - "copyPath": "复制路径", + "noMatch": "无匹配历史", + "copyImage": "复制图片", + "copyPath": "复制路径", "linkMenuSetProjectIde": "设置当前项目 IDE", "linkMenuUseVsCode": "使用 VS Code", "linkMenuUseCursor": "使用 Cursor", diff --git a/web/src/types/host.d.ts b/web/src/types/host.d.ts index 7cad0c6..11639fb 100644 --- a/web/src/types/host.d.ts +++ b/web/src/types/host.d.ts @@ -149,8 +149,16 @@ export type HistorySummary = { runtimeShell?: 'wsl' | 'windows' | 'unknown'; }; -export type MessageContent = { type: string; text: string }; -export type HistoryMessage = { role: string; content: MessageContent[] }; +export type MessageContent = { + type: string; + text: string; + tags?: string[]; + src?: string; + fallbackSrc?: string; + localPath?: string; + mimeType?: string; +}; +export type HistoryMessage = { role: string; content: MessageContent[] }; // ---- Host API 声明 ---- export interface PtyAPI { @@ -734,6 +742,7 @@ export interface ImagesAPI { saveDataURL(args: { dataURL: string; projectWinRoot?: string; projectWslRoot?: string; projectName?: string; ext?: string; prefix?: string; providerId?: string; runtimeEnv?: "wsl" | "windows" | "pwsh"; distro?: string }): Promise<{ ok: boolean; winPath?: string; wslPath?: string; fileName?: string; error?: string }>; clipboardHasImage(): Promise<{ ok: boolean; has?: boolean; error?: string }>; saveFromClipboard(args: { projectWinRoot?: string; projectWslRoot?: string; projectName?: string; prefix?: string; providerId?: string; runtimeEnv?: "wsl" | "windows" | "pwsh"; distro?: string }): Promise<{ ok: boolean; winPath?: string; wslPath?: string; fileName?: string; error?: string }>; + copyToClipboard(args: { localPath?: string; src?: string; fallbackSrc?: string }): Promise<{ ok: boolean; error?: string }>; trash(args: { winPath: string }): Promise<{ ok: boolean; error?: string }>; }