Skip to content

Commit 2b6fd8e

Browse files
committed
fix(grok): support /c/<id> URL pattern and responseNodes API shape
- Add /c/<id> to conversation ID regex (was only /chat/ and /i/grok/) - Fix response-node parsing: API returns { responseNodes: [] } wrapper, not a tree rooted at a single RawResponseNode — caused 0 messages saved - Keep legacy plain-array and nodes fallbacks for older API versions
1 parent edbd6ab commit 2b6fd8e

2 files changed

Lines changed: 131 additions & 38 deletions

File tree

src/content-scripts/grok.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import { mountWidget } from "./mount";
99
import { FloatingDial } from "./FloatingDial";
1010

1111
function getConversationId(): string | null {
12-
// URL pattern: grok.com/chat/<id> or x.com/i/grok/<id>
12+
// URL pattern: grok.com/c/<id>, grok.com/chat/<id>, or x.com/i/grok/<id>
1313
const match = window.location.pathname.match(
14-
/(?:\/chat\/|\/i\/grok\/)([a-zA-Z0-9_-]+)/
14+
/(?:\/c\/|\/chat\/|\/i\/grok\/)([a-zA-Z0-9_-]+)/
1515
);
1616
return match?.[1] ?? null;
1717
}
@@ -39,16 +39,14 @@ function attachViaFileInput(text: string): boolean {
3939
}
4040

