diff --git a/electron/agentSessions/grok/discovery.test.ts b/electron/agentSessions/grok/discovery.test.ts new file mode 100644 index 0000000..0c9ba44 --- /dev/null +++ b/electron/agentSessions/grok/discovery.test.ts @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const wslMocks = vi.hoisted(() => ({ + execInWslAsync: vi.fn(), + getDistroHomeSubPathUNCAsync: vi.fn(), + listDistrosAsync: vi.fn(), + wslToUNC: vi.fn(), +})); + +vi.mock("../../wsl", () => wslMocks); + +import { discoverGrokSessionFiles, getGrokRootCandidatesFastAsync } from "./discovery"; + +const originalGrokHome = process.env.GROK_HOME; + +beforeEach(() => { + delete process.env.GROK_HOME; + wslMocks.execInWslAsync.mockReset(); + wslMocks.getDistroHomeSubPathUNCAsync.mockReset(); + wslMocks.listDistrosAsync.mockReset(); + wslMocks.wslToUNC.mockReset(); + wslMocks.getDistroHomeSubPathUNCAsync.mockResolvedValue("\\\\wsl.localhost\\TestDistro\\home\\tester\\.grok\\sessions"); + wslMocks.wslToUNC.mockImplementation((posixPath: string, distro: string) => { + const relativePath = posixPath.replace(/^\/+/, "").replace(/\//g, "\\"); + return `\\\\wsl.localhost\\${distro}\\${relativePath}`; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (originalGrokHome === undefined) delete process.env.GROK_HOME; + else process.env.GROK_HOME = originalGrokHome; +}); + +describe("discoverGrokSessionFiles", () => { + it("只发现目录结构正确且按官方规则可见的 summary.json", async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "codexflow-grok-discovery-")); + const first = path.join(root, "encoded-a", "session-a"); + const second = path.join(root, "encoded-b", "session-b"); + const hidden = path.join(root, "encoded-c", "session-c"); + const subagent = path.join(root, "encoded-d", "session-d"); + const visibleSubagent = path.join(root, "encoded-e", "session-e"); + const invalid = path.join(root, "encoded-f", "session-f"); + await fs.promises.mkdir(first, { recursive: true }); + await fs.promises.mkdir(second, { recursive: true }); + await fs.promises.mkdir(hidden, { recursive: true }); + await fs.promises.mkdir(subagent, { recursive: true }); + await fs.promises.mkdir(visibleSubagent, { recursive: true }); + await fs.promises.mkdir(invalid, { recursive: true }); + await fs.promises.writeFile(path.join(first, "summary.json"), "{}", "utf8"); + await fs.promises.writeFile(path.join(first, "updates.jsonl"), "", "utf8"); + await fs.promises.writeFile(path.join(second, "notes.json"), "{}", "utf8"); + await fs.promises.writeFile(path.join(hidden, "summary.json"), JSON.stringify({ hidden: true }), "utf8"); + await fs.promises.writeFile(path.join(subagent, "summary.json"), JSON.stringify({ session_kind: "subagent_fork" }), "utf8"); + await fs.promises.writeFile( + path.join(visibleSubagent, "summary.json"), + JSON.stringify({ hidden: false, session_kind: "subagent_resume" }), + "utf8", + ); + await fs.promises.writeFile(path.join(invalid, "summary.json"), "{", "utf8"); + await fs.promises.writeFile(path.join(root, "summary.json"), "{}", "utf8"); + + const files = await discoverGrokSessionFiles(root); + + expect(files.map((filePath) => path.relative(root, filePath).replace(/\\/g, "/")).sort()).toEqual([ + "encoded-a/session-a/summary.json", + "encoded-e/session-e/summary.json", + ]); + await fs.promises.rm(root, { recursive: true, force: true }); + }); +}); + +describe("getGrokRootCandidatesFastAsync", () => { + it("发现每个 WSL 发行版自身配置的 GROK_HOME 会话目录", async () => { + vi.spyOn(os, "platform").mockReturnValue("win32"); + wslMocks.listDistrosAsync.mockResolvedValue([ + { name: "TestDistro", state: "Running", version: 2, isDefault: true }, + ]); + wslMocks.execInWslAsync.mockResolvedValue("/opt/grok-home"); + + const candidates = await getGrokRootCandidatesFastAsync(); + + expect(wslMocks.execInWslAsync).toHaveBeenCalledWith( + "TestDistro", + "printf '%s' \"${GROK_HOME:-}\"", + { timeoutMs: 3_000 }, + ); + expect(candidates).toEqual(expect.arrayContaining([ + expect.objectContaining({ + path: "\\\\wsl.localhost\\TestDistro\\opt\\grok-home\\sessions", + source: "wsl", + kind: "unc", + distro: "TestDistro", + }), + ])); + }); + + it.each([ + ["空值", ""], + ["相对路径", "relative/grok-home"], + ["含换行的路径", "/opt/grok-home\n/other"], + ["含反斜杠的路径", "/opt/grok\\home"], + ])("忽略%s形式的 WSL GROK_HOME", async (_label, configuredHome) => { + vi.spyOn(os, "platform").mockReturnValue("win32"); + wslMocks.listDistrosAsync.mockResolvedValue([ + { name: "TestDistro", state: "Running", version: 2, isDefault: true }, + ]); + wslMocks.execInWslAsync.mockResolvedValue(configuredHome); + + await getGrokRootCandidatesFastAsync(); + + expect(wslMocks.wslToUNC).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/agentSessions/grok/discovery.ts b/electron/agentSessions/grok/discovery.ts new file mode 100644 index 0000000..5748bf3 --- /dev/null +++ b/electron/agentSessions/grok/discovery.ts @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) + +import os from "node:os"; +import path from "node:path"; +import { promises as fsp } from "node:fs"; +import type { SessionsRootCandidate } from "../../wsl"; +import { execInWslAsync, getDistroHomeSubPathUNCAsync, listDistrosAsync, wslToUNC } from "../../wsl"; + +type GrokDiscoverySummary = { + hidden?: unknown; + session_kind?: unknown; +}; + +/** + * 判断路径是否为目录。 + */ +async function directoryExists(targetPath: string): Promise { + try { + return (await fsp.stat(targetPath)).isDirectory(); + } catch { + return false; + } +} + +/** + * 对会话根候选按规范化路径去重。 + */ +function dedupeCandidates(candidates: SessionsRootCandidate[]): SessionsRootCandidate[] { + const byPath = new Map(); + for (const candidate of candidates) { + const key = String(candidate.path || "").replace(/\\/g, "/").toLowerCase(); + if (!key) continue; + const previous = byPath.get(key); + if (!previous || (!previous.exists && candidate.exists)) byPath.set(key, candidate); + } + return Array.from(byPath.values()); +} + +/** + * 读取指定 WSL 发行版自己的 GROK_HOME,并转换为会话目录 UNC 路径。 + */ +async function getWslConfiguredGrokSessionsRoot(distro: string): Promise { + const configuredHome = await execInWslAsync( + distro, + "printf '%s' \"${GROK_HOME:-}\"", + { timeoutMs: 3_000 }, + ); + const normalizedHome = String(configuredHome || "").trim(); + if (!/^\/[^\r\n\\]*$/.test(normalizedHome)) return ""; + return wslToUNC(path.posix.join(normalizedHome, "sessions"), distro); +} + +/** + * 按 Grok Build 官方历史列表规则判断摘要文件是否应显示。 + */ +async function shouldIncludeGrokSummary(summaryPath: string): Promise { + try { + const raw = (await fsp.readFile(summaryPath, "utf8")).replace(/^\uFEFF/, ""); + const summary = JSON.parse(raw) as GrokDiscoverySummary; + if (!summary || typeof summary !== "object" || Array.isArray(summary)) return false; + if (summary.hidden === true) return false; + if (summary.hidden === false) return true; + return typeof summary.session_kind !== "string" || !summary.session_kind.startsWith("subagent"); + } catch { + return false; + } +} + +/** + * 获取 Grok Build 会话根候选(Windows 本地与所有 WSL 发行版)。 + */ +export async function getGrokRootCandidatesFastAsync(): Promise { + const candidates: SessionsRootCandidate[] = []; + const push = async (targetPath: string, source: "windows" | "wsl", kind: "local" | "unc", distro?: string) => { + candidates.push({ path: targetPath, exists: await directoryExists(targetPath), source, kind, distro }); + }; + + try { + const configuredHome = typeof process.env.GROK_HOME === "string" ? process.env.GROK_HOME.trim() : ""; + if (configuredHome) await push(path.join(configuredHome, "sessions"), "windows", "local"); + } catch {} + + try { + await push(path.join(os.homedir(), ".grok", "sessions"), "windows", "local"); + } catch {} + + if (os.platform() === "win32") { + try { + const distros = await listDistrosAsync(); + await Promise.all(distros.map(async (distro) => { + const [configuredPath, defaultPath] = await Promise.all([ + getWslConfiguredGrokSessionsRoot(distro.name), + getDistroHomeSubPathUNCAsync(distro.name, ".grok/sessions"), + ]); + await Promise.all([ + configuredPath ? push(configuredPath, "wsl", "unc", distro.name) : Promise.resolve(), + defaultPath ? push(defaultPath, "wsl", "unc", distro.name) : Promise.resolve(), + ]); + })); + } catch {} + } + + return dedupeCandidates(candidates); +} + +/** + * 扫描 Grok Build 的会话摘要文件。 + * 官方目录结构为 `sessions///summary.json`。 + * 官方历史列表会排除显式隐藏的会话,以及未显式设为可见的 subagent 会话。 + */ +export async function discoverGrokSessionFiles(root: string): Promise { + const sessionFiles: string[] = []; + const baseRoot = String(root || "").trim(); + if (!baseRoot || !(await directoryExists(baseRoot))) return sessionFiles; + + const projects = await fsp.readdir(baseRoot, { withFileTypes: true }).catch(() => [] as import("node:fs").Dirent[]); + for (const project of projects) { + if (!project.isDirectory()) continue; + const projectDir = path.join(baseRoot, project.name); + const sessions = await fsp.readdir(projectDir, { withFileTypes: true }).catch(() => [] as import("node:fs").Dirent[]); + for (const session of sessions) { + if (!session.isDirectory()) continue; + const summaryPath = path.join(projectDir, session.name, "summary.json"); + if (await shouldIncludeGrokSummary(summaryPath)) sessionFiles.push(summaryPath); + } + } + + return sessionFiles; +} diff --git a/electron/agentSessions/grok/parser.test.ts b/electron/agentSessions/grok/parser.test.ts new file mode 100644 index 0000000..2f503e9 --- /dev/null +++ b/electron/agentSessions/grok/parser.test.ts @@ -0,0 +1,349 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseGrokSessionFile } from "./parser"; + +const tempDirs: string[] = []; + +/** + * 创建最小 Grok 会话目录,并返回 summary.json 路径。 + */ +async function createGrokSession(summary: unknown, updates: unknown[]): Promise { + return createGrokSessionFromEnvelopes(summary, updates.map(createAcpEnvelope)); +} + +/** + * 构造官方现代 ACP session/update 封装。 + */ +function createAcpEnvelope(update: unknown): unknown { + return { method: "session/update", params: { sessionId: "session-test", update } }; +} + +/** + * 构造官方 xAI 扩展 session/update 封装。 + */ +function createXaiEnvelope(update: unknown): unknown { + return { method: "_x.ai/session/update", params: { sessionId: "session-test", update } }; +} + +/** + * 使用指定 JSONL 封装创建最小 Grok 会话目录。 + */ +async function createGrokSessionFromEnvelopes(summary: unknown, envelopes: unknown[]): Promise { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "codexflow-grok-parser-")); + tempDirs.push(root); + const sessionDir = path.join(root, "project", "session-test"); + await fs.promises.mkdir(sessionDir, { recursive: true }); + const summaryPath = path.join(sessionDir, "summary.json"); + await fs.promises.writeFile(summaryPath, JSON.stringify(summary), "utf8"); + await fs.promises.writeFile( + path.join(sessionDir, "updates.jsonl"), + envelopes.map((envelope) => JSON.stringify(envelope)).join("\n") + "\n", + "utf8", + ); + return summaryPath; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) continue; + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} + } +}); + +describe("parseGrokSessionFile", () => { + it("摘要优先使用 last_active_at,并跳过隐藏的首条用户消息", async () => { + const summaryPath = await createGrokSession( + { + info: { id: "session-test", cwd: "/workspace/project" }, + created_at: "2026-07-01T08:00:00.000Z", + updated_at: "2026-07-01T09:00:00.000Z", + last_active_at: "2026-07-01T10:00:00.000Z", + }, + [ + { + sessionUpdate: "user_message_chunk", + content: { text: "内部上下文", _meta: { hideFromScrollback: true } }, + }, + { sessionUpdate: "user_message_chunk", content: { text: "真实问题" } }, + ], + ); + const stat = await fs.promises.stat(summaryPath); + + const details = await parseGrokSessionFile(summaryPath, stat, { summaryOnly: true }); + + expect(details.providerId).toBe("grok"); + expect(details.resumeId).toBe("session-test"); + expect(details.preview).toBe("真实问题"); + expect(details.title).toBe("真实问题"); + expect(details.rawDate).toBe("2026-07-01T10:00:00.000Z"); + expect(details.date).toBe(Date.parse("2026-07-01T10:00:00.000Z")); + expect(details.dirKey).toBe("/workspace/project"); + expect(details.messages).toHaveLength(0); + }); + + it("详情模式合并连续流式文本,并保留思考、工具与用量消息", async () => { + const summaryPath = await createGrokSession( + { + info: { id: "session-detail", cwd: "/workspace/project" }, + generated_title: "详情会话", + last_active_at: "2026-07-02T10:00:00.000Z", + current_model_id: "grok-code-test", + }, + [ + { sessionUpdate: "user_message_chunk", content: { text: "请" } }, + { sessionUpdate: "user_message_chunk", content: { text: "处理" } }, + { sessionUpdate: "agent_thought_chunk", content: { text: "分析中" } }, + { sessionUpdate: "agent_message_chunk", content: { text: "已完成" } }, + { sessionUpdate: "tool_call", toolCallId: "tool-1", title: "Read", status: "completed" }, + { sessionUpdate: "turn_completed", stopReason: "end_turn", usage: { inputTokens: 10 } }, + ], + ); + const stat = await fs.promises.stat(summaryPath); + + const details = await parseGrokSessionFile(summaryPath, stat, { summaryOnly: false }); + + expect(details.title).toBe("详情会话"); + expect(details.preview).toBe("请处理"); + expect(details.messages[0]).toMatchObject({ role: "meta" }); + expect(details.messages[1]).toEqual({ + role: "user", + content: [{ type: "input_text", text: "请处理", tags: ["grok.user_message"] }], + }); + expect(details.messages.map((message) => message.role)).toEqual([ + "meta", + "user", + "assistant", + "assistant", + "assistant", + "state", + ]); + expect(details.messages[2].content[0]).toMatchObject({ type: "reasoning", text: "分析中" }); + expect(details.messages[4].content[0]).toMatchObject({ type: "tool_call" }); + expect(JSON.parse(details.messages[5].content[0].text)).toMatchObject({ + stopReason: "end_turn", + usage: { inputTokens: 10 }, + }); + }); + + it("现代封装与旧版顶层 update 产生相同历史", async () => { + const summary = { info: { id: "session-format" } }; + const update = { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "兼容旧版历史" }, + }; + const modernPath = await createGrokSession(summary, [update]); + const legacyPath = await createGrokSessionFromEnvelopes(summary, [ + { sessionId: "session-test", update }, + ]); + const [modernStat, legacyStat] = await Promise.all([ + fs.promises.stat(modernPath), + fs.promises.stat(legacyPath), + ]); + + const [modern, legacy] = await Promise.all([ + parseGrokSessionFile(modernPath, modernStat, { summaryOnly: false }), + parseGrokSessionFile(legacyPath, legacyStat, { summaryOnly: false }), + ]); + + expect(legacy.preview).toBe("兼容旧版历史"); + expect(legacy.messages).toEqual(modern.messages); + }); + + it("回退到指定提示词时移除死分支与回退标记", async () => { + const summaryPath = await createGrokSessionFromEnvelopes( + { info: { id: "session-rewind" } }, + [ + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "第一问" } }), + createAcpEnvelope({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "第一答" } }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "废弃问题" } }), + createAcpEnvelope({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "废弃回答" } }), + createXaiEnvelope({ sessionUpdate: "rewind_marker", target_prompt_index: 1 }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "替代问题" } }), + createAcpEnvelope({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "替代回答" } }), + ], + ); + const stat = await fs.promises.stat(summaryPath); + + const details = await parseGrokSessionFile(summaryPath, stat, { summaryOnly: false }); + const transcript = details.messages.flatMap((message) => message.content).map((content) => content.text).join("\n"); + + expect(transcript).toContain("第一问"); + expect(transcript).toContain("第一答"); + expect(transcript).toContain("替代问题"); + expect(transcript).toContain("替代回答"); + expect(transcript).not.toContain("废弃问题"); + expect(transcript).not.toContain("废弃回答"); + expect(transcript).not.toContain("rewind_marker"); + }); + + it("摘要模式读到回退至零后使用新分支首条提示词", async () => { + const summaryPath = await createGrokSessionFromEnvelopes( + { info: { id: "session-rewind-zero" } }, + [ + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "已撤销提示词" } }), + createAcpEnvelope({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "已撤销回答" } }), + createXaiEnvelope({ sessionUpdate: "rewind_marker", target_prompt_index: 0 }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "全新提示词" } }), + ], + ); + const stat = await fs.promises.stat(summaryPath); + + const details = await parseGrokSessionFile(summaryPath, stat, { summaryOnly: true }); + + expect(details.preview).toBe("全新提示词"); + expect(details.title).toBe("全新提示词"); + }); + + it("连续回退始终只保留最终有效分支", async () => { + const summaryPath = await createGrokSessionFromEnvelopes( + { info: { id: "session-double-rewind" } }, + [ + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "保留问题" } }), + createAcpEnvelope({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "保留回答" } }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "第二问题" } }), + createAcpEnvelope({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "第二回答" } }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "第三问题" } }), + createXaiEnvelope({ sessionUpdate: "rewind_marker", target_prompt_index: 2 }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "第一替代" } }), + createAcpEnvelope({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "第一替代回答" } }), + createXaiEnvelope({ sessionUpdate: "rewind_marker", target_prompt_index: 1 }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "最终问题" } }), + ], + ); + const stat = await fs.promises.stat(summaryPath); + + const details = await parseGrokSessionFile(summaryPath, stat, { summaryOnly: false }); + const transcript = details.messages.flatMap((message) => message.content).map((content) => content.text).join("\n"); + + expect(transcript).toContain("保留问题"); + expect(transcript).toContain("保留回答"); + expect(transcript).toContain("最终问题"); + expect(transcript).not.toContain("第二问题"); + expect(transcript).not.toContain("第三问题"); + expect(transcript).not.toContain("第一替代"); + }); + + it("promptIndex 出现后不把无标记的中间用户块计为回退边界", async () => { + const summaryPath = await createGrokSessionFromEnvelopes( + { info: { id: "session-prompt-index" } }, + [ + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "P0" }, _meta: { promptIndex: 0 } }), + createAcpEnvelope({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "A0" } }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "!pwd" } }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "P1" }, _meta: { promptIndex: 1 } }), + createAcpEnvelope({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "A1" } }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "P2" }, _meta: { promptIndex: 2 } }), + createXaiEnvelope({ sessionUpdate: "rewind_marker", target_prompt_index: 2 }), + createAcpEnvelope({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "替代 P2" }, _meta: { promptIndex: 2 } }), + ], + ); + const stat = await fs.promises.stat(summaryPath); + + const details = await parseGrokSessionFile(summaryPath, stat, { summaryOnly: false }); + const transcript = details.messages.flatMap((message) => message.content).map((content) => content.text).join("\n"); + + expect(transcript).toContain("!pwd"); + expect(transcript).toContain("P1"); + expect(transcript).toContain("替代 P2"); + expect(transcript).not.toMatch(/(?:^|\n)P2(?:\n|$)/); + }); + + it("连续取消的提示词按 promptIndex 保持为独立用户消息", async () => { + const summaryPath = await createGrokSession( + { info: { id: "session-cancelled-prompts" } }, + [ + { sessionUpdate: "user_message_chunk", content: { type: "text", text: "P0" }, _meta: { promptIndex: 0 } }, + { sessionUpdate: "user_message_chunk", content: { type: "text", text: "P1" }, _meta: { promptIndex: 1 } }, + { sessionUpdate: "user_message_chunk", content: { type: "text", text: "P2" }, _meta: { promptIndex: 2 } }, + ], + ); + const stat = await fs.promises.stat(summaryPath); + + const details = await parseGrokSessionFile(summaryPath, stat, { summaryOnly: false }); + + expect(details.preview).toBe("P0"); + expect(details.messages.filter((message) => message.role === "user")).toEqual([ + { role: "user", content: [{ type: "input_text", text: "P0", tags: ["grok.user_message"] }] }, + { role: "user", content: [{ type: "input_text", text: "P1", tags: ["grok.user_message"] }] }, + { role: "user", content: [{ type: "input_text", text: "P2", tags: ["grok.user_message"] }] }, + ]); + }); + + it("宿主内部轮次不进入摘要或可见历史", async () => { + const summaryPath = await createGrokSession( + { info: { id: "session-host-turn" } }, + [ + { sessionUpdate: "user_message_chunk", content: { type: "text", text: "/workflows" }, _meta: { hostTurn: true } }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "宿主内部输出" }, _meta: { hostTurn: true } }, + { sessionUpdate: "user_message_chunk", content: { type: "text", text: "真实问题" }, _meta: { promptIndex: 0 } }, + { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "真实回答" } }, + ], + ); + const stat = await fs.promises.stat(summaryPath); + + const details = await parseGrokSessionFile(summaryPath, stat, { summaryOnly: false }); + const transcript = details.messages.flatMap((message) => message.content).map((content) => content.text).join("\n"); + + expect(details.preview).toBe("真实问题"); + expect(transcript).toContain("真实问题"); + expect(transcript).toContain("真实回答"); + expect(transcript).not.toContain("/workflows"); + expect(transcript).not.toContain("宿主内部输出"); + }); + + it("恢复 image_url Data URL,并与同一用户文本合并", async () => { + const dataUrl = "data:image/png;base64,aGVsbG8="; + const summaryPath = await createGrokSession( + { info: { id: "session-image-url" } }, + [ + { sessionUpdate: "user_message_chunk", content: { type: "text", text: "请查看图片" } }, + { sessionUpdate: "user_message_chunk", content: { type: "image_url", url: dataUrl } }, + ], + ); + const stat = await fs.promises.stat(summaryPath); + + const details = await parseGrokSessionFile(summaryPath, stat, { summaryOnly: false }); + const userMessage = details.messages.find((message) => message.role === "user"); + + expect(details.preview).toBe("请查看图片"); + expect(userMessage?.content).toHaveLength(2); + expect(userMessage?.content[1]).toMatchObject({ + type: "image", + src: dataUrl, + mimeType: "image/png", + tags: ["grok.user_message", "grok.image"], + }); + }); + + it("恢复当前 ACP image 的 data、mimeType 与 uri 形态", async () => { + const summaryPath = await createGrokSession( + { info: { id: "session-image" }, generated_title: "图片会话" }, + [ + { + sessionUpdate: "user_message_chunk", + content: { + type: "image", + data: "aGVsbG8=", + mimeType: "image/jpeg", + uri: "file:///tmp/missing-grok-history-image.jpg", + }, + }, + ], + ); + const stat = await fs.promises.stat(summaryPath); + + const details = await parseGrokSessionFile(summaryPath, stat, { summaryOnly: false }); + const image = details.messages.find((message) => message.role === "user")?.content[0]; + + expect(details.preview).toBeUndefined(); + expect(image).toMatchObject({ + type: "image", + src: "data:image/jpeg;base64,aGVsbG8=", + mimeType: "image/jpeg", + }); + expect(image?.text).not.toContain("aGVsbG8="); + }); +}); diff --git a/electron/agentSessions/grok/parser.ts b/electron/agentSessions/grok/parser.ts new file mode 100644 index 0000000..454dfdb --- /dev/null +++ b/electron/agentSessions/grok/parser.ts @@ -0,0 +1,530 @@ +// 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 readline from "node:readline"; +import { fileURLToPath } from "node:url"; +import { promises as fsp, type Stats } from "node:fs"; +import { detectRuntimeShell, type Message, type MessageContent, type RuntimeShell } from "../../history"; +import { createHistoryImageContent } from "../shared/historyImage"; +import { dirKeyFromCwd } from "../shared/path"; +import { filterHistoryPreviewText } from "../shared/preview"; + +export type GrokParseOptions = { + /** 索引阶段仅解析摘要和首条用户消息。 */ + summaryOnly?: boolean; + /** `updates.jsonl` 最大读取字节数。 */ + maxBytes?: number; + /** 最大解析行数。 */ + maxLines?: number; + /** 单行最大字符数。 */ + maxLineChars?: number; +}; + +export type GrokSessionDetails = { + providerId: "grok"; + id: string; + title: string; + date: number; + filePath: string; + messages: Message[]; + skippedLines: number; + rawDate?: string; + preview?: string; + cwd?: string; + dirKey?: string; + resumeId?: string; + runtimeShell?: RuntimeShell; +}; + +type GrokSummary = { + info?: { id?: unknown; cwd?: unknown }; + session_summary?: unknown; + generated_title?: unknown; + created_at?: unknown; + updated_at?: unknown; + last_active_at?: unknown; + current_model_id?: unknown; +}; + +type ParsedGrokUpdate = { + update: any; + isXai: boolean; +}; + +type UserRunTurnTracker = { + seenMarker: boolean; + inUser: boolean; + currentRunPromptIndex?: number; +}; + +type UserRunTrackingResult = { + startsRun: boolean; + startsCountedTurn: boolean; +}; + +/** + * 将未知值安全转换为非空字符串。 + */ +function asNonEmptyString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** + * 将时间字段转换为毫秒时间戳。 + */ +function parseTimestamp(value: unknown): number { + const raw = asNonEmptyString(value); + if (!raw) return 0; + const parsed = Date.parse(raw); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** + * 将历史列表文案限制为稳定的单行长度。 + */ +function clampListText(value: string, maxLength = 120): string { + const normalized = String(value || "").replace(/[\r\n\t]+/g, " ").replace(/\s{2,}/g, " ").trim(); + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`; +} + +/** + * 对结构化值生成有长度保护的 JSON 文本。 + */ +function stringifyStructured(value: unknown, maxLength = 100_000): string { + try { + const text = JSON.stringify(value, null, 2) || ""; + return text.length <= maxLength ? text : `${text.slice(0, maxLength)}\n…`; + } catch { + return String(value ?? ""); + } +} + +/** + * 从现代或旧版 JSONL 封装中提取 Grok 更新。 + */ +function parseGrokUpdateEnvelope(envelope: any): ParsedGrokUpdate | null { + if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return null; + const nestedUpdate = envelope?.params?.update; + const legacyUpdate = envelope?.update; + const update = nestedUpdate && typeof nestedUpdate === "object" && !Array.isArray(nestedUpdate) + ? nestedUpdate + : legacyUpdate && typeof legacyUpdate === "object" && !Array.isArray(legacyUpdate) + ? legacyUpdate + : null; + if (!update) return null; + return { + update, + isXai: envelope.method === "_x.ai/session/update", + }; +} + +/** + * 创建官方渐进式用户轮次跟踪状态。 + */ +function createUserRunTurnTracker(): UserRunTurnTracker { + return { + seenMarker: false, + inUser: false, + currentRunPromptIndex: undefined, + }; +} + +/** + * 读取非负整数元数据;无效值按缺失处理。 + */ +function readNonNegativeInteger(value: unknown): number | undefined { + return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : undefined; +} + +/** + * 读取用户消息块的官方 promptIndex 元数据。 + */ +function getUserPromptIndex(update: any): number | undefined { + return readNonNegativeInteger(update?._meta?.promptIndex ?? update?.content?._meta?.promptIndex); +} + +/** + * 判断用户消息块是否属于宿主内部轮次。 + */ +function isHostTurnUpdate(update: any): boolean { + return update?._meta?.hostTurn === true || update?.content?._meta?.hostTurn === true; +} + +/** + * 按官方规则推进用户轮次跟踪器,并返回新连续块与可计数轮次状态。 + */ +function trackUserChunk(tracker: UserRunTurnTracker, promptIndex?: number): UserRunTrackingResult { + const hasPromptIndex = promptIndex !== undefined; + if (hasPromptIndex) tracker.seenMarker = true; + const counts = tracker.seenMarker ? hasPromptIndex : true; + const startsNewRun = !tracker.inUser + || ((tracker.seenMarker || hasPromptIndex) && promptIndex !== tracker.currentRunPromptIndex); + if (startsNewRun) { + tracker.currentRunPromptIndex = promptIndex; + tracker.inUser = true; + return { startsRun: true, startsCountedTurn: counts }; + } + tracker.inUser = true; + return { startsRun: false, startsCountedTurn: false }; +} + +/** + * 结束当前用户消息连续块,同时保留是否见过 promptIndex 的渐进式状态。 + */ +function endUserRun(tracker: UserRunTurnTracker): void { + tracker.inUser = false; + tracker.currentRunPromptIndex = undefined; +} + +/** + * 判断更新是否不应显示在历史滚动区。 + */ +function isHiddenFromScrollback(update: any): boolean { + return update?._meta?.hideFromScrollback === true || update?.content?._meta?.hideFromScrollback === true; +} + +/** + * 从 Grok ACP 内容块中提取可展示文本。 + */ +function extractContentText(content: any): string { + if (!content || typeof content !== "object") return ""; + if (typeof content.text === "string") return content.text; + return ""; +} + +/** + * 从最终有效更新时间线中提取完整的首条可见用户文本。 + */ +function extractFirstVisibleUserText(entries: ParsedGrokUpdate[]): string { + const tracker = createUserRunTurnTracker(); + let collecting = false; + let text = ""; + + for (const entry of entries) { + const update = entry.update; + const updateType = asNonEmptyString(update?.sessionUpdate).toLowerCase(); + const isUserChunk = !entry.isXai && updateType === "user_message_chunk" && !isHostTurnUpdate(update); + if (!isUserChunk) { + endUserRun(tracker); + if (collecting) break; + continue; + } + + const tracking = trackUserChunk(tracker, getUserPromptIndex(update)); + if (collecting && tracking.startsRun) break; + if (isHiddenFromScrollback(update)) continue; + const chunkText = extractContentText(update?.content); + if (!chunkText) { + if (collecting) break; + continue; + } + collecting = true; + text += chunkText; + } + + return text; +} + +/** + * 将 file URL 或绝对路径转换为历史图片可复用的本地路径。 + */ +function resolveLocalImagePath(source: string): string { + const value = String(source || "").trim(); + if (!value) return ""; + if (/^file:/i.test(value)) { + try { + return fileURLToPath(value); + } catch { + return ""; + } + } + if (/^(?:[A-Za-z]:[\\/]|\/|\\\\)/.test(value)) return value; + return ""; +} + +/** + * 将 Grok ACP 图片内容块转换为通用历史图片内容。 + */ +function createGrokImageContent(content: any): MessageContent | null { + if (!content || typeof content !== "object" || Array.isArray(content)) return null; + const contentType = asNonEmptyString(content.type).toLowerCase(); + if (contentType !== "image" && contentType !== "image_url") return null; + + const url = asNonEmptyString(content.url) || asNonEmptyString(content?.image_url?.url); + const uri = asNonEmptyString(content.uri); + const data = asNonEmptyString(content.data); + const sources = [url, uri].filter(Boolean); + const dataUrl = [...sources, data].find((candidate) => /^data:image\//i.test(candidate)) || ""; + const localPath = resolveLocalImagePath(sources.find((candidate) => !/^data:|^https?:/i.test(candidate)) || ""); + const mimeType = [content.mimeType, content.mime_type, content.mediaType, content.media_type] + .map(asNonEmptyString) + .find(Boolean) || ""; + const imageContent = createHistoryImageContent({ + localPath: localPath || undefined, + mimeType: mimeType || undefined, + dataUrl: dataUrl || undefined, + base64Data: data && !/^data:/i.test(data) ? data : undefined, + tags: ["grok.user_message", "grok.image"], + preferDataUrl: true, + }); + if (imageContent) return imageContent; + + const remoteSource = sources.find((candidate) => /^https?:\/\//i.test(candidate)); + if (!remoteSource) return null; + return { + type: "image", + text: mimeType ? `图片\n类型: ${mimeType}` : "图片", + src: remoteSource, + mimeType: mimeType || undefined, + tags: ["grok.user_message", "grok.image"], + }; +} + +/** + * 向消息列表追加文本;连续同类流式块会合并,避免每个 token 形成一条消息。 + */ +function appendTextMessage( + messages: Message[], + role: string, + type: string, + text: string, + tags: string[], + startNewMessage = false, +): void { + if (!text) return; + const previous = messages[messages.length - 1]; + const previousContent = previous?.content?.[previous.content.length - 1]; + if (!startNewMessage && previous?.role === role && previousContent?.type === type && (previous.content.length === 1 || role === "user")) { + previousContent.text += text; + return; + } + if (!startNewMessage && role === "user" && previous?.role === "user") { + previous.content.push({ type, text, tags }); + return; + } + messages.push({ role, content: [{ type, text, tags }] }); +} + +/** + * 向用户消息追加图片内容,并按轮次边界决定是否新建消息。 + */ +function appendUserImageMessage(messages: Message[], imageContent: MessageContent, startNewMessage: boolean): void { + const previous = messages[messages.length - 1]; + if (!startNewMessage && previous?.role === "user") { + previous.content.push(imageContent); + return; + } + messages.push({ role: "user", content: [imageContent] }); +} + +/** + * 将单条 Grok `session/update` 转换为通用历史消息,并返回是否追加了用户内容。 + */ +function appendUpdateMessage(messages: Message[], update: any, startNewUserMessage = false): boolean { + const updateType = asNonEmptyString(update?.sessionUpdate).toLowerCase(); + if (!updateType) return false; + + if (updateType === "user_message_chunk") { + if (isHiddenFromScrollback(update)) return false; + const imageContent = createGrokImageContent(update?.content); + if (imageContent) { + appendUserImageMessage(messages, imageContent, startNewUserMessage); + return true; + } + const contentText = extractContentText(update?.content); + if (!contentText) return false; + appendTextMessage( + messages, + "user", + "input_text", + contentText, + ["grok.user_message"], + startNewUserMessage, + ); + return true; + } + if (updateType === "agent_message_chunk") { + appendTextMessage(messages, "assistant", "output_text", extractContentText(update?.content), ["grok.agent_message"]); + return false; + } + if (updateType === "agent_thought_chunk") { + appendTextMessage(messages, "assistant", "reasoning", extractContentText(update?.content), ["grok.agent_thought"]); + return false; + } + if (updateType === "tool_call") { + const payload = { + id: update.toolCallId, + title: update.title, + kind: update.kind, + status: update.status, + locations: update.locations, + rawInput: update.rawInput, + content: update.content, + }; + messages.push({ role: "assistant", content: [{ type: "tool_call", text: stringifyStructured(payload), tags: ["grok.tool_call"] }] }); + return false; + } + if (updateType === "tool_call_update") { + const payload = { + id: update.toolCallId, + title: update.title, + kind: update.kind, + status: update.status, + content: update.content, + rawOutput: update.rawOutput, + }; + messages.push({ role: "tool", content: [{ type: "tool_result", text: stringifyStructured(payload), tags: ["grok.tool_call_update"] }] }); + return false; + } + if (updateType === "plan") { + messages.push({ role: "assistant", content: [{ type: "plan", text: stringifyStructured(update.entries ?? update), tags: ["grok.plan"] }] }); + return false; + } + if (updateType === "turn_completed") { + const payload = { stopReason: update.stopReason ?? update.stop_reason, usage: update.usage }; + messages.push({ role: "state", content: [{ type: "usage", text: stringifyStructured(payload), tags: ["grok.turn_completed", "grok.usage"] }] }); + return false; + } + + messages.push({ role: "meta", content: [{ type: "meta", text: stringifyStructured(update), tags: [`grok.${updateType}`] }] }); + return false; +} + +/** + * 解析 Grok Build 的 `summary.json + updates.jsonl` 会话。 + */ +export async function parseGrokSessionFile(filePath: string, stat: Stats, options?: GrokParseOptions): Promise { + const inputPath = String(filePath || "").trim(); + const sessionDir = path.dirname(inputPath); + const summaryPath = path.basename(inputPath).toLowerCase() === "summary.json" ? inputPath : path.join(sessionDir, "summary.json"); + const updatesPath = path.join(sessionDir, "updates.jsonl"); + const summaryOnly = options?.summaryOnly === true; + const maxBytes = Math.max(64 * 1024, Math.min(128 * 1024 * 1024, Number(options?.maxBytes ?? (summaryOnly ? 2 * 1024 * 1024 : 64 * 1024 * 1024)))); + const maxLines = Math.max(1, Math.min(500_000, Number(options?.maxLines ?? (summaryOnly ? 2_000 : 100_000)))); + const maxLineChars = Math.max(8 * 1024, Math.min(4 * 1024 * 1024, Number(options?.maxLineChars ?? 2 * 1024 * 1024))); + + let summary: GrokSummary = {}; + let skippedLines = 0; + try { + const rawSummary = (await fsp.readFile(summaryPath, "utf8")).replace(/^\uFEFF/, ""); + summary = JSON.parse(rawSummary) as GrokSummary; + } catch { + skippedLines += 1; + } + + const resumeId = asNonEmptyString(summary.info?.id) || path.basename(sessionDir); + const cwd = asNonEmptyString(summary.info?.cwd); + const rawDate = asNonEmptyString(summary.last_active_at) || asNonEmptyString(summary.updated_at) || asNonEmptyString(summary.created_at); + const date = parseTimestamp(rawDate) || Number(stat?.mtimeMs || 0); + const messages: Message[] = []; + const liveUpdates: ParsedGrokUpdate[] = []; + const promptStarts: number[] = []; + const userRunTracker = createUserRunTurnTracker(); + + try { + const updatesStat = await fsp.stat(updatesPath); + const readLimit = Math.min(Math.max(0, Number(updatesStat.size || 0)), maxBytes); + const input = fs.createReadStream(updatesPath, { encoding: "utf8", start: 0, end: Math.max(0, readLimit - 1) }); + const reader = readline.createInterface({ input, crlfDelay: Infinity }); + let lineNumber = 0; + for await (const rawLine of reader) { + lineNumber += 1; + if (lineNumber > maxLines) { + skippedLines += 1; + break; + } + const line = String(rawLine || "").replace(/^\uFEFF/, "").trim(); + if (!line) continue; + if (line.length > maxLineChars) { + skippedLines += 1; + continue; + } + try { + const envelope = JSON.parse(line); + const parsedUpdate = parseGrokUpdateEnvelope(envelope); + if (!parsedUpdate) { + endUserRun(userRunTracker); + continue; + } + const updateType = asNonEmptyString(parsedUpdate.update?.sessionUpdate).toLowerCase(); + const rewindTarget = readNonNegativeInteger(parsedUpdate.update?.target_prompt_index); + if (parsedUpdate.isXai && updateType === "rewind_marker" && rewindTarget !== undefined) { + const truncateAt = promptStarts[rewindTarget] ?? liveUpdates.length; + liveUpdates.splice(truncateAt); + promptStarts.splice(Math.min(rewindTarget, promptStarts.length)); + endUserRun(userRunTracker); + continue; + } + + const isUserChunk = !parsedUpdate.isXai + && updateType === "user_message_chunk" + && !isHostTurnUpdate(parsedUpdate.update); + if (isUserChunk) { + if (trackUserChunk(userRunTracker, getUserPromptIndex(parsedUpdate.update)).startsCountedTurn) { + promptStarts.push(liveUpdates.length); + } + } else { + endUserRun(userRunTracker); + } + liveUpdates.push(parsedUpdate); + } catch { + endUserRun(userRunTracker); + skippedLines += 1; + } + } + if (Number(updatesStat.size || 0) > maxBytes) skippedLines += 1; + } catch { + skippedLines += 1; + } + + const firstUserText = extractFirstVisibleUserText(liveUpdates); + if (!summaryOnly) { + const renderTracker = createUserRunTurnTracker(); + let pendingUserMessageBoundary = false; + for (const entry of liveUpdates) { + const update = entry.update; + const updateType = asNonEmptyString(update?.sessionUpdate).toLowerCase(); + const isHostTurn = isHostTurnUpdate(update); + const isUserChunk = !entry.isXai && updateType === "user_message_chunk" && !isHostTurn; + let startsNewUserMessage = false; + if (isUserChunk) { + startsNewUserMessage = trackUserChunk(renderTracker, getUserPromptIndex(update)).startsRun; + pendingUserMessageBoundary = pendingUserMessageBoundary || startsNewUserMessage; + } else { + endUserRun(renderTracker); + pendingUserMessageBoundary = false; + } + if (!isHostTurn && appendUpdateMessage(messages, update, pendingUserMessageBoundary)) { + pendingUserMessageBoundary = false; + } + } + } + + const summaryTitle = asNonEmptyString(summary.generated_title) || asNonEmptyString(summary.session_summary); + const preview = clampListText(filterHistoryPreviewText(firstUserText), 120); + const title = clampListText(summaryTitle || preview || `Grok ${resumeId}`, 120); + const modelId = asNonEmptyString(summary.current_model_id); + if (!summaryOnly && (cwd || modelId)) { + const details = [cwd ? `cwd: ${cwd}` : "", modelId ? `model: ${modelId}` : ""].filter(Boolean).join("\n"); + messages.unshift({ role: "meta", content: [{ type: "session_meta", text: details, tags: ["grok.session_meta"] }] }); + } + + return { + providerId: "grok", + id: `grok:${resumeId}`, + title, + date, + filePath: summaryPath, + messages, + skippedLines, + rawDate: rawDate || undefined, + preview: preview || undefined, + cwd: cwd || undefined, + dirKey: cwd ? dirKeyFromCwd(cwd) : undefined, + resumeId: resumeId || undefined, + runtimeShell: detectRuntimeShell(summaryPath), + }; +} diff --git a/electron/grok/notifications.test.ts b/electron/grok/notifications.test.ts new file mode 100644 index 0000000..d479a3a --- /dev/null +++ b/electron/grok/notifications.test.ts @@ -0,0 +1,205 @@ +import fs from "node:fs"; +import { spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../agentSessions/grok/discovery", () => ({ + getGrokRootCandidatesFastAsync: vi.fn(), +})); + +vi.mock("../wsl", () => ({ + uncToWsl: vi.fn(() => null), +})); + +const tempDirs: string[] = []; + +/** + * 构造 Hook 写入的通知行。 + */ +function createNotifyLine(rawEvent: string): string { + return [ + "v1", + Buffer.from("tab-1", "utf8").toString("base64"), + Buffer.from("PowerShell", "utf8").toString("base64"), + Buffer.from("grok", "utf8").toString("base64"), + Buffer.from(rawEvent, "utf8").toString("base64"), + ].join("\t"); +} + +/** + * 加载 Grok 通知模块,并把候选会话根指向临时目录。 + */ +async function loadGrokNotificationsModule(grokHome: string): Promise { + vi.resetModules(); + const discovery = await import("../agentSessions/grok/discovery"); + vi.mocked(discovery.getGrokRootCandidatesFastAsync).mockResolvedValue([ + { path: path.join(grokHome, "sessions"), exists: true, source: "windows", kind: "local" }, + ]); + return await import("./notifications"); +} + +afterEach(() => { + try { vi.restoreAllMocks(); } catch {} + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) continue; + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} + } +}); + +describe("electron/grok/notifications", () => { + it("生成 Stop/SubagentStop Hook 配置与无 BOM PowerShell 通知脚本", async () => { + const grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "codexflow-grok-notify-")); + tempDirs.push(grokHome); + const mod = await loadGrokNotificationsModule(grokHome); + + await mod.ensureAllGrokNotifications(); + + const config = JSON.parse(fs.readFileSync(path.join(grokHome, "hooks", "codexflow-notifications.json"), "utf8")); + const stopHandler = config.hooks.Stop[0].hooks[0]; + const subagentHandler = config.hooks.SubagentStop[0].hooks[0]; + expect(stopHandler).toEqual(subagentHandler); + expect(stopHandler).toMatchObject({ type: "command", timeout: 5 }); + expect(stopHandler.command).toContain("codexflow-notify.ps1"); + + const scriptBuffer = fs.readFileSync(path.join(grokHome, "hooks", "codexflow-notify.ps1")); + expect([...scriptBuffer.subarray(0, 3)]).not.toEqual([0xef, 0xbb, 0xbf]); + const script = scriptBuffer.toString("utf8"); + expect(script).toContain("GROK_CODEXFLOW_TAB_ID"); + expect(script).toContain("IsNullOrWhiteSpace($tabId)"); + expect(script).toContain("GROK_CODEXFLOW_ENV_LABEL"); + expect(script).toContain("GROK_CODEXFLOW_PROVIDER_ID"); + expect(script).toContain("[Console]::OpenStandardInput()"); + expect(script).toContain("$utf8.GetString($inputBuffer.ToArray())"); + expect(script).not.toContain("[Console]::In.ReadToEnd()"); + + expect(mod.__testing.resolveGrokAgentType({ subagentType: "explore", agentType: "legacy" })).toBe("explore"); + expect(mod.__testing.resolveGrokAgentType({ agentType: "legacy" })).toBe("legacy"); + expect(mod.__testing.shouldUsePosixHook({ path: "/tmp/.grok/sessions", exists: true, source: "windows", kind: "local" }, "linux")).toBe(true); + expect(mod.__testing.shouldUsePosixHook({ path: "C:\\fixture\\.grok\\sessions", exists: true, source: "windows", kind: "local" }, "win32")).toBe(false); + expect(mod.__testing.shouldUsePosixHook({ path: "\\\\wsl.localhost\\TestDistro\\home\\user\\.grok\\sessions", exists: true, source: "wsl", kind: "unc" }, "win32")).toBe(true); + }); + + it("缺少 CodexFlow 标签页标记的 Grok 通知不会进入转发链路", async () => { + const grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "codexflow-grok-notify-")); + tempDirs.push(grokHome); + const mod = await loadGrokNotificationsModule(grokHome); + const rawEvent = JSON.stringify({ + hookEventName: "stop", + sessionId: "external-session", + reason: "end_turn", + }); + const line = [ + "v1", + "", + "", + Buffer.from("grok", "utf8").toString("base64"), + Buffer.from(rawEvent, "utf8").toString("base64"), + ].join("\t"); + + const record = mod.__testing.parseNotifyLine(line); + + expect(record?.tabId).toBe(""); + expect(mod.__testing.hasGrokCodexFlowTabId(record)).toBe(false); + }); + + it("可解析有效的 Base64 通知事件", async () => { + const grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "codexflow-grok-notify-")); + tempDirs.push(grokHome); + const mod = await loadGrokNotificationsModule(grokHome); + + const record = mod.__testing.parseNotifyLine(createNotifyLine(JSON.stringify({ + hookEventName: "stop", + sessionId: "session-1", + cwd: "/workspace/project", + timestamp: "2026-07-24T11:40:38.000Z", + reason: "end_turn", + lastAssistantMessage: "Task completed", + }))); + + expect(record).toMatchObject({ + tabId: "tab-1", + envLabel: "PowerShell", + providerId: "grok", + event: { + hookEventName: "stop", + reason: "end_turn", + lastAssistantMessage: "Task completed", + }, + }); + }); + + it.skipIf(process.platform !== "win32")("PowerShell Hook 应保留 UTF-8 回复正文", async () => { + const grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "codexflow-grok-notify-")); + tempDirs.push(grokHome); + const mod = await loadGrokNotificationsModule(grokHome); + await mod.ensureAllGrokNotifications(); + const scriptPath = path.join(grokHome, "hooks", "codexflow-notify.ps1"); + const payload = JSON.stringify({ + hookEventName: "stop", + sessionId: "session-1", + cwd: "/workspace/project", + reason: "end_turn", + lastAssistantMessage: "你好,Grok 已完成。", + }); + + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ], { + cwd: grokHome, + env: { + ...process.env, + GROK_CODEXFLOW_TAB_ID: "tab-1", + GROK_CODEXFLOW_ENV_LABEL: "PowerShell", + GROK_CODEXFLOW_PROVIDER_ID: "grok", + }, + input: Buffer.from(payload, "utf8"), + encoding: "utf8", + timeout: 10_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + const notifyPath = path.join(grokHome, "codexflow", "after-agent-notify.jsonl"); + const parts = fs.readFileSync(notifyPath, "utf8").trim().split("\t"); + expect(parts[0]).toBe("v1"); + const event = JSON.parse(Buffer.from(parts.slice(4).join("\t"), "base64").toString("utf8")); + expect(event.lastAssistantMessage).toBe("你好,Grok 已完成。"); + }); + + it("回复正文损坏时仍恢复主代理完成元数据", async () => { + const grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "codexflow-grok-notify-")); + tempDirs.push(grokHome); + const mod = await loadGrokNotificationsModule(grokHome); + const rawEvent = '{"hookEventName":"stop","sessionId":"session-1","cwd":"/workspace/project","timestamp":"2026-07-24T11:40:38.000Z","reason":"end_turn","lastAssistantMessage":"message with an unescaped " quote"}'; + + const record = mod.__testing.parseNotifyLine(createNotifyLine(rawEvent)); + + expect(record).toMatchObject({ + tabId: "tab-1", + providerId: "grok", + event: { + hookEventName: "stop", + sessionId: "session-1", + reason: "end_turn", + }, + }); + expect(record?.event.lastAssistantMessage).toBeUndefined(); + expect(mod.__testing.resolveGrokCompletionKind(record?.event || {})).toBe("agent"); + }); + + it("正文损坏且并非完成事件时拒绝通知记录", async () => { + const grokHome = fs.mkdtempSync(path.join(os.tmpdir(), "codexflow-grok-notify-")); + tempDirs.push(grokHome); + const mod = await loadGrokNotificationsModule(grokHome); + const rawEvent = '{"hookEventName":"stop","sessionId":"session-1","reason":"tool_use","lastAssistantMessage":"message with an unescaped " quote"}'; + + expect(mod.__testing.parseNotifyLine(createNotifyLine(rawEvent))).toBeNull(); + }); +}); diff --git a/electron/grok/notifications.ts b/electron/grok/notifications.ts new file mode 100644 index 0000000..e49720b --- /dev/null +++ b/electron/grok/notifications.ts @@ -0,0 +1,491 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) + +import { BrowserWindow } from "electron"; +import { promises as fsp } from "node:fs"; +import path from "node:path"; +import { getGrokRootCandidatesFastAsync } from "../agentSessions/grok/discovery"; +import { requestHistoryFastRefresh } from "../indexer"; +import { perfLogger } from "../log"; +import { uncToWsl, type SessionsRootCandidate } from "../wsl"; + +const GROK_HOOK_CONFIG_FILENAME = "codexflow-notifications.json"; +const GROK_WINDOWS_HOOK_FILENAME = "codexflow-notify.ps1"; +const GROK_WSL_HOOK_FILENAME = "codexflow-notify.sh"; +const GROK_NOTIFY_DIRNAME = "codexflow"; +const GROK_NOTIFY_FILENAME = "after-agent-notify.jsonl"; +const GROK_NOTIFY_POLL_INTERVAL_MS = 1000; +const GROK_NOTIFY_READ_LIMIT_BYTES = 256 * 1024; + +const GROK_WINDOWS_HOOK_SCRIPT = [ + "# SPDX-License-Identifier: Apache-2.0", + '$ErrorActionPreference = "SilentlyContinue"', + '$tabId = [string]$env:GROK_CODEXFLOW_TAB_ID', + 'if ([string]::IsNullOrWhiteSpace($tabId)) { exit 0 }', + '$notifyPath = Join-Path (Split-Path -Parent $PSScriptRoot) "codexflow\\after-agent-notify.jsonl"', + "$notifyDir = Split-Path -Parent $notifyPath", + "[IO.Directory]::CreateDirectory($notifyDir) | Out-Null", + "$utf8 = New-Object Text.UTF8Encoding($false)", + '$inputJson = ""', + "$inputStream = [Console]::OpenStandardInput()", + "$inputBuffer = New-Object IO.MemoryStream", + "try {", + " $inputStream.CopyTo($inputBuffer)", + " $inputJson = $utf8.GetString($inputBuffer.ToArray())", + "} finally {", + " $inputBuffer.Dispose()", + "}", + "function Encode-Text([string]$value) {", + ' if ($null -eq $value) { $value = "" }', + " return [Convert]::ToBase64String($utf8.GetBytes($value))", + "}", + "try {", + " if ((Test-Path -LiteralPath $notifyPath) -and (Get-Item -LiteralPath $notifyPath).Length -gt 1048576) {", + ' [IO.File]::WriteAllText($notifyPath, "", $utf8)', + " }", + "} catch {}", + '$line = "v1`t" + (Encode-Text $tabId) + "`t" + (Encode-Text $env:GROK_CODEXFLOW_ENV_LABEL) + "`t" + (Encode-Text $env:GROK_CODEXFLOW_PROVIDER_ID) + "`t" + (Encode-Text $inputJson) + "`n"', + "try { [IO.File]::AppendAllText($notifyPath, $line, $utf8) } catch {}", + "exit 0", + "", +].join("\n"); + +const GROK_WSL_HOOK_SCRIPT = String.raw`#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +case "$GROK_CODEXFLOW_TAB_ID" in *[![:space:]]*) ;; *) exit 0 ;; esac +notify_path="$(dirname "$(dirname "$0")")/codexflow/after-agent-notify.jsonl" +mkdir -p "$(dirname "$notify_path")" 2>/dev/null || true +input_json=$(cat) +encode_text() { + printf '%s' "$1" | base64 | tr -d '\r\n' +} +if [ -f "$notify_path" ]; then + size=$(wc -c < "$notify_path" 2>/dev/null || printf '0') + if [ "$size" -gt 1048576 ] 2>/dev/null; then : > "$notify_path"; fi +fi +printf 'v1\t%s\t%s\t%s\t%s\n' \ + "$(encode_text "$GROK_CODEXFLOW_TAB_ID")" \ + "$(encode_text "$GROK_CODEXFLOW_ENV_LABEL")" \ + "$(encode_text "$GROK_CODEXFLOW_PROVIDER_ID")" \ + "$(encode_text "$input_json")" >> "$notify_path" 2>/dev/null || true +exit 0 +`; + +type GrokNotifySource = { + filePath: string; + offset: number; + remainder: string; +}; + +type GrokHookEvent = { + hookEventName?: string; + sessionId?: string; + cwd?: string; + workspaceRoot?: string; + timestamp?: string; + transcriptPath?: string; + lastAssistantMessage?: string; + reason?: string; + subagentId?: string; + agentId?: string; + subagentType?: string; + agentType?: string; +}; + +type ParsedGrokHookEvent = { + event: GrokHookEvent; + usedFallback: boolean; +}; + +const GROK_EVENT_STRING_FIELDS: ReadonlyArray = [ + "hookEventName", + "sessionId", + "cwd", + "workspaceRoot", + "timestamp", + "transcriptPath", + "lastAssistantMessage", + "reason", + "subagentId", + "agentId", + "subagentType", + "agentType", +]; + +let ensureInflight: Promise | null = null; +let notifyTimer: NodeJS.Timeout | null = null; +let notifyPolling = false; +let notifyWindowGetter: (() => BrowserWindow | null) | null = null; +let notifyGeneration = 0; +const notifySources = new Map(); + +/** + * 写入 Grok 通知诊断日志。 + */ +function logGrokNotification(message: string): void { + try { perfLogger.log(`[grok.notify] ${message}`); } catch {} +} + +/** + * 获取候选根对应的 `.grok` 目录。 + */ +function getGrokHomeFromCandidate(candidate: SessionsRootCandidate): string { + return path.dirname(String(candidate.path || "")); +} + +/** + * 判断通知 Hook 是否应使用 POSIX shell 脚本。 + */ +function shouldUsePosixHook(candidate: SessionsRootCandidate, platform: NodeJS.Platform = process.platform): boolean { + return platform !== "win32" || candidate.source === "wsl" || candidate.kind === "unc"; +} + +/** + * 将命令参数转义为 Windows 命令行双引号参数。 + */ +function quoteWindowsArgument(value: string): string { + return `"${String(value || "").replace(/"/g, '\\"')}"`; +} + +/** + * 将命令参数转义为 POSIX shell 单引号字面量。 + */ +function quotePosix(value: string): string { + return `'${String(value || "").replace(/'/g, `'"'"'`)}'`; +} + +/** + * 仅在内容变化时写入 UTF-8 文本文件。 + */ +async function writeFileIfChanged(filePath: string, content: string): Promise { + try { + const previous = await fsp.readFile(filePath, "utf8").catch(() => ""); + if (previous.replace(/^\uFEFF/, "") === content) return; + await fsp.writeFile(filePath, content, "utf8"); + } catch (error) { + logGrokNotification(`write failed path=${JSON.stringify(filePath)} error=${String(error)}`); + } +} + +/** + * 构造单个 Grok 根目录使用的 Hook 配置。 + */ +function buildHookConfig(command: string): string { + const handler = { type: "command", command, timeout: 5 }; + return `${JSON.stringify({ + hooks: { + Stop: [{ hooks: [handler] }], + SubagentStop: [{ hooks: [handler] }], + }, + }, null, 2)}\n`; +} + +/** + * 确保单个 Grok 根目录安装 CodexFlow 通知 Hook。 + */ +async function ensureGrokNotificationForCandidate(candidate: SessionsRootCandidate): Promise { + const grokHome = getGrokHomeFromCandidate(candidate); + if (!grokHome) return; + const hooksDir = path.join(grokHome, "hooks"); + const notifyDir = path.join(grokHome, GROK_NOTIFY_DIRNAME); + await fsp.mkdir(hooksDir, { recursive: true }); + await fsp.mkdir(notifyDir, { recursive: true }); + + let command = ""; + if (shouldUsePosixHook(candidate)) { + const scriptPath = path.join(hooksDir, GROK_WSL_HOOK_FILENAME); + await writeFileIfChanged(scriptPath, GROK_WSL_HOOK_SCRIPT); + const wslPath = uncToWsl(scriptPath)?.wslPath || scriptPath.replace(/\\/g, "/"); + command = `sh ${quotePosix(wslPath)}`; + } else { + const scriptPath = path.join(hooksDir, GROK_WINDOWS_HOOK_FILENAME); + await writeFileIfChanged(scriptPath, GROK_WINDOWS_HOOK_SCRIPT); + command = `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${quoteWindowsArgument(scriptPath)}`; + } + + await writeFileIfChanged(path.join(hooksDir, GROK_HOOK_CONFIG_FILENAME), buildHookConfig(command)); +} + +/** + * 确保 Windows 与 WSL 的所有 Grok 根均安装通知 Hook。 + */ +export async function ensureAllGrokNotifications(): Promise { + if (ensureInflight) return ensureInflight; + ensureInflight = (async () => { + const candidates = await getGrokRootCandidatesFastAsync().catch((error) => { + logGrokNotification(`discover failed error=${String(error)}`); + return [] as SessionsRootCandidate[]; + }); + await Promise.all(candidates.map(async (candidate) => { + try { await ensureGrokNotificationForCandidate(candidate); } catch (error) { + logGrokNotification(`ensure failed root=${JSON.stringify(candidate.path)} error=${String(error)}`); + } + })); + })().finally(() => { ensureInflight = null; }); + return ensureInflight; +} + +/** + * 列出所有 Grok 通知 JSONL 文件。 + */ +async function listGrokNotifyFiles(): Promise { + const candidates = await getGrokRootCandidatesFastAsync().catch((error) => { + logGrokNotification(`discover notify sources failed error=${String(error)}`); + return [] as SessionsRootCandidate[]; + }); + const output: string[] = []; + for (const candidate of candidates) { + const grokHome = getGrokHomeFromCandidate(candidate); + if (!grokHome) continue; + const filePath = path.join(grokHome, GROK_NOTIFY_DIRNAME, GROK_NOTIFY_FILENAME); + if (!output.includes(filePath)) output.push(filePath); + } + return output; +} + +/** + * 同步通知源列表,并从文件末尾开始监听,避免重复弹出旧通知。 + */ +async function syncNotifySources(paths: string[], generation: number): Promise { + if (generation !== notifyGeneration) return; + const nextKeys = new Set(paths.map((item) => path.normalize(item).toLowerCase())); + for (const [key] of notifySources) { + if (!nextKeys.has(key)) notifySources.delete(key); + } + for (const filePath of paths) { + const key = path.normalize(filePath).toLowerCase(); + if (notifySources.has(key)) continue; + const stat = await fsp.stat(filePath).catch(() => null); + notifySources.set(key, { filePath, offset: Number(stat?.size || 0), remainder: "" }); + } +} + +/** + * 解码 Hook 行中的 Base64 UTF-8 字段。 + */ +function decodeBase64(value: string): string { + try { return Buffer.from(String(value || ""), "base64").toString("utf8"); } catch { return ""; } +} + +/** + * 将未知的 JSON 值限制为通知所需的字符串字段。 + */ +function toGrokHookEvent(value: unknown): GrokHookEvent | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const source = value as Record; + const event: GrokHookEvent = {}; + for (const field of GROK_EVENT_STRING_FIELDS) { + const fieldValue = source[field]; + if (typeof fieldValue === "string") event[field] = fieldValue; + } + return event; +} + +/** + * 判断事件是否代表可发送系统通知的 Grok 完成状态。 + */ +function resolveGrokCompletionKind(event: GrokHookEvent): "agent" | "subagent" | null { + const hookEventName = String(event.hookEventName || "").trim().toLowerCase(); + if (hookEventName === "subagent_stop" || hookEventName === "subagentstop") return "subagent"; + if (hookEventName !== "stop") return null; + return String(event.reason || "").trim().toLowerCase() === "end_turn" ? "agent" : null; +} + +/** + * 在回复正文损坏时,仅恢复正文之前可严格解析的完成元数据。 + */ +function recoverMalformedGrokHookEvent(rawEvent: string): GrokHookEvent | null { + const messageFieldIndex = rawEvent.indexOf("\"lastAssistantMessage\""); + if (messageFieldIndex < 0) return null; + const metadataPrefix = rawEvent.slice(0, messageFieldIndex).replace(/,\s*$/, ""); + if (!metadataPrefix.trim()) return null; + try { + const event = toGrokHookEvent(JSON.parse(`${metadataPrefix}}`)); + return event && resolveGrokCompletionKind(event) ? event : null; + } catch { + return null; + } +} + +/** + * 优先严格解析 Grok 事件,并在仅回复正文损坏时执行受限降级恢复。 + */ +function parseGrokHookEvent(rawEvent: string): ParsedGrokHookEvent | null { + try { + const event = toGrokHookEvent(JSON.parse(rawEvent)); + return event ? { event, usedFallback: false } : null; + } catch { + const event = recoverMalformedGrokHookEvent(rawEvent); + return event ? { event, usedFallback: true } : null; + } +} + +/** + * 解析 Hook 写入的一行通知记录。 + */ +function parseNotifyLine(line: string): { tabId: string; envLabel: string; providerId: string; event: GrokHookEvent } | null { + const parts = String(line || "").split("\t"); + if (parts.length < 5 || parts[0] !== "v1") return null; + const rawEvent = decodeBase64(parts.slice(4).join("\t")).replace(/^\uFEFF/, ""); + const parsedEvent = parseGrokHookEvent(rawEvent); + if (!parsedEvent) { + logGrokNotification(`event parse rejected rawLength=${Buffer.byteLength(rawEvent, "utf8")}`); + return null; + } + if (parsedEvent.usedFallback) { + const completionKind = resolveGrokCompletionKind(parsedEvent.event) || "unknown"; + logGrokNotification(`event parse fallback completion=${completionKind} rawLength=${Buffer.byteLength(rawEvent, "utf8")}`); + } + return { + tabId: decodeBase64(parts[1]), + envLabel: decodeBase64(parts[2]), + providerId: decodeBase64(parts[3]) || "grok", + event: parsedEvent.event, + }; +} + +/** + * 清理通知预览中的不可见控制字符并限制长度。 + */ +function normalizePreview(value: unknown): string { + const text = String(value || "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, " ").trim(); + const chars = Array.from(text); + return chars.length <= 1200 ? text : `${chars.slice(0, 1200).join("")}...`; +} + +/** + * 解析 Grok 子代理类型,优先使用官方字段并兼容旧版字段。 + */ +function resolveGrokAgentType(event: GrokHookEvent): string { + return String(event.subagentType || event.agentType || ""); +} + +/** + * 判断通知是否来自由 CodexFlow 启动并注入标签页标记的 Grok 会话。 + */ +function hasGrokCodexFlowTabId(record: ReturnType): boolean { + return Boolean(String(record?.tabId || "").trim()); +} + +/** + * 将 Grok Hook 事件转发给渲染进程。 + */ +function emitNotifyRecord(record: ReturnType, sourcePath: string, generation: number): void { + if (!record || generation !== notifyGeneration) return; + if (!hasGrokCodexFlowTabId(record)) { + logGrokNotification("event dropped reason=missing-codexflow-tab-id"); + return; + } + const providerId = String(record.providerId || "grok").trim().toLowerCase(); + if (providerId !== "grok") return; + const hookEventName = String(record.event.hookEventName || "").trim().toLowerCase(); + const completionKind = resolveGrokCompletionKind(record.event); + if (!completionKind) return; + + const win = notifyWindowGetter?.() || null; + if (!win || win.isDestroyed()) return; + const timestamp = String(record.event.timestamp || new Date().toISOString()); + const sessionId = String(record.event.sessionId || ""); + const payload = { + providerId: "grok" as const, + tabId: String(record.tabId || ""), + envLabel: String(record.envLabel || ""), + preview: normalizePreview(record.event.lastAssistantMessage), + timestamp, + eventId: `${sessionId || "grok"}-${hookEventName}-${timestamp}`, + hookEventName, + completionKind, + agentType: resolveGrokAgentType(record.event), + agentId: String(record.event.subagentId || record.event.agentId || ""), + }; + try { + win.webContents.send("notifications:externalAgentComplete", payload); + requestHistoryFastRefresh({ providerId: "grok", sourcePath }); + } catch (error) { + logGrokNotification(`emit failed error=${String(error)}`); + } +} + +/** + * 读取单个通知文件自上次 offset 之后的新增内容。 + */ +async function pollNotifySource(source: GrokNotifySource, generation: number): Promise { + const stat = await fsp.stat(source.filePath).catch(() => null); + if (!stat?.isFile()) return; + if (stat.size < source.offset) { + source.offset = 0; + source.remainder = ""; + } + if (stat.size <= source.offset) return; + const end = Math.min(stat.size, source.offset + GROK_NOTIFY_READ_LIMIT_BYTES); + const handle = await fsp.open(source.filePath, "r"); + try { + const length = Math.max(0, end - source.offset); + const buffer = Buffer.alloc(length); + const read = await handle.read(buffer, 0, length, source.offset); + source.offset += read.bytesRead; + const text = source.remainder + buffer.subarray(0, read.bytesRead).toString("utf8"); + const lines = text.split(/\r?\n/); + source.remainder = lines.pop() || ""; + for (const line of lines) emitNotifyRecord(parseNotifyLine(line), source.filePath, generation); + } finally { + await handle.close().catch(() => {}); + } +} + +/** + * 轮询所有 Grok 通知源。 + */ +async function pollNotifyFiles(): Promise { + if (notifyPolling) return; + const generation = notifyGeneration; + notifyPolling = true; + try { + for (const source of notifySources.values()) { + if (generation !== notifyGeneration) return; + await pollNotifySource(source, generation).catch(() => {}); + } + } finally { + if (generation === notifyGeneration) notifyPolling = false; + } +} + +/** + * 启动 Grok 通知桥接;重复调用时仅刷新通知源。 + */ +export async function startGrokNotificationBridge(getWindow: () => BrowserWindow | null): Promise { + const generation = notifyGeneration; + notifyWindowGetter = getWindow; + try { + await ensureAllGrokNotifications(); + const paths = await listGrokNotifyFiles(); + if (generation !== notifyGeneration) return; + await syncNotifySources(paths, generation); + if (notifyTimer) return; + notifyTimer = setInterval(() => { void pollNotifyFiles(); }, GROK_NOTIFY_POLL_INTERVAL_MS); + logGrokNotification(`bridge started sources=${notifySources.size} paths=${paths.length}`); + } catch (error) { + logGrokNotification(`bridge start failed error=${String(error)}`); + throw error; + } +} + +/** + * 停止 Grok 通知桥接并清理轮询状态。 + */ +export function stopGrokNotificationBridge(): void { + notifyGeneration += 1; + if (notifyTimer) clearInterval(notifyTimer); + notifyTimer = null; + notifyPolling = false; + notifyWindowGetter = null; + notifySources.clear(); +} + +export const __testing = { + parseNotifyLine, + resolveGrokCompletionKind, + resolveGrokAgentType, + hasGrokCodexFlowTabId, + shouldUsePosixHook, +}; diff --git a/electron/grok/usage.test.ts b/electron/grok/usage.test.ts new file mode 100644 index 0000000..1717fe9 --- /dev/null +++ b/electron/grok/usage.test.ts @@ -0,0 +1,228 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const discoveryMocks = vi.hoisted(() => ({ + getGrokRootCandidatesFastAsync: vi.fn(), +})); + +vi.mock("../agentSessions/grok/discovery", () => discoveryMocks); + +import { __grokUsageTest, getGrokUsageSnapshotAsync } from "./usage"; + +let tempRoot = ""; +let fetchMock: ReturnType; + +beforeEach(async () => { + tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "codexflow-grok-usage-")); + discoveryMocks.getGrokRootCandidatesFastAsync.mockReset(); + discoveryMocks.getGrokRootCandidatesFastAsync.mockResolvedValue([ + { path: path.join(tempRoot, "sessions"), exists: false, source: "windows", kind: "local" }, + ]); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (tempRoot) await fs.promises.rm(tempRoot, { recursive: true, force: true }); +}); + +/** + * 写入去标识化的 Grok 认证测试夹具。 + */ +async function writeAuthFixture( + home: string, + mode: "oidc" | "api_key", + overrides: Record = {}, +): Promise { + await fs.promises.mkdir(home, { recursive: true }); + const scope = mode === "oidc" ? "https://auth.x.ai::test-client" : "xai::api_key"; + await fs.promises.writeFile(path.join(home, "auth.json"), JSON.stringify({ + [scope]: { + key: "test-token", + auth_mode: mode, + user_id: "test-user", + email: "user@example.test", + oidc_issuer: mode === "oidc" ? "https://auth.x.ai" : undefined, + ...overrides, + }, + }), "utf8"); +} + +/** + * 让模拟 billing 接口返回指定 JSON 数据。 + */ +function mockBillingResponse(data: unknown, status = 200): void { + fetchMock.mockResolvedValue(new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + })); +} + +/** + * 写入一条 Grok 官方额度缓存日志。 + */ +async function writeBillingLogFixture(home: string, ctx: unknown): Promise { + const logDir = path.join(home, "logs"); + await fs.promises.mkdir(logDir, { recursive: true }); + await fs.promises.writeFile(path.join(logDir, "unified.jsonl"), JSON.stringify({ + ts: "2026-01-02T03:04:05.000Z", + src: "shell", + lvl: "info", + msg: "billing: fetched credits config", + ctx, + }) + "\n", "utf8"); +} + +describe("getGrokUsageSnapshotAsync", () => { + it("通过官方 OAuth 接口读取新版账号额度", async () => { + await writeAuthFixture(tempRoot, "oidc"); + mockBillingResponse({ + config: { + creditUsagePercent: 42.5, + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-01-01T00:00:00Z", + end: "2026-01-08T00:00:00Z", + }, + onDemandCap: { val: 2500 }, + onDemandUsed: { val: 350 }, + prepaidBalance: { val: 1200 }, + isUnifiedBillingUser: true, + }, + onDemandEnabled: true, + subscriptionTier: "SuperGrok", + }); + + const snapshot = await getGrokUsageSnapshotAsync({ terminal: "pwsh" }); + + expect(snapshot).toMatchObject({ + providerId: "grok", + source: "billing-api", + accountEmail: "user@example.test", + subscriptionTier: "SuperGrok", + quota: { + usedPercent: 42.5, + periodType: "USAGE_PERIOD_TYPE_WEEKLY", + periodStartAt: Date.parse("2026-01-01T00:00:00Z"), + periodEndAt: Date.parse("2026-01-08T00:00:00Z"), + onDemandEnabled: true, + onDemandCapCents: 2500, + onDemandUsedCents: 350, + prepaidBalanceCents: 1200, + isUnifiedBillingUser: true, + }, + }); + const request = fetchMock.mock.calls[0]; + expect(request[0]).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits"); + expect(request[1].headers).toMatchObject({ + Authorization: "Bearer test-token", + "X-XAI-Token-Auth": "xai-grok-cli", + "x-userid": "test-user", + "x-grok-client-mode": "interactive", + }); + }); + + it("兼容旧版金额字段并计算账号额度百分比", async () => { + await writeAuthFixture(tempRoot, "oidc"); + mockBillingResponse({ + config: { + monthlyLimit: { val: 2000 }, + used: { val: 500 }, + billingPeriodStart: "2026-02-01T00:00:00Z", + billingPeriodEnd: "2026-03-01T00:00:00Z", + }, + }); + + const snapshot = await getGrokUsageSnapshotAsync({ terminal: "windows" }); + + expect(snapshot.quota).toMatchObject({ + usedPercent: 25, + includedUsedCents: 500, + includedLimitCents: 2000, + periodStartAt: Date.parse("2026-02-01T00:00:00Z"), + periodEndAt: Date.parse("2026-03-01T00:00:00Z"), + }); + }); + + it("WSL 模式只读取指定发行版的 Grok 认证", async () => { + const wslHome = path.join(tempRoot, "wsl-home"); + await writeAuthFixture(wslHome, "oidc"); + discoveryMocks.getGrokRootCandidatesFastAsync.mockResolvedValue([ + { path: path.join(tempRoot, "windows-home", "sessions"), exists: false, source: "windows", kind: "local" }, + { path: path.join(wslHome, "sessions"), exists: false, source: "wsl", kind: "unc", distro: "TestDistro" }, + { path: path.join(tempRoot, "other-home", "sessions"), exists: false, source: "wsl", kind: "unc", distro: "OtherDistro" }, + ]); + mockBillingResponse({ config: { creditUsagePercent: 10 } }); + + const snapshot = await getGrokUsageSnapshotAsync({ terminal: "wsl", distro: "TestDistro" }); + + expect(snapshot.quota.usedPercent).toBe(10); + }); + + it("API Key 登录明确返回账号额度不受支持", async () => { + await writeAuthFixture(tempRoot, "api_key"); + + await expect(getGrokUsageSnapshotAsync({ terminal: "cmd" })) + .rejects.toThrow("GROK_USAGE_API_KEY_UNSUPPORTED"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("团队 OAuth 登录遵循官方规则隐藏消费者额度", async () => { + await writeAuthFixture(tempRoot, "oidc", { team_id: "test-team" }); + + await expect(getGrokUsageSnapshotAsync({ terminal: "pwsh" })) + .rejects.toThrow("GROK_USAGE_TEAM_UNSUPPORTED"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("实时请求失败时回退到 Grok 官方额度日志", async () => { + await writeAuthFixture(tempRoot, "oidc"); + await writeBillingLogFixture(tempRoot, { + config: { + creditUsagePercent: 67, + currentPeriod: { type: "USAGE_PERIOD_TYPE_MONTHLY", end: "2026-04-01T00:00:00Z" }, + prepaidBalance: {}, + }, + subscriptionTier: "SuperGrok Heavy", + }); + fetchMock.mockRejectedValue(new Error("offline")); + + const snapshot = await getGrokUsageSnapshotAsync({ terminal: "pwsh" }); + + expect(snapshot).toMatchObject({ + source: "billing-cache", + updatedAt: Date.parse("2026-01-02T03:04:05.000Z"), + subscriptionTier: "SuperGrok Heavy", + quota: { + usedPercent: 67, + periodType: "USAGE_PERIOD_TYPE_MONTHLY", + prepaidBalanceCents: 0, + }, + }); + }); + + it("无有效 OAuth 登录时返回登录提示错误", async () => { + await fs.promises.writeFile(path.join(tempRoot, "auth.json"), "{invalid", "utf8"); + + await expect(getGrokUsageSnapshotAsync({ terminal: "pwsh" })) + .rejects.toThrow("GROK_USAGE_AUTH_REQUIRED"); + }); +}); + +describe("__grokUsageTest", () => { + it("新版百分比优先于旧版金额推导结果", () => { + const snapshot = __grokUsageTest.parseBillingSnapshot({ + config: { + creditUsagePercent: 12, + used: { val: 900 }, + monthlyLimit: { val: 1000 }, + }, + }, "billing-api", 1, null); + + expect(snapshot?.quota.usedPercent).toBe(12); + }); +}); diff --git a/electron/grok/usage.ts b/electron/grok/usage.ts new file mode 100644 index 0000000..2af05f2 --- /dev/null +++ b/electron/grok/usage.ts @@ -0,0 +1,377 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) + +import path from "node:path"; +import { promises as fsp } from "node:fs"; +import { getGrokRootCandidatesFastAsync } from "../agentSessions/grok/discovery"; +import { perfLogger } from "../log"; + +export type GrokUsageSnapshot = { + providerId: "grok"; + source: "billing-api" | "billing-cache"; + collectedAt: number; + updatedAt: number; + accountEmail: string | null; + subscriptionTier: string | null; + quota: { + usedPercent: number | null; + periodType: string | null; + periodStartAt: number | null; + periodEndAt: number | null; + includedUsedCents: number | null; + includedLimitCents: number | null; + onDemandEnabled: boolean | null; + onDemandUsedCents: number | null; + onDemandCapCents: number | null; + prepaidBalanceCents: number | null; + isUnifiedBillingUser: boolean | null; + }; +}; + +type ProviderRuntimeEnv = { terminal: "wsl" | "windows" | "pwsh" | "cmd"; distro?: string }; + +type GrokAuthRecord = { + key?: unknown; + auth_mode?: unknown; + user_id?: unknown; + email?: unknown; + team_id?: unknown; + team_name?: unknown; + oidc_issuer?: unknown; +}; + +type GrokOAuthCredential = { + token: string; + userId: string; + email: string | null; + isTeam: boolean; +}; + +type GrokCredentialState = + | { kind: "oauth"; credential: GrokOAuthCredential } + | { kind: "api-key" } + | { kind: "missing" }; + +type GrokUsageContext = { + home: string; + credential: GrokCredentialState; +}; + +const DEFAULT_BILLING_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; +const BILLING_REQUEST_TIMEOUT_MS = 15_000; +const MAX_BILLING_LOG_TAIL_BYTES = 2 * 1024 * 1024; +const BILLING_LOG_MESSAGE = "billing: fetched credits config"; + +/** + * 将未知值转换为普通对象。 + */ +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +/** + * 将未知值转换为去除首尾空白的字符串。 + */ +function toTrimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** + * 将未知值转换为非负有限数。 + */ +function toNonNegativeNumber(value: unknown): number | null { + const normalized = typeof value === "string" ? value.trim() : value; + if (normalized == null || normalized === "") return null; + const numberValue = typeof normalized === "number" ? normalized : Number(normalized); + if (!Number.isFinite(numberValue) || numberValue < 0) return null; + return numberValue; +} + +/** + * 将未知值转换为可选布尔值。 + */ +function toOptionalBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +/** + * 将 RFC 3339 时间转换为毫秒时间戳。 + */ +function toTimestampMs(value: unknown): number | null { + const text = toTrimmedString(value); + if (!text) return null; + const timestamp = Date.parse(text); + return Number.isFinite(timestamp) ? timestamp : null; +} + +/** + * 读取 JSON 对象,并兼容文件开头的 UTF-8 BOM。 + */ +async function readJsonObjectAsync(filePath: string): Promise | null> { + try { + const raw = (await fsp.readFile(filePath, "utf8")).replace(/^\uFEFF/, ""); + return asRecord(JSON.parse(raw)); + } catch { + return null; + } +} + +/** + * 判断认证记录是否属于 Grok 官方 OAuth 登录。 + */ +function isXaiOAuthRecord(scope: string, auth: GrokAuthRecord): boolean { + const mode = toTrimmedString(auth.auth_mode).toLowerCase(); + if (mode !== "oidc") return false; + const issuer = toTrimmedString(auth.oidc_issuer).replace(/\/$/, "").toLowerCase(); + const normalizedScope = scope.trim().toLowerCase(); + return issuer === "https://auth.x.ai" + || issuer === "http://auth.x.ai.localhost" + || normalizedScope.startsWith("https://auth.x.ai::") + || normalizedScope.startsWith("http://auth.x.ai.localhost::"); +} + +/** + * 按 Grok Build 默认优先级解析 OAuth 或 API Key 登录状态。 + */ +function resolveCredentialState(store: Record | null): GrokCredentialState { + const entries = Object.entries(store || {}); + for (const [scope, rawAuth] of entries) { + const auth = asRecord(rawAuth) as GrokAuthRecord | null; + if (!auth || !isXaiOAuthRecord(scope, auth)) continue; + const token = toTrimmedString(auth.key); + const userId = toTrimmedString(auth.user_id); + if (!token || !userId) continue; + return { + kind: "oauth", + credential: { + token, + userId, + email: toTrimmedString(auth.email) || null, + isTeam: Boolean(toTrimmedString(auth.team_id) || toTrimmedString(auth.team_name)), + }, + }; + } + + const hasApiKeyRecord = entries.some(([scope, rawAuth]) => { + const auth = asRecord(rawAuth) as GrokAuthRecord | null; + const mode = toTrimmedString(auth?.auth_mode).toLowerCase(); + return scope.trim().toLowerCase() === "xai::api_key" || mode === "api_key"; + }); + return hasApiKeyRecord ? { kind: "api-key" } : { kind: "missing" }; +} + +/** + * 按设置中的终端环境定位当前 Grok 主目录。 + */ +async function resolveGrokHomeAsync(env: ProviderRuntimeEnv): Promise { + const candidates = await getGrokRootCandidatesFastAsync(); + const expectedDistro = toTrimmedString(env.distro).toLowerCase(); + const candidate = candidates.find((item) => { + if (env.terminal !== "wsl") return item.source === "windows"; + if (item.source !== "wsl") return false; + if (!expectedDistro) return true; + return toTrimmedString(item.distro).toLowerCase() === expectedDistro; + }); + return candidate?.path ? path.dirname(candidate.path) : null; +} + +/** + * 读取当前 Grok 主目录中的认证状态。 + */ +async function resolveUsageContextAsync(env: ProviderRuntimeEnv): Promise { + const home = await resolveGrokHomeAsync(env); + if (!home) throw new Error("GROK_USAGE_AUTH_REQUIRED"); + const store = await readJsonObjectAsync(path.join(home, "auth.json")); + const credential = resolveCredentialState(store); + if (credential.kind === "missing" && env.terminal !== "wsl" && toTrimmedString(process.env.XAI_API_KEY)) + return { home, credential: { kind: "api-key" } }; + return { home, credential }; +} + +/** + * 读取官方 Cent 对象;字段存在但 val 被 proto3 省略时按 0 处理。 + */ +function readCent(config: Record, key: string): number | null { + if (!Object.prototype.hasOwnProperty.call(config, key)) return null; + const cent = asRecord(config[key]); + if (!cent) return null; + return toNonNegativeNumber(cent.val) ?? 0; +} + +/** + * 判断额度快照是否包含至少一项可展示的账号信息。 + */ +function hasQuotaData(snapshot: GrokUsageSnapshot): boolean { + const quota = snapshot.quota; + return snapshot.subscriptionTier != null + || quota.usedPercent != null + || quota.includedUsedCents != null + || quota.includedLimitCents != null + || quota.onDemandUsedCents != null + || quota.onDemandCapCents != null + || quota.prepaidBalanceCents != null; +} + +/** + * 将 Grok 官方 billing 响应解析为稳定的账号额度快照。 + */ +function parseBillingSnapshot( + value: unknown, + source: GrokUsageSnapshot["source"], + updatedAt: number, + accountEmail: string | null, +): GrokUsageSnapshot | null { + const response = asRecord(value); + const config = asRecord(response?.config); + if (!response || !config) return null; + + const includedUsedCents = readCent(config, "used"); + const includedLimitCents = readCent(config, "monthlyLimit"); + const reportedPercent = toNonNegativeNumber(config.creditUsagePercent); + const derivedPercent = includedUsedCents != null && includedLimitCents != null && includedLimitCents > 0 + ? (includedUsedCents / includedLimitCents) * 100 + : null; + const currentPeriod = asRecord(config.currentPeriod); + const snapshot: GrokUsageSnapshot = { + providerId: "grok", + source, + collectedAt: Date.now(), + updatedAt, + accountEmail, + subscriptionTier: toTrimmedString(response.subscriptionTier) || null, + quota: { + usedPercent: reportedPercent ?? derivedPercent, + periodType: toTrimmedString(currentPeriod?.type) || null, + periodStartAt: toTimestampMs(currentPeriod?.start) ?? toTimestampMs(config.billingPeriodStart), + periodEndAt: toTimestampMs(currentPeriod?.end) ?? toTimestampMs(config.billingPeriodEnd), + includedUsedCents, + includedLimitCents, + onDemandEnabled: toOptionalBoolean(response.onDemandEnabled), + onDemandUsedCents: readCent(config, "onDemandUsed"), + onDemandCapCents: readCent(config, "onDemandCap"), + prepaidBalanceCents: readCent(config, "prepaidBalance"), + isUnifiedBillingUser: toOptionalBoolean(config.isUnifiedBillingUser), + }, + }; + return hasQuotaData(snapshot) ? snapshot : null; +} + +/** + * 解析可选的 Grok 代理地址,仅允许 HTTP(S) 地址。 + */ +function resolveBillingBaseUrl(): string { + const configured = toTrimmedString(process.env.GROK_CLI_CHAT_PROXY_BASE_URL).replace(/\/$/, ""); + if (/^https?:\/\//i.test(configured)) return configured; + return DEFAULT_BILLING_BASE_URL; +} + +/** + * 使用 Grok 官方 OAuth 身份实时获取账号额度。 + */ +async function fetchBillingSnapshotAsync(credential: GrokOAuthCredential): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), BILLING_REQUEST_TIMEOUT_MS); + try { + const response = await fetch(`${resolveBillingBaseUrl()}/billing?format=credits`, { + method: "GET", + signal: controller.signal, + headers: { + Accept: "application/json", + Authorization: `Bearer ${credential.token}`, + "X-XAI-Token-Auth": "xai-grok-cli", + "x-userid": credential.userId, + "x-grok-client-version": "codexflow", + "x-grok-client-mode": "interactive", + }, + }); + const raw = await response.text().catch(() => ""); + if (!response.ok) { + if (response.status === 401 || response.status === 403) + throw new Error("GROK_USAGE_AUTH_EXPIRED"); + throw new Error(`GROK_USAGE_REQUEST_FAILED:${response.status}`); + } + let parsed: unknown = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + throw new Error("GROK_USAGE_RESPONSE_INVALID"); + } + const snapshot = parseBillingSnapshot(parsed, "billing-api", Date.now(), credential.email); + if (!snapshot) throw new Error("GROK_USAGE_NOT_AVAILABLE"); + return snapshot; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") + throw new Error("GROK_USAGE_REQUEST_TIMEOUT"); + throw error; + } finally { + clearTimeout(timeout); + } +} + +/** + * 从文件尾部读取最近的完整 JSONL 行。 + */ +async function readJsonlTailLinesAsync(filePath: string): Promise { + let handle: Awaited> | null = null; + try { + handle = await fsp.open(filePath, "r"); + const stat = await handle.stat(); + const readLength = Math.min(stat.size, MAX_BILLING_LOG_TAIL_BYTES); + if (readLength <= 0) return []; + const start = stat.size - readLength; + const buffer = Buffer.alloc(readLength); + const { bytesRead } = await handle.read(buffer, 0, readLength, start); + const lines = buffer.subarray(0, bytesRead).toString("utf8").replace(/^\uFEFF/, "").split(/\r?\n/); + if (start > 0) lines.shift(); + return lines; + } catch { + return []; + } finally { + await handle?.close().catch(() => undefined); + } +} + +/** + * 读取 Grok 官方统一日志中最近一次成功的额度快照。 + */ +async function readCachedBillingSnapshotAsync(home: string, accountEmail: string | null): Promise { + const lines = await readJsonlTailLinesAsync(path.join(home, "logs", "unified.jsonl")); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = toTrimmedString(lines[index]); + if (!line || !line.includes(BILLING_LOG_MESSAGE)) continue; + try { + const entry = asRecord(JSON.parse(line)); + if (toTrimmedString(entry?.msg) !== BILLING_LOG_MESSAGE) continue; + const timestamp = toTimestampMs(entry?.ts) ?? Date.now(); + const snapshot = parseBillingSnapshot(entry?.ctx, "billing-cache", timestamp, accountEmail); + if (snapshot) return snapshot; + } catch {} + } + return null; +} + +/** + * 获取 Grok Build 当前账号的消费者额度快照。 + */ +export async function getGrokUsageSnapshotAsync(env: ProviderRuntimeEnv): Promise { + return perfLogger.time("[grok] account usage snapshot", async () => { + const { home, credential } = await resolveUsageContextAsync(env); + if (credential.kind === "api-key") throw new Error("GROK_USAGE_API_KEY_UNSUPPORTED"); + if (credential.kind === "missing") throw new Error("GROK_USAGE_AUTH_REQUIRED"); + if (credential.credential.isTeam) throw new Error("GROK_USAGE_TEAM_UNSUPPORTED"); + + try { + return await fetchBillingSnapshotAsync(credential.credential); + } catch (error) { + const cached = await readCachedBillingSnapshotAsync(home, credential.credential.email); + if (cached) return cached; + throw error; + } + }); +} + +export const __grokUsageTest = { + parseBillingSnapshot, + resolveCredentialState, +}; diff --git a/electron/history.ts b/electron/history.ts index 3c73c63..b191415 100644 --- a/electron/history.ts +++ b/electron/history.ts @@ -24,7 +24,7 @@ import { pathMatchesDirKeyScope, tidyPathCandidate } from "./agentSessions/share export type RuntimeShell = 'wsl' | 'windows' | 'unknown'; -export type ProviderId = 'codex' | 'claude' | 'gemini' | 'antigravity'; +export type ProviderId = 'codex' | 'claude' | 'gemini' | 'antigravity' | 'grok'; export type HistorySummary = { providerId: ProviderId; @@ -583,7 +583,7 @@ type HistoryListCacheEntry = { }; // 中文说明:历史归属语义变更后提升版本,强制失效旧的列表缓存,避免继续复用错误归属结果。 -const PARSER_VERSION = 'v16'; +const PARSER_VERSION = 'v17'; const CACHE_SCHEMA_VERSION = '2'; /** @@ -593,6 +593,7 @@ function inferHistoryProviderIdFromPath(filePath: string): ProviderId { try { const fp = String(filePath || '').replace(/\\/g, '/').toLowerCase(); const base = fp.split('/').pop() || ''; + if (fp.includes('/.grok/sessions/')) return 'grok'; if (fp.includes('/.claude/')) return 'claude'; if (fp.includes('/.gemini/')) return 'gemini'; if (base.endsWith('.ndjson')) return 'claude'; diff --git a/electron/historyDelete.test.ts b/electron/historyDelete.test.ts index 89a0c26..baa9244 100644 --- a/electron/historyDelete.test.ts +++ b/electron/historyDelete.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { expandAntigravityConversationDeleteCandidates, isAntigravityConversationDbPath } from "./historyDelete"; +import { + expandAntigravityConversationDeleteCandidates, + expandHistoryDeleteCandidates, + isAntigravityConversationDbPath, + isGrokSessionSummaryPath, +} from "./historyDelete"; describe("electron/historyDelete", () => { it("识别 Antigravity conversation SQLite 主库", () => { @@ -24,4 +29,45 @@ describe("electron/historyDelete", () => { expect(expandAntigravityConversationDeleteCandidates([filePath])).toEqual([filePath]); }); + + it("删除 Grok summary.json 时会回收整个会话目录", () => { + const grokSessionRoots = [ + "X:\\fixture\\.grok\\sessions", + "X:\\grok-data\\sessions\\", + "\\\\wsl.localhost\\TestDistro\\opt\\grok-home\\sessions", + ]; + const defaultSummaryPath = "X:\\fixture\\.grok\\sessions\\encoded-project\\session-1\\summary.json"; + const customSummaryPath = "X:\\grok-data\\sessions\\encoded-project\\session-2\\summary.json"; + const wslSummaryPath = "\\\\wsl.localhost\\TestDistro\\opt\\grok-home\\sessions\\encoded-project\\session-3\\summary.json"; + const otherAppSummaryPath = "X:\\OtherApp\\sessions\\tenant-a\\record-1\\summary.json"; + const traversalSummaryPath = "X:\\fixture\\.grok\\sessions\\..\\..\\summary.json"; + + expect(isGrokSessionSummaryPath(defaultSummaryPath, grokSessionRoots)).toBe(true); + expect(isGrokSessionSummaryPath(customSummaryPath, grokSessionRoots)).toBe(true); + expect(isGrokSessionSummaryPath(wslSummaryPath, grokSessionRoots)).toBe(true); + expect(isGrokSessionSummaryPath(otherAppSummaryPath, grokSessionRoots)).toBe(false); + expect(isGrokSessionSummaryPath(traversalSummaryPath, grokSessionRoots)).toBe(false); + expect(isGrokSessionSummaryPath("X:\\fixture\\sessions\\summary.json", grokSessionRoots)).toBe(false); + expect(isGrokSessionSummaryPath("X:\\fixture\\summary.json", grokSessionRoots)).toBe(false); + expect(expandHistoryDeleteCandidates( + [defaultSummaryPath, customSummaryPath, wslSummaryPath, otherAppSummaryPath, traversalSummaryPath], + grokSessionRoots, + )).toEqual([ + "X:\\fixture\\.grok\\sessions\\encoded-project\\session-1", + "X:\\grok-data\\sessions\\encoded-project\\session-2", + "\\\\wsl.localhost\\TestDistro\\opt\\grok-home\\sessions\\encoded-project\\session-3", + otherAppSummaryPath, + traversalSummaryPath, + ]); + }); + + it("POSIX 路径应保持正斜杠并回收整个 Grok 会话目录", () => { + const root = "/home/test-user/.grok/sessions"; + const summaryPath = `${root}/encoded-project/session-1/summary.json`; + + expect(isGrokSessionSummaryPath(summaryPath, [root])).toBe(true); + expect(expandHistoryDeleteCandidates([summaryPath], [root])).toEqual([ + `${root}/encoded-project/session-1`, + ]); + }); }); diff --git a/electron/historyDelete.ts b/electron/historyDelete.ts index d3c80a6..043d3a3 100644 --- a/electron/historyDelete.ts +++ b/electron/historyDelete.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) +import path from "node:path"; + const ANTIGRAVITY_CONVERSATIONS_SEGMENT = "/.gemini/antigravity-cli/conversations/"; const SQLITE_SIDECAR_SUFFIXES = ["-wal", "-shm", "-journal"] as const; @@ -40,3 +42,65 @@ export function expandAntigravityConversationDeleteCandidates(candidates: readon return out; } + +/** + * 判断路径是否使用 Windows 盘符、UNC 或反斜杠格式。 + */ +function isWindowsStylePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) + || /^[/\\]{2}[^/\\]+[/\\]/.test(value) + || value.includes("\\"); +} + +/** + * 解析已发现 Grok 根目录中的 `summary.json`,并返回规范化后的会话目录。 + */ +function resolveGrokSessionDirectory(filePath: string, grokSessionRoots: readonly string[]): string | null { + const raw = String(filePath || "").trim(); + if (!raw) return null; + const rawSegments = raw.replace(/\\/g, "/").split("/"); + if (rawSegments.some((segment) => segment === "." || segment === "..")) return null; + + for (const root of grokSessionRoots) { + const rawRoot = String(root || "").trim(); + if (!rawRoot) continue; + const windowsStyle = isWindowsStylePath(raw) || isWindowsStylePath(rawRoot); + const pathApi = windowsStyle ? path.win32 : path.posix; + const summaryPath = pathApi.resolve(raw); + if (pathApi.basename(summaryPath).toLowerCase() !== "summary.json") continue; + const sessionDir = pathApi.dirname(summaryPath); + const encodedCwdDir = pathApi.dirname(sessionDir); + const inferredRoot = pathApi.dirname(encodedCwdDir); + if (!pathApi.basename(sessionDir) || !pathApi.basename(encodedCwdDir)) continue; + + const resolvedRoot = pathApi.resolve(rawRoot); + const caseInsensitive = /^[a-z]:[\\/]/i.test(summaryPath) && /^[a-z]:[\\/]/i.test(resolvedRoot); + const inferredRootKey = caseInsensitive ? inferredRoot.toLowerCase() : inferredRoot; + const resolvedRootKey = caseInsensitive ? resolvedRoot.toLowerCase() : resolvedRoot; + if (inferredRootKey === resolvedRootKey) return sessionDir; + } + return null; +} + +/** + * 判断路径是否为已发现 Grok 根目录中的 `summary.json`。 + */ +export function isGrokSessionSummaryPath(filePath: string, grokSessionRoots: readonly string[]): boolean { + return resolveGrokSessionDirectory(filePath, grokSessionRoots) !== null; +} + +/** + * 扩展通用历史删除候选:Grok 必须删除整个会话目录,Antigravity 必须清理 SQLite 辅助文件。 + */ +export function expandHistoryDeleteCandidates( + candidates: readonly string[], + grokSessionRoots: readonly string[], +): string[] { + const antigravityExpanded = expandAntigravityConversationDeleteCandidates(candidates); + const output: string[] = []; + for (const candidate of antigravityExpanded) { + const target = resolveGrokSessionDirectory(candidate, grokSessionRoots) || candidate; + if (target && !output.includes(target)) output.push(target); + } + return output; +} diff --git a/electron/historyScope.test.ts b/electron/historyScope.test.ts index 490f7ae..2bdcd95 100644 --- a/electron/historyScope.test.ts +++ b/electron/historyScope.test.ts @@ -8,6 +8,7 @@ const TEST_PARENT_PROJECT = "/mnt/c/users/example-user"; const TEST_CODEX_CHILD_PROJECT = `${TEST_PARENT_PROJECT}/.codex/worktrees/135b/codexflow`; const TEST_CLAUDE_CHILD_PROJECT = `${TEST_PARENT_PROJECT}/projects/monorepo/apps/claude-demo`; const TEST_GEMINI_CHILD_PROJECT = `${TEST_PARENT_PROJECT}/projects/monorepo/packages/gemini-demo`; +const TEST_GROK_CHILD_PROJECT = `${TEST_PARENT_PROJECT}/projects/monorepo/tools/grok-demo`; /** * 中文说明:构造父项目与子项目并存时的历史筛选参数。 @@ -57,6 +58,17 @@ describe("electron/historyScope.historyItemBelongsToScope", () => { )).toBe(false); }); + it("grok 嵌套项目历史不会再被父项目吞掉", () => { + expect(historyItemBelongsToScope( + { + providerId: "grok", + dirKey: TEST_GROK_CHILD_PROJECT, + filePath: "grok-summary.json", + }, + createNestedScopeOptions(TEST_GROK_CHILD_PROJECT), + )).toBe(false); + }); + it("最具体子项目作为当前项目时仍能命中对应历史", () => { expect(historyItemBelongsToScope( { diff --git a/electron/historyScope.ts b/electron/historyScope.ts index a02d13d..1cab5da 100644 --- a/electron/historyScope.ts +++ b/electron/historyScope.ts @@ -4,7 +4,7 @@ import { findBestMatchingDirKeyScope, pathMatchesDirKeyScope } from "./agentSessions/shared/path"; export type HistoryScopeMode = "current_project" | "project_group" | "all_sessions"; -export type HistoryScopeProviderId = "codex" | "claude" | "gemini" | "antigravity"; +export type HistoryScopeProviderId = "codex" | "claude" | "gemini" | "antigravity" | "grok"; export type HistoryScopeFilterItem = { providerId?: HistoryScopeProviderId | string; diff --git a/electron/indexer.test.ts b/electron/indexer.test.ts index 8c0775a..36b1836 100644 --- a/electron/indexer.test.ts +++ b/electron/indexer.test.ts @@ -41,6 +41,14 @@ vi.mock("./agentSessions/gemini/discovery", async (importOriginal) => { }; }); +vi.mock("./agentSessions/grok/discovery", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getGrokRootCandidatesFastAsync: vi.fn(async () => []), + }; +}); + import { getIndexedSummaries, startHistoryIndexer, stopHistoryIndexer } from "./indexer"; const originalCwd = process.cwd(); @@ -115,8 +123,8 @@ describe("electron/indexer Codex preview", () => { await startHistoryIndexer(() => null); await stopHistoryIndexer(); - const index = JSON.parse(await fsp.readFile(path.join(userDataDir, "history.index.v16.json"), "utf8")); - const details = JSON.parse(await fsp.readFile(path.join(userDataDir, "history.details.v16.json"), "utf8")); + const index = JSON.parse(await fsp.readFile(path.join(userDataDir, "history.index.v17.json"), "utf8")); + const details = JSON.parse(await fsp.readFile(path.join(userDataDir, "history.details.v17.json"), "utf8")); const detailEntry = Object.values(details.files as Record) .find((entry: any) => entry?.details?.filePath === filePath) as any; diff --git a/electron/indexer.ts b/electron/indexer.ts index 9b09a7f..1288c11 100644 --- a/electron/indexer.ts +++ b/electron/indexer.ts @@ -15,9 +15,11 @@ import settings from "./settings"; import { getClaudeRootCandidatesFastAsync, discoverClaudeSessionFiles } from "./agentSessions/claude/discovery"; import { getGeminiRootCandidatesFastAsync, discoverGeminiSessionFiles } from "./agentSessions/gemini/discovery"; import { getAntigravityRootCandidatesFastAsync, discoverAntigravitySessionFiles } from "./agentSessions/antigravity/discovery"; +import { getGrokRootCandidatesFastAsync, discoverGrokSessionFiles } from "./agentSessions/grok/discovery"; import { parseClaudeSessionFile } from "./agentSessions/claude/parser"; import { parseGeminiSessionFile, deriveGeminiProjectHashCandidatesFromPath } from "./agentSessions/gemini/parser"; import { parseAntigravitySessionFile } from "./agentSessions/antigravity/parser"; +import { parseGrokSessionFile } from "./agentSessions/grok/parser"; import { filterCodexHistoryPreviewText } from "./agentSessions/shared/preview"; import { extractTaggedPrefix } from "./agentSessions/shared/taggedPrefix"; import { @@ -30,7 +32,7 @@ import { let chokidar: any = null; try { chokidar = require("chokidar"); } catch {} -type ProviderId = "codex" | "claude" | "gemini" | "antigravity"; +type ProviderId = "codex" | "claude" | "gemini" | "antigravity" | "grok"; type FileSig = { mtimeMs: number; size: number }; type IndexSummary = HistorySummary & { providerId: ProviderId; dirKey: string; projectHash?: string }; type Details = { @@ -409,7 +411,7 @@ function stripDetailsForPersist(details: Details): Details { } // 中文说明:历史索引语义调整后提升版本,强制丢弃旧的 index/details 缓存,避免继续沿用错误 dirKey。 -const VERSION = "v16"; +const VERSION = "v17"; /** * 读取 Claude Code 的 Agent 历史开关(默认 false)。 @@ -575,9 +577,10 @@ function getExistingRootsByProvider(): Record { claude: Array.isArray(raw?.claude) ? raw.claude : [], gemini: Array.isArray(raw?.gemini) ? raw.gemini : [], antigravity: Array.isArray(raw?.antigravity) ? raw.antigravity : [], + grok: Array.isArray(raw?.grok) ? raw.grok : [], }; } catch { - return { codex: [], claude: [], gemini: [], antigravity: [] }; + return { codex: [], claude: [], gemini: [], antigravity: [], grok: [] }; } } @@ -596,7 +599,7 @@ function getIndexedClaudeAgentHistorySetting(): boolean { * 基于已探测 roots 或路径特征推断指定历史文件所属 Provider。 */ function resolveProviderIdFromRoots(filePath: string, providerHint?: ProviderId): ProviderId { - if (providerHint === "codex" || providerHint === "claude" || providerHint === "gemini" || providerHint === "antigravity") return providerHint; + if (providerHint === "codex" || providerHint === "claude" || providerHint === "gemini" || providerHint === "antigravity" || providerHint === "grok") return providerHint; try { const f = canonicalKey(filePath); const rootsByProvider = getExistingRootsByProvider(); @@ -606,6 +609,7 @@ function resolveProviderIdFromRoots(filePath: string, providerHint?: ProviderId) ...rootsByProvider.claude.map((root) => ({ providerId: "claude" as const, root })), ...rootsByProvider.gemini.map((root) => ({ providerId: "gemini" as const, root })), ...rootsByProvider.antigravity.map((root) => ({ providerId: "antigravity" as const, root })), + ...rootsByProvider.grok.map((root) => ({ providerId: "grok" as const, root })), ]; for (const entry of entries) { const rootKey = canonicalKey(entry.root); @@ -616,6 +620,7 @@ function resolveProviderIdFromRoots(filePath: string, providerHint?: ProviderId) } if (best) return best.providerId; if (f.includes("/.claude/")) return "claude"; + if (f.includes("/.grok/sessions/")) return "grok"; if (f.includes("/.gemini/antigravity-cli/")) return "antigravity"; if (f.includes("/.gemini/")) return "gemini"; } catch {} @@ -635,6 +640,7 @@ function shouldIndexProviderFile(providerId: ProviderId, filePath: string): bool } if (providerId === "gemini") return base.startsWith("session-") && (base.endsWith(".jsonl") || base.endsWith(".json")); if (providerId === "antigravity") return base.endsWith(".db") && !base.endsWith(".db-wal") && !base.endsWith(".db-shm"); + if (providerId === "grok") return base === "summary.json"; return false; } catch { return false; @@ -678,6 +684,7 @@ async function parseDetailsForProvider(providerId: ProviderId, filePath: string, if (providerId === "claude") return await parseClaudeSessionFile(filePath, stat, { summaryOnly: true, maxLines: getIndexedClaudeAgentHistorySetting() ? 400 : 200 } as any); if (providerId === "gemini") return await parseGeminiSessionFile(filePath, stat, { summaryOnly: true } as any); if (providerId === "antigravity") return await parseAntigravitySessionFile(filePath, stat, { summaryOnly: true } as any); + if (providerId === "grok") return await parseGrokSessionFile(filePath, stat, { summaryOnly: true } as any); return await parseCodexDetails(filePath, stat, { summaryOnly: true }); } @@ -759,6 +766,11 @@ function deriveRefreshRootFromSource(providerId: ProviderId, sourcePath?: string if (providerId === "codex") return path.join(dir, "sessions"); if ((providerId === "claude" || providerId === "gemini") && path.basename(dir).toLowerCase() === "hooks") return path.dirname(dir); if (providerId === "antigravity" && path.basename(dir).toLowerCase() === "hooks") return path.join(path.dirname(dir), "conversations"); + if (providerId === "grok") { + const normalized = p.replace(/\\/g, "/"); + const marker = normalized.toLowerCase().lastIndexOf("/.grok/"); + if (marker >= 0) return path.join(p.slice(0, marker + "/.grok".length), "sessions"); + } return ""; } catch { return ""; @@ -829,10 +841,12 @@ async function flushFastRefreshQueue(): Promise { } for (const entry of roots) { - if (entry.providerId !== "codex" && entry.providerId !== "antigravity") continue; + if (entry.providerId !== "codex" && entry.providerId !== "antigravity" && entry.providerId !== "grok") continue; try { const recent = entry.providerId === "antigravity" ? (await discoverAntigravitySessionFiles(entry.root)).slice(-FAST_REFRESH_RECENT_FILE_LIMIT) + : entry.providerId === "grok" + ? (await discoverGrokSessionFiles(entry.root)).slice(-FAST_REFRESH_RECENT_FILE_LIMIT) : await listRecentCodexSessionFiles(entry.root); for (const fp of recent) { try { await upsertIndexedFile(fp, entry.providerId); } catch {} @@ -854,7 +868,7 @@ async function flushFastRefreshQueue(): Promise { export function requestHistoryFastRefresh(req: HistoryFastRefreshRequest): void { try { const rawProvider = String(req?.providerId || "codex").trim().toLowerCase(); - const providerId: ProviderId = rawProvider === "claude" || rawProvider === "gemini" || rawProvider === "antigravity" ? rawProvider : "codex"; + const providerId: ProviderId = rawProvider === "claude" || rawProvider === "gemini" || rawProvider === "antigravity" || rawProvider === "grok" ? rawProvider : "codex"; const st = getFastRefreshState(); const explicitFile = resolveAccessibleHistoryFilePath(String(req?.filePath || "").trim(), req?.sourcePath); @@ -1581,6 +1595,7 @@ export async function startHistoryIndexer(getWindow: () => BrowserWindow | null) const claudeRootCandidates = await getClaudeRootCandidatesFastAsync(); const geminiRootCandidates = await getGeminiRootCandidatesFastAsync(); const antigravityRootCandidates = await getAntigravityRootCandidatesFastAsync(); + const grokRootCandidates = await getGrokRootCandidatesFastAsync(); const uniq = (xs: string[]) => Array.from(new Set(xs.filter(Boolean))); const rootsByProviderAll: Record = { @@ -1588,18 +1603,21 @@ export async function startHistoryIndexer(getWindow: () => BrowserWindow | null) claude: uniq(claudeRootCandidates.map((c) => c.path)), gemini: uniq(geminiRootCandidates.map((c) => c.path)), antigravity: uniq(antigravityRootCandidates.map((c) => c.path)), + grok: uniq(grokRootCandidates.map((c) => c.path)), }; const rootsByProviderExisting: Record = { codex: uniq(codexRootCandidates.filter((c) => c.exists).map((c) => c.path)), claude: uniq(claudeRootCandidates.filter((c) => c.exists).map((c) => c.path)), gemini: uniq(geminiRootCandidates.filter((c) => c.exists).map((c) => c.path)), antigravity: uniq(antigravityRootCandidates.filter((c) => c.exists).map((c) => c.path)), + grok: uniq(grokRootCandidates.filter((c) => c.exists).map((c) => c.path)), }; const rootsByProviderMissing: Record = { codex: uniq(codexRootCandidates.filter((c) => !c.exists).map((c) => c.path)), claude: uniq(claudeRootCandidates.filter((c) => !c.exists).map((c) => c.path)), gemini: uniq(geminiRootCandidates.filter((c) => !c.exists).map((c) => c.path)), antigravity: uniq(antigravityRootCandidates.filter((c) => !c.exists).map((c) => c.path)), + grok: uniq(grokRootCandidates.filter((c) => !c.exists).map((c) => c.path)), }; const rootsExisting = uniq([ @@ -1607,18 +1625,21 @@ export async function startHistoryIndexer(getWindow: () => BrowserWindow | null) ...rootsByProviderExisting.claude, ...rootsByProviderExisting.gemini, ...rootsByProviderExisting.antigravity, + ...rootsByProviderExisting.grok, ]); const rootsMissing = uniq([ ...rootsByProviderMissing.codex, ...rootsByProviderMissing.claude, ...rootsByProviderMissing.gemini, ...rootsByProviderMissing.antigravity, + ...rootsByProviderMissing.grok, ]); perfLogger.log(`[roots] codex.existing=${JSON.stringify(rootsByProviderExisting.codex)} codex.missing=${JSON.stringify(rootsByProviderMissing.codex)}`); perfLogger.log(`[roots] claude.existing=${JSON.stringify(rootsByProviderExisting.claude)} claude.missing=${JSON.stringify(rootsByProviderMissing.claude)}`); perfLogger.log(`[roots] gemini.existing=${JSON.stringify(rootsByProviderExisting.gemini)} gemini.missing=${JSON.stringify(rootsByProviderMissing.gemini)}`); perfLogger.log(`[roots] antigravity.existing=${JSON.stringify(rootsByProviderExisting.antigravity)} antigravity.missing=${JSON.stringify(rootsByProviderMissing.antigravity)}`); + perfLogger.log(`[roots] grok.existing=${JSON.stringify(rootsByProviderExisting.grok)} grok.missing=${JSON.stringify(rootsByProviderMissing.grok)}`); try { // 兼容旧字段:roots 仍指向 Codex sessions roots(供 settings.codexRoots 等旧逻辑复用) @@ -1681,7 +1702,11 @@ export async function startHistoryIndexer(getWindow: () => BrowserWindow | null) try { addFiles("antigravity", await discoverAntigravitySessionFiles(root)); } catch {} }))); - perfLogger.log(`[files] codex=${files.filter((f) => f.providerId === "codex").length} claude=${files.filter((f) => f.providerId === "claude").length} gemini=${files.filter((f) => f.providerId === "gemini").length} antigravity=${files.filter((f) => f.providerId === "antigravity").length} total=${files.length}`); + await Promise.all(rootsByProviderExisting.grok.map((root) => scanLimit(async () => { + try { addFiles("grok", await discoverGrokSessionFiles(root)); } catch {} + }))); + + perfLogger.log(`[files] codex=${files.filter((f) => f.providerId === "codex").length} claude=${files.filter((f) => f.providerId === "claude").length} gemini=${files.filter((f) => f.providerId === "gemini").length} antigravity=${files.filter((f) => f.providerId === "antigravity").length} grok=${files.filter((f) => f.providerId === "grok").length} total=${files.length}`); const ix: PersistIndex = g.__indexer.index; const det: PersistDetails = g.__indexer.details; @@ -1692,6 +1717,7 @@ export async function startHistoryIndexer(getWindow: () => BrowserWindow | null) ...rootsByProviderExisting.claude.map((root) => ({ providerId: "claude" as ProviderId, root })), ...rootsByProviderExisting.gemini.map((root) => ({ providerId: "gemini" as ProviderId, root })), ...rootsByProviderExisting.antigravity.map((root) => ({ providerId: "antigravity" as ProviderId, root })), + ...rootsByProviderExisting.grok.map((root) => ({ providerId: "grok" as ProviderId, root })), ]; const normPath = (p: string): string => { @@ -1714,6 +1740,7 @@ export async function startHistoryIndexer(getWindow: () => BrowserWindow | null) } if (best) return best.providerId; if (f.includes("/.claude/")) return "claude"; + if (f.includes("/.grok/sessions/")) return "grok"; if (f.includes("/.gemini/antigravity-cli/")) return "antigravity"; if (f.includes("/.gemini/")) return "gemini"; if (f.includes("/.codex/")) return "codex"; @@ -1734,6 +1761,7 @@ export async function startHistoryIndexer(getWindow: () => BrowserWindow | null) } if (providerId === "gemini") return base.startsWith("session-") && (base.endsWith(".jsonl") || base.endsWith(".json")); if (providerId === "antigravity") return base.endsWith(".db") && !base.endsWith(".db-wal") && !base.endsWith(".db-shm"); + if (providerId === "grok") return base === "summary.json"; return false; } catch { return false; @@ -1810,6 +1838,9 @@ export async function startHistoryIndexer(getWindow: () => BrowserWindow | null) if (providerId === "antigravity") { return await parseAntigravitySessionFile(fp, stat, opts as any) as any; } + if (providerId === "grok") { + return await parseGrokSessionFile(fp, stat, opts as any) as any; + } return await parseCodexDetails(fp, stat, opts); }; @@ -2131,6 +2162,7 @@ export async function startHistoryIndexer(getWindow: () => BrowserWindow | null) if (providerId === "claude") return ["*.jsonl", "*.ndjson"]; if (providerId === "gemini") return ["session-*.jsonl", "session-*.json"]; if (providerId === "antigravity") return ["*.db", "*.db-wal", "*.db-shm"]; + if (providerId === "grok") return ["summary.json"]; return ["*.jsonl"]; }; diff --git a/electron/main.ts b/electron/main.ts index 822772a..2d2fcb0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -19,11 +19,13 @@ import { getSessionsRootsFastAsync } from "./wsl"; import { getClaudeRootCandidatesFastAsync, discoverClaudeSessionFiles } from "./agentSessions/claude/discovery"; import { getGeminiRootCandidatesFastAsync, discoverGeminiSessionFiles } from "./agentSessions/gemini/discovery"; import { getAntigravityRootCandidatesFastAsync, discoverAntigravitySessionFiles } from "./agentSessions/antigravity/discovery"; +import { getGrokRootCandidatesFastAsync, discoverGrokSessionFiles } from "./agentSessions/grok/discovery"; import { parseClaudeSessionFile } from "./agentSessions/claude/parser"; import { parseGeminiSessionFile, extractGeminiProjectHashFromPath, deriveGeminiProjectHashCandidatesFromPath } from "./agentSessions/gemini/parser"; import { parseAntigravitySessionFile } from "./agentSessions/antigravity/parser"; +import { parseGrokSessionFile } from "./agentSessions/grok/parser"; import { hasNonEmptyIOFromMessages } from "./agentSessions/shared/empty"; -import { expandAntigravityConversationDeleteCandidates } from "./historyDelete"; +import { expandHistoryDeleteCandidates } from "./historyDelete"; import { historyItemBelongsToScope } from "./historyScope"; import { perfLogger } from "./log"; import settings, { ensureSettingsAutodetect, ensureFirstRunTerminalSelection, hasSavedRuntimeEnvSelection, type ThemeSetting as SettingsThemeSetting, type AppSettings, type IdeOpenSettings } from "./settings"; @@ -50,6 +52,8 @@ import { ensureAllClaudeNotifications, startClaudeNotificationBridge, stopClaude import { getClaudeUsageSnapshotAsync } from "./claude/usage"; import { ensureAllGeminiNotifications, startGeminiNotificationBridge, stopGeminiNotificationBridge } from "./gemini/notifications"; import { ensureAllAntigravityNotifications, startAntigravityNotificationBridge, stopAntigravityNotificationBridge } from "./antigravity/notifications"; +import { ensureAllGrokNotifications, startGrokNotificationBridge, stopGrokNotificationBridge } from "./grok/notifications"; +import { getGrokUsageSnapshotAsync } from "./grok/usage"; import { getAntigravityUsageSnapshotAsync } from "./antigravity/usage"; import geminiWindowsEditor from "./gemini/windowsEditor"; import geminiWslEditor from "./gemini/wslEditor"; @@ -1673,6 +1677,7 @@ async function flushSettingsMaintenance(): Promise { ensureAllClaudeNotifications(), ensureAllGeminiNotifications(), ensureAllAntigravityNotifications(), + ensureAllGrokNotifications(), ]); if (!settingsMaintenanceStopping) { await Promise.allSettled([ @@ -1680,6 +1685,7 @@ async function flushSettingsMaintenance(): Promise { startClaudeNotificationBridge(() => mainWindow), startGeminiNotificationBridge(() => mainWindow), startAntigravityNotificationBridge(() => mainWindow), + startGrokNotificationBridge(() => mainWindow), ]); } } @@ -2860,10 +2866,12 @@ if (!gotLock) { try { await ensureAllClaudeNotifications(); } catch {} try { await ensureAllGeminiNotifications(); } catch {} try { await ensureAllAntigravityNotifications(); } catch {} + try { await ensureAllGrokNotifications(); } catch {} try { await startCodexNotificationBridge(() => mainWindow); } catch {} try { await startClaudeNotificationBridge(() => mainWindow); } catch {} try { await startGeminiNotificationBridge(() => mainWindow); } catch {} try { await startAntigravityNotificationBridge(() => mainWindow); } catch {} + try { await startGrokNotificationBridge(() => mainWindow); } catch {} if (DIAG) { try { perfLogger.log(`[BOOT] Locale: ${i18n.getCurrentLocale?.()}`); } catch {} } try { registerNotificationIPC(() => mainWindow, { appUserModelId, protocolScheme: PROTOCOL_SCHEME, profileId: instanceProfile.profileId }); } catch {} // 启动时静默检查更新由渲染进程完成(仅提示,不下载) @@ -2930,6 +2938,7 @@ if (!gotLock) { disposeCodexBridges(); try { unregisterNotificationIPC({ closeNotifications: true }); } catch {} try { stopAntigravityNotificationBridge(); } catch {} + try { stopGrokNotificationBridge(); } catch {} // 主动关闭文件索引 watcher,避免退出阶段残留句柄 try { (fileIndex as any).setActiveRoots?.([]); } catch {} tryStopIndexer(); @@ -2942,6 +2951,7 @@ if (!gotLock) { try { stopClaudeNotificationBridge(); } catch {} try { stopGeminiNotificationBridge(); } catch {} try { stopAntigravityNotificationBridge(); } catch {} + try { stopGrokNotificationBridge(); } catch {} disposeAllPtys(); cleanupPastedImages().catch(() => {}); disposeCodexBridges(); @@ -4549,7 +4559,7 @@ ipcMain.handle('history.list', async (_e, args: { const fallbackPageLimit = Math.max(1, Number(args.limit || 0) || 300); const fallbackTargetCount = Math.max(1, Math.max(0, Number(args.offset || 0)) + fallbackPageLimit + 32); type FallbackHistorySummary = { - providerId: 'codex' | 'claude' | 'gemini' | 'antigravity'; + providerId: 'codex' | 'claude' | 'gemini' | 'antigravity' | 'grok'; id: string; title: string; date: number; @@ -4579,7 +4589,7 @@ ipcMain.handle('history.list', async (_e, args: { * 中文说明:将不同来源的历史摘要规整为渲染端一致使用的结构。 */ const normalizeHistorySummary = (item: any): FallbackHistorySummary => ({ - providerId: item?.providerId === 'claude' || item?.providerId === 'gemini' || item?.providerId === 'antigravity' ? item.providerId : 'codex', + providerId: item?.providerId === 'claude' || item?.providerId === 'gemini' || item?.providerId === 'antigravity' || item?.providerId === 'grok' ? item.providerId : 'codex', id: String(item?.id || ''), title: String(item?.title || ''), date: Number(item?.date || 0), @@ -4677,12 +4687,14 @@ ipcMain.handle('history.list', async (_e, args: { /** * 中文说明:采集 Claude/Gemini 的回退历史,并按分页预算提前截止,避免先解析完整个 Provider。 */ - const collectNonCodexFallbackSummaries = async (providerId: 'claude' | 'gemini' | 'antigravity'): Promise => { + const collectNonCodexFallbackSummaries = async (providerId: 'claude' | 'gemini' | 'antigravity' | 'grok'): Promise => { const roots = providerId === 'claude' ? (await getClaudeRootCandidatesFastAsync()).filter((item) => item.exists).map((item) => item.path) : providerId === 'gemini' ? (await getGeminiRootCandidatesFastAsync()).filter((item) => item.exists).map((item) => item.path) - : (await getAntigravityRootCandidatesFastAsync()).filter((item) => item.exists).map((item) => item.path); + : providerId === 'antigravity' + ? (await getAntigravityRootCandidatesFastAsync()).filter((item) => item.exists).map((item) => item.path) + : (await getGrokRootCandidatesFastAsync()).filter((item) => item.exists).map((item) => item.path); const files: string[] = []; for (const root of roots) { try { @@ -4690,7 +4702,9 @@ ipcMain.handle('history.list', async (_e, args: { ? await discoverClaudeSessionFiles(root, { includeAgentHistory: includeClaudeAgentHistory }) : providerId === 'gemini' ? await discoverGeminiSessionFiles(root) - : await discoverAntigravitySessionFiles(root); + : providerId === 'antigravity' + ? await discoverAntigravitySessionFiles(root) + : await discoverGrokSessionFiles(root); files.push(...discovered); } catch {} } @@ -4710,7 +4724,9 @@ ipcMain.handle('history.list', async (_e, args: { ? await parseClaudeSessionFile(currentFile.filePath, currentFile.stat, { summaryOnly: true }) : providerId === 'gemini' ? await parseGeminiSessionFile(currentFile.filePath, currentFile.stat, { summaryOnly: true }) - : await parseAntigravitySessionFile(currentFile.filePath, currentFile.stat, { summaryOnly: true }); + : providerId === 'antigravity' + ? await parseAntigravitySessionFile(currentFile.filePath, currentFile.stat, { summaryOnly: true }) + : await parseGrokSessionFile(currentFile.filePath, currentFile.stat, { summaryOnly: true }); const summary = normalizeHistorySummary(parsed); if (!belongsToScope(summary)) continue; results.push(summary); @@ -4725,13 +4741,14 @@ ipcMain.handle('history.list', async (_e, args: { * 中文说明:统一回退到跨 Provider 扫描,并在合并后再做排序/分页。 */ const collectFallbackSummaries = async (): Promise => { - const [codexItems, claudeItems, geminiItems, antigravityItems] = await Promise.all([ + const [codexItems, claudeItems, geminiItems, antigravityItems, grokItems] = await Promise.all([ collectCodexFallbackSummaries(), collectNonCodexFallbackSummaries('claude'), collectNonCodexFallbackSummaries('gemini'), collectNonCodexFallbackSummaries('antigravity'), + collectNonCodexFallbackSummaries('grok'), ]); - return dedupeSortHistorySummaries([...codexItems, ...claudeItems, ...geminiItems, ...antigravityItems]).slice(0, fallbackTargetCount); + return dedupeSortHistorySummaries([...codexItems, ...claudeItems, ...geminiItems, ...antigravityItems, ...grokItems]).slice(0, fallbackTargetCount); }; const all = getIndexedSummaries(); // Minimal probe logging (opt-in): only when CODEX_HISTORY_DEBUG=1 @@ -4778,14 +4795,14 @@ ipcMain.handle('history.list', async (_e, args: { } }); -type HistoryReadProviderId = "codex" | "claude" | "gemini" | "antigravity"; +type HistoryReadProviderId = "codex" | "claude" | "gemini" | "antigravity" | "grok"; /** * 中文说明:规范化 history.read 的 provider hint,避免无效字符串影响路径推断。 */ function normalizeHistoryReadProviderHint(providerHint?: string): HistoryReadProviderId | null { const hint = String(providerHint || "").trim().toLowerCase(); - if (hint === "codex" || hint === "claude" || hint === "gemini" || hint === "antigravity") return hint; + if (hint === "codex" || hint === "claude" || hint === "gemini" || hint === "antigravity" || hint === "grok") return hint; return null; } @@ -4797,6 +4814,7 @@ function inferHistoryReadProviderFromPath(filePath?: string): HistoryReadProvide if (!fp) return null; const base = fp.split("/").pop() || ""; if (fp.includes("/.codex/")) return "codex"; + if (fp.includes("/.grok/sessions/")) return "grok"; if (fp.includes("/.claude/")) return "claude"; if (fp.includes("/.gemini/antigravity-cli/")) return "antigravity"; if (fp.includes("/.gemini/")) return "gemini"; @@ -4929,6 +4947,12 @@ ipcMain.handle('history.read', async (_e, args: { filePath: string; providerId?: cacheHistoryReadDetails(lookupPaths, parsed as any); return parsed as any; } + if (providerId === "grok") { + const stat = await fsp.stat(filePath); + const parsed = await parseGrokSessionFile(filePath, stat, { summaryOnly: false, maxBytes: 64 * 1024 * 1024 }); + cacheHistoryReadDetails(lookupPaths, parsed as any); + return parsed as any; + } const parsed = await history.readHistoryFile(filePath, { maxLines: 0 }); const withMeta = { ...(parsed as any), providerId: "codex", filePath: requestedFilePath || filePath }; @@ -4967,12 +4991,13 @@ ipcMain.handle('history.findEmptySessions', async () => { /** * 推断 providerId(优先使用索引字段,其次按路径特征兜底)。 */ - const inferProviderId = (summary: any, filePath: string): "codex" | "claude" | "gemini" | "antigravity" => { + const inferProviderId = (summary: any, filePath: string): "codex" | "claude" | "gemini" | "antigravity" | "grok" => { const hinted = String(summary?.providerId || "").trim().toLowerCase(); - if (hinted === "codex" || hinted === "claude" || hinted === "gemini" || hinted === "antigravity") return hinted as any; + if (hinted === "codex" || hinted === "claude" || hinted === "gemini" || hinted === "antigravity" || hinted === "grok") return hinted as any; try { const fp = String(filePath || "").replace(/\\/g, "/").toLowerCase(); const base = fp.split("/").pop() || ""; + if (fp.includes("/.grok/sessions/")) return "grok"; if (fp.includes("/.claude/")) return "claude"; if (fp.includes("/.gemini/antigravity-cli/")) return "antigravity"; if (fp.includes("/.gemini/")) return "gemini"; @@ -5033,6 +5058,8 @@ ipcMain.handle('history.findEmptySessions', async () => { parsed = await parseGeminiSessionFile(resolvedPath, st, { summaryOnly: false, maxBytes: SAFE_MAX_BYTES }); } else if (providerId === "antigravity") { parsed = await parseAntigravitySessionFile(resolvedPath, st, { summaryOnly: false }); + } else if (providerId === "grok") { + parsed = await parseGrokSessionFile(resolvedPath, st, { summaryOnly: false, maxBytes: SAFE_MAX_BYTES }); } else { parsed = await history.readHistoryFile(resolvedPath, { maxLines: 80_000 }); } @@ -5055,6 +5082,7 @@ ipcMain.handle('history.findEmptySessions', async () => { if (providerId === "claude" && skippedLines > 0) continue; // Antigravity DB 若解析过程出现跳过项,说明存在未知/损坏结构,安全起见不纳入清理候选。 if (providerId === "antigravity" && skippedLines > 0) continue; + if (providerId === "grok" && skippedLines > 0) continue; // Codex:若文件明显不小,避免仅凭前若干行就判空(安全优先) if (providerId === "codex" && sizeBytes > 256 * 1024) continue; @@ -5070,6 +5098,7 @@ ipcMain.handle('history.findEmptySessions', async () => { // 批量彻底删除(逐个尝试) ipcMain.handle('history.trashMany', async (_e, { filePaths }: { filePaths: string[] }) => { try { + const grokSessionRoots = (await getGrokRootCandidatesFastAsync().catch(() => [])).map((candidate) => candidate.path); const results: { filePath: string; ok: boolean; notFound?: boolean; error?: string }[] = []; const codexStateCleanupPaths = new Set(); let okCount = 0; let notFoundCount = 0; let failCount = 0; @@ -5110,7 +5139,7 @@ ipcMain.handle('history.trashMany', async (_e, { filePaths }: { filePaths: strin } } push(normSlashes(p0)); - const deleteCandidates = expandAntigravityConversationDeleteCandidates(candidates); + const deleteCandidates = expandHistoryDeleteCandidates(candidates, grokSessionRoots); const anyExists = deleteCandidates.some((c) => { try { return fs.existsSync(c); } catch { return false; } }); if (!anyExists) { rememberCodexStateCleanupPaths([p0, ...deleteCandidates]); @@ -5121,7 +5150,8 @@ ipcMain.handle('history.trashMany', async (_e, { filePaths }: { filePaths: strin try { if (!fs.existsSync(cand)) { failed.push({ cand, err: 'not_exists' }); continue; } try { - await fsp.rm(cand, { force: true }); + const targetStat = await fsp.stat(cand).catch(() => null as fs.Stats | null); + await fsp.rm(cand, { force: true, recursive: targetStat?.isDirectory() === true }); try { const idx = require('./indexer'); if (typeof idx.removeFromIndex === 'function') idx.removeFromIndex(cand); } catch {} try { const hist = require('./history').default; await hist.removePathFromCache(cand); } catch {} try { const win = BrowserWindow.getFocusedWindow(); win?.webContents.send('history:index:remove', { filePath: cand }); } catch {} @@ -5135,6 +5165,9 @@ ipcMain.handle('history.trashMany', async (_e, { filePaths }: { filePaths: strin try { return fs.existsSync(cand); } catch { return false; } }); if (deletedAny && remaining.length === 0) { + try { const idx = require('./indexer'); if (typeof idx.removeFromIndex === 'function') idx.removeFromIndex(p0); } catch {} + try { const hist = require('./history').default; await hist.removePathFromCache(p0); } catch {} + try { const win = BrowserWindow.getFocusedWindow(); win?.webContents.send('history:index:remove', { filePath: p0 }); } catch {} rememberCodexStateCleanupPaths([p0, ...deleteCandidates]); return { filePath: p0, ok: true }; } @@ -5167,9 +5200,10 @@ ipcMain.handle('history.trashMany', async (_e, { filePaths }: { filePaths: strin }); // 彻底删除指定历史文件(支持 WSL/UNC/Windows 路径候选) -ipcMain.handle('history.trash', async (_e, { filePath }: { filePath: string }) => { - try { - if (!filePath || typeof filePath !== 'string') throw new Error('invalid filePath'); +ipcMain.handle('history.trash', async (_e, { filePath }: { filePath: string }) => { + try { + if (!filePath || typeof filePath !== 'string') throw new Error('invalid filePath'); + const grokSessionRoots = (await getGrokRootCandidatesFastAsync().catch(() => [])).map((candidate) => candidate.path); const candidates: string[] = []; const push = (p?: string) => { if (p && !candidates.includes(p)) candidates.push(p); }; const normSlashes = (p: string) => (process.platform === 'win32' ? p.replace(/\//g, '\\') : p); @@ -5186,7 +5220,7 @@ ipcMain.handle('history.trash', async (_e, { filePath }: { filePath: string }) = } } push(normSlashes(p0)); - const deleteCandidates = expandAntigravityConversationDeleteCandidates(candidates); + const deleteCandidates = expandHistoryDeleteCandidates(candidates, grokSessionRoots); // 候选均不存在则视为成功(无需删除) const anyExists = deleteCandidates.some((c) => { try { return fs.existsSync(c); } catch { return false; } }); if (!anyExists) { @@ -5201,7 +5235,8 @@ ipcMain.handle('history.trash', async (_e, { filePath }: { filePath: string }) = try { if (!fs.existsSync(cand)) { failed.push({ cand, err: 'not_exists' }); continue; } try { - await fsp.rm(cand, { force: true }); + const targetStat = await fsp.stat(cand).catch(() => null as fs.Stats | null); + await fsp.rm(cand, { force: true, recursive: targetStat?.isDirectory() === true }); // 删除成功:同步清理索引与历史缓存,并通知渲染进程移除该项 try { const idx = require('./indexer'); if (typeof idx.removeFromIndex === 'function') idx.removeFromIndex(cand); } catch {} try { const hist = require('./history').default; await hist.removePathFromCache(cand); } catch {} @@ -5218,6 +5253,9 @@ ipcMain.handle('history.trash', async (_e, { filePath }: { filePath: string }) = try { return fs.existsSync(cand); } catch { return false; } }); if (deletedAny && remaining.length === 0) { + try { const idx = require('./indexer'); if (typeof idx.removeFromIndex === 'function') idx.removeFromIndex(p0); } catch {} + try { const hist = require('./history').default; await hist.removePathFromCache(p0); } catch {} + try { const win = BrowserWindow.getFocusedWindow(); win?.webContents.send('history:index:remove', { filePath: p0 }); } catch {} // 历史文件、索引和缓存均已处理完成,SQLite 收尾无需继续占用删除弹窗。 void cleanupCodexStateForDeletedPathsSafely([p0, ...deleteCandidates], 'history.trash'); return { ok: true }; @@ -6970,7 +7008,7 @@ ipcMain.handle("codex.rateLimit", async () => { /** * 读取设置中的 Provider 环境(若缺失则回退到全局 terminal/distro)。 */ -function resolveProviderRuntimeEnv(providerId: "claude" | "gemini"): { terminal: TerminalMode; distro?: string } { +function resolveProviderRuntimeEnv(providerId: "claude" | "gemini" | "grok"): { terminal: TerminalMode; distro?: string } { const cfg = settings.getSettings(); return resolveProviderRuntimeEnvFromSettings(cfg, providerId); } @@ -7004,6 +7042,16 @@ ipcMain.handle("antigravity.usage", async () => { } }); +ipcMain.handle("grok.usage", async () => { + try { + const env = resolveProviderRuntimeEnv("grok"); + const snapshot = await getGrokUsageSnapshotAsync(env); + return { ok: true, snapshot }; + } catch (e: any) { + return { ok: false, error: String(e) }; + } +}); + // Settings ipcMain.handle('settings.get', async () => { const notifyRuntimeRepair = hasSavedRuntimeEnvSelection(); @@ -7160,7 +7208,7 @@ ipcMain.handle('settings.codexRoots', async () => { } }); -// Read-only: return the detected session roots for a given provider (codex/claude/gemini/antigravity) +// Read-only: return the detected session roots for a given provider ipcMain.handle('settings.sessionRoots', async (_e, args: { providerId?: string }) => { const id = String(args?.providerId || 'codex').trim().toLowerCase(); try { @@ -7189,6 +7237,10 @@ ipcMain.handle('settings.sessionRoots', async (_e, args: { providerId?: string } const cands = await getAntigravityRootCandidatesFastAsync(); return { ok: true, roots: cands.filter((c) => c.exists).map((c) => c.path) }; } + if (id === 'grok') { + const cands = await getGrokRootCandidatesFastAsync(); + return { ok: true, roots: cands.filter((c) => c.exists).map((c) => c.path) }; + } return { ok: true, roots: [] as string[] }; } catch (e: any) { return { ok: false, error: String(e) }; diff --git a/electron/preload.ts b/electron/preload.ts index 6011355..9962681 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -628,7 +628,7 @@ contextBridge.exposeInMainWorld('host', { }) => { return await ipcRenderer.invoke('history.list', args); }, - read: async (args: { filePath: string; providerId?: "codex" | "claude" | "gemini" | "antigravity"; forceParse?: boolean }) => { + read: async (args: { filePath: string; providerId?: "codex" | "claude" | "gemini" | "antigravity" | "grok"; forceParse?: boolean }) => { return await ipcRenderer.invoke('history.read', args); }, findEmptySessions: async () => { @@ -680,7 +680,7 @@ contextBridge.exposeInMainWorld('host', { if (res && res.ok && Array.isArray(res.roots)) return res.roots as string[]; return [] as string[]; }, - sessionRoots: async (args: { providerId: "codex" | "claude" | "gemini" | "antigravity" }) => { + sessionRoots: async (args: { providerId: "codex" | "claude" | "gemini" | "antigravity" | "grok" }) => { const res = await ipcRenderer.invoke('settings.sessionRoots', args); if (res && res.ok && Array.isArray(res.roots)) return res.roots as string[]; return [] as string[]; @@ -757,6 +757,11 @@ contextBridge.exposeInMainWorld('host', { return await ipcRenderer.invoke('antigravity.usage'); } } + , grok: { + getUsage: async () => { + return await ipcRenderer.invoke('grok.usage'); + } + } , notifications: { setBadgeCount: (count: number) => { ipcRenderer.send('notifications:setBadge', { count }); @@ -768,7 +773,7 @@ contextBridge.exposeInMainWorld('host', { showAgentCompletion: (payload: { tabId: string; tabName?: string; projectName?: string; preview?: string; title: string; body: string; appTitle?: string }) => { ipcRenderer.send('notifications:agentComplete', payload); }, - // 监听主进程转发的外部完成通知(Codex/Gemini/Claude/Antigravity hook -> JSONL 桥接)。 + // 监听主进程转发的外部完成通知(Codex/Gemini/Claude/Antigravity/Grok hook -> JSONL 桥接)。 onExternalAgentComplete: (handler: (payload: any) => void) => { const listener = (_: unknown, payload: any) => handler(payload); ipcRenderer.on('notifications:externalAgentComplete', listener); diff --git a/electron/projects.fast.ts b/electron/projects.fast.ts index c4edf81..0d15b47 100644 --- a/electron/projects.fast.ts +++ b/electron/projects.fast.ts @@ -9,8 +9,10 @@ import { app } from "electron"; import { wslToUNC, isUNCPath, uncToWsl, getCodexRootsFastAsync, normalizeWinPath } from "./wsl"; import { getClaudeRootCandidatesFastAsync, discoverClaudeSessionFiles } from "./agentSessions/claude/discovery"; import { getGeminiRootCandidatesFastAsync, discoverGeminiSessionFiles } from "./agentSessions/gemini/discovery"; +import { getGrokRootCandidatesFastAsync, discoverGrokSessionFiles } from "./agentSessions/grok/discovery"; import { parseClaudeSessionFile } from "./agentSessions/claude/parser"; import { parseGeminiSessionFile, extractGeminiProjectHashFromPath } from "./agentSessions/gemini/parser"; +import { parseGrokSessionFile } from "./agentSessions/grok/parser"; import { tidyPathCandidate } from "./agentSessions/shared/path"; import { perfLogger } from "./log"; import { getDebugConfig } from "./debugConfig"; @@ -32,7 +34,7 @@ export type Project = { worktreePostSetup?: WorktreePostSetupConfig; createdAt: number; lastOpenedAt?: number; - /** 是否已确认存在内置代理引擎(codex/claude/gemini/antigravity)的会话记录。 */ + /** 是否已确认存在内置代理引擎(codex/claude/gemini/antigravity/grok)的会话记录。 */ hasBuiltInSessions?: boolean; /** 自定义引擎无法从会话文件反推 cwd 时,用于“保留该目录”的显式记录。 */ dirRecord?: ProjectDirRecord; @@ -478,7 +480,7 @@ export async function scanProjectsAsync(_roots?: string[], verbose = false): Pro }); // Gemini CLI:尝试从 session JSON 中提取 cwd(若无法解析则跳过) - await perfLogger.time('projectsFast.enumerateGemini', async () => { + await perfLogger.time('projectsFast.enumerateGemini', async () => { try { const roots = (await getGeminiRootCandidatesFastAsync()).filter((c) => c.exists); await Promise.all(roots.map(({ path: root, distro }) => limit(async () => { @@ -513,7 +515,27 @@ export async function scanProjectsAsync(_roots?: string[], verbose = false): Pro } catch {} }))); } catch {} - }); + }); + + // Grok Build:从 summary.json 中提取官方记录的 cwd 以补全项目列表。 + await perfLogger.time('projectsFast.enumerateGrok', async () => { + try { + const roots = (await getGrokRootCandidatesFastAsync()).filter((candidate) => candidate.exists); + await Promise.all(roots.map(({ path: root, distro }) => limit(async () => { + try { + const files = await discoverGrokSessionFiles(root); + for (const filePath of files) { + try { + const stat = await fsp.stat(filePath).catch(() => null as any); + if (!stat) continue; + const details = await parseGrokSessionFile(filePath, stat, { summaryOnly: true }); + if (details?.cwd) await addProjectFromCwd(String(details.cwd), { root, distro }); + } catch {} + } + } catch {} + }))); + } catch {} + }); // 输出聚合摘要(一次性、低噪声) try { diff --git a/electron/providers/ids.ts b/electron/providers/ids.ts index 0da0be7..0c449a3 100644 --- a/electron/providers/ids.ts +++ b/electron/providers/ids.ts @@ -1,14 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) -export type BuiltInAgentProviderId = "codex" | "claude" | "gemini" | "antigravity"; +export type BuiltInAgentProviderId = "codex" | "claude" | "gemini" | "antigravity" | "grok"; -export const BUILT_IN_AGENT_PROVIDER_IDS: readonly BuiltInAgentProviderId[] = ["codex", "claude", "gemini", "antigravity"]; +export const BUILT_IN_AGENT_PROVIDER_IDS: readonly BuiltInAgentProviderId[] = ["codex", "claude", "gemini", "antigravity", "grok"]; /** * 判断输入是否为内置代理引擎 ProviderId。 */ export function isBuiltInAgentProviderId(value: unknown): value is BuiltInAgentProviderId { const id = String(value || "").trim().toLowerCase(); - return id === "codex" || id === "claude" || id === "gemini" || id === "antigravity"; + return id === "codex" || id === "claude" || id === "gemini" || id === "antigravity" || id === "grok"; } diff --git a/electron/providers/runtime.test.ts b/electron/providers/runtime.test.ts index 727a5ef..8d45c6d 100644 --- a/electron/providers/runtime.test.ts +++ b/electron/providers/runtime.test.ts @@ -67,12 +67,13 @@ describe("providers/runtime(主进程 Provider 默认值解析)", () => { expect(resolveProviderStartupCmdFromSettings(cfg, "codex")).toBe("codex-x"); }); - it("resolveProviderStartupCmdFromSettings 内置兜底:codex/claude/gemini/antigravity", () => { + it("resolveProviderStartupCmdFromSettings 内置兜底包含 Grok", () => { const cfg = { terminal: "wsl", distro: "Ubuntu-24.04", codexCmd: "codex-x", historyRoot: "~/.codex/sessions" } as any; expect(resolveProviderStartupCmdFromSettings(cfg, "codex")).toBe("codex-x"); expect(resolveProviderStartupCmdFromSettings(cfg, "claude")).toBe("claude"); expect(resolveProviderStartupCmdFromSettings(cfg, "gemini")).toBe("gemini"); expect(resolveProviderStartupCmdFromSettings(cfg, "antigravity")).toBe("agy"); + expect(resolveProviderStartupCmdFromSettings(cfg, "grok")).toBe("grok"); }); it("resolveProviderRuntimeEnvFromSettings 优先读取 providers.env,缺失时回退到 legacy 字段", () => { diff --git a/electron/providers/runtime.ts b/electron/providers/runtime.ts index 19ccfd2..a8f9aef 100644 --- a/electron/providers/runtime.ts +++ b/electron/providers/runtime.ts @@ -39,7 +39,7 @@ export function resolveProviderRuntimeEnvFromSettings(cfg: AppSettings, provider * - 优先读取 providers.items 对应条目的 startupCmd(空/仅空白则视为未设置) * - 内置兜底: * - codex:cfg.codexCmd(再回退到 "codex") - * - claude/gemini/antigravity:使用各自的默认命令 + * - claude/gemini/antigravity/grok:使用各自的默认命令 * - 其它自定义 Provider:缺失时返回空字符串 */ export function resolveProviderStartupCmdFromSettings(cfg: AppSettings, providerId: string): string { @@ -53,6 +53,7 @@ export function resolveProviderStartupCmdFromSettings(cfg: AppSettings, provider if (pid === "claude") return "claude"; if (pid === "gemini") return "gemini"; if (pid === "antigravity") return "agy"; + if (pid === "grok") return "grok"; if (pid === "codex") { const legacy = typeof (cfg as any)?.codexCmd === "string" ? String((cfg as any).codexCmd).trim() : ""; return legacy || "codex"; diff --git a/electron/settings.ts b/electron/settings.ts index f7e7366..1ef724d 100644 --- a/electron/settings.ts +++ b/electron/settings.ts @@ -116,7 +116,7 @@ export type ClaudeCodeSettings = { }; export type ProviderItem = { - /** Provider 唯一标识(内置:codex/claude/gemini/antigravity;自定义:任意非空字符串) */ + /** Provider 唯一标识(内置:codex/claude/gemini/antigravity/grok;自定义:任意非空字符串) */ id: ProviderId; /** 展示名称:仅用于自定义 Provider(内置 Provider 优先由渲染层 i18n 决定) */ displayName?: string; @@ -124,7 +124,7 @@ export type ProviderItem = { iconDataUrl?: string; /** 图标(暗色模式,DataURL);为空则回退到 iconDataUrl 或内置默认暗色图标 */ iconDataUrlDark?: string; - /** 启动命令(例如 codex / claude / gemini / agy),可覆盖内置默认值 */ + /** 启动命令(例如 codex / claude / gemini / agy / grok),可覆盖内置默认值 */ startupCmd?: string; }; @@ -582,6 +582,7 @@ function defaultProviderItems(): ProviderItem[] { { id: 'claude' }, { id: 'gemini' }, { id: 'antigravity' }, + { id: 'grok' }, ]; } @@ -640,6 +641,7 @@ function normalizeProviders(raw: Partial, distros: DistroInfo[]): P if (!env.claude) env.claude = { terminal: legacyTerminal, distro: legacyDistro }; if (!env.gemini) env.gemini = { terminal: legacyTerminal, distro: legacyDistro }; if (!env.antigravity) env.antigravity = { terminal: legacyTerminal, distro: legacyDistro }; + if (!env.grok) env.grok = { terminal: legacyTerminal, distro: legacyDistro }; // 将 legacyCodexCmd 写入 codex 的 startupCmd 兜底(仅当 providers 未显式覆盖) const codexItem = items.find((x) => x.id === 'codex'); diff --git a/web/src/App.tsx b/web/src/App.tsx index 952a7ed..f440a6c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -65,6 +65,7 @@ import { emitCodexRateRefresh } from "@/lib/codex-status"; import { emitClaudeUsageRefresh } from "@/lib/claude-status"; import { emitGeminiUsageRefresh } from "@/lib/gemini-status"; import { emitAntigravityUsageRefresh } from "@/lib/antigravity-status"; +import { emitGrokUsageRefresh } from "@/lib/grok-status"; import { checkForUpdate, type UpdateCheckErrorType } from "@/lib/about"; import TerminalManager from "@/lib/TerminalManager"; import { isGeminiLikeProvider, isGeminiProvider, writeBracketedPaste, writeBracketedPasteAndEnter } from "@/lib/terminal-send"; @@ -121,7 +122,7 @@ import { isCurrentProviderEnvironmentRequest, } from "@/lib/providers/environment"; import { normalizeProvidersSettings } from "@/lib/providers/normalize"; -import { isBuiltInSessionProviderId, openaiIconUrl, openaiDarkIconUrl, claudeIconUrl, geminiIconUrl, antigravityIconUrl } from "@/lib/providers/builtins"; +import { isBuiltInSessionProviderId, openaiIconUrl, openaiDarkIconUrl, claudeIconUrl, geminiIconUrl, antigravityIconUrl, grokIconUrl, grokDarkIconUrl } from "@/lib/providers/builtins"; import { BUILT_IN_AGENT_PROVIDER_IDS, isBuiltInAgentProviderId } from "@/lib/providers/ids"; import { resolveProvider } from "@/lib/providers/resolve"; import { @@ -136,6 +137,7 @@ import { injectCodexTraceEnv } from "@/providers/codex/commands"; import { buildClaudeResumeStartupCmd } from "@/providers/claude/commands"; import { buildGeminiResumeStartupCmd } from "@/providers/gemini/commands"; import { buildAntigravityResumeStartupCmd } from "@/providers/antigravity/commands"; +import { buildGrokResumeStartupCmd } from "@/providers/grok/commands"; import { BUILT_IN_RULE_PROVIDER_IDS, getProjectRuleFilePath, @@ -295,8 +297,9 @@ import { normalizeThemeSetting, parseDateFromFilename, parseRawDate, - parseRecycleStashes, - pickUuidFromString, + parseRecycleStashes, + pickUuidFromString, + resolveGrokAttachmentPaths, resolveHistoryTimelineMeta, shouldDedupeCrossSourceCompletion, shouldDedupeRepeatedExternalCompletion, @@ -3771,7 +3774,7 @@ export default function CodexFlowManagerUI() { * 判断并消费恢复期守卫;命中时返回 true,表示本次完成通知应被抑制。 * 说明:守卫仅在 Codex 恢复路径中被设置,因此这里不再依赖 providerId,避免标签映射尚未同步时误清除守卫。 */ - function consumeResumeCompletionGuardIfNeeded(tabId: string, hasWorkingTimer: boolean): boolean { + function consumeResumeCompletionGuardIfNeeded(tabId: string, hasWorkingTimer: boolean): boolean { const safeId = String(tabId || "").trim(); if (!safeId) return false; const expireAt = Number(resumeCompletionGuardByTabRef.current[safeId] || 0); @@ -3790,13 +3793,13 @@ export default function CodexFlowManagerUI() { } delete resumeCompletionGuardByTabRef.current[safeId]; notifyLog(`resumeGuard.hit tab=${safeId}`); - return true; - } - - /** - * 根据外部通知负载推断对应的 tabId。 - */ - function resolveExternalTabId(payload: { tabId?: string; providerId?: string; envLabel?: string }): string | null { + return true; + } + + /** + * 根据外部通知负载推断对应的 tabId。 + */ + function resolveExternalTabId(payload: { tabId?: string; providerId?: string; envLabel?: string }): string | null { const direct = String(payload?.tabId || "").trim(); if (direct) { let existsInCurrentWindow = false; @@ -3808,10 +3811,10 @@ export default function CodexFlowManagerUI() { } if (existsInCurrentWindow) return direct; return null; - } - const providerId = String(payload?.providerId || "codex").trim().toLowerCase(); - const envLabel = String(payload?.envLabel || "").trim(); - const matched: ConsoleTab[] = []; + } + const providerId = String(payload?.providerId || "codex").trim().toLowerCase(); + const envLabel = String(payload?.envLabel || "").trim(); + const matched: ConsoleTab[] = []; for (const list of Object.values(tabsByProjectRef.current)) { for (const tab of list || []) { if (String(tab?.providerId || "").trim().toLowerCase() !== providerId) continue; @@ -4115,6 +4118,7 @@ export default function CodexFlowManagerUI() { try { emitClaudeUsageRefresh('agent-complete'); } catch {} try { emitGeminiUsageRefresh('agent-complete'); } catch {} try { emitAntigravityUsageRefresh('agent-complete'); } catch {} + try { emitGrokUsageRefresh('agent-complete'); } catch {} // worktree 自动提交:每次 agent 完成输出后,若有变更则提交一次(仅对非主 worktree 生效) try { @@ -5292,7 +5296,7 @@ export default function CodexFlowManagerUI() { */ const mapHistoryListItemToSession = useCallback((it: any): HistorySession => { return { - providerId: (it?.providerId === "claude" || it?.providerId === "gemini" || it?.providerId === "antigravity") ? it.providerId : "codex", + providerId: (it?.providerId === "claude" || it?.providerId === "gemini" || it?.providerId === "antigravity" || it?.providerId === "grok") ? it.providerId : "codex", id: String(it?.id || ""), title: typeof it?.rawDate === "string" ? String(it.rawDate) : String(it?.title || ""), date: normalizeMsToIso(it?.date), @@ -5674,6 +5678,7 @@ export default function CodexFlowManagerUI() { { id: "claude" }, { id: "gemini" }, { id: "antigravity" }, + { id: "grok" }, ]); const providerItemById = useMemo(() => buildProviderItemIndex(providerItems), [providerItems]); const [providerEnvById, setProviderEnvById] = useState>>(() => ({ @@ -5681,6 +5686,7 @@ export default function CodexFlowManagerUI() { claude: { terminal: "wsl", distro: "Ubuntu-24.04" }, gemini: { terminal: "wsl", distro: "Ubuntu-24.04" }, antigravity: { terminal: "wsl", distro: "Ubuntu-24.04" }, + grok: { terminal: "wsl", distro: "Ubuntu-24.04" }, })); const activeProviderIdRef = useRef(activeProviderId); const providerItemsRef = useRef(providerItems); @@ -5759,6 +5765,7 @@ export default function CodexFlowManagerUI() { if (!nextEnvById.claude) nextEnvById.claude = { terminal: codexEnv.terminal, distro: codexEnv.distro }; if (!nextEnvById.gemini) nextEnvById.gemini = { terminal: codexEnv.terminal, distro: codexEnv.distro }; if (!nextEnvById.antigravity) nextEnvById.antigravity = { terminal: codexEnv.terminal, distro: codexEnv.distro }; + if (!nextEnvById.grok) nextEnvById.grok = { terminal: codexEnv.terminal, distro: codexEnv.distro }; const nextProviders = { activeId: activeProviderId, items: nextItems, env: nextEnvById }; await window.host.settings.update({ providers: nextProviders as any, @@ -6376,6 +6383,7 @@ export default function CodexFlowManagerUI() { projectWinRoot: args.promptSource.projectWinRoot, projectWslRoot: args.promptSource.projectWslRoot, terminalMode: env.terminal as any, + excludeImageChips: args.providerId === "grok", }); }, [getProviderEnv]); @@ -7171,22 +7179,22 @@ export default function CodexFlowManagerUI() { return () => { try { off && off(); } catch {} }; }, [focusTabFromNotification]); - // 监听主进程转发的外部完成通知(Codex/Gemini/Claude/Antigravity,JSONL 桥接) + // 监听主进程转发的外部完成通知(Codex/Gemini/Claude/Antigravity/Grok,JSONL 桥接) useEffect(() => { let off: (() => void) | undefined; try { off = window.host.notifications?.onExternalAgentComplete?.((payload: ExternalAgentCompletePayload) => { const providerId = String(payload?.providerId || "").trim().toLowerCase(); - if (providerId && providerId !== "codex" && providerId !== "gemini" && providerId !== "claude" && providerId !== "antigravity") return; + if (providerId && providerId !== "codex" && providerId !== "gemini" && providerId !== "claude" && providerId !== "antigravity" && providerId !== "grok") return; const preview = String(payload?.preview || ""); const normalizeOptions: CompletionPreviewNormalizeOptions = {}; if (typeof payload?.previewEscapedWhitespace === "boolean") normalizeOptions.previewEscapedWhitespace = payload.previewEscapedWhitespace; const resolvedTabId = resolveExternalTabId({ - tabId: payload?.tabId, - providerId: providerId || "codex", - envLabel: payload?.envLabel, - }); + tabId: payload?.tabId, + providerId: providerId || "codex", + envLabel: payload?.envLabel, + }); if (!resolvedTabId) { notifyLog(`externalCompletion skip: no tab match provider=${providerId} tab=${String(payload?.tabId || "")} env=${payload?.envLabel || ""}`); return; @@ -8256,18 +8264,37 @@ export default function CodexFlowManagerUI() { return () => { try { unsubExit(); } catch {} }; }, [selectedProjectId]); + /** + * 收集 Grok 图片附件的绝对路径;官方 TUI 要求每张图片作为独立粘贴事件发送。 + */ + function collectGrokAttachmentPaths(tabId: string): string[] { + const targetTab = tabs.find((tab) => tab.id === tabId) || null; + if (targetTab?.providerId !== "grok") return []; + const execEnv = getTabExecEnv(tabId, targetTab.providerId); + return resolveGrokAttachmentPaths({ + chips: chipsByTab[tabId] || [], + terminalMode: execEnv.terminal as any, + projectWinRoot: selectedProject?.winPath, + projectWslRoot: selectedProject?.wslPath, + }); + } + + /** + * 将当前标签页的路径 Chip 与草稿编译为发送正文。 + */ function compileTextFromChipsAndDraft(tabId: string): string { const targetTab = tabs.find((tab) => tab.id === tabId) || null; const isGeminiTab = targetTab?.providerId === "gemini" || targetTab?.providerId === "antigravity"; + const isGrokTab = targetTab?.providerId === "grok"; const execEnv = targetTab ? getTabExecEnv(tabId, targetTab.providerId) : { terminal: terminalMode, distro: wslDistro }; const execTerminal = execEnv.terminal as any; const chips = chipsByTab[tabId] || []; const draft = draftByTab[tabId] || ""; const parts: string[] = []; if (chips.length > 0) { - parts.push( - chips + const chipText = chips .map((c) => { + if (isGrokTab && isGeminiImageChip(c)) return ""; if (isGeminiTab && isGeminiImageChip(c)) { const geminiPath = isWindowsLike(execTerminal) ? String(c.winPath || "").trim() @@ -8300,8 +8327,9 @@ export default function CodexFlowManagerUI() { // 默认 WSL 模式 return c.wslPath ? ('`' + c.wslPath + '`') : (c.winPath || c.fileName || ''); }) - .join("\n") - ); + .filter(Boolean) + .join("\n"); + if (chipText) parts.push(chipText); } if (draft && draft.trim()) { if (parts.length > 0) parts.push(""); @@ -8469,7 +8497,8 @@ export default function CodexFlowManagerUI() { async function sendCommand() { if (!activeTab) return; const text = compileTextFromChipsAndDraft(activeTab.id); - if (!text.trim()) return; + const attachmentPaths = collectGrokAttachmentPaths(activeTab.id); + if (!text.trim() && attachmentPaths.length <= 0) return; const pid = ptyByTabRef.current[activeTab.id]; if (!pid) return; const activeEnv = tabExecEnvByTabRef.current[activeTab.id] || getProviderEnv(activeTab.providerId); @@ -8497,13 +8526,19 @@ export default function CodexFlowManagerUI() { geminiWindowsEditorReady: !!geminiWindowsEditorReadyByTabRef.current[activeTab.id], geminiWslEditorReady: !!geminiWslEditorReadyByTabRef.current[activeTab.id], geminiExternalEditorShortcut: geminiExternalEditorShortcutByTabRef.current[activeTab.id] || "auto", + attachmentPaths, }; if (sendMode === 'write_and_enter') await tm.sendTextAndEnter(activeTab.id, text, sendOptions); else await tm.sendText(activeTab.id, text, sendOptions); } catch { // 兜底:避免 Gemini 直接写入 `\n` 被吞,统一使用 bracketed paste +(可选)延迟回车 try { - if (isGeminiLikeProvider(activeTab.providerId)) { + if (activeTab.providerId === "grok") { + const write = (data: string) => { try { window.host.pty.write(pid, data); } catch {} }; + for (const attachmentPath of attachmentPaths) writeBracketedPaste(write, attachmentPath); + if (text) writeBracketedPaste(write, text); + if (sendMode === "write_and_enter") write("\r"); + } else if (isGeminiLikeProvider(activeTab.providerId)) { const write = (data: string) => { try { window.host.pty.write(pid, data); } catch {} }; if (sendMode === "write_and_enter") writeBracketedPasteAndEnter(write, text, { providerId: activeTab.providerId }); else writeBracketedPaste(write, text); @@ -9546,23 +9581,53 @@ export default function CodexFlowManagerUI() { source: "worktree", }); if (!prepared.ok) return prepared; + const attachmentPaths = providerId === "grok" && args.promptSource + ? resolveGrokAttachmentPaths({ + chips: args.promptSource.chips, + terminalMode: prepared.env.terminal as any, + projectWinRoot: args.promptSource.projectWinRoot, + projectWslRoot: args.promptSource.projectWslRoot, + }) + : []; const prompt = buildWorktreePromptForProvider({ providerId, prompt: args.prompt, promptSource: args.promptSource, envOverride: prepared.env, }); - const startupCmd = buildProviderStartupCmdWithInitialPrompt({ providerId, terminalMode: prepared.env.terminal as any, baseCmd: prepared.startupCmd, prompt }); + const sendGrokPromptAfterStartup = providerId === "grok" && attachmentPaths.length > 0; + const startupCmd = sendGrokPromptAfterStartup + ? prepared.startupCmd + : buildProviderStartupCmdWithInitialPrompt({ providerId, terminalMode: prepared.env.terminal as any, baseCmd: prepared.startupCmd, prompt }); const started = await openProviderConsoleInProject({ project, providerId, startupCmd, envOverride: prepared.env }); - const hasInitialPrompt = String(prompt || "").trim().length > 0; const tabId = String(started.tabId || "").trim(); + if (started.ok && tabId && sendGrokPromptAfterStartup) { + const ready = await tm.waitForOutputText(tabId, ["New worktree", "Build anything", "minimal · /help"], { timeoutMs: 30_000 }); + if (!ready) { + return { + ok: false, + tabId, + error: t("projects:grokInitialPromptNotReady", "Grok input was not ready; initial attachments were not sent") as string, + }; + } + await tm.sendTextAndEnter(tabId, prompt, { + providerId, + terminalMode: prepared.env.terminal as any, + projectWinRoot: project.winPath, + projectWslRoot: project.wslPath, + projectName: project.name, + distro: prepared.env.terminal === "wsl" ? prepared.env.distro : undefined, + attachmentPaths, + }); + } + const hasInitialPrompt = String(prompt || "").trim().length > 0 || attachmentPaths.length > 0; if (started.ok && tabId && hasInitialPrompt && shouldEnableAgentTimerForProvider(providerId)) startAgentTurnTimer(tabId); return started; } catch (e: any) { return { ok: false, error: String(e?.message || e) }; } - }, [buildProviderStartupCmdForWorktreeCreate, buildWorktreePromptForProvider, getProviderEnv, openProviderConsoleInProject, prepareProviderLaunch, resolveVisibleProviderEnv, shouldEnableAgentTimerForProvider, startAgentTurnTimer]); + }, [buildProviderStartupCmdForWorktreeCreate, buildWorktreePromptForProvider, getProviderEnv, openProviderConsoleInProject, prepareProviderLaunch, resolveVisibleProviderEnv, shouldEnableAgentTimerForProvider, startAgentTurnTimer, t, tm]); /** * 执行创建 worktree,并在每个 worktree 内启动对应引擎 CLI。 @@ -12609,9 +12674,9 @@ export default function CodexFlowManagerUI() { /** * 从历史会话中解析出稳定的 ProviderId(避免脏数据导致分支异常)。 */ - const resolveHistoryProviderId = (session?: HistorySession | null): HistorySession["providerId"] => { - const id = session?.providerId; - if (id === "claude" || id === "gemini" || id === "antigravity" || id === "codex") return id; + const resolveHistoryProviderId = (session?: HistorySession | null): HistorySession["providerId"] => { + const id = session?.providerId; + if (id === "claude" || id === "gemini" || id === "antigravity" || id === "grok" || id === "codex") return id; return "codex"; }; @@ -12637,8 +12702,9 @@ export default function CodexFlowManagerUI() { * 构造“继续对话”的启动命令(按 provider 分流)。 * - Codex:优先 resume ,失败回退 experimental_resume * - Claude:优先 --resume ,失败回退 --continue - * - Gemini:优先 --resume ,缺失则 --resume latest + * - Gemini:优先 --resume ,缺失则 --resume latest * - Antigravity:优先 --conversation + * - Grok:优先 --resume ,缺失则 --continue */ const buildResumeStartup = (filePath: string, mode: TerminalMode, options?: { forceLegacyCli?: boolean }): ResumeStartup => { const session = findSessionForFile(filePath); @@ -12678,6 +12744,14 @@ export default function CodexFlowManagerUI() { return { providerId: "antigravity", startupCmd, session, resumeLabel: conversationId || "agy" }; } + // ---- Grok ---- + if (providerId === "grok") { + const sessionId = String(session?.resumeId || "").trim() || null; + const providerCmd = resolveProvider(providerItemById["grok"] ?? { id: "grok" }).startupCmd || "grok"; + const startupCmd = buildGrokResumeStartupCmd({ cmd: providerCmd, terminalMode: mode, sessionId }); + return { providerId: "grok", startupCmd, session, resumeLabel: sessionId || "continue" }; + } + // ---- Codex ---- const preferredId = typeof session?.resumeId === 'string' ? session.resumeId : null; const guessedId = inferSessionUuid(session, filePath); @@ -14688,7 +14762,7 @@ export default function CodexFlowManagerUI() { -
+
{BUILT_IN_AGENT_PROVIDER_IDS.map((pid) => { const icon = pid === "codex" ? (themeMode === "dark" ? openaiDarkIconUrl : openaiIconUrl) @@ -14696,7 +14770,9 @@ export default function CodexFlowManagerUI() { ? claudeIconUrl : pid === "gemini" ? geminiIconUrl - : antigravityIconUrl; + : pid === "antigravity" + ? antigravityIconUrl + : (themeMode === "dark" ? grokDarkIconUrl : grokIconUrl); const isMulti = worktreeCreateDialog.useMultipleModels; const count = Math.max(0, Math.floor(Number(worktreeCreateDialog.multiCounts?.[pid]) || 0)); const enabledInMulti = count > 0; diff --git a/web/src/app/app-shared.notify.test.ts b/web/src/app/app-shared.notify.test.ts index 4751b25..6a41828 100644 --- a/web/src/app/app-shared.notify.test.ts +++ b/web/src/app/app-shared.notify.test.ts @@ -4,6 +4,7 @@ import { CLAUDE_NOTIFY_ENV_KEYS, GEMINI_NOTIFY_ENV_KEYS, ANTIGRAVITY_NOTIFY_ENV_KEYS, + GROK_NOTIFY_ENV_KEYS, buildProviderNotifyEnv, areEquivalentCompletionPreviews, hasMeaningfulCompletionPreview, @@ -162,6 +163,15 @@ describe("app-shared(完成通知:识别与环境变量注入)", () => { }); }); + it("buildProviderNotifyEnv:Grok 注入 GROK_CODEXFLOW_*", () => { + const env = buildProviderNotifyEnv("tab-5", "grok", "PowerShell"); + expect(env).toEqual({ + [GROK_NOTIFY_ENV_KEYS.tabId]: "tab-5", + [GROK_NOTIFY_ENV_KEYS.envLabel]: "PowerShell", + [GROK_NOTIFY_ENV_KEYS.providerId]: "grok", + }); + }); + it("buildProviderNotifyEnv:其它 provider 不注入", () => { expect(buildProviderNotifyEnv("tab-3", "terminal", "Ubuntu-24.04")).toEqual({}); }); diff --git a/web/src/app/app-shared.tsx b/web/src/app/app-shared.tsx index e4093cb..bc44c1f 100644 --- a/web/src/app/app-shared.tsx +++ b/web/src/app/app-shared.tsx @@ -9,6 +9,7 @@ import { Combobox } from "@/components/ui/combobox"; import PathChipsInput, { type PathChip } from "@/components/ui/path-chips-input"; import { retainPreviewUrl, releasePreviewUrl } from "@/lib/previewUrlRegistry"; import { retainPastedImage, releasePastedImage, requestTrashWinPath } from "@/lib/imageResourceRegistry"; +import { isGeminiImageChip } from "@/lib/gemini-attachments"; import { normalizePathScopeKey } from "@/lib/path-scope"; import { setActiveFileIndexRoot } from "@/lib/atSearch"; import { Badge } from "@/components/ui/badge"; @@ -349,6 +350,12 @@ const ANTIGRAVITY_NOTIFY_ENV_KEYS = { providerId: "ANTIGRAVITY_CODEXFLOW_PROVIDER_ID", } as const; +const GROK_NOTIFY_ENV_KEYS = { + tabId: "GROK_CODEXFLOW_TAB_ID", + envLabel: "GROK_CODEXFLOW_ENV_LABEL", + providerId: "GROK_CODEXFLOW_PROVIDER_ID", +} as const; + /** * 构建 ProviderItem 的 id -> item 索引,避免在标签渲染时重复线性扫描。 */ @@ -430,6 +437,21 @@ function buildAntigravityNotifyEnv(tabId: string, providerId: string, envLabel: }; } +/** + * 中文说明:构造 Grok Stop Hook 所需的通知环境变量。 + */ +function buildGrokNotifyEnv(tabId: string, providerId: string, envLabel: string): Record { + const pid = String(providerId || "").trim().toLowerCase(); + if (pid !== "grok") return {}; + const tid = String(tabId || "").trim(); + if (!tid) return {}; + return { + [GROK_NOTIFY_ENV_KEYS.tabId]: tid, + [GROK_NOTIFY_ENV_KEYS.envLabel]: String(envLabel || "").trim(), + [GROK_NOTIFY_ENV_KEYS.providerId]: pid, + }; +} + /** * 中文说明:构造 Provider 完成通知链路所需的环境变量(按 providerId 注入)。 */ @@ -439,6 +461,7 @@ function buildProviderNotifyEnv(tabId: string, providerId: string, envLabel: str if (pid === "gemini") return buildGeminiNotifyEnv(tabId, pid, envLabel); if (pid === "claude") return buildClaudeNotifyEnv(tabId, pid, envLabel); if (pid === "antigravity") return buildAntigravityNotifyEnv(tabId, pid, envLabel); + if (pid === "grok") return buildGrokNotifyEnv(tabId, pid, envLabel); return {}; } @@ -464,7 +487,7 @@ type MessageContent = { }; type HistoryMessage = { role: string; content: MessageContent[] }; type HistorySession = { - providerId: "codex" | "claude" | "gemini" | "antigravity"; + providerId: "codex" | "claude" | "gemini" | "antigravity" | "grok"; id: string; title: string; date: string; // ISO @@ -1152,6 +1175,73 @@ function toWorktreePromptRelPath(args: { pathText: string; projectWinRoot?: stri return raw.replace(/\\/g, "/"); } +/** + * 将 Grok 图片 chip 转换为目标终端可直接粘贴的绝对路径。 + * + * @param args 图片 chips、源项目根目录与目标终端类型 + */ +function resolveGrokAttachmentPaths(args: { + chips: PathChip[]; + projectWinRoot?: string; + projectWslRoot?: string; + terminalMode?: TerminalMode; +}): string[] { + const chips = Array.isArray(args.chips) ? args.chips : []; + const preferWindows = args.terminalMode !== "wsl"; + const projectWinRoot = String(args.projectWinRoot || "").trim().replace(/[\\/]+$/, ""); + const projectWslRoot = String(args.projectWslRoot || "").trim().replace(/\/+$/, ""); + const output: string[] = []; + const seen = new Set(); + + for (const chip of chips) { + if (!isGeminiImageChip(chip)) continue; + const chipAny = chip as any; + const winPath = String(chipAny?.winPath || "").trim(); + const wslPath = String(chipAny?.wslPath || "").trim(); + const fileName = String(chipAny?.fileName || "").trim(); + let resolved = ""; + + if (preferWindows) { + if (/^[A-Za-z]:[\\/]/.test(winPath) || winPath.startsWith("\\\\")) { + resolved = winPath; + } else { + const mounted = wslPath.match(/^\/mnt\/([a-zA-Z])\/(.*)$/); + if (mounted) + resolved = `${mounted[1].toUpperCase()}:\\${mounted[2].replace(/\//g, "\\")}`; + else if (winPath && projectWinRoot) + resolved = `${projectWinRoot}\\${winPath.replace(/^[\\/]+/, "")}`; + else if (wslPath && !wslPath.startsWith("/") && projectWinRoot) + resolved = `${projectWinRoot}\\${wslPath.replace(/^[\\/]+/, "").replace(/\//g, "\\")}`; + else if (fileName && projectWinRoot) + resolved = `${projectWinRoot}\\${fileName.replace(/^[\\/]+/, "")}`; + else + resolved = winPath || wslPath || fileName; + } + } else if (wslPath.startsWith("/")) { + resolved = wslPath; + } else if (wslPath && projectWslRoot) { + resolved = `${projectWslRoot}/${wslPath.replace(/^[\\/]+/, "").replace(/\\/g, "/")}`; + } else if (/^[A-Za-z]:[\\/]/.test(winPath) || winPath.startsWith("\\\\")) { + resolved = toWSLForInsert(winPath); + } else if (winPath && projectWslRoot) { + resolved = `${projectWslRoot}/${winPath.replace(/^[\\/]+/, "").replace(/\\/g, "/")}`; + } else if (fileName && projectWslRoot) { + resolved = `${projectWslRoot}/${fileName.replace(/^[\\/]+/, "").replace(/\\/g, "/")}`; + } else { + resolved = wslPath || winPath || fileName; + } + + resolved = String(resolved || "").trim(); + if (!resolved) continue; + const key = preferWindows ? resolved.toLowerCase() : resolved; + if (seen.has(key)) continue; + seen.add(key); + output.push(resolved); + } + + return output; +} + /** * 将 worktree 创建面板中的 chips + 草稿合并为最终提示词: * - 每个 chip 独占一行,并用反引号包裹 @@ -1164,8 +1254,11 @@ function compileWorktreePromptText(args: { projectWinRoot?: string; projectWslRoot?: string; terminalMode?: TerminalMode; + /** Grok 图片会通过独立粘贴事件发送,正文中不再重复插入图片路径。 */ + excludeImageChips?: boolean; }): string { - const chips = Array.isArray(args.chips) ? args.chips : []; + const chips = (Array.isArray(args.chips) ? args.chips : []) + .filter((chip) => !args.excludeImageChips || !isGeminiImageChip(chip)); const draft = String(args.draft || ""); const parts: string[] = []; if (chips.length > 0) { @@ -2089,6 +2182,7 @@ export { CLAUDE_NOTIFY_ENV_KEYS, CODEX_NOTIFY_ENV_KEYS, ANTIGRAVITY_NOTIFY_ENV_KEYS, + GROK_NOTIFY_ENV_KEYS, PROJECT_SORT_STORAGE_KEY, INPUT_FULLSCREEN_TRANSITION_MS, OSC_NOTIFICATION_PREFIX, @@ -2122,6 +2216,7 @@ export { trimSelectedIdsByOrder, areStringArraysEqual, toWorktreePromptRelPath, + resolveGrokAttachmentPaths, compileWorktreePromptText, buildProviderStartupCmdWithInitialPrompt, summarizeForCommitMessage, diff --git a/web/src/app/app-shared.worktree.test.ts b/web/src/app/app-shared.worktree.test.ts index bd7a7bd..f49358c 100644 --- a/web/src/app/app-shared.worktree.test.ts +++ b/web/src/app/app-shared.worktree.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { compileWorktreePromptText, toWorktreePromptRelPath } from "./app-shared"; +import { compileWorktreePromptText, resolveGrokAttachmentPaths, toWorktreePromptRelPath } from "./app-shared"; describe("app-shared(worktree 初始提示词编译)", () => { it("toWorktreePromptRelPath:将项目内 Windows 绝对路径转换为相对路径", () => { @@ -64,4 +64,62 @@ describe("app-shared(worktree 初始提示词编译)", () => { terminalMode: "wsl", })).toBe("`/mnt/d/shared/brief.md`"); }); + + it("Grok 图片由独立粘贴事件发送,正文只保留普通文件与草稿", () => { + expect(compileWorktreePromptText({ + chips: [ + { + chipKind: "image", + type: "image/png", + fileName: "screen.png", + winPath: "X:\\workspace\\screen.png", + wslPath: "/mnt/x/workspace/screen.png", + } as any, + { + chipKind: "file", + fileName: "task.md", + winPath: "X:\\workspace\\docs\\task.md", + wslPath: "/mnt/x/workspace/docs/task.md", + } as any, + ], + draft: "请结合截图处理", + projectWinRoot: "X:\\workspace", + projectWslRoot: "/mnt/x/workspace", + terminalMode: "pwsh", + excludeImageChips: true, + })).toBe("`docs/task.md`\n\n请结合截图处理"); + }); + + it("resolveGrokAttachmentPaths:按目标终端解析图片绝对路径并去重", () => { + const chips = [ + { + chipKind: "image", + type: "image/png", + fileName: "screen.png", + winPath: "X:\\workspace\\screen.png", + wslPath: "/mnt/x/workspace/screen.png", + } as any, + { + chipKind: "image", + type: "image/png", + fileName: "duplicate.png", + winPath: "x:\\workspace\\screen.png", + wslPath: "/mnt/x/workspace/screen.png", + } as any, + { chipKind: "file", fileName: "task.md", winPath: "X:\\workspace\\task.md" } as any, + ]; + + expect(resolveGrokAttachmentPaths({ + chips, + projectWinRoot: "X:\\workspace", + projectWslRoot: "/mnt/x/workspace", + terminalMode: "pwsh", + })).toEqual(["X:\\workspace\\screen.png"]); + expect(resolveGrokAttachmentPaths({ + chips, + projectWinRoot: "X:\\workspace", + projectWslRoot: "/mnt/x/workspace", + terminalMode: "wsl", + })).toEqual(["/mnt/x/workspace/screen.png"]); + }); }); diff --git a/web/src/assets/providers/grok-dark.svg b/web/src/assets/providers/grok-dark.svg new file mode 100644 index 0000000..cb1537f --- /dev/null +++ b/web/src/assets/providers/grok-dark.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/web/src/assets/providers/grok.svg b/web/src/assets/providers/grok.svg new file mode 100644 index 0000000..efb1a61 --- /dev/null +++ b/web/src/assets/providers/grok.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/web/src/components/topbar/grok-status.tsx b/web/src/components/topbar/grok-status.tsx new file mode 100644 index 0000000..a39e025 --- /dev/null +++ b/web/src/components/topbar/grok-status.tsx @@ -0,0 +1,260 @@ +// 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 { useTranslation } from "react-i18next"; +import { RotateCcw } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useHoverCard } from "@/components/topbar/hover-card"; +import { cn } from "@/lib/utils"; +import { formatGrokUsageErrorText, GROK_USAGE_REFRESH_EVENT } from "@/lib/grok-status"; +import type { AppSettings, GrokUsageSnapshot } from "@/types/host"; + +type TerminalMode = NonNullable; + +type FetchState = { + loading: boolean; + error: string | null; + data: GrokUsageSnapshot | null; +}; + +type GrokUsageHoverCardTriggerArgs = { + usageState: FetchState; + percentLabel: string; + summaryLabel: string; +}; + +export type GrokUsageHoverCardProps = { + className?: string; + terminalMode?: TerminalMode; + distro?: string; + renderTrigger: (args: GrokUsageHoverCardTriggerArgs) => React.ReactNode; + panelAlign?: "start" | "end"; +}; + +/** + * 将百分比格式化为顶部栏使用的整数文本。 + */ +function formatPercent(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "–"; + return String(Math.round(value)) + "%"; +} + +/** + * 将美元分格式化为当前语言的美元金额。 + */ +function formatUsdCents(value: number | null | undefined, language?: string): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "–"; + try { + return new Intl.NumberFormat(language, { style: "currency", currency: "USD" }).format(value / 100); + } catch { + return `$${(value / 100).toFixed(2)}`; + } +} + +/** + * 将毫秒时间戳格式化为当前语言日期时间。 + */ +function formatTimeMs(value: number | null | undefined, language?: string): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "–"; + try { + return new Date(value).toLocaleString(language); + } catch { + return "–"; + } +} + +/** + * Grok Build 用量悬浮卡:展示当前登录账号的官方额度。 + */ +export const GrokUsageHoverCard: React.FC = ({ + className, + terminalMode, + distro, + renderTrigger, + panelAlign = "start", +}) => { + const { t, i18n } = useTranslation(["common"]); + const hover = useHoverCard(); + const lastRefreshAtRef = useRef(0); + const [usageState, setUsageState] = useState({ loading: false, error: null, data: null }); + + /** + * 从主进程重新获取 Grok 账号额度。 + */ + const reloadUsage = useCallback(async () => { + setUsageState((previous) => ({ ...previous, loading: true, error: null })); + try { + const result = await window.host.grok.getUsage(); + if (result.ok) { + setUsageState({ loading: false, error: null, data: result.snapshot ?? null }); + return; + } + setUsageState({ loading: false, error: formatGrokUsageErrorText(result.error, t), data: null }); + } catch (reason) { + setUsageState({ loading: false, error: formatGrokUsageErrorText(reason, t), data: null }); + } + }, [distro, t, terminalMode]); + + useEffect(() => { + reloadUsage(); + }, [reloadUsage]); + + useEffect(() => { + /** + * 响应任务完成事件,并限制自动刷新频率。 + */ + const onRefresh = () => { + const now = Date.now(); + if (now - lastRefreshAtRef.current < 60_000) return; + lastRefreshAtRef.current = now; + reloadUsage(); + }; + window.addEventListener(GROK_USAGE_REFRESH_EVENT, onRefresh as EventListener); + return () => window.removeEventListener(GROK_USAGE_REFRESH_EVENT, onRefresh as EventListener); + }, [reloadUsage]); + + const snapshot = usageState.data; + const quota = snapshot?.quota; + const percentLabel = formatPercent(quota?.usedPercent); + const summaryLabel = usageState.loading + ? t("common:grokUsage.loading", "正在同步账号额度…") + : usageState.error + ? t("common:grokUsage.unavailable", "账号额度不可用") + : t("common:grokUsage.title", "账号额度"); + const errorLines = String(usageState.error || "").split(String.fromCharCode(10)).map((line) => line.trim()).filter(Boolean); + const periodLabel = quota?.periodType?.includes("WEEKLY") + ? t("common:grokUsage.periodWeekly", "每周额度") + : quota?.periodType?.includes("MONTHLY") + ? t("common:grokUsage.periodMonthly", "每月额度") + : t("common:grokUsage.periodUnknown", "账号额度"); + const hasIncludedAmounts = quota?.includedUsedCents != null || quota?.includedLimitCents != null; + const hasAdditionalCredits = quota?.prepaidBalanceCents != null + || quota?.onDemandUsedCents != null + || quota?.onDemandCapCents != null + || quota?.onDemandEnabled === false; + const sourceLabel = snapshot?.source === "billing-cache" + ? t("common:grokUsage.sourceCache", "Grok 官方缓存") + : t("common:grokUsage.sourceLive", "实时"); + + return ( +
+ {renderTrigger({ usageState, percentLabel, summaryLabel })} + {hover.open ? ( +
+ {usageState.loading && !snapshot ? ( +
+ {t("common:grokUsage.loading", "正在同步账号额度…")} +
+ ) : usageState.error ? ( +
+
{errorLines[0] || summaryLabel}
+ {errorLines[1] ?
{errorLines[1]}
: null} +
+ ) : snapshot && quota ? ( +
+
+ + {snapshot.accountEmail || t("common:grokUsage.account", "Grok Build 账号")} + + {snapshot.subscriptionTier ? ( + + {snapshot.subscriptionTier} + + ) : null} +
+ +
+
+ + {t("common:grokUsage.includedQuota", "包含额度")} + + {periodLabel} +
+
+ + {t("common:grokUsage.used", { percent: percentLabel })} + + + {t("common:grokUsage.resetAt", { + time: quota.periodEndAt == null + ? t("common:grokUsage.resetNotProvided", "未提供") + : formatTimeMs(quota.periodEndAt, i18n.language), + })} + +
+ {hasIncludedAmounts ? ( +
+ {t("common:grokUsage.amount", { + used: formatUsdCents(quota.includedUsedCents, i18n.language), + limit: formatUsdCents(quota.includedLimitCents, i18n.language), + })} +
+ ) : null} +
+ + {hasAdditionalCredits ? ( +
+ + {t("common:grokUsage.additionalCredits", "额外额度")} + +
+ {quota.prepaidBalanceCents != null ? ( + <> + {t("common:grokUsage.prepaidBalance", "已购余额")} + {formatUsdCents(quota.prepaidBalanceCents, i18n.language)} + + ) : null} + {quota.onDemandUsedCents != null || quota.onDemandCapCents != null || quota.onDemandEnabled === false ? ( + <> + {t("common:grokUsage.onDemandUsage", "按需用量")} + + {quota.onDemandEnabled === false && quota.onDemandUsedCents == null && quota.onDemandCapCents == null + ? t("common:grokUsage.onDemandDisabled", "未启用") + : t("common:grokUsage.amount", { + used: formatUsdCents(quota.onDemandUsedCents, i18n.language), + limit: formatUsdCents(quota.onDemandCapCents, i18n.language), + })} + + + ) : null} +
+
+ ) : null} + +
+ {t("common:grokUsage.updatedAt", { time: formatTimeMs(snapshot.updatedAt, i18n.language) })} + {sourceLabel} +
+
+ ) : ( +
{t("common:grokUsage.empty", "暂无账号额度信息")}
+ )} + +
+ +
+
+ ) : null} +
+ ); +}; diff --git a/web/src/components/topbar/provider-switcher.tsx b/web/src/components/topbar/provider-switcher.tsx index 42f3001..496ac68 100644 --- a/web/src/components/topbar/provider-switcher.tsx +++ b/web/src/components/topbar/provider-switcher.tsx @@ -13,6 +13,7 @@ import { CodexUsageHoverCard } from "@/components/topbar/codex-status"; import { ClaudeUsageHoverCard } from "@/components/topbar/claude-status"; import { GeminiUsageHoverCard } from "@/components/topbar/gemini-status"; import { AntigravityUsageHoverCard } from "@/components/topbar/antigravity-status"; +import { GrokUsageHoverCard } from "@/components/topbar/grok-status"; type TerminalMode = NonNullable; @@ -68,6 +69,7 @@ export const ProviderSwitcher: React.FC = ({ activeId, pr const isClaude = p.id === "claude"; const isGemini = p.id === "gemini"; const isAntigravity = p.id === "antigravity"; + const isGrok = p.id === "grok"; return (
{isCodex ? ( @@ -179,6 +181,40 @@ export const ProviderSwitcher: React.FC = ({ activeId, pr {p.iconSrc ? {label} : {label[0] || "?"}} ) + ) : isGrok ? ( + selected ? ( + ( + + )} + /> + ) : ( + + ) ) : isAntigravity ? ( selected ? ( ; type SendMode = "write_only" | "write_and_enter"; type PathStyle = "absolute" | "relative"; -type SessionRootsProviderId = BuiltInRuleProviderId | "antigravity"; +type SessionRootsProviderId = BuiltInRuleProviderId | "antigravity" | "grok"; type RenderEngineRootsOptions = { showRuleActions?: boolean; }; @@ -650,6 +650,7 @@ export const SettingsDialog: React.FC = ({ const [claudeRoots, setClaudeRoots] = useState([]); const [geminiRoots, setGeminiRoots] = useState([]); const [antigravityRoots, setAntigravityRoots] = useState([]); + const [grokRoots, setGrokRoots] = useState([]); const [lang, setLang] = useState(values.locale || "en"); const [theme, setTheme] = useState(normalizeThemeSetting(values.theme)); const [multiInstanceEnabled, setMultiInstanceEnabled] = useState(!!values.multiInstanceEnabled); @@ -859,7 +860,7 @@ export const SettingsDialog: React.FC = ({ try { if (window.host.settings.sessionRoots) { const roots = await window.host.settings.sessionRoots({ providerId }); - if (providerId === "antigravity") + if (providerId === "antigravity" || providerId === "grok") return normalizeReadOnlySessionRootPaths(Array.isArray(roots) ? roots : []); return normalizeEngineRootPaths(providerId, Array.isArray(roots) ? roots : []); } @@ -1068,21 +1069,24 @@ export const SettingsDialog: React.FC = ({ if (!open) return; (async () => { try { - const [codex, claude, gemini, antigravity] = await Promise.all([ + const [codex, claude, gemini, antigravity, grok] = await Promise.all([ fetchSessionRoots("codex"), fetchSessionRoots("claude"), fetchSessionRoots("gemini"), fetchSessionRoots("antigravity"), + fetchSessionRoots("grok"), ]); setCodexRoots(codex); setClaudeRoots(claude); setGeminiRoots(gemini); setAntigravityRoots(antigravity); + setGrokRoots(grok); } catch { setCodexRoots([]); setClaudeRoots([]); setGeminiRoots([]); setAntigravityRoots([]); + setGrokRoots([]); } try { const result: any = await (window.host as any).wsl?.listDistros?.(); @@ -2816,6 +2820,15 @@ export const SettingsDialog: React.FC = ({ {renderEngineRoots(null, antigravityRoots, "settings:antigravityRoots.empty", { showRuleActions: false })} + + + {t("settings:grokRoots.label")} + + +

{t("settings:grokRoots.help")}

+ {renderEngineRoots(null, grokRoots, "settings:grokRoots.empty", { showRuleActions: false })} +
+
{t("settings:historyCleanup.label")} @@ -3131,6 +3144,7 @@ export const SettingsDialog: React.FC = ({ claudeRoots, geminiRoots, antigravityRoots, + grokRoots, renderEngineRoots, openSessionRootPath, // 字体与显示相关依赖,确保“显示所有字体”等交互即时生效 diff --git a/web/src/lib/TerminalManager.ts b/web/src/lib/TerminalManager.ts index 76b4126..0b1cab5 100644 --- a/web/src/lib/TerminalManager.ts +++ b/web/src/lib/TerminalManager.ts @@ -23,6 +23,7 @@ import { isClaudeProvider, isGeminiLikeProvider, isGeminiProvider, + isGrokProvider, stripTrailingNewlines, writeBracketedPaste, } from '@/lib/terminal-send'; @@ -63,6 +64,8 @@ type TerminalSendOptions = { geminiWindowsEditorReady?: boolean; geminiWslEditorReady?: boolean; geminiExternalEditorShortcut?: GeminiExternalEditorShortcut; + /** Grok 图片附件路径;每个路径必须作为独立 paste 事件发送。 */ + attachmentPaths?: string[]; }; const TERMINAL_SEND_SCREEN_ACK_POLL_MS = 40; @@ -85,6 +88,9 @@ const GEMINI_WINDOWS_EDITOR_STATUS_TIMEOUT_MS = 15000; const GEMINI_EXTERNAL_EDITOR_AUTO_PROBE_TIMEOUT_MS = 3000; const GEMINI_WSL_EDITOR_TRIGGER_CHAR_THRESHOLD = 12000; const GEMINI_WSL_EDITOR_TRIGGER_DISPATCH_THRESHOLD_MS = 2500; +const GROK_PASTE_CHIP_DISPLAY_BYTES = 10_000; +const GROK_PASTE_CHIP_MIN_LINES = 4; +const GROK_ATTACHMENT_PASTE_GAP_MS = 100; const TERMINAL_PTY_RESIZE_STABLE_DELAY_MS = 220; const TERMINAL_PTY_RESIZE_MIN_INTERVAL_MS = 320; const TERMINAL_PTY_RESIZE_MAX_DEFER_MS = 1200; @@ -809,7 +815,7 @@ export default class TerminalManager { */ private shouldTraceSendDiagnostics(providerId?: string | null): boolean { const normalized = String(providerId || "").trim().toLowerCase(); - return normalized === "codex" || normalized === "gemini" || normalized === "antigravity"; + return normalized === "codex" || normalized === "gemini" || normalized === "antigravity" || normalized === "grok"; } /** @@ -1036,6 +1042,27 @@ export default class TerminalManager { return null; } + /** + * 根据 Grok Build 的官方 paste chip 规则构造屏幕确认标记。 + */ + private buildGrokScreenAckMarker(text: string): string | null { + const normalized = this.normalizeSendProbeText(text); + const lineCount = normalized.split("\n").length; + let byteLength = normalized.length; + try { byteLength = new TextEncoder().encode(normalized).length; } catch {} + if (byteLength > GROK_PASTE_CHIP_DISPLAY_BYTES) { + const size = byteLength >= 1_000_000 + ? `${(byteLength / 1_000_000).toFixed(1)} MB` + : byteLength >= 1000 + ? `${Math.floor(byteLength / 1000)} KB` + : `${byteLength} bytes`; + return `[Pasted: ${size}]`; + } + if (lineCount >= GROK_PASTE_CHIP_MIN_LINES) + return `[Pasted: ${lineCount} lines]`; + return null; + } + /** * 中文说明:为 Claude 的长文本粘贴构造局部屏幕 ACK 标记。 * @param text 已规范化的待发送文本 @@ -1084,6 +1111,8 @@ export default class TerminalManager { const placeholderMarkers = provider === "codex" ? [this.buildCodexScreenAckMarker(normalized)] + : provider === "grok" + ? [this.buildGrokScreenAckMarker(normalized)] : provider === "gemini" || provider === "antigravity" ? [this.buildGeminiScreenAckMarker(normalized)] : provider === "claude" @@ -1423,6 +1452,36 @@ export default class TerminalManager { }); } + /** + * 将 Grok 图片路径逐个作为独立 bracketed-paste 事件发送。 + */ + private async sendGrokAttachments( + ptyId: string, + adapter: TerminalAdapterAPI | null, + attachmentPaths: readonly string[] | null | undefined, + traceId?: string | null, + ): Promise { + const paths = Array.from(new Set((Array.isArray(attachmentPaths) ? attachmentPaths : []) + .map((item) => String(item || "").trim()) + .filter(Boolean))); + for (const attachmentPath of paths) { + let sent = false; + if (adapter && typeof (adapter as any).paste === "function") { + try { + (adapter as any).paste(attachmentPath); + sent = true; + this.logSendDiagnostic(traceId, `grok.attachment.mode=adapter.paste chars=${attachmentPath.length}`); + } catch {} + } + if (!sent) { + this.hostPty.write(ptyId, buildBracketedPastePayload(attachmentPath)); + this.logSendDiagnostic(traceId, `grok.attachment.mode=host.bracketed chars=${attachmentPath.length}`); + } + await new Promise((resolve) => window.setTimeout(resolve, GROK_ATTACHMENT_PASTE_GAP_MS)); + } + return paths.length; + } + /** * 中文说明:Codex 在 Windows/PowerShell 短文本场景下,快速写入正文并在最小 settle 后提交。 * @@ -2572,7 +2631,7 @@ export default class TerminalManager { /** * 将指定 tab 的 PTY 与 adapter 做双向绑定:主进程 onData -> adapter.write,adapter.onData -> 主进程 write */ - private wireUp(tabId: string, ptyId: string, options?: { skipInitialResize?: boolean }): void { + private wireUp(tabId: string, ptyId: string, options?: { skipInitialResize?: boolean }): void { this.dlog(`wireUp tab=${tabId} pty=${ptyId}`); const adapter = this.adapters[tabId]; if (!adapter) return; @@ -2586,7 +2645,96 @@ export default class TerminalManager { } try { this.notifyPtyFrontendReady(ptyId); } catch {} } - + + /** + * 等待终端输出或当前屏幕出现任一指定文本,用于在全屏 TUI 可输入后再自动发送首条消息。 + * + * @param tabId 目标标签页 id + * @param markers 可确认就绪的文本标记 + * @param options 等待超时与输出缓存上限 + */ + async waitForOutputText( + tabId: string, + markers: readonly string[], + options?: { timeoutMs?: number; maxBufferChars?: number }, + ): Promise { + const ptyId = this.getPtyId(tabId); + const targets = Array.from(new Set((Array.isArray(markers) ? markers : []) + .map((item) => String(item || "").trim()) + .filter(Boolean))); + if (!ptyId || targets.length === 0) return false; + + const timeoutMs = Math.max(100, Math.min(120_000, Math.floor(Number(options?.timeoutMs) || 20_000))); + const maxBufferChars = Math.max(4_096, Math.min(1_000_000, Math.floor(Number(options?.maxBufferChars) || 128_000))); + const normalizedTargets = targets.map((item) => item.replace(/\s+/g, " ").trim()); + const adapter = this.adapters[tabId]; + let buffer = ""; + + const normalizeOutput = (value: string): string => String(value || "") + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, " ") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, " ") + .replace(/\s+/g, " ") + .trim(); + const containsTarget = (value: string): boolean => { + const raw = String(value || ""); + if (!raw) return false; + if (targets.some((target) => raw.includes(target))) return true; + const normalized = normalizeOutput(raw); + return !!normalized && normalizedTargets.some((target) => normalized.includes(target)); + }; + const readScreenText = (): string => { + if (!adapter || typeof adapter.readCursorTextSnapshot !== "function") return ""; + try { + return String(adapter.readCursorTextSnapshot({ + linesBefore: 80, + linesAfter: 80, + maxChars: maxBufferChars, + })?.text || ""); + } catch { + return ""; + } + }; + + return await new Promise((resolve) => { + let settled = false; + let unsubscribe: (() => void) | null = null; + let pollTimer: number | undefined; + let timeoutTimer: number | undefined; + const finish = (matched: boolean) => { + if (settled) return; + settled = true; + try { unsubscribe?.(); } catch {} + if (pollTimer !== undefined) { + try { window.clearInterval(pollTimer); } catch {} + } + if (timeoutTimer !== undefined) { + try { window.clearTimeout(timeoutTimer); } catch {} + } + resolve(matched); + }; + const inspect = (chunk?: string) => { + if (settled) return; + if (chunk) buffer = `${buffer}${chunk}`.slice(-maxBufferChars); + if (containsTarget(buffer) || containsTarget(readScreenText())) finish(true); + }; + + try { + unsubscribe = this.hostPty.onData(ptyId, (data) => inspect(String(data || ""))); + } catch {} + inspect(); + pollTimer = window.setInterval(() => inspect(), 100); + timeoutTimer = window.setTimeout(() => finish(false), timeoutMs); + + if (typeof this.hostPty.backlog === "function") { + void this.hostPty.backlog(ptyId, { maxChars: maxBufferChars }) + .then((result) => { + if (result?.ok && result.data) inspect(result.data); + }) + .catch(() => {}); + } + }); + } + /** * 发送一段文本到指定 tab 对应的终端: * - 优先走 xterm 的 paste 通道(若可用,可触发 bracketed paste,避免应用层对逐字输入做清洗)。 @@ -2598,6 +2746,10 @@ export default class TerminalManager { const adapter = this.adapters[tabId]; const ptyId = this.getPtyId(tabId); const traceId = this.shouldTraceSendDiagnostics(options?.providerId) ? this.nextSendTraceId(options?.providerId) : null; + if (ptyId && isGrokProvider(options?.providerId)) { + await this.sendGrokAttachments(ptyId, adapter, options?.attachmentPaths, traceId); + if (!String(text ?? "")) return; + } if (ptyId && isGeminiLikeProvider(options?.providerId)) { const chunkPlan = this.createGeminiPasteChunkPlan(String(text ?? "")); if (chunkPlan.chunks.length > 1) { @@ -2657,6 +2809,13 @@ export default class TerminalManager { const text = stripTrailingNewlines(String(raw ?? "")); const BRACKET_END = '\x1b[201~'; const traceId = this.shouldTraceSendDiagnostics(options?.providerId) ? this.nextSendTraceId(options?.providerId) : null; + if (isGrokProvider(options?.providerId)) { + const attachmentCount = await this.sendGrokAttachments(ptyId, adapter, options?.attachmentPaths, traceId); + if (!text && attachmentCount > 0) { + try { this.hostPty.write(ptyId, "\r"); } catch {} + return; + } + } const selectedStrategy = this.shouldUseGeminiWindowsEditorStrategy(options?.providerId, options?.terminalMode, options?.geminiWindowsEditorReady) ? "gemini-windows-editor" : this.shouldUseGeminiWslEditorStrategy(options?.providerId, options?.terminalMode, options?.geminiWslEditorReady, text) diff --git a/web/src/lib/grok-status.test.ts b/web/src/lib/grok-status.test.ts new file mode 100644 index 0000000..0081206 --- /dev/null +++ b/web/src/lib/grok-status.test.ts @@ -0,0 +1,36 @@ +import type { TFunction } from "i18next"; +import { describe, expect, it } from "vitest"; +import { formatGrokUsageErrorText } from "./grok-status"; + +/** + * 构造直接返回默认文案的最小翻译函数桩。 + */ +function createTranslator(): TFunction { + return ((key: string, fallback?: string | { defaultValue?: string }) => { + if (typeof fallback === "string") return fallback; + return fallback?.defaultValue || key; + }) as unknown as TFunction; +} + +describe("formatGrokUsageErrorText", () => { + it("API Key 登录说明官方限制和查询入口", () => { + const text = formatGrokUsageErrorText(new Error("GROK_USAGE_API_KEY_UNSUPPORTED"), createTranslator()); + + expect(text).toContain("API Key 不提供账号额度"); + expect(text).toContain("console.x.ai"); + }); + + it("未登录时提示使用 Grok 官方网页登录", () => { + const text = formatGrokUsageErrorText("Error: GROK_USAGE_AUTH_REQUIRED", createTranslator()); + + expect(text).toContain("未使用网页登录"); + expect(text).toContain("grok login"); + }); + + it("未知错误不暴露底层错误正文", () => { + const text = formatGrokUsageErrorText("request failed with secret detail", createTranslator()); + + expect(text).toBe("无法获取 Grok 账号额度\n请检查网络和 Grok Build 登录状态后重试"); + expect(text).not.toContain("secret detail"); + }); +}); diff --git a/web/src/lib/grok-status.ts b/web/src/lib/grok-status.ts new file mode 100644 index 0000000..176ae89 --- /dev/null +++ b/web/src/lib/grok-status.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) + +import type { TFunction } from "i18next"; + +export const GROK_USAGE_REFRESH_EVENT = "grok:usage-refresh-request"; +export type GrokUsageRefreshDetail = { source?: string }; + +/** + * 归一化主进程返回的错误文本。 + */ +function normalizeGrokUsageErrorText(raw: unknown): string { + if (raw == null) return ""; + const text = raw instanceof Error ? String(raw.message || "") : String(raw); + return text.replace(/^error:\s*/i, "").trim(); +} + +/** + * 将 Grok 用量错误转换为面向用户的“标题 + 提示”两行文案。 + */ +export function formatGrokUsageErrorText(raw: unknown, t: TFunction): string { + const message = normalizeGrokUsageErrorText(raw); + const defaultTitle = t("common:grokUsage.errors.default.title", "无法获取 Grok 账号额度"); + const defaultHint = t("common:grokUsage.errors.default.hint", "请检查网络和 Grok Build 登录状态后重试"); + if (message.includes("GROK_USAGE_API_KEY_UNSUPPORTED")) + return [ + t("common:grokUsage.errors.apiKey.title", "API Key 不提供账号额度"), + t("common:grokUsage.errors.apiKey.hint", "这是 Grok Build 的官方限制,请前往 console.x.ai 查看 API 用量"), + ].join("\n"); + if (message.includes("GROK_USAGE_TEAM_UNSUPPORTED")) + return [ + t("common:grokUsage.errors.team.title", "团队账号不显示消费者额度"), + t("common:grokUsage.errors.team.hint", "Grok Build 官方仅向个人 OAuth 账号开放这项额度信息"), + ].join("\n"); + if (message.includes("GROK_USAGE_AUTH_REQUIRED")) + return [ + t("common:grokUsage.errors.notLoggedIn.title", "Grok Build 未使用网页登录"), + t("common:grokUsage.errors.notLoggedIn.hint", "请运行 grok login 完成网页登录,然后刷新"), + ].join("\n"); + if (message.includes("GROK_USAGE_AUTH_EXPIRED")) + return [ + t("common:grokUsage.errors.expired.title", "Grok Build 登录已失效"), + t("common:grokUsage.errors.expired.hint", "请重新运行 grok login 登录,然后刷新"), + ].join("\n"); + if (message.includes("GROK_USAGE_NOT_AVAILABLE")) + return [ + t("common:grokUsage.errors.notAvailable.title", "当前账号没有可显示的额度"), + t("common:grokUsage.errors.notAvailable.hint", "该账号或订阅暂未由 Grok Build 提供额度信息"), + ].join("\n"); + return [defaultTitle, defaultHint].join("\n"); +} + +/** + * 触发一次 Grok 用量刷新请求。 + */ +export function emitGrokUsageRefresh(source?: string): void { + try { + const detail: GrokUsageRefreshDetail | undefined = source ? { source } : undefined; + window.dispatchEvent(new CustomEvent(GROK_USAGE_REFRESH_EVENT as any, { detail } as any)); + } catch {} +} diff --git a/web/src/lib/providers/builtins.test.ts b/web/src/lib/providers/builtins.test.ts new file mode 100644 index 0000000..4ef4c95 --- /dev/null +++ b/web/src/lib/providers/builtins.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { getBuiltInProviders, isBuiltInProviderId, isBuiltInSessionProviderId } from "./builtins"; +import { BUILT_IN_AGENT_PROVIDER_IDS, isBuiltInAgentProviderId } from "./ids"; + +describe("内置 Provider 注册", () => { + it("Grok 作为第五个内置会话引擎注册,并使用独立明暗图标", () => { + expect(BUILT_IN_AGENT_PROVIDER_IDS).toEqual(["codex", "claude", "gemini", "antigravity", "grok"]); + expect(isBuiltInAgentProviderId("grok")).toBe(true); + expect(isBuiltInProviderId("grok")).toBe(true); + expect(isBuiltInSessionProviderId("grok")).toBe(true); + + const grok = getBuiltInProviders().find((provider) => provider.id === "grok"); + expect(grok).toMatchObject({ defaultStartupCmd: "grok", labelKey: "providers:items.grok" }); + expect(grok?.iconUrl).toBeTruthy(); + expect(grok?.iconUrlDark).toBeTruthy(); + expect(grok?.iconUrlDark).not.toBe(grok?.iconUrl); + }); +}); diff --git a/web/src/lib/providers/builtins.ts b/web/src/lib/providers/builtins.ts index 3619055..4131555 100644 --- a/web/src/lib/providers/builtins.ts +++ b/web/src/lib/providers/builtins.ts @@ -6,15 +6,17 @@ import openaiDarkIconUrl from "@/assets/providers/openai-dark.png"; import claudeIconUrl from "@/assets/providers/claude-color.svg"; import geminiIconUrl from "@/assets/providers/gemini-color.svg"; import antigravityIconUrl from "@/assets/providers/antigravity-color.svg"; +import grokIconUrl from "@/assets/providers/grok.svg"; +import grokDarkIconUrl from "@/assets/providers/grok-dark.svg"; import terminalIconUrl from "@/assets/providers/black-terminal-icon.svg"; import terminalDarkIconUrl from "@/assets/providers/white-terminal-icon.svg"; import type { ThemeMode } from "@/lib/theme"; -export { openaiIconUrl, openaiDarkIconUrl, claudeIconUrl, geminiIconUrl, antigravityIconUrl }; +export { openaiIconUrl, openaiDarkIconUrl, claudeIconUrl, geminiIconUrl, antigravityIconUrl, grokIconUrl, grokDarkIconUrl }; -export type BuiltInProviderId = "codex" | "claude" | "gemini" | "antigravity" | "terminal"; +export type BuiltInProviderId = "codex" | "claude" | "gemini" | "antigravity" | "grok" | "terminal"; -export type BuiltInSessionProviderId = "codex" | "claude" | "gemini" | "antigravity"; +export type BuiltInSessionProviderId = "codex" | "claude" | "gemini" | "antigravity" | "grok"; export type BuiltInProviderMeta = { id: BuiltInProviderId; @@ -44,14 +46,14 @@ export function getDefaultProviderIconUrl(themeMode?: ThemeMode): string { * 判断是否为内置 Provider id。 */ export function isBuiltInProviderId(id: string): id is BuiltInProviderId { - return id === "codex" || id === "claude" || id === "gemini" || id === "antigravity" || id === "terminal"; + return id === "codex" || id === "claude" || id === "gemini" || id === "antigravity" || id === "grok" || id === "terminal"; } /** - * 判断是否为“会话型内置 Provider”(具备会话扫描/历史索引能力:codex/claude/gemini/antigravity)。 + * 判断是否为“会话型内置 Provider”(具备会话扫描/历史索引能力)。 */ export function isBuiltInSessionProviderId(id: string): id is BuiltInSessionProviderId { - return id === "codex" || id === "claude" || id === "gemini" || id === "antigravity"; + return id === "codex" || id === "claude" || id === "gemini" || id === "antigravity" || id === "grok"; } /** @@ -63,6 +65,7 @@ export function getBuiltInProviders(): BuiltInProviderMeta[] { { id: "claude", defaultStartupCmd: "claude", iconUrl: claudeIconUrl, iconUrlDark: claudeIconUrl, labelKey: "providers:items.claude" }, { id: "gemini", defaultStartupCmd: "gemini", iconUrl: geminiIconUrl, iconUrlDark: geminiIconUrl, labelKey: "providers:items.gemini" }, { id: "antigravity", defaultStartupCmd: "agy", iconUrl: antigravityIconUrl, iconUrlDark: antigravityIconUrl, labelKey: "providers:items.antigravity" }, + { id: "grok", defaultStartupCmd: "grok", iconUrl: grokIconUrl, iconUrlDark: grokDarkIconUrl, labelKey: "providers:items.grok" }, { id: "terminal", defaultStartupCmd: "", iconUrl: terminalIconUrl, iconUrlDark: terminalDarkIconUrl, labelKey: "providers:items.terminal" }, ]; } diff --git a/web/src/lib/providers/ids.ts b/web/src/lib/providers/ids.ts index 0da0be7..0c449a3 100644 --- a/web/src/lib/providers/ids.ts +++ b/web/src/lib/providers/ids.ts @@ -1,14 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) -export type BuiltInAgentProviderId = "codex" | "claude" | "gemini" | "antigravity"; +export type BuiltInAgentProviderId = "codex" | "claude" | "gemini" | "antigravity" | "grok"; -export const BUILT_IN_AGENT_PROVIDER_IDS: readonly BuiltInAgentProviderId[] = ["codex", "claude", "gemini", "antigravity"]; +export const BUILT_IN_AGENT_PROVIDER_IDS: readonly BuiltInAgentProviderId[] = ["codex", "claude", "gemini", "antigravity", "grok"]; /** * 判断输入是否为内置代理引擎 ProviderId。 */ export function isBuiltInAgentProviderId(value: unknown): value is BuiltInAgentProviderId { const id = String(value || "").trim().toLowerCase(); - return id === "codex" || id === "claude" || id === "gemini" || id === "antigravity"; + return id === "codex" || id === "claude" || id === "gemini" || id === "antigravity" || id === "grok"; } diff --git a/web/src/lib/providers/yolo.test.ts b/web/src/lib/providers/yolo.test.ts index 750cff3..d3b6a5d 100644 --- a/web/src/lib/providers/yolo.test.ts +++ b/web/src/lib/providers/yolo.test.ts @@ -16,6 +16,7 @@ describe("providers/yolo(YOLO 预设工具)", () => { { id: "claude", startupCmd: "claude --foo" }, { id: "gemini", startupCmd: "gemini" }, { id: "antigravity", startupCmd: "agy" }, + { id: "grok", startupCmd: "grok" }, { id: "custom-a", startupCmd: "custom-a --run" }, ]); @@ -23,6 +24,7 @@ describe("providers/yolo(YOLO 预设工具)", () => { expect(items.find((it) => it.id === "claude")?.startupCmd).toBe("claude --dangerously-skip-permissions"); expect(items.find((it) => it.id === "gemini")?.startupCmd).toBe("gemini --yolo"); expect(items.find((it) => it.id === "antigravity")?.startupCmd).toBe("agy --dangerously-skip-permissions"); + expect(items.find((it) => it.id === "grok")?.startupCmd).toBe("grok --yolo"); expect(items.find((it) => it.id === "custom-a")?.startupCmd).toBe("custom-a --run"); }); @@ -38,6 +40,7 @@ describe("providers/yolo(YOLO 预设工具)", () => { { id: "claude", startupCmd: "claude" }, { id: "gemini", startupCmd: "gemini" }, { id: "antigravity", startupCmd: "agy" }, + { id: "grok", startupCmd: "grok" }, ])).toBe(false); }); diff --git a/web/src/lib/providers/yolo.ts b/web/src/lib/providers/yolo.ts index 31047f8..4b79303 100644 --- a/web/src/lib/providers/yolo.ts +++ b/web/src/lib/providers/yolo.ts @@ -25,12 +25,14 @@ export function isYoloSupportedProviderId(providerId: string): providerId is Yol * - Claude:claude --dangerously-skip-permissions * - Gemini:gemini --yolo * - Antigravity:agy --dangerously-skip-permissions + * - Grok:grok --yolo */ export function getYoloPresetStartupCmd(providerId: string): string | null { if (providerId === "codex") return "codex --yolo"; if (providerId === "claude") return "claude --dangerously-skip-permissions"; if (providerId === "gemini") return "gemini --yolo"; if (providerId === "antigravity") return "agy --dangerously-skip-permissions"; + if (providerId === "grok") return "grok --yolo"; return null; } @@ -40,12 +42,14 @@ export function getYoloPresetStartupCmd(providerId: string): string | null { * - Claude:claude * - Gemini:gemini * - Antigravity:agy + * - Grok:grok */ export function getNonYoloStartupCmd(providerId: string): string | null { if (providerId === "codex") return "codex"; if (providerId === "claude") return "claude"; if (providerId === "gemini") return "gemini"; if (providerId === "antigravity") return "agy"; + if (providerId === "grok") return "grok"; return null; } diff --git a/web/src/lib/renderer-draft-recovery.test.ts b/web/src/lib/renderer-draft-recovery.test.ts index 1f8827f..06c96e6 100644 --- a/web/src/lib/renderer-draft-recovery.test.ts +++ b/web/src/lib/renderer-draft-recovery.test.ts @@ -51,7 +51,7 @@ describe("renderer-draft-recovery(渲染刷新草稿恢复)", () => { useYolo: false, useMultipleModels: true, singleProviderId: "antigravity", - multiCounts: { codex: 0, claude: 1, gemini: 2, antigravity: 3 }, + multiCounts: { codex: 0, claude: 1, gemini: 2, antigravity: 3, grok: 4 }, }, }, }); @@ -61,6 +61,7 @@ describe("renderer-draft-recovery(渲染刷新草稿恢复)", () => { expect(restored?.worktreeCreateDraftByRepoId["repo-1"]?.promptDraft).toBe("请先看截图"); expect(restored?.worktreeCreateDraftByRepoId["repo-1"]?.singleProviderId).toBe("antigravity"); expect(restored?.worktreeCreateDraftByRepoId["repo-1"]?.multiCounts.antigravity).toBe(3); + expect(restored?.worktreeCreateDraftByRepoId["repo-1"]?.multiCounts.grok).toBe(4); const restoredChips = restoreRecoveryPathChips(restored?.worktreeCreateDraftByRepoId["repo-1"]?.promptChips); expect(restoredChips).toHaveLength(1); diff --git a/web/src/lib/terminal-manager-send.test.ts b/web/src/lib/terminal-manager-send.test.ts index 3b21cd8..caaa386 100644 --- a/web/src/lib/terminal-manager-send.test.ts +++ b/web/src/lib/terminal-manager-send.test.ts @@ -1011,4 +1011,75 @@ describe("TerminalManager(长文本发送策略)", () => { tm.disposeAll(false); }); + + it("waitForOutputText 能跨 PTY 分片识别 Grok 欢迎界面就绪标记", async () => { + const adapter: any = createAdapterStub(); + const hostPty = createHostPtyStub(); + createTerminalAdapterMock.mockReturnValue(adapter); + + const ptyByTab: Record = { "tab-grok-ready": "pty-grok-ready" }; + const tm = new TerminalManager((tabId) => ptyByTab[tabId], hostPty as any, {}); + tm.ensurePersistentContainer("tab-grok-ready"); + tm.setPty("tab-grok-ready", "pty-grok-ready"); + + const readyPromise = tm.waitForOutputText("tab-grok-ready", ["New worktree", "Build anything"], { timeoutMs: 1000 }); + hostPty.emitData("pty-grok-ready", "\x1b[2JNew work"); + hostPty.emitData("pty-grok-ready", "tree\x1b[0m"); + + await expect(readyPromise).resolves.toBe(true); + tm.disposeAll(false); + }); + + it("Grok 会依次粘贴每张图片,再粘贴正文并提交", async () => { + vi.useFakeTimers(); + const adapter: any = createAdapterStub(); + const hostPty = createHostPtyStub(); + createTerminalAdapterMock.mockReturnValue(adapter); + + const ptyByTab: Record = { "tab-grok-images": "pty-grok-images" }; + const tm = new TerminalManager((tabId) => ptyByTab[tabId], hostPty as any, {}); + tm.ensurePersistentContainer("tab-grok-images"); + tm.setPty("tab-grok-images", "pty-grok-images"); + + const sendPromise = tm.sendTextAndEnter("tab-grok-images", "请处理图片", { + providerId: "grok", + attachmentPaths: ["X:\\fixture\\first.png", "X:\\fixture\\second.png"], + }); + await vi.advanceTimersByTimeAsync(500); + await sendPromise; + + expect(adapter.paste.mock.calls.map((call: unknown[]) => call[0])).toEqual([ + "X:\\fixture\\first.png", + "X:\\fixture\\second.png", + "请处理图片", + ]); + expect(hostPty.write).not.toHaveBeenCalledWith("pty-grok-images", "\r"); + + await vi.advanceTimersByTimeAsync(2500); + expect(hostPty.write).toHaveBeenCalledWith("pty-grok-images", "\r"); + tm.disposeAll(false); + }); + + it("Grok 只有图片时也会在图片粘贴完成后提交", async () => { + vi.useFakeTimers(); + const adapter: any = createAdapterStub(); + const hostPty = createHostPtyStub(); + createTerminalAdapterMock.mockReturnValue(adapter); + + const ptyByTab: Record = { "tab-grok-image-only": "pty-grok-image-only" }; + const tm = new TerminalManager((tabId) => ptyByTab[tabId], hostPty as any, {}); + tm.ensurePersistentContainer("tab-grok-image-only"); + tm.setPty("tab-grok-image-only", "pty-grok-image-only"); + + const sendPromise = tm.sendTextAndEnter("tab-grok-image-only", "", { + providerId: "grok", + attachmentPaths: ["/workspace/only.png"], + }); + await vi.advanceTimersByTimeAsync(200); + await sendPromise; + + expect(adapter.paste).toHaveBeenCalledWith("/workspace/only.png"); + expect(hostPty.write).toHaveBeenCalledWith("pty-grok-image-only", "\r"); + tm.disposeAll(false); + }); }); diff --git a/web/src/lib/terminal-send.test.ts b/web/src/lib/terminal-send.test.ts index 927b2c7..93e55c3 100644 --- a/web/src/lib/terminal-send.test.ts +++ b/web/src/lib/terminal-send.test.ts @@ -31,6 +31,7 @@ describe("terminal-send(Bracketed Paste / Gemini 发送策略)", () => { expect(getPasteEnterDelayMs("gemini")).toBe(GEMINI_PASTE_ENTER_DELAY_MS); expect(getPasteEnterDelayMs("GEMINI")).toBe(GEMINI_PASTE_ENTER_DELAY_MS); expect(getPasteEnterDelayMs("antigravity")).toBe(GEMINI_PASTE_ENTER_DELAY_MS); + expect(getPasteEnterDelayMs("grok")).toBe(GEMINI_PASTE_ENTER_DELAY_MS); expect(getPasteEnterDelayMs("codex")).toBe(0); expect(getPasteEnterDelayMs("claude")).toBe(0); expect(getPasteEnterDelayMs("unknown")).toBe(0); @@ -41,6 +42,7 @@ describe("terminal-send(Bracketed Paste / Gemini 发送策略)", () => { expect(getPasteSubmitMinWaitMs({ providerId: "codex", terminalMode: "wsl", textLength: 12000 })).toBe(0); expect(getPasteSubmitMinWaitMs({ providerId: "gemini", terminalMode: "pwsh", textLength: 512 })).toBeGreaterThan(0); expect(getPasteSubmitMinWaitMs({ providerId: "antigravity", terminalMode: "pwsh", textLength: 512 })).toBeGreaterThan(0); + expect(getPasteSubmitMinWaitMs({ providerId: "grok", terminalMode: "pwsh", textLength: 512 })).toBeGreaterThan(0); expect(getPasteSubmitMinWaitMs({ providerId: "claude", terminalMode: "pwsh", textLength: 512 })).toBeGreaterThan(0); expect(getPasteSubmitMinWaitMs({ providerId: "claude", terminalMode: "pwsh", textLength: 12000 })) .toBeGreaterThan(getPasteSubmitMinWaitMs({ providerId: "claude", terminalMode: "pwsh", textLength: 512 })); @@ -76,6 +78,16 @@ describe("terminal-send(Bracketed Paste / Gemini 发送策略)", () => { expect(writes).toEqual([`${BRACKETED_PASTE_START}a\nb${BRACKETED_PASTE_END}`, "\r"]); }); + it("writeBracketedPasteAndEnter:Grok 复用 Gemini 类延迟回车", () => { + vi.useFakeTimers(); + const writes: string[] = []; + writeBracketedPasteAndEnter((d) => writes.push(d), "a\nb\n", { providerId: "grok" }); + + expect(writes).toEqual([`${BRACKETED_PASTE_START}a\nb${BRACKETED_PASTE_END}`]); + vi.advanceTimersByTime(GEMINI_PASTE_ENTER_DELAY_MS); + expect(writes).toEqual([`${BRACKETED_PASTE_START}a\nb${BRACKETED_PASTE_END}`, "\r"]); + }); + it("writeBracketedPasteAndEnter:非 Gemini 立即回车", () => { const writes: string[] = []; writeBracketedPasteAndEnter((d) => writes.push(d), "hi\n", { providerId: "codex" }); diff --git a/web/src/lib/terminal-send.ts b/web/src/lib/terminal-send.ts index d162f80..4bbe910 100644 --- a/web/src/lib/terminal-send.ts +++ b/web/src/lib/terminal-send.ts @@ -6,7 +6,7 @@ import type { TerminalMode } from "./shell"; /** * 终端输入发送工具: * - 负责构造 Bracketed Paste 序列(ESC[200~ ... ESC[201~) - * - 负责 Provider 归一化与 Gemini/Antigravity 的“粘贴后延迟回车”策略 + * - 负责 Provider 归一化与 Gemini/Antigravity/Grok 的“粘贴后延迟回车”策略 * * 说明 * - Gemini CLI 将 `\r` 识别为 `return`(默认提交),将 `\n` 识别为 `enter`(默认不绑定)。 @@ -43,6 +43,15 @@ export function isGeminiProvider(providerId?: string | null): boolean { return normalizeProviderId(providerId) === "gemini"; } +/** + * 判断当前 provider 是否为 Grok Build。 + * @param providerId providerId + * @returns 是否 Grok Build + */ +export function isGrokProvider(providerId?: string | null): boolean { + return normalizeProviderId(providerId) === "grok"; +} + /** * 判断当前 provider 是否复用 Gemini 类 CLI 输入策略。 * @param providerId providerId @@ -50,7 +59,7 @@ export function isGeminiProvider(providerId?: string | null): boolean { */ export function isGeminiLikeProvider(providerId?: string | null): boolean { const normalized = normalizeProviderId(providerId); - return normalized === "gemini" || normalized === "antigravity"; + return normalized === "gemini" || normalized === "antigravity" || normalized === "grok"; } /** @@ -120,7 +129,7 @@ export function getPasteSubmitMinWaitMs(args: { const length = Math.max(0, Math.floor(Number(args.textLength) || 0)); const chunks = Math.max(1, Math.ceil(length / 2048)); - if (providerId === "gemini" || providerId === "antigravity") + if (providerId === "gemini" || providerId === "antigravity" || providerId === "grok") return Math.min(2400, 140 + chunks * 110); if (providerId === "claude" && args.terminalMode && args.terminalMode !== "wsl") diff --git a/web/src/lib/worktree-create-prefs.test.ts b/web/src/lib/worktree-create-prefs.test.ts index 66effc7..8ef29fc 100644 --- a/web/src/lib/worktree-create-prefs.test.ts +++ b/web/src/lib/worktree-create-prefs.test.ts @@ -25,7 +25,7 @@ describe("worktree-create-prefs(创建面板偏好持久化)", () => { useYolo: false, useMultipleModels: true, singleProviderId: "claude", - multiCounts: { codex: 1, claude: 2, gemini: 0, antigravity: 0 }, + multiCounts: { codex: 1, claude: 2, gemini: 0, antigravity: 0, grok: 3 }, }; saveWorktreeCreatePrefs(repoProjectId, prefs); @@ -49,7 +49,7 @@ describe("worktree-create-prefs(创建面板偏好持久化)", () => { useYolo: true, useMultipleModels: true, singleProviderId: "gemini", - multiCounts: { codex: 0, claude: 1, gemini: 2, antigravity: 1 }, + multiCounts: { codex: 0, claude: 1, gemini: 2, antigravity: 1, grok: 3 }, }); clearWorktreeCreateTransientPrefs(repoProjectId, { remarkBaseName: "默认备注" }); @@ -63,7 +63,7 @@ describe("worktree-create-prefs(创建面板偏好持久化)", () => { useYolo: true, useMultipleModels: true, singleProviderId: "gemini", - multiCounts: { codex: 0, claude: 1, gemini: 2, antigravity: 1 }, + multiCounts: { codex: 0, claude: 1, gemini: 2, antigravity: 1, grok: 3 }, }); }); }); diff --git a/web/src/locales/en/common.json b/web/src/locales/en/common.json index 8ce77da..305975d 100644 --- a/web/src/locales/en/common.json +++ b/web/src/locales/en/common.json @@ -181,6 +181,54 @@ } } }, + "grokUsage": { + "loading": "Loading account usage…", + "unavailable": "Account usage unavailable", + "title": "Account usage", + "account": "Grok Build account", + "includedQuota": "Included quota", + "used": "{percent} used", + "amount": "{used} / {limit}", + "resetAt": "Resets {time}", + "resetNotProvided": "Not provided", + "periodWeekly": "Weekly quota", + "periodMonthly": "Monthly quota", + "periodUnknown": "Account quota", + "additionalCredits": "Additional credits", + "prepaidBalance": "Purchased balance", + "onDemandUsage": "On-demand usage", + "onDemandDisabled": "Disabled", + "updatedAt": "Updated {time}", + "sourceLive": "Live", + "sourceCache": "Official Grok cache", + "empty": "No account usage data", + "errors": { + "default": { + "title": "Unable to fetch Grok account usage", + "hint": "Check your network and Grok Build sign-in, then try again" + }, + "apiKey": { + "title": "Account usage is unavailable with an API key", + "hint": "This is a Grok Build limitation; view API usage at console.x.ai" + }, + "team": { + "title": "Consumer usage is hidden for team accounts", + "hint": "Grok Build only exposes this usage to personal OAuth accounts" + }, + "notLoggedIn": { + "title": "Grok Build is not signed in with OAuth", + "hint": "Run grok login in a terminal, complete browser sign-in, then refresh" + }, + "expired": { + "title": "Grok Build sign-in has expired", + "hint": "Run grok login again, then refresh" + }, + "notAvailable": { + "title": "No account usage is available", + "hint": "Grok Build does not currently expose usage for this account or plan" + } + } + }, "notifications": { "taskCompleted": "{tab} completed", "taskCompletedWithProject": "{tab} · {project}", diff --git a/web/src/locales/en/projects.json b/web/src/locales/en/projects.json index 42fe40b..ac0e397 100644 --- a/web/src/locales/en/projects.json +++ b/web/src/locales/en/projects.json @@ -148,6 +148,7 @@ "worktreeReuseEmpty": "No reusable child worktrees.", "worktreeReuseSummary": "Reuse {reuse}, create {create}", "worktreeMissingBaseBranch": "Failed to read base branch", + "grokInitialPromptNotReady": "Grok did not reach an input-ready screen, so the initial attachments were not sent.", "worktreeCreateFailed": "Failed to create worktree: {{error}}", "worktreeCreateWarnings": "Completed, but warnings occurred:\n{{warnings}}", "worktreePostSetupTitle": "Worktree Post-Setup", diff --git a/web/src/locales/en/providers.json b/web/src/locales/en/providers.json index 2514dc7..d0e5d17 100644 --- a/web/src/locales/en/providers.json +++ b/web/src/locales/en/providers.json @@ -4,6 +4,7 @@ "claude": "Claude", "gemini": "Gemini", "antigravity": "Antigravity", + "grok": "Grok Build", "terminal": "Terminal" }, "customEngine": "Custom Engine", diff --git a/web/src/locales/en/settings.json b/web/src/locales/en/settings.json index 995fc71..97c4343 100644 --- a/web/src/locales/en/settings.json +++ b/web/src/locales/en/settings.json @@ -414,6 +414,11 @@ "help": "Detected Antigravity CLI session data directories. Use this only to inspect or open the folder.", "empty": "No Antigravity session roots detected" }, + "grokRoots": { + "label": "Grok Build session roots (read-only)", + "help": "Detected Grok Build session directories under .grok. Use this only to inspect or open the folder.", + "empty": "No Grok Build session roots detected" + }, "appData": { "label": "App data directory", "desc": "Inspect the current instance (profile) user data footprint and clean it when necessary. Multi-instance mode uses separate directories per profile.", diff --git a/web/src/locales/zh/common.json b/web/src/locales/zh/common.json index b97c5d1..c34f511 100644 --- a/web/src/locales/zh/common.json +++ b/web/src/locales/zh/common.json @@ -181,6 +181,54 @@ } } }, + "grokUsage": { + "loading": "正在同步账号额度…", + "unavailable": "账号额度不可用", + "title": "账号额度", + "account": "Grok Build 账号", + "includedQuota": "包含额度", + "used": "已使用 {percent}", + "amount": "{used} / {limit}", + "resetAt": "重置时间 {time}", + "resetNotProvided": "未提供", + "periodWeekly": "每周额度", + "periodMonthly": "每月额度", + "periodUnknown": "账号额度", + "additionalCredits": "额外额度", + "prepaidBalance": "已购余额", + "onDemandUsage": "按需用量", + "onDemandDisabled": "未启用", + "updatedAt": "更新时间 {time}", + "sourceLive": "实时", + "sourceCache": "Grok 官方缓存", + "empty": "暂无账号额度信息", + "errors": { + "default": { + "title": "无法获取 Grok 账号额度", + "hint": "请检查网络和 Grok Build 登录状态后重试" + }, + "apiKey": { + "title": "API Key 不提供账号额度", + "hint": "这是 Grok Build 的官方限制,请前往 console.x.ai 查看 API 用量" + }, + "team": { + "title": "团队账号不显示消费者额度", + "hint": "Grok Build 官方仅向个人 OAuth 账号开放这项额度信息" + }, + "notLoggedIn": { + "title": "Grok Build 未使用网页登录", + "hint": "请运行 grok login 完成网页登录,然后刷新" + }, + "expired": { + "title": "Grok Build 登录已失效", + "hint": "请重新运行 grok login 登录,然后刷新" + }, + "notAvailable": { + "title": "当前账号没有可显示的额度", + "hint": "该账号或订阅暂未由 Grok Build 提供额度信息" + } + } + }, "notifications": { "taskCompleted": "{tab} 已完成", "taskCompletedWithProject": "{tab} · {project} 已完成", diff --git a/web/src/locales/zh/projects.json b/web/src/locales/zh/projects.json index 0775152..f920cbb 100644 --- a/web/src/locales/zh/projects.json +++ b/web/src/locales/zh/projects.json @@ -148,6 +148,7 @@ "worktreeReuseEmpty": "暂无可复用的子 worktree", "worktreeReuseSummary": "复用 {reuse},新建 {create}", "worktreeMissingBaseBranch": "未能读取到基分支", + "grokInitialPromptNotReady": "Grok 未进入可输入界面,因此尚未发送初始图片附件。", "worktreeCreateFailed": "创建 worktree 失败:{{error}}", "worktreeCreateWarnings": "操作已完成,但存在警告:\n{{warnings}}", "worktreePostSetupTitle": "工作区保留项设置", diff --git a/web/src/locales/zh/providers.json b/web/src/locales/zh/providers.json index dd1e103..5929794 100644 --- a/web/src/locales/zh/providers.json +++ b/web/src/locales/zh/providers.json @@ -4,6 +4,7 @@ "claude": "Claude", "gemini": "Gemini", "antigravity": "Antigravity", + "grok": "Grok Build", "terminal": "Terminal" }, "customEngine": "自定义引擎", diff --git a/web/src/locales/zh/settings.json b/web/src/locales/zh/settings.json index 7b20dc4..68c8b88 100644 --- a/web/src/locales/zh/settings.json +++ b/web/src/locales/zh/settings.json @@ -414,6 +414,11 @@ "help": "显示磁盘上检测到的 Antigravity CLI 会话数据目录,仅用于查看和打开目录。", "empty": "未检测到 Antigravity 会话根路径" }, + "grokRoots": { + "label": "Grok Build 会话根路径(只读)", + "help": "显示 .grok 下检测到的 Grok Build 会话目录,仅用于查看和打开目录。", + "empty": "未检测到 Grok Build 会话根路径" + }, "appData": { "label": "应用数据目录", "desc": "查看当前实例(Profile)的用户数据目录占用情况,可按需清理缓存;多实例模式下每个实例都有独立目录。", diff --git a/web/src/providers/grok/commands.test.ts b/web/src/providers/grok/commands.test.ts new file mode 100644 index 0000000..6266580 --- /dev/null +++ b/web/src/providers/grok/commands.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { buildGrokResumeStartupCmd, resolveGrokStartupCmd } from "./commands"; + +describe("Grok commands", () => { + it("空命令回退为 grok", () => { + expect(resolveGrokStartupCmd("")).toBe("grok"); + expect(resolveGrokStartupCmd(" grok --yolo ")).toBe("grok --yolo"); + }); + + it("按终端安全构造指定会话恢复命令", () => { + expect(buildGrokResumeStartupCmd({ cmd: "grok", terminalMode: "wsl", sessionId: "session-1" })).toBe("grok --resume 'session-1'"); + expect(buildGrokResumeStartupCmd({ cmd: "grok --yolo", terminalMode: "pwsh", sessionId: "session-1" })).toBe("& 'grok' '--yolo' '--resume' 'session-1'"); + expect(buildGrokResumeStartupCmd({ cmd: "grok", terminalMode: "cmd", sessionId: "session-1" })).toBe("grok --resume session-1"); + }); + + it("缺少会话 ID 时恢复当前目录最近会话", () => { + expect(buildGrokResumeStartupCmd({ cmd: "grok", terminalMode: "wsl", sessionId: "" })).toBe("grok --continue"); + expect(buildGrokResumeStartupCmd({ cmd: "grok", terminalMode: "pwsh", sessionId: null })).toBe("& 'grok' '--continue'"); + }); +}); diff --git a/web/src/providers/grok/commands.ts b/web/src/providers/grok/commands.ts new file mode 100644 index 0000000..b650985 --- /dev/null +++ b/web/src/providers/grok/commands.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2025 Lulu (GitHub: lulu-sk, https://github.com/lulu-sk) + +import type { AppSettings } from "@/types/host"; +import { bashSingleQuote, buildCmdCall, buildPowerShellCall, isCmdTerminal, isWindowsLikeTerminal, splitCommandLineToArgv } from "@/lib/shell"; + +type TerminalMode = NonNullable; + +/** + * 解析 Grok Build 的启动命令(为空则回退为 `grok`)。 + */ +export function resolveGrokStartupCmd(cmd: string | null | undefined): string { + const value = String(cmd || "").trim(); + return value || "grok"; +} + +/** + * 构造 Grok Build 的会话恢复命令;缺少会话 ID 时恢复当前目录最近会话。 + */ +export function buildGrokResumeStartupCmd(args: { + cmd: string | null | undefined; + terminalMode: TerminalMode; + sessionId: string | null | undefined; +}): string { + const baseCmdRaw = resolveGrokStartupCmd(args.cmd); + const sessionId = String(args.sessionId || "").trim(); + const resumeArgs = sessionId ? ["--resume", sessionId] : ["--continue"]; + + if (isCmdTerminal(args.terminalMode)) { + const baseArgv = splitCommandLineToArgv(baseCmdRaw); + return buildCmdCall([...(baseArgv.length > 0 ? baseArgv : ["grok"]), ...resumeArgs]); + } + + if (isWindowsLikeTerminal(args.terminalMode)) { + const baseArgv = splitCommandLineToArgv(baseCmdRaw); + return buildPowerShellCall([...(baseArgv.length > 0 ? baseArgv : ["grok"]), ...resumeArgs]); + } + + return sessionId + ? `${baseCmdRaw} --resume ${bashSingleQuote(sessionId)}` + : `${baseCmdRaw} --continue`; +} diff --git a/web/src/types/host.d.ts b/web/src/types/host.d.ts index 58163aa..915b227 100644 --- a/web/src/types/host.d.ts +++ b/web/src/types/host.d.ts @@ -386,7 +386,7 @@ export type GitUpdateSessionProgressSnapshot = { }; export type HistorySummary = { - providerId: "codex" | "claude" | "gemini" | "antigravity"; + providerId: "codex" | "claude" | "gemini" | "antigravity" | "grok"; id: string; title: string; date: number | string; // 主进程用 mtimeMs(number),前端常转成 ISO string @@ -560,7 +560,7 @@ export type IsWorktreeAlignedToMainResult = | { ok: false; error?: string }; export type CreatedWorktree = { - providerId: "codex" | "claude" | "gemini" | "antigravity"; + providerId: "codex" | "claude" | "gemini" | "antigravity" | "grok"; /** 默认操作落点 worktree 路径(通常为主 worktree;若基分支由其他 worktree 持有,则可能为该基 worktree)。 */ repoMainPath: string; worktreePath: string; @@ -576,7 +576,7 @@ export type WorktreeCreateTaskItemStatus = "creating" | "success" | "error" | "c export type WorktreeCreateTaskItemSnapshot = { key: string; - providerId: "codex" | "claude" | "gemini" | "antigravity"; + providerId: "codex" | "claude" | "gemini" | "antigravity" | "grok"; worktreePath: string; wtBranch: string; index: number; @@ -608,7 +608,7 @@ export type WorktreeCreateTaskSnapshot = { taskId: string; repoDir: string; baseBranch: string; - instances: Array<{ providerId: "codex" | "claude" | "gemini" | "antigravity"; count: number }>; + instances: Array<{ providerId: "codex" | "claude" | "gemini" | "antigravity" | "grok"; count: number }>; copyRules: boolean; status: WorktreeCreateTaskStatus; createdAt: number; @@ -705,8 +705,8 @@ export interface GitWorktreeAPI { listBranches(repoDir: string): Promise<{ ok: boolean; repoRoot?: string; branches?: string[]; current?: string; detached?: boolean; headSha?: string; error?: string }>; initRepo(args: { dir: string }): Promise; getMeta(worktreePath: string): Promise<{ ok: boolean; meta?: WorktreeMeta | null; error?: string }>; - create(args: { repoDir: string; baseBranch: string; instances: Array<{ providerId: "codex" | "claude" | "gemini" | "antigravity"; count: number }>; copyRules?: boolean; postSetup?: WorktreePostSetupConfig }): Promise<{ ok: boolean; items?: CreatedWorktree[]; error?: string }>; - createTaskStart(args: { repoDir: string; baseBranch: string; instances: Array<{ providerId: "codex" | "claude" | "gemini" | "antigravity"; count: number }>; copyRules?: boolean; postSetup?: WorktreePostSetupConfig }): Promise<{ ok: boolean; taskId?: string; reused?: boolean; error?: string }>; + create(args: { repoDir: string; baseBranch: string; instances: Array<{ providerId: "codex" | "claude" | "gemini" | "antigravity" | "grok"; count: number }>; copyRules?: boolean; postSetup?: WorktreePostSetupConfig }): Promise<{ ok: boolean; items?: CreatedWorktree[]; error?: string }>; + createTaskStart(args: { repoDir: string; baseBranch: string; instances: Array<{ providerId: "codex" | "claude" | "gemini" | "antigravity" | "grok"; count: number }>; copyRules?: boolean; postSetup?: WorktreePostSetupConfig }): Promise<{ ok: boolean; taskId?: string; reused?: boolean; error?: string }>; createTaskGet(args: { taskId: string; from?: number }): Promise<{ ok: boolean; task?: WorktreeCreateTaskSnapshot; append?: string; error?: string }>; createTaskCancel(args: { taskId: string }): Promise<{ ok: boolean; alreadyFinished?: boolean; error?: string }>; recycleTaskStart(args: { worktreePath: string; baseBranch: string; wtBranch: string; range?: RecycleWorktreeRange; forkBaseRef?: string; mode: "squash" | "rebase"; commitMessage?: string; autoStashBaseWorktree?: boolean }): Promise<{ ok: boolean; taskId?: string; reused?: boolean; error?: string }>; @@ -777,7 +777,7 @@ export interface HistoryAPI { offset?: number; historyRoot?: string; }): Promise<{ ok: boolean; sessions?: HistorySummary[]; error?: string }>; - read(args: { filePath: string; providerId?: "codex" | "claude" | "gemini" | "antigravity"; forceParse?: boolean }): Promise<{ id: string; title: string; date: number; messages: HistoryMessage[]; skippedLines: number; providerId?: "codex" | "claude" | "gemini" | "antigravity" }>; + read(args: { filePath: string; providerId?: "codex" | "claude" | "gemini" | "antigravity" | "grok"; forceParse?: boolean }): Promise<{ id: string; title: string; date: number; messages: HistoryMessage[]; skippedLines: number; providerId?: "codex" | "claude" | "gemini" | "antigravity" | "grok" }>; findEmptySessions(): Promise<{ ok: boolean; candidates?: Array<{ id: string; title: string; rawDate?: string; date: number; filePath: string; sizeKB?: number }>; error?: string }>; trash(args: { filePath: string }): Promise<{ ok: true; notFound?: boolean } | { ok: false; error: string }>; trashMany(args: { filePaths: string[] }): Promise<{ ok: boolean; results?: Array<{ filePath: string; ok: boolean; notFound?: boolean; error?: string }>; summary?: { ok: number; notFound: number; failed: number }; error?: string }>; @@ -793,7 +793,7 @@ export interface SettingsAPI { resolveRuntimeEnv?(args: { terminal?: TerminalMode; distro?: string }): Promise<{ ok: boolean; terminal?: TerminalMode; distro?: string; changed?: boolean; reason?: string; availableDistros?: string[]; error?: string }>; checkRuntimeCli?(args: { terminal?: TerminalMode; distro?: string; startupCmd?: string }): Promise<{ ok: boolean; cli?: string; terminal?: TerminalMode; distro?: string; reason?: string; error?: string }>; codexRoots(): Promise; - sessionRoots?(args: { providerId: "codex" | "claude" | "gemini" | "antigravity" }): Promise; + sessionRoots?(args: { providerId: "codex" | "claude" | "gemini" | "antigravity" | "grok" }): Promise; } export interface OnboardingAPI { @@ -985,6 +985,28 @@ export type AntigravityUsageSnapshot = { planName?: string | null; windows: AntigravityUsageWindow[]; }; + +export type GrokUsageSnapshot = { + providerId: "grok"; + source: "billing-api" | "billing-cache"; + collectedAt: number; + updatedAt: number; + accountEmail: string | null; + subscriptionTier: string | null; + quota: { + usedPercent: number | null; + periodType: string | null; + periodStartAt: number | null; + periodEndAt: number | null; + includedUsedCents: number | null; + includedLimitCents: number | null; + onDemandEnabled: boolean | null; + onDemandUsedCents: number | null; + onDemandCapCents: number | null; + prepaidBalanceCents: number | null; + isUnifiedBillingUser: boolean | null; + }; +}; export interface CodexAPI { getAccountInfo(): Promise<{ ok: boolean; info?: CodexAccountInfo; error?: string }>; @@ -1006,6 +1028,10 @@ export interface AntigravityAPI { getUsage(): Promise<{ ok: boolean; snapshot?: AntigravityUsageSnapshot; error?: string }>; } +export interface GrokAPI { + getUsage(): Promise<{ ok: boolean; snapshot?: GrokUsageSnapshot; error?: string }>; +} + export type ProjectPreferredIde = BuiltinIdeId; export interface NotificationsAPI { @@ -1013,8 +1039,8 @@ export interface NotificationsAPI { /** 同步任务栏角标状态;错误优先,其次完成数量,最后运行中提示。 */ setTaskbarBadgeState?(state: { errorCount?: number; hasError?: boolean; completedCount?: number; runningCount?: number; hasRunningTask?: boolean }): void; showAgentCompletion(payload: { tabId: string; tabName?: string; projectName?: string; preview?: string; title: string; body: string; appTitle?: string }): void; - /** 监听主进程转发的外部完成通知(如 Codex/Gemini/Claude/Antigravity hook -> JSONL 桥接)。 */ - onExternalAgentComplete?(handler: (payload: { providerId?: "codex" | "gemini" | "claude" | "antigravity"; tabId?: string; envLabel?: string; preview?: string; previewEscapedWhitespace?: boolean; timestamp?: string; eventId?: string; hookEventName?: string; completionKind?: "agent" | "subagent"; agentType?: string; agentId?: string }) => void): () => void; + /** 监听主进程转发的外部完成通知(如 Codex/Gemini/Claude/Antigravity/Grok hook -> JSONL 桥接)。 */ + onExternalAgentComplete?(handler: (payload: { providerId?: "codex" | "gemini" | "claude" | "antigravity" | "grok"; tabId?: string; envLabel?: string; preview?: string; previewEscapedWhitespace?: boolean; timestamp?: string; eventId?: string; hookEventName?: string; completionKind?: "agent" | "subagent"; agentType?: string; agentId?: string; threadId?: string; turnId?: string; cwd?: string; sqliteHome?: string }) => void): () => void; onFocusTab?(handler: (payload: { tabId: string }) => void): () => void; } @@ -1262,6 +1288,7 @@ declare global { claude: ClaudeAPI; gemini: GeminiAPI; antigravity: AntigravityAPI; + grok: GrokAPI; notifications: NotificationsAPI; wsl?: WslAPI; fileIndex?: FileIndexAPI;