ikun2 / chat.js
bingn's picture
Upload 19 files
f1357b6 verified
/**
* chat.js - ChatAIBot.pro 聊天协议层
*
* 逆向自前端 JS chunks:
* POST /api/message/context → 创建聊天上下文
* POST /api/message/streaming → 流式聊天 (NDJSON SSE)
* GET /api/user/answers-count/v2 → 剩余额度
*/
import { post, get, requestStream } from './http.js';
import config from './config.js';
const API = config.siteBase;
const HEADERS = {
'Origin': config.siteBase,
'Referer': `${config.siteBase}/app/chat`,
'Accept-Language': 'en',
};
/**
* 创建聊天上下文
* @returns {{ chatId: number }}
*/
export async function createContext(cookies, model, title = 'API Chat') {
const resp = await post(`${API}/api/message/context`, {
title: title.substring(0, 150),
chatModel: model,
isInternational: true,
}, {
cookies,
headers: HEADERS,
});
if (!resp.ok) {
const body = resp.text();
throw Object.assign(new Error(`创建上下文失败 (${resp.status}): ${body.substring(0, 200)}`), {
statusCode: resp.status,
});
}
const data = resp.json();
return { chatId: data.id, cookies: resp.cookies };
}
/**
* 流式聊天 — 返回原始 NDJSON 流
* 响应流中每行是一个 JSON: {"type":"chunk","data":"..."} 等
*/
export async function sendMessageStreaming(cookies, chatId, text, model) {
const resp = await requestStream(`${API}/api/message/streaming`, {
method: 'POST',
body: {
text,
chatId,
withPotentialQuestions: false,
model,
from: 1,
},
cookies,
headers: {
...HEADERS,
'Accept': 'text/event-stream',
},
});
if (!resp.ok) {
// 需要消费流以获取错误信息
let errBody = '';
resp.stream.setEncoding('utf8');
for await (const chunk of resp.stream) errBody += chunk;
throw Object.assign(new Error(`流式请求失败 (${resp.status}): ${errBody.substring(0, 200)}`), {
statusCode: resp.status,
});
}
resp.stream.setEncoding('utf8');
return { stream: resp.stream, cookies: resp.cookies };
}
/**
* 非流式聊天
*/
export async function sendMessage(cookies, chatId, text, model) {
const resp = await post(`${API}/api/message`, {
text,
chatId,
withPotentialQuestions: true,
model,
from: 1,
isInternational: true,
}, {
cookies,
headers: HEADERS,
});
if (!resp.ok) {
throw Object.assign(new Error(`消息发送失败 (${resp.status}): ${resp.text().substring(0, 200)}`), {
statusCode: resp.status,
});
}
return { data: resp.json(), cookies: resp.cookies };
}
/**
* 多消息流式聊天 — 先发送上下文消息,最后一条流式返回
*
* 用于突破 chataibot.pro 单消息 2500 字符限制:
* - chunks[0..n-2]: 作为上下文消息发送 (流式发送但丢弃响应)
* - chunks[n-1]: 流式请求,返回模型真正回复
*
* @param {Map} cookies
* @param {number} chatId
* @param {string[]} chunks - splitToChunks() 返回的消息数组
* @param {string} model
* @returns {{ stream, cookies }}
*/
export async function sendMultiChunkStreaming(cookies, chatId, chunks, model) {
let currentCookies = cookies;
// 发送上下文消息 (前 N-1 条)
for (let i = 0; i < chunks.length - 1; i++) {
console.log(`[Chat] 发送上下文消息 ${i + 1}/${chunks.length - 1} (${chunks[i].length} 字符)`);
// 用流式接口发送,但需要完整消费流才能继续下一条
const resp = await requestStream(`${API}/api/message/streaming`, {
method: 'POST',
body: {
text: chunks[i],
chatId,
withPotentialQuestions: false,
model,
from: 1,
},
cookies: currentCookies,
headers: {
...HEADERS,
'Accept': 'text/event-stream',
},
});
if (resp.cookies) currentCookies = resp.cookies;
if (!resp.ok) {
let errBody = '';
resp.stream.setEncoding('utf8');
for await (const chunk of resp.stream) errBody += chunk;
throw Object.assign(
new Error(`上下文消息 ${i + 1} 失败 (${resp.status}): ${errBody.substring(0, 200)}`),
{ statusCode: resp.status },
);
}
// 消费完整响应流 (丢弃内容,等待模型回复完毕)
resp.stream.setEncoding('utf8');
for await (const _chunk of resp.stream) { /* discard */ }
}
// 发送最后一条消息 — 流式返回
const lastChunk = chunks[chunks.length - 1];
console.log(`[Chat] 发送最终消息 (${lastChunk.length} 字符)`);
const resp = await requestStream(`${API}/api/message/streaming`, {
method: 'POST',
body: {
text: lastChunk,
chatId,
withPotentialQuestions: false,
model,
from: 1,
},
cookies: currentCookies,
headers: {
...HEADERS,
'Accept': 'text/event-stream',
},
});
if (resp.cookies) currentCookies = resp.cookies;
if (!resp.ok) {
let errBody = '';
resp.stream.setEncoding('utf8');
for await (const chunk of resp.stream) errBody += chunk;
throw Object.assign(
new Error(`流式请求失败 (${resp.status}): ${errBody.substring(0, 200)}`),
{ statusCode: resp.status },
);
}
resp.stream.setEncoding('utf8');
return { stream: resp.stream, cookies: currentCookies };
}
/**
* 获取剩余额度
* @returns {{ remaining: number } | null}
*/
export async function getQuota(cookies) {
try {
const resp = await get(`${API}/api/user/answers-count/v2`, {
cookies,
headers: HEADERS,
});
if (!resp.ok) return null;
const data = resp.json();
// 从返回数据中提取剩余额度 — 兼容多种字段名
let remaining = null;
if (typeof data === 'object' && data !== null) {
// 尝试已知字段名
for (const key of ['freeAnswersCount', 'answersCount', 'remaining', 'count', 'free']) {
if (typeof data[key] === 'number') {
remaining = data[key];
break;
}
}
// 如果还是 null,遍历找第一个数字字段
if (remaining === null) {
for (const val of Object.values(data)) {
if (typeof val === 'number' && val >= 0) {
remaining = val;
break;
}
}
}
}
// 首次调试:打印完整返回以便确认字段
if (remaining === null) {
console.log(`[Quota] API 返回未知格式: ${JSON.stringify(data)}`);
}
return { remaining, raw: data };
} catch {
return null;
}
}
/**
* 获取用户信息 (验证 session 是否有效)
*/
export async function getUserInfo(cookies) {
try {
const resp = await get(`${API}/api/user`, {
cookies,
headers: HEADERS,
});
if (!resp.ok) return null;
return resp.json();
} catch {
return null;
}
}