4141
function injectIntoTextarea(text: string) {
42-
const tryAttach = () => attachViaFileInput(text);
43-
44-
if (tryAttach()) return;
42+
if (attachViaFileInput(text)) return;
4543

4644
// Retry – the file input may not be in the DOM until the user has focused
4745
// the composer at least once.
4846
let attempts = 0;
4947
const interval = setInterval(() => {
5048
attempts++;
51-
if (tryAttach() || attempts > 20) {
49+
if (attachViaFileInput(text) || attempts > 20) {
5250
clearInterval(interval);
5351
}
5452
}, 250);

src/extractors/grok.ts

Lines changed: 127 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,42 @@
11
/* ──────────────────────────────────────────────
22
* Extractor – Grok (grok.com)
33
*
4-
* Endpoint: /rest/app-chat/conversations
4+
* Three-step fetch per conversation:
5+
* 1. LIST GET /rest/app-chat/conversations?pageSize=60[&cursor=…]
6+
* 2. IDs GET /rest/app-chat/conversations/{id}/response-node?includeThreads=true
7+
* 3. BODY POST /rest/app-chat/conversations/{id}/load-responses
8+
* body: { responseIds: string[] }
9+
*
510
* Auth: cookie-based (sso + sso-rw). No CSRF header required.
6-
* Pagination: pageSize + cursor (X/Twitter API style).
11+
* Pagination: cursor-based (nextCursor field in response wrapper).
712
* ────────────────────────────────────────────── */
813

914
import type { Extractor } from "./base";
10-
import type { Message, Thread } from "../types/schema";
15+
import type { Message, Thread, Role } from "../types/schema";
1116
import { uuid, jitteredDelay } from "../utils/helpers";
1217

13-
const BASE = "";
1418
const PAGE_SIZE = 60;
1519

1620
async function apiFetch<T>(path: string): Promise<T> {
17-
const res = await fetch(`${BASE}${path}`, {
21+
const res = await fetch(path, {
1822
credentials: "include",
19-
headers: {
20-
"Content-Type": "application/json",
21-
},
23+
headers: { "Content-Type": "application/json" },
2224
});
2325
if (!res.ok) {
24-
throw new Error(
25-
`Grok API ${path} returned ${res.status}: ${res.statusText}`
26-
);
26+
throw new Error(`Grok API GET ${path}${res.status}: ${res.statusText}`);
27+
}
28+
return res.json() as Promise<T>;
29+
}
30+
31+
async function apiPost<T>(path: string, body: unknown): Promise<T> {
32+
const res = await fetch(path, {
33+
method: "POST",
34+
credentials: "include",
35+
headers: { "Content-Type": "application/json" },
36+
body: JSON.stringify(body),
37+
});
38+
if (!res.ok) {
39+
throw new Error(`Grok API POST ${path}${res.status}: ${res.statusText}`);
2740
}
2841
return res.json() as Promise<T>;
2942
}
@@ -46,28 +59,57 @@ type RawConvListResponse =
4659
| RawConvListItem[]
4760
| { conversations: RawConvListItem[]; nextCursor?: string; cursor?: string };
4861

49-
interface RawGrokMessage {
62+
/**
63+
* Shape returned by GET .../response-node?includeThreads=true
64+
*
65+
* Grok returns a flat list of nodes under `responseNodes`.
66+
* Each node has a `responseId` and optional `parentResponseId`.
67+
* Older API versions may return a tree via `children`/`nodes`.
68+
*/
69+
interface RawResponseNode {
70+
responseId?: string;
5071
id?: string;
72+
sender?: string;
73+
parentResponseId?: string;
74+
children?: RawResponseNode[];
75+
nodes?: RawResponseNode[];
76+
responses?: RawResponseNode[];
77+
}
78+
79+
interface RawResponseNodeResult {
80+
responseNodes?: RawResponseNode[];
81+
// legacy / alternate shapes
82+
nodes?: RawResponseNode[];
83+
}
84+
85+
/** Shape of each item returned by POST .../load-responses */
86+
interface RawLoadedMessage {
87+
responseId?: string;
5188
messageId?: string;
89+
id?: string;
5290
sender?: "human" | "assistant";
5391
role?: "user" | "assistant";
5492
message?: string;
5593
text?: string;
94+
query?: string; // user turn (some versions)
95+
response?: string; // assistant turn (some versions)
5696
createTime?: string;
5797
created_at?: string;
5898
model?: string;
5999
}
60100

61-
interface RawConversation {
101+
type RawLoadResponsesResult =
102+
| RawLoadedMessage[]
103+
| { responses: RawLoadedMessage[] };
104+
105+
interface RawConvMeta {
62106
conversationId?: string;
63107
id?: string;
64108
title?: string;
65109
createTime?: string;
66110
modifyTime?: string;
67111
created_at?: string;
68112
updated_at?: string;
69-
responses?: RawGrokMessage[];
70-
messages?: RawGrokMessage[];
71113
}
72114

73115
/* ── helpers ──────────────────────────────────────────────── */
@@ -81,18 +123,38 @@ function normaliseItem(item: RawConvListItem): { providerId: string; title: stri
81123
};
82124
}
83125

84-
function mapMessages(raw: RawGrokMessage[]): Message[] {
85-
return raw.map((m) => {
86-
const role: "user" | "assistant" =
126+
/** Recursively collect all responseId values from a node tree. */
127+
function collectResponseIds(node: RawResponseNode): string[] {
128+
const ids: string[] = [];
129+
const id = node.responseId ?? node.id;
130+
if (id) ids.push(id);
131+
const children = node.children ?? node.nodes ?? node.responses ?? [];
132+
for (const child of children) {
133+
ids.push(...collectResponseIds(child));
134+
}
135+
return ids;
136+
}
137+
138+
function mapMessages(raw: RawLoadedMessage[]): Message[] {
139+
const out: Message[] = [];
140+
for (const m of raw) {
141+
const role: Role =
87142
m.role === "user" || m.sender === "human" ? "user" : "assistant";
88-
return {
89-
id: m.messageId ?? m.id ?? uuid(),
143+
const content =
144+
m.message ??
145+
m.text ??
146+
(role === "user" ? m.query : m.response) ??
147+
"";
148+
if (!content) continue;
149+
out.push({
150+
id: m.responseId ?? m.messageId ?? m.id ?? uuid(),
90151
role,
91-
content: m.message ?? m.text ?? "",
152+
content,
92153
createdAt: m.createTime ?? m.created_at ?? "",
93154
model: m.model,
94-
};
95-
});
155+
});
156+
}
157+
return out;
96158
}
97159

98160
/* ── Extractor implementation ─────────────────────────────── */
@@ -102,7 +164,6 @@ export const grokExtractor: Extractor = {
102164
const all: { providerId: string; title: string; createdAt: string; updatedAt: string }[] = [];
103165
let cursor: string | undefined;
104166

105-
// eslint-disable-next-line no-constant-condition
106167
while (true) {
107168
const qs = cursor
108169
? `pageSize=${PAGE_SIZE}&cursor=${encodeURIComponent(cursor)}`
@@ -131,10 +192,7 @@ export const grokExtractor: Extractor = {
131192
if (nextCursor) {
132193
cursor = nextCursor;
133194
await jitteredDelay(500, 1200);
134-
} else if (items.length < PAGE_SIZE) {
135-
break;
136195
} else {
137-
// No cursor returned but page was full — stop to avoid infinite loop.
138196
break;
139197
}
140198
}
@@ -143,19 +201,56 @@ export const grokExtractor: Extractor = {
143201
},
144202

145203
async fetchThread(conversationId: string): Promise<Thread> {
146-
const raw = await apiFetch<RawConversation>(
204+
// Step 1: fetch conversation metadata (title, timestamps)
205+
const meta = await apiFetch<RawConvMeta>(
147206
`/rest/app-chat/conversations/${conversationId}`
148207
);
149208

150-
const messages = mapMessages(raw.responses ?? raw.messages ?? []);
209+
// Step 2: fetch the response-node list to collect all response IDs
210+
const nodeResult = await apiFetch<RawResponseNodeResult | RawResponseNode[]>(
211+
`/rest/app-chat/conversations/${conversationId}/response-node?includeThreads=true`
212+
);
213+
214+
let responseIds: string[];
215+
if (Array.isArray(nodeResult)) {
216+
// legacy: root is a plain array
217+
responseIds = nodeResult.flatMap(collectResponseIds);
218+
} else {
219+
// current API: { responseNodes: [...] }
220+
const flatNodes =
221+
(nodeResult as RawResponseNodeResult).responseNodes ??
222+
(nodeResult as RawResponseNodeResult).nodes ??
223+
[];
224+
responseIds = flatNodes.flatMap(collectResponseIds);
225+
}
226+
227+
// Step 3: load full message bodies
228+
let messages: Message[] = [];
229+
if (responseIds.length > 0) {
230+
const loaded = await apiPost<RawLoadResponsesResult>(
231+
`/rest/app-chat/conversations/${conversationId}/load-responses`,
232+
{ responseIds }
233+
);
234+
235+
const rawMessages = Array.isArray(loaded)
236+
? loaded
237+
: (loaded.responses ?? []);
238+
239+
messages = mapMessages(rawMessages);
240+
}
151241

152242
return {
153243
id: uuid(),
154244
providerId: conversationId,
155245
provider: "grok",
156-
title: raw.title || "Untitled",
157-
createdAt: raw.createTime ?? raw.created_at ?? "",
158-
updatedAt: raw.modifyTime ?? raw.updated_at ?? raw.createTime ?? raw.created_at ?? "",
246+
title: meta.title || "Untitled",
247+
createdAt: meta.createTime ?? meta.created_at ?? "",
248+
updatedAt:
249+
meta.modifyTime ??
250+
meta.updated_at ??
251+
meta.createTime ??
252+
meta.created_at ??
253+
"",
159254
messages,
160255
tags: [],
161256
};

0 commit comments

Comments
 (0)