@miruzero/zgpt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/docs/architecture.md +51 -0
- package/package.json +29 -0
- package/src/api/catalog.json +17002 -0
- package/src/api/index.js +153 -0
- package/src/auth/index.js +87 -0
- package/src/cli/formatter.js +111 -0
- package/src/cli.js +117 -0
- package/src/config/paths.js +19 -0
- package/src/connector/index.js +6 -0
- package/src/connector/read.js +56 -0
- package/src/connector/write.js +26 -0
- package/src/conversation/attachments.js +77 -0
- package/src/conversation/index.js +65 -0
- package/src/conversation/model.js +117 -0
- package/src/conversation/read.js +378 -0
- package/src/conversation/temp-state.js +43 -0
- package/src/conversation/temporary.js +59 -0
- package/src/conversation/write.js +508 -0
- package/src/index.js +8 -0
- package/src/project/index.js +6 -0
- package/src/project/read.js +91 -0
- package/src/project/write.js +50 -0
- package/src/protocol/conduit.js +26 -0
- package/src/protocol/conversation-payload.js +214 -0
- package/src/protocol/sentinel-client.js +35 -0
- package/src/protocol/sentinel-proof.js +19 -0
- package/src/protocol/sentinel-requirements.js +25 -0
- package/src/protocol/sse.js +56 -0
- package/src/session/exchange.js +12 -0
- package/src/session/index.js +70 -0
- package/src/session/local-storage.js +75 -0
- package/src/transport/direct/http-client.js +166 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const auth = require('../auth');
|
|
5
|
+
const paths = require('../config/paths');
|
|
6
|
+
const read = require('./read');
|
|
7
|
+
const write = require('./write');
|
|
8
|
+
const temporary = require('./temporary');
|
|
9
|
+
|
|
10
|
+
const sessionHeaders = (env) => {
|
|
11
|
+
const file = paths.sessionFile(env);
|
|
12
|
+
let state;
|
|
13
|
+
try { state = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
14
|
+
catch {
|
|
15
|
+
throw Object.assign(new Error('ChatGPT session context required'), { code: 'SESSION_REQUIRED' });
|
|
16
|
+
}
|
|
17
|
+
if (!state?.headers || typeof state.headers !== 'object' || Array.isArray(state.headers)) {
|
|
18
|
+
throw Object.assign(new Error('ChatGPT session context required'), { code: 'SESSION_REQUIRED' });
|
|
19
|
+
}
|
|
20
|
+
return { ...state.headers };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const bindCanonicalContext = async (options = {}) => {
|
|
24
|
+
const resolved = { ...options };
|
|
25
|
+
const env = resolved.env || process.env;
|
|
26
|
+
if (!resolved.token) {
|
|
27
|
+
const current = await auth.ensure({ env, acquireSession: false });
|
|
28
|
+
resolved.token = current.accessToken;
|
|
29
|
+
}
|
|
30
|
+
if (!resolved.sessionHeaders) resolved.sessionHeaders = sessionHeaders(env);
|
|
31
|
+
return resolved;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
Object.assign(exports, read, write);
|
|
35
|
+
exports.get = async (id, options = {}) => read.getConversation({
|
|
36
|
+
conversationId: id,
|
|
37
|
+
...(await bindCanonicalContext(options))
|
|
38
|
+
});
|
|
39
|
+
exports.streamStatus = async (id, options = {}) => read.getConversationStreamStatus({
|
|
40
|
+
conversationId: id,
|
|
41
|
+
...(await bindCanonicalContext(options))
|
|
42
|
+
});
|
|
43
|
+
exports.resume = async (id, options = {}) => read.resumeConversation({
|
|
44
|
+
conversationId: id,
|
|
45
|
+
...(await bindCanonicalContext(options))
|
|
46
|
+
});
|
|
47
|
+
exports.send = async (id, message, options = {}) => write.sendConversation({
|
|
48
|
+
conversationId: id,
|
|
49
|
+
message,
|
|
50
|
+
...(await bindCanonicalContext(options))
|
|
51
|
+
});
|
|
52
|
+
exports.temp = {
|
|
53
|
+
new: async (message, options = {}) => temporary.createConversation({
|
|
54
|
+
message,
|
|
55
|
+
...(await bindCanonicalContext(options))
|
|
56
|
+
}),
|
|
57
|
+
send: async (id, message, options = {}) => temporary.sendConversation({
|
|
58
|
+
conversationId: id,
|
|
59
|
+
message,
|
|
60
|
+
...(await bindCanonicalContext(options))
|
|
61
|
+
})
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
exports.attachments = require('./attachments');
|
|
65
|
+
exports.model = require('./model');
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const direct = require('../transport/direct/http-client');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
6
|
+
let state = { models: [], defaultModelSlug: null, refreshedAt: 0 };
|
|
7
|
+
|
|
8
|
+
const clone = (value) => value == null ? value : structuredClone(value);
|
|
9
|
+
const cookieString = (headers = {}) => headers.Cookie || headers.cookie || '';
|
|
10
|
+
const parseCookies = (headers = {}) => {
|
|
11
|
+
const out = new Map();
|
|
12
|
+
for (const pair of String(cookieString(headers)).split(';')) {
|
|
13
|
+
const index = pair.indexOf('=');
|
|
14
|
+
if (index <= 0) continue;
|
|
15
|
+
out.set(pair.slice(0, index).trim(), pair.slice(index + 1).trim());
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
};
|
|
19
|
+
const parseJsonCookie = (cookies, name) => {
|
|
20
|
+
const raw = cookies.get(name);
|
|
21
|
+
if (!raw) return null;
|
|
22
|
+
try { return JSON.parse(decodeURIComponent(raw)); }
|
|
23
|
+
catch { return null; }
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const sessionPreference = (headers = {}) => {
|
|
27
|
+
const cookies = parseCookies(headers);
|
|
28
|
+
const last = parseJsonCookie(cookies, 'oai-last-model-config');
|
|
29
|
+
if (typeof last?.model === 'string' && last.model) {
|
|
30
|
+
return { model: last.model, thinkingEffort: typeof last.effort === 'string' ? last.effort : null };
|
|
31
|
+
}
|
|
32
|
+
const tpp = parseJsonCookie(cookies, 'oai-tpp-model-settings');
|
|
33
|
+
const model = tpp?.modelSettings?.lastUsedModelSlug;
|
|
34
|
+
if (typeof model === 'string' && model) {
|
|
35
|
+
return { model, thinkingEffort: typeof tpp?.thinkingEffort === 'string' ? tpp.thinkingEffort : null };
|
|
36
|
+
}
|
|
37
|
+
return { model: null, thinkingEffort: null };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const normalizeModel = (item) => {
|
|
41
|
+
const id = typeof item?.slug === 'string' && item.slug.trim()
|
|
42
|
+
? item.slug.trim()
|
|
43
|
+
: typeof item?.id === 'string' && item.id.trim()
|
|
44
|
+
? item.id.trim()
|
|
45
|
+
: null;
|
|
46
|
+
return id ? { ...clone(item), id, slug: item.slug || id, backendModel: id } : null;
|
|
47
|
+
};
|
|
48
|
+
const allowedEfforts = (model) => (Array.isArray(model?.thinking_efforts) ? model.thinking_efforts : [])
|
|
49
|
+
.map((item) => typeof item === 'string' ? item : item?.thinking_effort)
|
|
50
|
+
.filter((item) => typeof item === 'string' && item);
|
|
51
|
+
const validEffort = (model, effort) => {
|
|
52
|
+
if (!effort) return null;
|
|
53
|
+
const allowed = allowedEfforts(model);
|
|
54
|
+
return allowed.length === 0 || allowed.includes(effort) ? effort : null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
exports.clearCache = () => { state = { models: [], defaultModelSlug: null, refreshedAt: 0 }; };
|
|
58
|
+
exports.list = () => state.models.map(clone);
|
|
59
|
+
exports.get = (id) => clone(state.models.find((item) => item.id === id) || null);
|
|
60
|
+
exports.sessionPreference = sessionPreference;
|
|
61
|
+
|
|
62
|
+
exports.refresh = async ({
|
|
63
|
+
token,
|
|
64
|
+
sessionHeaders = {},
|
|
65
|
+
requestJson = direct.requestJson,
|
|
66
|
+
cacheTtlMs = DEFAULT_CACHE_TTL_MS,
|
|
67
|
+
force = false
|
|
68
|
+
} = {}) => {
|
|
69
|
+
const fresh = state.models.length && (Date.now() - state.refreshedAt) < Math.max(0, cacheTtlMs);
|
|
70
|
+
if (!force && fresh) return { refreshed: false, cached: true, count: state.models.length, defaultModelSlug: state.defaultModelSlug };
|
|
71
|
+
if (!token || typeof requestJson !== 'function') throw Object.assign(new Error('ChatGPT model catalog context required'), { code: 'MODEL_CONTEXT_REQUIRED' });
|
|
72
|
+
const target = '/backend-api/models';
|
|
73
|
+
const response = await requestJson(target, token, sessionHeaders, { route: target, retry: false });
|
|
74
|
+
if (response?.status !== 200 || !Array.isArray(response.json?.models) || response.json.models.length === 0) {
|
|
75
|
+
throw Object.assign(new Error(`ChatGPT model catalog request failed (${response?.status || 'invalid'})`), { code: 'MODEL_CATALOG_UNAVAILABLE', status: response?.status || null });
|
|
76
|
+
}
|
|
77
|
+
const models = response.json.models.map(normalizeModel).filter(Boolean);
|
|
78
|
+
if (!models.length) throw Object.assign(new Error('ChatGPT model catalog is empty'), { code: 'MODEL_CATALOG_EMPTY' });
|
|
79
|
+
state = {
|
|
80
|
+
models,
|
|
81
|
+
defaultModelSlug: typeof response.json.default_model_slug === 'string' ? response.json.default_model_slug : null,
|
|
82
|
+
refreshedAt: Date.now()
|
|
83
|
+
};
|
|
84
|
+
return { refreshed: true, cached: false, count: models.length, defaultModelSlug: state.defaultModelSlug };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
exports.resolve = async ({
|
|
88
|
+
requestedModel = 'auto',
|
|
89
|
+
requestedThinkingEffort = null,
|
|
90
|
+
initDefaultModel = null,
|
|
91
|
+
token,
|
|
92
|
+
sessionHeaders = {},
|
|
93
|
+
requestJson = direct.requestJson,
|
|
94
|
+
cacheTtlMs = DEFAULT_CACHE_TTL_MS
|
|
95
|
+
} = {}) => {
|
|
96
|
+
await exports.refresh({ token, sessionHeaders, requestJson, cacheTtlMs });
|
|
97
|
+
const requested = requestedModel && requestedModel !== 'auto' ? exports.get(requestedModel) : null;
|
|
98
|
+
const preference = sessionPreference(sessionHeaders);
|
|
99
|
+
const preferred = preference.model ? exports.get(preference.model) : null;
|
|
100
|
+
const initialized = initDefaultModel ? exports.get(initDefaultModel) : null;
|
|
101
|
+
const catalogDefault = state.defaultModelSlug ? exports.get(state.defaultModelSlug) : null;
|
|
102
|
+
const selected = requested || preferred || initialized || catalogDefault || exports.list()[0] || null;
|
|
103
|
+
if (!selected) throw Object.assign(new Error('No ChatGPT model is available'), { code: 'MODEL_NOT_FOUND' });
|
|
104
|
+
const source = requested ? 'requested' : preferred ? 'session-last-used' : initialized ? 'conversation-init' : catalogDefault ? 'catalog-default' : 'catalog-first';
|
|
105
|
+
const preferredEffort = source === 'session-last-used' ? preference.thinkingEffort : null;
|
|
106
|
+
const efforts = allowedEfforts(selected);
|
|
107
|
+
const thinkingEffort = validEffort(selected, requestedThinkingEffort)
|
|
108
|
+
|| validEffort(selected, preferredEffort)
|
|
109
|
+
|| (efforts.length ? efforts[0] : null);
|
|
110
|
+
return {
|
|
111
|
+
model: selected.id,
|
|
112
|
+
thinkingEffort,
|
|
113
|
+
source,
|
|
114
|
+
requestedValid: Boolean(requested),
|
|
115
|
+
catalogModel: selected
|
|
116
|
+
};
|
|
117
|
+
};
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const direct = require('../transport/direct/http-client');
|
|
4
|
+
|
|
5
|
+
const requireOk = (response, target) => {
|
|
6
|
+
if (!response || response.status !== 200 || !response.json) {
|
|
7
|
+
const error = new Error(`ChatGPT request failed (${response?.status || 'no-status'}): ${target}`);
|
|
8
|
+
error.status = response?.status || null;
|
|
9
|
+
error.target = target;
|
|
10
|
+
error.retryAfterMs = direct.parseRetryAfterMs(response?.headers || {});
|
|
11
|
+
if (error.status === 429) error.code = 'RATE_LIMITED';
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
return response.json;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const requireSuccess = (response, target) => {
|
|
18
|
+
if (!response || response.status < 200 || response.status >= 300) {
|
|
19
|
+
const error = new Error(`ChatGPT request failed (${response?.status || 'no-status'}): ${target}`);
|
|
20
|
+
error.status = response?.status || null;
|
|
21
|
+
error.target = target;
|
|
22
|
+
error.retryAfterMs = direct.parseRetryAfterMs(response?.headers || {});
|
|
23
|
+
if (error.status === 429) error.code = 'RATE_LIMITED';
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
return response.json || { success: true };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
exports.listConversationPage = async ({
|
|
30
|
+
token,
|
|
31
|
+
sessionHeaders,
|
|
32
|
+
offset = 0,
|
|
33
|
+
limit = 50,
|
|
34
|
+
requestJson = direct.requestJson,
|
|
35
|
+
onProgress = () => {}
|
|
36
|
+
}) => {
|
|
37
|
+
const target = `/backend-api/conversations?offset=${offset}&limit=${limit}&order=updated&is_archived=false&is_starred=false`;
|
|
38
|
+
const page = requireOk(await requestJson(target, token, sessionHeaders), target);
|
|
39
|
+
const items = Array.isArray(page.items) ? page.items : [];
|
|
40
|
+
const totalValue = Number(page.total);
|
|
41
|
+
const total = Number.isFinite(totalValue) ? totalValue : items.length;
|
|
42
|
+
const pageLimit = Number(page.limit) || limit;
|
|
43
|
+
const pageOffset = Number(page.offset) || offset;
|
|
44
|
+
const nextOffset = items.length > 0 && pageOffset + pageLimit < total
|
|
45
|
+
? pageOffset + pageLimit
|
|
46
|
+
: null;
|
|
47
|
+
onProgress({ type: 'conversation-page', count: items.length, pageCount: items.length, offset: pageOffset, total });
|
|
48
|
+
return { items, total, limit: pageLimit, offset: pageOffset, nextOffset };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
exports.listStandalone = async ({
|
|
52
|
+
token,
|
|
53
|
+
sessionHeaders,
|
|
54
|
+
requestJson = direct.requestJson,
|
|
55
|
+
onProgress = () => {}
|
|
56
|
+
}) => {
|
|
57
|
+
const items = [];
|
|
58
|
+
let offset = 0;
|
|
59
|
+
const limit = 50;
|
|
60
|
+
|
|
61
|
+
for (let pageNumber = 0; pageNumber < 1000; pageNumber += 1) {
|
|
62
|
+
const target = `/backend-api/conversations?offset=${offset}&limit=${limit}&order=updated&is_archived=false&is_starred=false`;
|
|
63
|
+
const page = requireOk(await requestJson(target, token, sessionHeaders), target);
|
|
64
|
+
const pageItems = Array.isArray(page.items) ? page.items : [];
|
|
65
|
+
items.push(...pageItems);
|
|
66
|
+
onProgress({ type: 'conversation-page', count: items.length, pageCount: pageItems.length, offset });
|
|
67
|
+
|
|
68
|
+
const total = Number(page.total);
|
|
69
|
+
const pageLimit = Number(page.limit) || limit;
|
|
70
|
+
offset += pageLimit;
|
|
71
|
+
if (!Number.isFinite(total) || offset >= total || pageItems.length === 0) return items;
|
|
72
|
+
}
|
|
73
|
+
throw Object.assign(new Error('conversation pagination exceeded 1000 pages'), { code: 'PAGINATION_LIMIT' });
|
|
74
|
+
};
|
|
75
|
+
exports.searchGlobal = async ({
|
|
76
|
+
query,
|
|
77
|
+
cursor = null,
|
|
78
|
+
limit = 20,
|
|
79
|
+
source = 'conversation',
|
|
80
|
+
token,
|
|
81
|
+
sessionHeaders,
|
|
82
|
+
requestJson = direct.requestJson
|
|
83
|
+
}) => {
|
|
84
|
+
const normalized = String(query || '').trim();
|
|
85
|
+
if (!normalized) throw new Error('query is required');
|
|
86
|
+
const params = new URLSearchParams({ query: normalized, limit: String(limit) });
|
|
87
|
+
if (cursor) params.set('cursor', cursor);
|
|
88
|
+
if (source) params.set('sources', source);
|
|
89
|
+
const target = `/backend-api/global/search?${params.toString()}`;
|
|
90
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
91
|
+
route: '/backend-api/global/search'
|
|
92
|
+
}), target);
|
|
93
|
+
};
|
|
94
|
+
exports.getConversation = async ({
|
|
95
|
+
conversationId,
|
|
96
|
+
token,
|
|
97
|
+
sessionHeaders,
|
|
98
|
+
requestJson = direct.requestJson
|
|
99
|
+
}) => {
|
|
100
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
101
|
+
const target = `/backend-api/conversations/${encodeURIComponent(conversationId)}`;
|
|
102
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
103
|
+
route: '/backend-api/conversations/{conversation_id}'
|
|
104
|
+
}), target);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
exports.getConversationStreamStatus = async ({
|
|
108
|
+
conversationId,
|
|
109
|
+
token,
|
|
110
|
+
sessionHeaders,
|
|
111
|
+
requestJson = direct.requestJson
|
|
112
|
+
}) => {
|
|
113
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
114
|
+
const target = `/backend-api/conversation/${encodeURIComponent(conversationId)}/stream_status`;
|
|
115
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
116
|
+
route: '/backend-api/conversation/{conversation_id}/stream_status'
|
|
117
|
+
}), target);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
exports.resumeConversation = async ({
|
|
121
|
+
conversationId,
|
|
122
|
+
offset = 0,
|
|
123
|
+
conduitToken = null,
|
|
124
|
+
turnTraceId = null,
|
|
125
|
+
onChunk = null,
|
|
126
|
+
token,
|
|
127
|
+
sessionHeaders,
|
|
128
|
+
requestText = direct.requestText
|
|
129
|
+
}) => {
|
|
130
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
131
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('offset must be a non-negative integer');
|
|
132
|
+
const target = '/backend-api/f/conversation/resume';
|
|
133
|
+
const response = await requestText(target, token, sessionHeaders, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
body: { conversation_id: conversationId, offset },
|
|
136
|
+
route: target,
|
|
137
|
+
headers: {
|
|
138
|
+
accept: 'text/event-stream',
|
|
139
|
+
origin: sessionHeaders?.origin || sessionHeaders?.Origin || 'https://chatgpt.com',
|
|
140
|
+
'x-openai-web-frontend': sessionHeaders?.['x-openai-web-frontend'] || 'core_web',
|
|
141
|
+
...(conduitToken ? { 'x-conduit-token': conduitToken } : {}),
|
|
142
|
+
...(turnTraceId ? { 'x-oai-turn-trace-id': turnTraceId } : {})
|
|
143
|
+
},
|
|
144
|
+
retry: false,
|
|
145
|
+
idleTimeoutMs: 60_000,
|
|
146
|
+
onChunk
|
|
147
|
+
});
|
|
148
|
+
if (!response || response.status < 200 || response.status >= 300) {
|
|
149
|
+
const error = new Error(`ChatGPT request failed (${response?.status || 'no-status'}): ${target}`);
|
|
150
|
+
error.status = response?.status || null;
|
|
151
|
+
error.target = target;
|
|
152
|
+
error.body = response?.text || null;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
conversation_id: conversationId,
|
|
157
|
+
status: response.status,
|
|
158
|
+
content_type: response.contentType || null,
|
|
159
|
+
text: response.text || ''
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
exports.getConversationMessagesPage = async ({
|
|
164
|
+
conversationId, before, numTurns = 10, includeHasVersions = true,
|
|
165
|
+
token, sessionHeaders, requestJson = direct.requestJson
|
|
166
|
+
}) => {
|
|
167
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
168
|
+
if (!before) throw new Error('before is required');
|
|
169
|
+
const params = new URLSearchParams({
|
|
170
|
+
before: String(before),
|
|
171
|
+
include_has_versions: includeHasVersions ? 'true' : 'false',
|
|
172
|
+
num_turns: String(numTurns)
|
|
173
|
+
});
|
|
174
|
+
const target = `/backend-api/conversations/${encodeURIComponent(conversationId)}/messages?${params}`;
|
|
175
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
176
|
+
route: '/backend-api/conversations/{conversation_id}/messages'
|
|
177
|
+
}), target);
|
|
178
|
+
};
|
|
179
|
+
exports.findConversationMessage = async ({
|
|
180
|
+
conversationId, messageId, numTurns = 10, includeHasVersions = true,
|
|
181
|
+
token, sessionHeaders, requestJson = direct.requestJson,
|
|
182
|
+
getConversation = exports.getConversation, getMessagesPage = exports.getConversationMessagesPage
|
|
183
|
+
}) => {
|
|
184
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
185
|
+
if (!messageId) throw new Error('messageId is required');
|
|
186
|
+
const current = await getConversation({ conversationId, token, sessionHeaders, requestJson });
|
|
187
|
+
let pagesScanned = 1;
|
|
188
|
+
let nodesScanned = Array.isArray(current?.messages) ? current.messages.length : 0;
|
|
189
|
+
const currentMatch = Array.isArray(current?.messages) ? current.messages.find((item) => item?.id === messageId) : null;
|
|
190
|
+
if (currentMatch) return { found: true, conversation_id: conversationId, message_id: messageId, source: 'current_window', page_info: current?.page_info || null, message: currentMatch, pages_scanned: pagesScanned, nodes_scanned: nodesScanned };
|
|
191
|
+
let pageInfo = current?.page_info || {};
|
|
192
|
+
let before = pageInfo.start_cursor || null;
|
|
193
|
+
let hasPrevious = pageInfo.has_previous_page === true;
|
|
194
|
+
const seen = new Set();
|
|
195
|
+
while (hasPrevious && before) {
|
|
196
|
+
if (seen.has(before)) throw Object.assign(new Error('conversation history cursor stalled'), { code: 'PAGINATION_STALLED' });
|
|
197
|
+
seen.add(before);
|
|
198
|
+
const page = await getMessagesPage({ conversationId, before, numTurns, includeHasVersions, token, sessionHeaders, requestJson });
|
|
199
|
+
pagesScanned += 1;
|
|
200
|
+
const messages = Array.isArray(page?.messages) ? page.messages : [];
|
|
201
|
+
nodesScanned += messages.length;
|
|
202
|
+
const match = messages.find((item) => item?.id === messageId);
|
|
203
|
+
if (match) return { found: true, conversation_id: conversationId, message_id: messageId, source: 'historical_page', before, page_info: page?.page_info || null, message: match, pages_scanned: pagesScanned, nodes_scanned: nodesScanned };
|
|
204
|
+
pageInfo = page?.page_info || {};
|
|
205
|
+
hasPrevious = pageInfo.has_previous_page === true;
|
|
206
|
+
const nextBefore = pageInfo.start_cursor || null;
|
|
207
|
+
if (hasPrevious && (!nextBefore || nextBefore === before)) throw Object.assign(new Error('conversation history cursor stalled'), { code: 'PAGINATION_STALLED' });
|
|
208
|
+
before = nextBefore;
|
|
209
|
+
}
|
|
210
|
+
return { found: false, conversation_id: conversationId, message_id: messageId, source: null, page_info: pageInfo, message: null, pages_scanned: pagesScanned, nodes_scanned: nodesScanned };
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
exports.getConversationHistory = async ({
|
|
214
|
+
conversationId, numTurns = 10, includeHasVersions = true,
|
|
215
|
+
token, sessionHeaders, requestJson = direct.requestJson,
|
|
216
|
+
getConversation = exports.getConversation, getMessagesPage = exports.getConversationMessagesPage
|
|
217
|
+
}) => {
|
|
218
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
219
|
+
const current = await getConversation({ conversationId, token, sessionHeaders, requestJson });
|
|
220
|
+
const pages = [{ ...current, kind: 'current_window', before: null }];
|
|
221
|
+
let messageCount = Array.isArray(current?.messages) ? current.messages.length : 0;
|
|
222
|
+
let pageInfo = current?.page_info || {};
|
|
223
|
+
let before = pageInfo.start_cursor || null;
|
|
224
|
+
let hasPrevious = pageInfo.has_previous_page === true;
|
|
225
|
+
const seen = new Set();
|
|
226
|
+
while (hasPrevious && before) {
|
|
227
|
+
if (seen.has(before)) throw Object.assign(new Error('conversation history cursor stalled'), { code: 'PAGINATION_STALLED' });
|
|
228
|
+
seen.add(before);
|
|
229
|
+
const page = await getMessagesPage({ conversationId, before, numTurns, includeHasVersions, token, sessionHeaders, requestJson });
|
|
230
|
+
pages.push({ ...page, kind: 'historical_page', before });
|
|
231
|
+
messageCount += Array.isArray(page?.messages) ? page.messages.length : 0;
|
|
232
|
+
pageInfo = page?.page_info || {};
|
|
233
|
+
hasPrevious = pageInfo.has_previous_page === true;
|
|
234
|
+
const nextBefore = pageInfo.start_cursor || null;
|
|
235
|
+
if (hasPrevious && (!nextBefore || nextBefore === before)) throw Object.assign(new Error('conversation history cursor stalled'), { code: 'PAGINATION_STALLED' });
|
|
236
|
+
before = nextBefore;
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
conversation_id: current?.conversation_id || current?.id || conversationId,
|
|
240
|
+
title: current?.title || null,
|
|
241
|
+
project_id: current?.gizmo_id || current?.conversation_template_id || null,
|
|
242
|
+
current_node: current?.current_node || null,
|
|
243
|
+
complete: true,
|
|
244
|
+
direction: 'current_to_older',
|
|
245
|
+
page_count: pages.length,
|
|
246
|
+
message_count: messageCount,
|
|
247
|
+
pages
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
exports.getConversationThread = async ({
|
|
252
|
+
conversationId, includeFullConversation = false,
|
|
253
|
+
token, sessionHeaders, requestJson = direct.requestJson
|
|
254
|
+
}) => {
|
|
255
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
256
|
+
const query = includeFullConversation ? '?include_full_conversation=true' : '';
|
|
257
|
+
const target = `/backend-api/conversation/${encodeURIComponent(conversationId)}${query}`;
|
|
258
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
259
|
+
route: '/backend-api/conversation/{conversation_id}'
|
|
260
|
+
}), target);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
exports.getConversationMessageSupplements = async ({
|
|
264
|
+
conversationId, token, sessionHeaders, requestJson = direct.requestJson
|
|
265
|
+
}) => {
|
|
266
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
267
|
+
const target = `/backend-api/conversations/${encodeURIComponent(conversationId)}/message_supplements`;
|
|
268
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
269
|
+
route: '/backend-api/conversations/{conversation_id}/message_supplements'
|
|
270
|
+
}), target);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
exports.getConversationTextdocs = async ({
|
|
274
|
+
conversationId, token, sessionHeaders, requestJson = direct.requestJson
|
|
275
|
+
}) => {
|
|
276
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
277
|
+
const target = `/backend-api/conversation/${encodeURIComponent(conversationId)}/textdocs`;
|
|
278
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
279
|
+
route: '/backend-api/conversation/{conversation_id}/textdocs'
|
|
280
|
+
}), target);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
exports.checkConversationUrlSafety = async ({
|
|
284
|
+
conversationId, url, historyAndTrainingDisabled = false,
|
|
285
|
+
token, sessionHeaders, requestJson = direct.requestJson
|
|
286
|
+
}) => {
|
|
287
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
288
|
+
if (!url) throw new Error('url is required');
|
|
289
|
+
const params = new URLSearchParams({
|
|
290
|
+
url: String(url),
|
|
291
|
+
history_and_training_disabled: historyAndTrainingDisabled ? 'true' : 'false'
|
|
292
|
+
});
|
|
293
|
+
const target = `/backend-api/conversation/${encodeURIComponent(conversationId)}/url_safe?${params}`;
|
|
294
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
295
|
+
route: '/backend-api/conversation/{conversation_id}/url_safe'
|
|
296
|
+
}), target);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
exports.getVoiceFeedbackOptions = async ({
|
|
300
|
+
token, sessionHeaders, requestJson = direct.requestJson
|
|
301
|
+
} = {}) => {
|
|
302
|
+
const target = '/backend-api/conversation/voice/feedback-options';
|
|
303
|
+
return requireOk(await requestJson(target, token, sessionHeaders, { route: target }), target);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
exports.listConversationFilesPage = async ({
|
|
307
|
+
conversationId, cursor = null, limit = 200,
|
|
308
|
+
token, sessionHeaders, requestJson = direct.requestJson
|
|
309
|
+
}) => {
|
|
310
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
311
|
+
const safeLimit = Math.max(1, Math.min(200, Number.parseInt(limit, 10) || 200));
|
|
312
|
+
const params = new URLSearchParams();
|
|
313
|
+
if (cursor) params.set('cursor', String(cursor));
|
|
314
|
+
params.set('limit', String(safeLimit));
|
|
315
|
+
const target = `/backend-api/conversations/${encodeURIComponent(conversationId)}/files?${params}`;
|
|
316
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
317
|
+
route: '/backend-api/conversations/{conversation_id}/files'
|
|
318
|
+
}), target);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
exports.listConversationFiles = async ({
|
|
322
|
+
conversationId, limit = 200, maxPages = 1000,
|
|
323
|
+
token, sessionHeaders, requestJson = direct.requestJson
|
|
324
|
+
}) => {
|
|
325
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
326
|
+
const files = [];
|
|
327
|
+
const seenFiles = new Set();
|
|
328
|
+
const seenCursors = new Set();
|
|
329
|
+
let cursor = null;
|
|
330
|
+
const pageLimit = Math.max(1, Math.min(1000, Number.parseInt(maxPages, 10) || 1000));
|
|
331
|
+
for (let page = 0; page < pageLimit; page += 1) {
|
|
332
|
+
const result = await exports.listConversationFilesPage({ conversationId, cursor, limit, token, sessionHeaders, requestJson });
|
|
333
|
+
for (const file of Array.isArray(result?.items) ? result.items : []) {
|
|
334
|
+
const id = file?.file_id;
|
|
335
|
+
if (id && seenFiles.has(id)) continue;
|
|
336
|
+
if (id) seenFiles.add(id);
|
|
337
|
+
files.push(file);
|
|
338
|
+
}
|
|
339
|
+
const next = result?.cursor ?? null;
|
|
340
|
+
if (!next || seenCursors.has(next)) return files;
|
|
341
|
+
seenCursors.add(next);
|
|
342
|
+
cursor = next;
|
|
343
|
+
}
|
|
344
|
+
throw Object.assign(new Error(`conversation file pagination exceeded ${pageLimit} pages`), { code: 'PAGINATION_LIMIT' });
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
exports.searchConversations = async ({
|
|
348
|
+
query, cursor = null, token, sessionHeaders, requestJson = direct.requestJson
|
|
349
|
+
}) => {
|
|
350
|
+
const normalized = String(query || '').trim();
|
|
351
|
+
if (!normalized) throw new Error('query is required');
|
|
352
|
+
const params = new URLSearchParams({ query: normalized });
|
|
353
|
+
if (cursor) params.set('cursor', String(cursor));
|
|
354
|
+
const target = `/backend-api/conversations/search?${params}`;
|
|
355
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
356
|
+
route: '/backend-api/conversations/search'
|
|
357
|
+
}), target);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
exports.requestInterpreterDownload = async ({
|
|
361
|
+
conversationId, messageId, sandboxPath, downloadIntent = null,
|
|
362
|
+
projectId = null, ownerUserId = null, requestSource = null,
|
|
363
|
+
token, sessionHeaders, requestJson = direct.requestJson
|
|
364
|
+
}) => {
|
|
365
|
+
if (!conversationId) throw new Error('conversationId is required');
|
|
366
|
+
if (!messageId) throw new Error('messageId is required');
|
|
367
|
+
if (!sandboxPath) throw new Error('sandboxPath is required');
|
|
368
|
+
const params = new URLSearchParams({ message_id: String(messageId), sandbox_path: String(sandboxPath) });
|
|
369
|
+
if (downloadIntent !== null && downloadIntent !== undefined) params.set('download_intent', String(downloadIntent));
|
|
370
|
+
const headers = {};
|
|
371
|
+
if (projectId) headers['chatgpt-project-id'] = String(projectId);
|
|
372
|
+
if (ownerUserId) headers['chatgpt-conv-owner-id'] = String(ownerUserId);
|
|
373
|
+
if (requestSource) headers['x-chatgpt-sandbox-download-source'] = String(requestSource);
|
|
374
|
+
const target = `/backend-api/conversation/${encodeURIComponent(conversationId)}/interpreter/download?${params}`;
|
|
375
|
+
return requireOk(await requestJson(target, token, sessionHeaders, {
|
|
376
|
+
route: '/backend-api/conversation/{conversation_id}/interpreter/download', headers
|
|
377
|
+
}), target);
|
|
378
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const paths = require('../config/paths');
|
|
6
|
+
|
|
7
|
+
const readState = (env = process.env) => {
|
|
8
|
+
const file = paths.tempConversationFile(env);
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
11
|
+
return parsed && typeof parsed === 'object' && parsed.items && typeof parsed.items === 'object'
|
|
12
|
+
? parsed
|
|
13
|
+
: { version: 1, items: {} };
|
|
14
|
+
} catch {
|
|
15
|
+
return { version: 1, items: {} };
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const writeState = (state, env = process.env) => {
|
|
20
|
+
const file = paths.tempConversationFile(env);
|
|
21
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
22
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
23
|
+
fs.writeFileSync(temp, `${JSON.stringify(state, null, 2)}
|
|
24
|
+
`, { encoding: 'utf8', mode: 0o600 });
|
|
25
|
+
fs.renameSync(temp, file);
|
|
26
|
+
return file;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
exports.get = (conversationId, env = process.env) => readState(env).items[conversationId] || null;
|
|
30
|
+
exports.save = ({ conversationId, currentNode, env = process.env }) => {
|
|
31
|
+
if (!conversationId || !currentNode) throw new TypeError('temporary conversation cursor requires conversationId and currentNode');
|
|
32
|
+
const state = readState(env);
|
|
33
|
+
state.items[conversationId] = { current_node: currentNode, updated_at: new Date().toISOString() };
|
|
34
|
+
writeState(state, env);
|
|
35
|
+
return { conversation_id: conversationId, current_node: currentNode };
|
|
36
|
+
};
|
|
37
|
+
exports.remove = (conversationId, env = process.env) => {
|
|
38
|
+
const state = readState(env);
|
|
39
|
+
const existed = Boolean(state.items[conversationId]);
|
|
40
|
+
delete state.items[conversationId];
|
|
41
|
+
writeState(state, env);
|
|
42
|
+
return existed;
|
|
43
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const write = require('./write');
|
|
4
|
+
const tempState = require('./temp-state');
|
|
5
|
+
|
|
6
|
+
const VALID_PERSONALIZATION = new Set(['inherit', 'on', 'off']);
|
|
7
|
+
|
|
8
|
+
exports.resolvePersonalization = (mode = 'inherit', nativeValue = undefined) => {
|
|
9
|
+
if (!VALID_PERSONALIZATION.has(mode)) {
|
|
10
|
+
throw new TypeError(`invalid personalization mode: ${mode}`);
|
|
11
|
+
}
|
|
12
|
+
if (mode === 'on') return true;
|
|
13
|
+
if (mode === 'off') return false;
|
|
14
|
+
return typeof nativeValue === 'boolean' ? nativeValue : undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
exports.createConversation = async ({
|
|
18
|
+
message,
|
|
19
|
+
personalization = 'inherit',
|
|
20
|
+
nativePersonalization = undefined,
|
|
21
|
+
env = process.env,
|
|
22
|
+
...options
|
|
23
|
+
}) => {
|
|
24
|
+
const result = await write.createConversation({
|
|
25
|
+
...options,
|
|
26
|
+
message,
|
|
27
|
+
temporaryChat: true,
|
|
28
|
+
historyAndTrainingDisabled: true,
|
|
29
|
+
isDoNotRemember: false,
|
|
30
|
+
temporaryChatRequestsPersonalization: exports.resolvePersonalization(personalization, nativePersonalization)
|
|
31
|
+
});
|
|
32
|
+
tempState.save({ conversationId: result.conversation_id, currentNode: result.current_node, env });
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
exports.sendConversation = async ({
|
|
37
|
+
conversationId,
|
|
38
|
+
message,
|
|
39
|
+
env = process.env,
|
|
40
|
+
parentMessageId = null,
|
|
41
|
+
...options
|
|
42
|
+
}) => {
|
|
43
|
+
const cursor = parentMessageId ? { current_node: parentMessageId } : tempState.get(conversationId, env);
|
|
44
|
+
if (!cursor?.current_node) {
|
|
45
|
+
throw Object.assign(new Error(`temporary conversation cursor not found: ${conversationId}`), { code: 'TEMP_STATE_NOT_FOUND' });
|
|
46
|
+
}
|
|
47
|
+
const result = await write.sendConversation({
|
|
48
|
+
...options,
|
|
49
|
+
conversationId,
|
|
50
|
+
message,
|
|
51
|
+
parentMessageId: cursor.current_node,
|
|
52
|
+
temporaryChat: true,
|
|
53
|
+
historyAndTrainingDisabled: true,
|
|
54
|
+
isDoNotRemember: false,
|
|
55
|
+
temporaryChatRequestsPersonalization: null
|
|
56
|
+
});
|
|
57
|
+
tempState.save({ conversationId, currentNode: result.current_node, env });
|
|
58
|
+
return result;
|
|
59
|
+
};
|