import { spawn as defaultSpawn, spawnSync, type ChildProcess } from "node:child_process"; import { mkdir, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, extname, isAbsolute, join, resolve } from "node:path"; /** * Session Markdown Review — 从当前会话 branch 组装 user/assistant turns, * 渲染预览并导出 Markdown / Word(DOCX) / PDF。 * * 参考 G:/github_lib/pi-markdown-preview 的概念(assistant 消息提取、pandoc PDF * 链路、参数化命令分发),但针对本插件的 branch 结构与多 turn 场景重新实现: * 提取层保留 user 消息,导出层复用 pandoc 完成 md/docx/pdf 转换。 */ export interface ReviewTurn { /** 1-based 序号,按 branch 中出现的顺序递增。 */ index: number; role: "user" | "assistant"; text: string; } export type ReviewExportFormat = "markdown" | "docx" | "pdf"; export const REVIEW_EXPORT_EXTENSION: Record = { markdown: "md", docx: "docx", pdf: "pdf", }; export const REVIEW_EXPORT_FORMAT_LABELS: Record = { markdown: "Markdown (.md)", docx: "Word (.docx)", pdf: "PDF (.pdf)", }; /** * 从 message content 提取文本块,字符串与 {type:"text"} 数组均支持。 * 只做空判定,保留原始文本(Markdown 前导缩进有语义,不能被 trim 改变)。 */ export function messageContentText(content: unknown): string { if (typeof content === "string") return content.trim() ? content : ""; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const block of content) { if (!block || typeof block !== "object") continue; const record = block as { type?: unknown; text?: unknown }; if (record.type !== "text") continue; const text = typeof record.text === "string" ? record.text : ""; if (text.trim()) parts.push(text); } return parts.join("\n\n"); } /** * 从 session branch entries 组装 user/assistant turns。 * 只接受 `entry.type === "message"` 且 `entry.message.role` 为 user/assistant * 的条目;toolResult / custom / 空文本被跳过(与 goal-verification 的提取一致)。 */ export function collectReviewTurns(entries: unknown[]): ReviewTurn[] { const turns: ReviewTurn[] = []; for (const rawEntry of entries) { if (!rawEntry || typeof rawEntry !== "object") continue; const entry = rawEntry as { type?: unknown; message?: unknown }; if (entry.type !== "message") continue; const rawMessage = entry.message; if (!rawMessage || typeof rawMessage !== "object") continue; const message = rawMessage as { role?: unknown; content?: unknown }; if (message.role !== "user" && message.role !== "assistant") continue; const text = messageContentText(message.content); if (!text) continue; turns.push({ index: turns.length + 1, role: message.role, text }); } return turns; } export interface ReviewAssemblyOptions { title?: string; now?: Date; } const DEFAULT_REVIEW_TITLE = "Session Review"; /** 将选中的 turns 组装为完整 Markdown 文档(标题 + 元信息 + 分 turn 段落)。 */ export function assembleReviewMarkdown( turns: readonly ReviewTurn[], options: ReviewAssemblyOptions = {}, ): string { const title = options.title?.trim() ? options.title.trim() : DEFAULT_REVIEW_TITLE; const now = options.now ?? new Date(); const stamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; const lines: string[] = [ `# ${title}`, "", `> ${turns.length} 个 turn · 导出时间 ${stamp}`, "", ]; for (const turn of turns) { lines.push(`## ${turn.index}. ${turn.role === "user" ? "User" : "Assistant"}`); lines.push(""); lines.push(turn.text); lines.push(""); lines.push("---"); lines.push(""); } return lines.join("\n"); } export type ReviewCliParse = | { kind: "interactive" } | { kind: "help" } | { kind: "error"; message: string } | { kind: "cli"; turns: number[] | "all"; format: ReviewExportFormat; output?: string }; const FORMAT_ALIASES: Record = { md: "markdown", markdown: "markdown", docx: "docx", word: "docx", pdf: "pdf", }; /** * 引号感知的分词:支持双引号/单引号包裹含空格的路径。 * 未闭合引号抛错(由 parseReviewArgs 转成 error 结果)。 */ export function tokenizeArgs(args: string): string[] { const tokens: string[] = []; const input = args.trim(); let current = ""; let inToken = false; let quote: '"' | "'" | undefined; for (let index = 0; index < input.length; index++) { const ch = input[index]!; if (quote) { if (ch === quote) { quote = undefined; inToken = true; } else { current += ch; } continue; } if (ch === '"' || ch === "'") { quote = ch; inToken = true; continue; } if (/\s/.test(ch)) { if (inToken) { tokens.push(current); current = ""; inToken = false; } continue; } current += ch; inToken = true; } if (quote) throw new Error("未闭合的引号"); if (inToken) tokens.push(current); return tokens; } function parseTurnList(value: string): number[] | "all" | undefined { const normalized = value.trim().toLowerCase(); if (normalized === "all") return "all"; if (!/^\d+(,\d+)*$/.test(normalized)) return undefined; const indexes = normalized.split(",").map((part) => Number(part)); if (indexes.some((index) => !Number.isInteger(index) || index < 1)) return undefined; return [...new Set(indexes)]; } /** * 解析 `/markdown-review` 参数。没有任何 `--turn/--format/--output` 时进入 * 交互模式;`--format` 单独出现时默认导出全部 turns。 */ export function parseReviewArgs(args: string): ReviewCliParse { let tokens: string[]; try { tokens = tokenizeArgs(args); } catch { return { kind: "error", message: "参数中存在未闭合的引号。" }; } if (tokens.length === 0) return { kind: "interactive" }; if (tokens.includes("--help") || tokens.includes("-h") || tokens.includes("help")) { return { kind: "help" }; } let turnValue: string | undefined; let formatValue: string | undefined; let output: string | undefined; const positionals: string[] = []; for (let index = 0; index < tokens.length; index++) { const token = tokens[index]!; if (token === "--turn") { const value = tokens[index + 1]; if (!value || value.startsWith("-")) return { kind: "error", message: "Missing value after --turn. Use --turn ." }; turnValue = value; index++; continue; } if (token === "--format") { const value = tokens[index + 1]; if (!value || value.startsWith("-")) return { kind: "error", message: "Missing value after --format. Use --format ." }; formatValue = value; index++; continue; } if (token === "--output") { const value = tokens[index + 1]; if (!value || value.startsWith("-")) return { kind: "error", message: "Missing value after --output ." }; output = value; index++; continue; } if (token.startsWith("-")) { return { kind: "error", message: `Unknown argument "${token}". See /markdown-review --help.` }; } positionals.push(token); } if (turnValue === undefined && formatValue === undefined && output === undefined) { return { kind: "interactive" }; } let turns: number[] | "all" = "all"; if (turnValue !== undefined) { const parsed = parseTurnList(turnValue); if (parsed === undefined) { return { kind: "error", message: `Invalid --turn "${turnValue}". Use a 1-based turn number, a comma list, or "all".` }; } turns = parsed; } let format: ReviewExportFormat = "markdown"; if (formatValue !== undefined) { const resolved = FORMAT_ALIASES[formatValue.trim().toLowerCase()]; if (!resolved) { return { kind: "error", message: `Invalid --format "${formatValue}". Use md|markdown|docx|word|pdf.` }; } format = resolved; } if (positionals.length > 0) { if (output !== undefined) { return { kind: "error", message: `Unexpected positional "${positionals[0]}". Use --output for the destination.` }; } output = positionals.join(" "); } return { kind: "cli", turns, format, output }; } /** 按 --turn 选择过滤 turns;越界索引返回错误信息。 */ export function resolveSelectedTurns( turns: readonly ReviewTurn[], selection: number[] | "all", ): { ok: true; turns: ReviewTurn[] } | { ok: false; message: string } { if (selection === "all") return { ok: true, turns: [...turns] }; const byIndex = new Map(turns.map((turn) => [turn.index, turn])); const missing = selection.filter((index) => !byIndex.has(index)); if (missing.length > 0) { return { ok: false, message: `Turn 不存在: ${missing.join(", ")}(共 ${turns.length} 个 turn)` }; } return { ok: true, turns: selection.map((index) => byIndex.get(index)!).filter(Boolean) }; } /** 默认导出文件名:session-review-YYYYMMDD-HHmmss.。 */ export function defaultReviewOutputName(format: ReviewExportFormat, now: Date = new Date()): string { const pad = (value: number): string => String(value).padStart(2, "0"); const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; return `session-review-${stamp}.${REVIEW_EXPORT_EXTENSION[format]}`; } /** * 解析用户输出路径: * - `~` / `~/...` 展开为 homedir; * - 已存在目录 → 生成默认文件名放入该目录; * - 无对应扩展名 → 追加 `.md/.docx/.pdf`。 */ export async function resolveReviewOutputPath( format: ReviewExportFormat, cwd: string, output?: string, ): Promise { if (!output?.trim()) return join(cwd, defaultReviewOutputName(format)); let trimmed = output.trim(); if (trimmed === "~") { trimmed = homedir(); } else if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { trimmed = join(homedir(), trimmed.slice(2)); } const resolved = isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed); try { const info = await stat(resolved); if (info.isDirectory()) return join(resolved, defaultReviewOutputName(format)); } catch { // 目标不存在 — 按文件路径处理 } const hasKnownExtension = extname(resolved).toLowerCase() === `.${REVIEW_EXPORT_EXTENSION[format]}`; if (hasKnownExtension) return resolved; return `${resolved}.${REVIEW_EXPORT_EXTENSION[format]}`; } export interface ReviewExportOptions { /** 可注入的 spawn 实现(测试用);默认使用 node:child_process 的 spawn。 */ spawnFn?: typeof defaultSpawn; /** 传给子进程的环境变量;默认 process.env。 */ env?: NodeJS.ProcessEnv; /** PDF/转换超时(毫秒),默认 120000。 */ timeoutMs?: number; } const DEFAULT_EXPORT_TIMEOUT_MS = 120_000; /** stderr 只保留有界尾部,避免嘈杂转换器无界累积内存。 */ const MAX_STDERR_TAIL = 8_000; /** 将已组装的 Markdown 写入 .md 文件。 */ export async function writeMarkdownFile(markdown: string, outputPath: string): Promise { await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, markdown, "utf8"); } function pandocCommand(env: NodeJS.ProcessEnv): string { return env.PANDOC_PATH?.trim() || "pandoc"; } function pdfEngine(env: NodeJS.ProcessEnv): string { return env.PANDOC_PDF_ENGINE?.trim() || "xelatex"; } interface PandocInvocation { args: string[]; command: string; } function buildPandocArgs(format: ReviewExportFormat, outputPath: string, env: NodeJS.ProcessEnv): PandocInvocation { const command = pandocCommand(env); if (format === "docx") { return { command, args: ["-f", "markdown", "-t", "docx", "-o", outputPath] }; } if (format === "pdf") { return { command, args: [ "-f", "markdown", "-o", outputPath, `--pdf-engine=${pdfEngine(env)}`, "-V", "geometry:margin=2cm", "-V", "fontsize=11pt", "-V", "urlcolor=blue", "-V", "linkcolor=blue", ], }; } throw new Error(`Unsupported export format: ${format}`); } /** Windows 用 taskkill /T /F 终止整棵进程树;POSIX 用负 pid 进程组(需 detached 子进程)。 */ function terminateProcessTree(child: ChildProcess): void { if (!child.pid) return; try { if (process.platform === "win32") { const result = spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, }); if (result.error) throw result.error; } else { process.kill(-child.pid, "SIGTERM"); } } catch { try { child.kill("SIGTERM"); } catch { // 已退出或无权终止 — 忽略 } } } function runPandoc( spawnFn: typeof defaultSpawn, command: string, args: string[], markdown: string, env: NodeJS.ProcessEnv, timeoutMs: number, ): Promise { return new Promise((resolvePromise, reject) => { let child: ChildProcess; try { // stdout 使用 -o 时无输出,直接 ignore;stderr 保留有界尾部。 // POSIX 下 detached 使子进程成为独立进程组组长,超时才能用 -pid 整组终止。 const detached = process.platform !== "win32"; child = spawnFn(command, args, { stdio: ["pipe", "ignore", "pipe"], env, detached }); } catch (error) { reject(error instanceof Error ? error : new Error(String(error))); return; } let stderrTail = ""; let stdinFailed = false; let settled = false; const timeout = setTimeout(() => { if (settled) return; settled = true; terminateProcessTree(child); reject(new Error(`pandoc export timed out after ${Math.round(timeoutMs / 1000)}s.`)); }, timeoutMs); timeout.unref?.(); child.stderr?.on("data", (chunk: Buffer | string) => { stderrTail = (stderrTail + (typeof chunk === "string" ? chunk : chunk.toString("utf8"))).slice(-MAX_STDERR_TAIL); }); // stdin 写失败(如转换器提前退出)会被记下;close(0) 但 stdin 失败仍视为导出失败, // 避免把截断的输入当成成功。 child.stdin?.on("error", () => { stdinFailed = true; }); child.once("error", (error) => { if (settled) return; settled = true; clearTimeout(timeout); const errno = error as NodeJS.ErrnoException; if (errno.code === "ENOENT") { reject(new Error("pandoc 未找到。请安装 pandoc 或设置 PANDOC_PATH 环境变量。")); return; } reject(error); }); child.once("close", (code) => { if (settled) return; settled = true; clearTimeout(timeout); if (code === 0 && !stdinFailed) { resolvePromise(); return; } const reason = stdinFailed ? "stdin 写入失败(输入可能被截断)" : ""; reject(new Error(`pandoc 导出失败(exit ${code ?? "?"})${reason}${stderrTail ? `: ${stderrTail.slice(-500)}` : ""}`)); }); child.stdin?.end(markdown); }); } /** * 导出 Review 文档:markdown 直接写盘;docx/pdf 走 pandoc 转换链路 * (docx: markdown→docx;pdf: markdown→pdf,默认 xelatex 引擎)。 */ export async function exportReviewDocument( markdown: string, format: ReviewExportFormat, outputPath: string, options: ReviewExportOptions = {}, ): Promise { const spawnFn = options.spawnFn ?? defaultSpawn; const env = options.env ?? process.env; const timeoutMs = options.timeoutMs ?? DEFAULT_EXPORT_TIMEOUT_MS; await mkdir(dirname(outputPath), { recursive: true }); if (format === "markdown") { await writeMarkdownFile(markdown, outputPath); return; } const { command, args } = buildPandocArgs(format, outputPath, env); await runPandoc(spawnFn, command, args, markdown, env, timeoutMs); } export const REVIEW_USAGE = `用法:/markdown-review [--turn ] [--format ] [--output ] [--help] 无参数时进入交互模式: ↑↓ 选择 · Space 勾选 turn · a 全选 · n 清空 · Enter 预览(窄屏)· e 导出 · Esc 关闭 示例: /markdown-review /markdown-review --turn all --format pdf --output ~/review.pdf /markdown-review --turn 1,3 --format docx`;