Spaces:
Sleeping
Sleeping
| import { PassThrough } from "stream"; | |
| import path from "path"; | |
| import _ from "lodash"; | |
| import mime from "mime"; | |
| import axios, { AxiosResponse } from "axios"; | |
| import APIException from "@/lib/exceptions/APIException.ts"; | |
| import EX from "@/api/consts/exceptions.ts"; | |
| import logger from "@/lib/logger.ts"; | |
| import util from "@/lib/util.ts"; | |
| // 模型名称 | |
| const MODEL_NAME = "step"; | |
| // access_token有效期 | |
| const ACCESS_TOKEN_EXPIRES = 900; | |
| // 最大重试次数 | |
| const MAX_RETRY_COUNT = 0; | |
| // 重试延迟 | |
| const RETRY_DELAY = 5000; | |
| // 伪装headers | |
| const FAKE_HEADERS = { | |
| Accept: "*/*", | |
| "Accept-Encoding": "gzip, deflate, br, zstd", | |
| "Accept-Language": "zh-CN,zh;q=0.9", | |
| Origin: "https://stepchat.cn", | |
| "Connect-Protocol-Version": "1", | |
| "Oasis-Appid": "10200", | |
| "Oasis-Platform": "web", | |
| "Oasis-Webid": util.uuid(), | |
| "Sec-Ch-Ua": | |
| '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"', | |
| "Sec-Ch-Ua-Mobile": "?0", | |
| "Sec-Ch-Ua-Platform": '"Windows"', | |
| "Sec-Fetch-Dest": "empty", | |
| "Sec-Fetch-Mode": "cors", | |
| "Sec-Fetch-Site": "same-origin", | |
| "User-Agent": | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", | |
| }; | |
| // 文件最大大小 | |
| const FILE_MAX_SIZE = 100 * 1024 * 1024; | |
| // access_token映射 | |
| const accessTokenMap = new Map(); | |
| // access_token请求队列映射 | |
| const accessTokenRequestQueueMap: Record<string, Function[]> = {}; | |
| /** | |
| * 请求access_token | |
| * | |
| * 使用refresh_token去刷新获得access_token | |
| * | |
| * @param refreshToken 用于刷新access_token的refresh_token | |
| */ | |
| async function requestToken(refreshToken: string) { | |
| if (accessTokenRequestQueueMap[refreshToken]) | |
| return new Promise((resolve) => | |
| accessTokenRequestQueueMap[refreshToken].push(resolve) | |
| ); | |
| accessTokenRequestQueueMap[refreshToken] = []; | |
| logger.info(`Refresh token: ${refreshToken}`); | |
| const result = await (async () => { | |
| const result = await axios.post( | |
| "https://stepchat.cn/passport/proto.api.passport.v1.PassportService/RegisterDevice", | |
| {}, | |
| { | |
| headers: { | |
| Cookie: `Oasis-Token=${refreshToken}`, | |
| Referer: "https://stepchat.cn/chats/new", | |
| ...FAKE_HEADERS, | |
| }, | |
| timeout: 15000, | |
| validateStatus: () => true, | |
| } | |
| ); | |
| const { | |
| accessToken: { raw: accessTokenRaw }, | |
| refreshToken: { raw: refreshTokenRaw }, | |
| device: { deviceID: deviceId }, | |
| } = checkResult(result, refreshToken); | |
| return { | |
| deviceId, | |
| accessToken: accessTokenRaw, | |
| refreshToken: refreshTokenRaw, | |
| refreshTime: util.unixTimestamp() + ACCESS_TOKEN_EXPIRES, | |
| }; | |
| })() | |
| .then((result) => { | |
| if (accessTokenRequestQueueMap[refreshToken]) { | |
| accessTokenRequestQueueMap[refreshToken].forEach((resolve) => | |
| resolve(result) | |
| ); | |
| delete accessTokenRequestQueueMap[refreshToken]; | |
| } | |
| logger.success(`Refresh successful`); | |
| return result; | |
| }) | |
| .catch((err) => { | |
| if (accessTokenRequestQueueMap[refreshToken]) { | |
| accessTokenRequestQueueMap[refreshToken].forEach((resolve) => | |
| resolve(err) | |
| ); | |
| delete accessTokenRequestQueueMap[refreshToken]; | |
| } | |
| return err; | |
| }); | |
| if (_.isError(result)) throw result; | |
| return result; | |
| } | |
| /** | |
| * 获取缓存中的access_token | |
| * | |
| * 避免短时间大量刷新token,未加锁,如果有并发要求还需加锁 | |
| * | |
| * @param refreshToken 用于刷新access_token的refresh_token | |
| */ | |
| async function acquireToken(refreshToken: string) { | |
| let result = accessTokenMap.get(refreshToken); | |
| if (!result) { | |
| result = await requestToken(refreshToken); | |
| accessTokenMap.set(refreshToken, result); | |
| } | |
| if (util.unixTimestamp() > result.refreshTime) { | |
| result = await requestToken(refreshToken); | |
| accessTokenMap.set(refreshToken, result); | |
| } | |
| return { | |
| deviceId: result.deviceId, | |
| token: result.accessToken + "..." + result.refreshToken, | |
| }; | |
| } | |
| /** | |
| * 创建会话 | |
| * | |
| * 创建临时的会话用于对话补全 | |
| * | |
| * @param refreshToken 用于刷新access_token的refresh_token | |
| */ | |
| async function createConversation(name: string, refreshToken: string) { | |
| const { deviceId, token } = await acquireToken(refreshToken); | |
| const result = await axios.post( | |
| "https://stepchat.cn/api/proto.chat.v1.ChatService/CreateChat", | |
| { | |
| chatName: name, | |
| }, | |
| { | |
| headers: { | |
| Cookie: generateCookie(deviceId, token), | |
| "Oasis-Webid": deviceId, | |
| Referer: "https://stepchat.cn/chats/new", | |
| ...FAKE_HEADERS, | |
| }, | |
| timeout: 15000, | |
| validateStatus: () => true, | |
| } | |
| ); | |
| const { chatId: convId } = checkResult(result, refreshToken); | |
| return convId; | |
| } | |
| /** | |
| * 移除会话 | |
| * | |
| * 在对话流传输完毕后移除会话,避免创建的会话出现在用户的对话列表中 | |
| * | |
| * @param refreshToken 用于刷新access_token的refresh_token | |
| */ | |
| async function removeConversation(convId: string, refreshToken: string) { | |
| const { deviceId, token } = await acquireToken(refreshToken); | |
| const result = await axios.post( | |
| `https://stepchat.cn/api/proto.chat.v1.ChatService/DelChat`, | |
| { | |
| chatIds: [convId], | |
| }, | |
| { | |
| headers: { | |
| Cookie: generateCookie(deviceId, token), | |
| "Oasis-Webid": deviceId, | |
| Referer: `https://stepchat.cn/chats/${convId}`, | |
| ...FAKE_HEADERS, | |
| }, | |
| timeout: 15000, | |
| validateStatus: () => true, | |
| } | |
| ); | |
| checkResult(result, refreshToken); | |
| } | |
| /** | |
| * 同步对话补全 | |
| * | |
| * @param model 模型名称 | |
| * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 | |
| * @param refreshToken 用于刷新access_token的refresh_token | |
| * @param useSearch 是否开启联网搜索 | |
| * @param retryCount 重试次数 | |
| */ | |
| async function createCompletion( | |
| model = MODEL_NAME, | |
| messages: any[], | |
| refreshToken: string, | |
| useSearch = true, | |
| retryCount = 0 | |
| ) { | |
| return (async () => { | |
| logger.info(messages); | |
| // 提取引用文件URL并上传step获得引用的文件ID列表 | |
| const refFileUrls = extractRefFileUrls(messages); | |
| const refs = refFileUrls.length | |
| ? await Promise.all( | |
| refFileUrls.map((fileUrl) => uploadFile(fileUrl, refreshToken)) | |
| ) | |
| : []; | |
| // 创建会话 | |
| const convId = await createConversation("新会话", refreshToken); | |
| // 请求流 | |
| const { deviceId, token } = await acquireToken(refreshToken); | |
| const result = await axios.post( | |
| `https://stepchat.cn/api/proto.chat.v1.ChatMessageService/SendMessageStream`, | |
| messagesPrepare(convId, messages, refs), | |
| { | |
| headers: { | |
| "Content-Type": "application/connect+json", | |
| Cookie: generateCookie(deviceId, token), | |
| "Oasis-Webid": deviceId, | |
| Referer: `https://stepchat.cn/chats/${convId}`, | |
| ...FAKE_HEADERS, | |
| }, | |
| // 120秒超时 | |
| timeout: 120000, | |
| validateStatus: () => true, | |
| responseType: "stream", | |
| } | |
| ); | |
| const streamStartTime = util.timestamp(); | |
| // 接收流为输出文本 | |
| const answer = await receiveStream(model, convId, result.data); | |
| logger.success( | |
| `Stream has completed transfer ${util.timestamp() - streamStartTime}ms` | |
| ); | |
| // 异步移除会话,如果消息不合规,此操作可能会抛出数据库错误异常,请忽略 | |
| removeConversation(convId, refreshToken).catch((err) => console.error(err)); | |
| return answer; | |
| })().catch((err) => { | |
| if (retryCount < MAX_RETRY_COUNT) { | |
| logger.error(`Stream response error: ${err.message}`); | |
| logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`); | |
| return (async () => { | |
| await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); | |
| return createCompletion( | |
| model, | |
| messages, | |
| refreshToken, | |
| useSearch, | |
| retryCount + 1 | |
| ); | |
| })(); | |
| } | |
| throw err; | |
| }); | |
| } | |
| /** | |
| * 流式对话补全 | |
| * | |
| * @param model 模型名称 | |
| * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 | |
| * @param refreshToken 用于刷新access_token的refresh_token | |
| * @param useSearch 是否开启联网搜索 | |
| * @param retryCount 重试次数 | |
| */ | |
| async function createCompletionStream( | |
| model = MODEL_NAME, | |
| messages: any[], | |
| refreshToken: string, | |
| useSearch = true, | |
| retryCount = 0 | |
| ) { | |
| return (async () => { | |
| logger.info(messages); | |
| // 提取引用文件URL并上传step获得引用的文件ID列表 | |
| const refFileUrls = extractRefFileUrls(messages); | |
| const refs = refFileUrls.length | |
| ? await Promise.all( | |
| refFileUrls.map((fileUrl) => uploadFile(fileUrl, refreshToken)) | |
| ) | |
| : []; | |
| // 创建会话 | |
| const convId = await createConversation("新会话", refreshToken); | |
| // 请求流 | |
| const { deviceId, token } = await acquireToken(refreshToken); | |
| const result = await axios.post( | |
| `https://stepchat.cn/api/proto.chat.v1.ChatMessageService/SendMessageStream`, | |
| messagesPrepare(convId, messages, refs), | |
| { | |
| headers: { | |
| "Content-Type": "application/connect+json", | |
| Cookie: generateCookie(deviceId, token), | |
| "Oasis-Webid": deviceId, | |
| Referer: `https://stepchat.cn/chats/${convId}`, | |
| ...FAKE_HEADERS, | |
| }, | |
| // 120秒超时 | |
| timeout: 120000, | |
| validateStatus: () => true, | |
| responseType: "stream", | |
| } | |
| ); | |
| const streamStartTime = util.timestamp(); | |
| // 创建转换流将消息格式转换为gpt兼容格式 | |
| return createTransStream(model, convId, result.data, () => { | |
| logger.success( | |
| `Stream has completed transfer ${util.timestamp() - streamStartTime}ms` | |
| ); | |
| // 流传输结束后异步移除会话,如果消息不合规,此操作可能会抛出数据库错误异常,请忽略 | |
| removeConversation(convId, refreshToken).catch((err) => | |
| console.error(err) | |
| ); | |
| }); | |
| })().catch((err) => { | |
| if (retryCount < MAX_RETRY_COUNT) { | |
| logger.error(`Stream response error: ${err.message}`); | |
| logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`); | |
| return (async () => { | |
| await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); | |
| return createCompletionStream( | |
| model, | |
| messages, | |
| refreshToken, | |
| useSearch, | |
| retryCount + 1 | |
| ); | |
| })(); | |
| } | |
| throw err; | |
| }); | |
| } | |
| /** | |
| * 提取消息中引用的文件URL | |
| * | |
| * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 | |
| */ | |
| function extractRefFileUrls(messages: any[]) { | |
| const urls = []; | |
| // 如果没有消息,则返回[] | |
| if (!messages.length) { | |
| return urls; | |
| } | |
| // 只获取最新的消息 | |
| const lastMessage = messages[messages.length - 1]; | |
| if (_.isArray(lastMessage.content)) { | |
| lastMessage.content.forEach(v => { | |
| if (!_.isObject(v) || !['file', 'image_url'].includes(v['type'])) | |
| return; | |
| // step-free-api支持格式 | |
| if (v['type'] == 'file' && _.isObject(v['file_url']) && _.isString(v['file_url']['url'])) | |
| urls.push(v['file_url']['url']); | |
| // 兼容gpt-4-vision-preview API格式 | |
| else if (v['type'] == 'image_url' && _.isObject(v['image_url']) && _.isString(v['image_url']['url'])) | |
| urls.push(v['image_url']['url']); | |
| }); | |
| } | |
| logger.info("本次请求上传:" + urls.length + "个文件"); | |
| return urls; | |
| } | |
| /** | |
| * 消息预处理 | |
| * | |
| * 由于接口只取第一条消息,此处会将多条消息合并为一条,实现多轮对话效果 | |
| * user:旧消息1 | |
| * assistant:旧消息2 | |
| * user:新消息 | |
| * | |
| * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 | |
| */ | |
| function messagesPrepare(convId: string, messages: any[], refs: any[]) { | |
| // 检查最新消息是否含有"type": "image_url"或"type": "file",如果有则注入消息 | |
| let latestMessage = messages[messages.length - 1]; | |
| let hasFileOrImage = Array.isArray(latestMessage.content) | |
| && latestMessage.content.some(v => (typeof v === 'object' && ['file', 'image_url'].includes(v['type']))); | |
| if (hasFileOrImage) { | |
| let newFileMessage = { | |
| "content": "以上为历史消息,关注以下用户发送的文件和消息", | |
| "role": "system" | |
| }; | |
| messages.splice(messages.length - 1, 0, newFileMessage); | |
| logger.info("注入提升尾部文件注意力system prompt"); | |
| } else { | |
| let newTextMessage = { | |
| "content": "以上为历史消息,关注以下用户消息", | |
| "role": "system" | |
| }; | |
| messages.splice(messages.length - 1, 0, newTextMessage); | |
| logger.info("注入提升尾部消息注意力system prompt"); | |
| } | |
| const content = messages.reduce((content, message) => { | |
| if (_.isArray(message.content)) { | |
| return message.content.reduce((_content, v) => { | |
| if (!_.isObject(v) || v["type"] != "text") return _content; | |
| return _content + `${message.role || "user"}:${v["text"] || ""}\n`; | |
| }, content); | |
| } | |
| return (content += `${message.role || "user"}:${message.content}\n`); | |
| }, "") + 'assistant:'; | |
| logger.info("\n对话合并:\n" + content); | |
| const json = JSON.stringify({ | |
| chatId: convId, | |
| messageInfo: { | |
| text: content, | |
| attachments: refs.length > 0 ? refs : undefined, | |
| }, | |
| }); | |
| const data = wrapData(json); | |
| return data; | |
| } | |
| /** | |
| * 检查请求结果 | |
| * | |
| * @param result 结果 | |
| * @param refreshToken 用于刷新access_token的refresh_token | |
| */ | |
| function checkResult(result: AxiosResponse, refreshToken: string) { | |
| if (!result.data) return null; | |
| const { code, message } = result.data; | |
| if (!_.isString(code)) return result.data; | |
| if (code == "unauthenticated") accessTokenMap.delete(refreshToken); | |
| throw new APIException(EX.API_REQUEST_FAILED, `[请求step失败]: ${message}`); | |
| } | |
| /** | |
| * 从流接收完整的消息内容 | |
| * | |
| * @param model 模型名称 | |
| * @param convId 会话ID | |
| * @param stream 消息流 | |
| */ | |
| async function receiveStream(model: string, convId: string, stream: any) { | |
| return new Promise((resolve, reject) => { | |
| // 消息初始化 | |
| const data = { | |
| id: convId, | |
| model, | |
| object: "chat.completion", | |
| choices: [ | |
| { | |
| index: 0, | |
| message: { role: "assistant", content: "" }, | |
| finish_reason: "stop", | |
| }, | |
| ], | |
| usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, | |
| created: util.unixTimestamp(), | |
| }; | |
| let refContent = ""; | |
| const parser = (buffer: Buffer) => { | |
| const result = _.attempt(() => JSON.parse(buffer.toString())); | |
| if (_.isError(result)) { | |
| logger.warn(`Error response: ${buffer.toString()}`); | |
| throw new Error(`Stream response invalid: ${result}`); | |
| } | |
| if (result.error && result.error.code) | |
| data.choices[0].message.content += `服务暂时不可用,第三方响应错误:[${result.error.code}] ${result.error.message}`; | |
| else if (result.pipelineEvent) { | |
| if ( | |
| result.pipelineEvent.eventSearch && | |
| result.pipelineEvent.eventSearch.results | |
| ) { | |
| refContent = result.pipelineEvent.eventSearch.results.reduce( | |
| (str, v) => { | |
| return (str += `${v.title}(${v.url})\n`); | |
| }, | |
| "" | |
| ); | |
| } | |
| } else if (result.textEvent && result.textEvent.text) | |
| data.choices[0].message.content += result.textEvent.text; | |
| else if (result.doneEvent) { | |
| data.choices[0].message.content += refContent | |
| ? `\n\n搜索结果来自:\n${refContent.replace(/\n$/, "")}` | |
| : ""; | |
| } | |
| }; | |
| let chunk = Buffer.from([]); | |
| let temp = Buffer.from([]); | |
| // 将流数据传到转换器 | |
| stream.on("data", (buffer: Buffer) => { | |
| // 接收数据头 | |
| chunk = Buffer.concat([temp, chunk, buffer]); | |
| if(chunk.length < 5) | |
| return; | |
| // 读取当前数据块大小 | |
| const chunkSize = chunk.readUint32BE(1); | |
| // 根据当前大小接收完整数据块 | |
| temp = chunk.subarray(chunkSize + 5); | |
| chunk = chunk.subarray(0, chunkSize + 5); | |
| if(chunk.length < chunkSize + 5) | |
| return; | |
| parser(chunk.subarray(5)); | |
| chunk = Buffer.from([]); | |
| }); | |
| stream.once("error", (err) => reject(err)); | |
| stream.once("close", () => resolve(data)); | |
| }); | |
| } | |
| /** | |
| * 创建转换流 | |
| * | |
| * 将流格式转换为gpt兼容流格式 | |
| * | |
| * @param model 模型名称 | |
| * @param convId 会话ID | |
| * @param stream 消息流 | |
| * @param endCallback 传输结束回调 | |
| */ | |
| function createTransStream( | |
| model: string, | |
| convId: string, | |
| stream: any, | |
| endCallback?: Function | |
| ) { | |
| // 消息创建时间 | |
| const created = util.unixTimestamp(); | |
| // 创建转换流 | |
| const transStream = new PassThrough(); | |
| !transStream.closed && | |
| transStream.write( | |
| `data: ${JSON.stringify({ | |
| id: convId, | |
| model, | |
| object: "chat.completion.chunk", | |
| choices: [ | |
| { | |
| index: 0, | |
| delta: { role: "assistant", content: "" }, | |
| finish_reason: null, | |
| }, | |
| ], | |
| created, | |
| })}\n\n` | |
| ); | |
| const parser = (buffer: Buffer) => { | |
| const result = _.attempt(() => JSON.parse(buffer.toString())); | |
| if (_.isError(result)) | |
| throw new Error(`Stream response invalid: ${result}`); | |
| if (result.error && result.error.code) { | |
| const data = `data: ${JSON.stringify({ | |
| id: convId, | |
| model, | |
| object: "chat.completion.chunk", | |
| choices: [ | |
| { | |
| index: 0, | |
| delta: { | |
| content: `服务暂时不可用,第三方响应错误:[${result.error.code}] ${result.error.message}`, | |
| }, | |
| finish_reason: "stop", | |
| }, | |
| ], | |
| usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, | |
| created, | |
| })}\n\n`; | |
| !transStream.closed && transStream.write(data); | |
| !transStream.closed && transStream.end("data: [DONE]\n\n"); | |
| endCallback && endCallback(); | |
| } else if (result.pipelineEvent) { | |
| if ( | |
| result.pipelineEvent.eventSearch && | |
| result.pipelineEvent.eventSearch.results | |
| ) { | |
| const refContent = result.pipelineEvent.eventSearch.results.reduce( | |
| (str, v) => { | |
| return (str += `检索 ${v.title}(${v.url}) ...\n`); | |
| }, | |
| "" | |
| ); | |
| const data = `data: ${JSON.stringify({ | |
| id: convId, | |
| model, | |
| object: "chat.completion.chunk", | |
| choices: [ | |
| { | |
| index: 0, | |
| delta: { | |
| content: `${refContent}\n`, | |
| }, | |
| finish_reason: null, | |
| }, | |
| ], | |
| created, | |
| })}\n\n`; | |
| !transStream.closed && transStream.write(data); | |
| } | |
| } else if (result.textEvent && result.textEvent.text) { | |
| const data = `data: ${JSON.stringify({ | |
| id: convId, | |
| model, | |
| object: "chat.completion.chunk", | |
| choices: [ | |
| { | |
| index: 0, | |
| delta: { content: result.textEvent.text }, | |
| finish_reason: null, | |
| }, | |
| ], | |
| created, | |
| })}\n\n`; | |
| !transStream.closed && transStream.write(data); | |
| } else if (result.doneEvent) { | |
| const data = `data: ${JSON.stringify({ | |
| id: convId, | |
| model, | |
| object: "chat.completion.chunk", | |
| choices: [ | |
| { | |
| index: 0, | |
| delta: {}, | |
| finish_reason: "stop", | |
| }, | |
| ], | |
| usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, | |
| created, | |
| })}\n\n`; | |
| !transStream.closed && transStream.write(data); | |
| !transStream.closed && transStream.end("data: [DONE]\n\n"); | |
| endCallback && endCallback(); | |
| } | |
| }; | |
| let chunk = Buffer.from([]); | |
| let temp = Buffer.from([]); | |
| // 将流数据传到转换器 | |
| stream.on("data", (buffer: Buffer) => { | |
| // 接收数据头 | |
| chunk = Buffer.concat([temp, chunk, buffer]); | |
| if(chunk.length < 5) | |
| return; | |
| // 读取当前数据块大小 | |
| const chunkSize = chunk.readUint32BE(1); | |
| // 根据当前大小接收完整数据块 | |
| temp = chunk.subarray(chunkSize + 5); | |
| chunk = chunk.subarray(0, chunkSize + 5); | |
| if(chunk.length < chunkSize + 5) | |
| return; | |
| parser(chunk.subarray(5)); | |
| chunk = Buffer.from([]); | |
| }); | |
| stream.once( | |
| "error", | |
| () => !transStream.closed && transStream.end("data: [DONE]\n\n") | |
| ); | |
| stream.once( | |
| "close", | |
| () => !transStream.closed && transStream.end("data: [DONE]\n\n") | |
| ); | |
| return transStream; | |
| } | |
| /** | |
| * 构建数据包 | |
| * | |
| * @param json 需要发送的JSON字符串 | |
| */ | |
| function wrapData(json: string) { | |
| const data = Buffer.from(json); | |
| const buffer = Buffer.alloc(data.length + 5); | |
| buffer.set(data, 5); | |
| const dataView = new DataView( | |
| buffer.buffer, | |
| buffer.byteOffset, | |
| buffer.byteLength | |
| ); | |
| dataView.setUint8(0, 0x00); | |
| dataView.setUint32(1, data.length); | |
| return buffer; | |
| } | |
| /** | |
| * 生成cookie | |
| */ | |
| function generateCookie(deviceId: string, accessToken: string) { | |
| return [`Oasis-Token=${accessToken}`, `Oasis-Webid=${deviceId}`].join("; "); | |
| } | |
| /** | |
| * 预检查文件URL有效性 | |
| * | |
| * @param fileUrl 文件URL | |
| */ | |
| async function checkFileUrl(fileUrl: string) { | |
| if (util.isBASE64Data(fileUrl)) return; | |
| const result = await axios.head(fileUrl, { | |
| timeout: 15000, | |
| headers: { | |
| UserAgent: | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", | |
| }, | |
| validateStatus: () => true, | |
| }); | |
| if (result.status >= 400) | |
| throw new APIException( | |
| EX.API_FILE_URL_INVALID, | |
| `File ${fileUrl} is not valid: [${result.status}] ${result.statusText}` | |
| ); | |
| // 检查文件大小 | |
| if (result.headers && result.headers["content-length"]) { | |
| const fileSize = parseInt(result.headers["content-length"], 10); | |
| if (fileSize > FILE_MAX_SIZE) | |
| throw new APIException( | |
| EX.API_FILE_EXECEEDS_SIZE, | |
| `File ${fileUrl} is not valid` | |
| ); | |
| } | |
| } | |
| /** | |
| * 上传文件 | |
| * | |
| * @param fileUrl 文件URL | |
| * @param refreshToken 用于刷新access_token的refresh_token | |
| */ | |
| async function uploadFile(fileUrl: string, refreshToken: string) { | |
| // 预检查远程文件URL可用性 | |
| await checkFileUrl(fileUrl); | |
| let filename, fileData: Buffer, mimeType; | |
| // 如果是BASE64数据则直接转换为Buffer | |
| if (util.isBASE64Data(fileUrl)) { | |
| mimeType = util.extractBASE64DataFormat(fileUrl); | |
| const ext = mime.getExtension(mimeType); | |
| filename = `${util.uuid()}.${ext}`; | |
| fileData = Buffer.from(util.removeBASE64DataHeader(fileUrl), "base64"); | |
| } | |
| // 下载文件到内存,如果您的服务器内存很小,建议考虑改造为流直传到下一个接口上,避免停留占用内存 | |
| else { | |
| filename = path.basename(fileUrl); | |
| const queryIndex = filename.indexOf("?"); | |
| if (queryIndex != -1) filename = filename.substring(0, queryIndex); | |
| ({ data: fileData } = await axios.get(fileUrl, { | |
| responseType: "arraybuffer", | |
| // 100M限制 | |
| maxContentLength: FILE_MAX_SIZE, | |
| headers: { | |
| UserAgent: | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", | |
| }, | |
| // 60秒超时 | |
| timeout: 60000, | |
| })); | |
| } | |
| // 获取文件的MIME类型 | |
| mimeType = mimeType || mime.getType(filename); | |
| // 上传文件到目标OSS | |
| const { deviceId, token } = await acquireToken(refreshToken); | |
| let result = await axios.request({ | |
| method: "PUT", | |
| url: `https://stepchat.cn/api/storage?file_name=${filename}`, | |
| data: fileData, | |
| // 100M限制 | |
| maxBodyLength: FILE_MAX_SIZE, | |
| // 60秒超时 | |
| timeout: 60000, | |
| headers: { | |
| 'Content-Type': mimeType, | |
| Cookie: generateCookie(deviceId, token), | |
| "Oasis-Webid": deviceId, | |
| Referer: "https://stepchat.cn/chats/new", | |
| "Stepchat-Meta-Width": "undefined", | |
| "Stepchat-Meta-Height": "undefined", | |
| "Stepchat-Meta-Size": `${fileData.byteLength}`, | |
| ...FAKE_HEADERS, | |
| }, | |
| validateStatus: () => true, | |
| }); | |
| const { id: fileId } = checkResult(result, refreshToken); | |
| let fileStatus, needFurtherCall = true; | |
| const startTime = util.unixTimestamp(); | |
| while (needFurtherCall) { | |
| // 获取文件上传结果 | |
| result = await axios.post( | |
| "https://stepchat.cn/api/proto.file.v1.FileService/GetFileStatus", | |
| { | |
| id: fileId, | |
| }, | |
| { | |
| headers: { | |
| Cookie: generateCookie(deviceId, token), | |
| "Oasis-Webid": deviceId, | |
| Referer: "https://stepchat.cn/chats/new", | |
| ...FAKE_HEADERS, | |
| }, | |
| timeout: 15000, | |
| } | |
| ); | |
| ({ fileStatus, needFurtherCall } = checkResult(result, refreshToken)); | |
| // 上传失败处理 | |
| if ([12, 22, 59, 404].includes(fileStatus)) | |
| throw new APIException(EX.API_FILE_UPLOAD_FAILED); | |
| // 上传超时处理 | |
| if (util.unixTimestamp() - startTime > 60) | |
| throw new APIException(EX.API_FILE_UPLOAD_TIMEOUT); | |
| } | |
| await new Promise(resolve => setTimeout(resolve, 5000)); | |
| return { | |
| attachmentType: mimeType, | |
| attachmentId: fileId, | |
| name: filename, | |
| width: "undefined", | |
| height: "undefined", | |
| size: `${fileData.byteLength}`, | |
| }; | |
| } | |
| /** | |
| * Token切分 | |
| * | |
| * @param authorization 认证字符串 | |
| */ | |
| function tokenSplit(authorization: string) { | |
| return authorization.replace("Bearer ", "").split(","); | |
| } | |
| /** | |
| * 获取Token存活状态 | |
| */ | |
| async function getTokenLiveStatus(refreshToken: string) { | |
| const result = await axios.post( | |
| "https://stepchat.cn/passport/proto.api.passport.v1.PassportService/RegisterDevice", | |
| {}, | |
| { | |
| headers: { | |
| Cookie: `Oasis-Token=${refreshToken}`, | |
| Referer: "https://stepchat.cn/chats/new", | |
| ...FAKE_HEADERS, | |
| }, | |
| timeout: 15000, | |
| validateStatus: () => true, | |
| } | |
| ); | |
| try { | |
| const { | |
| accessToken: { raw: accessTokenRaw }, | |
| refreshToken: { raw: refreshTokenRaw } | |
| } = checkResult(result, refreshToken); | |
| return !!(accessTokenRaw && refreshTokenRaw) | |
| } | |
| catch(err) { | |
| return false; | |
| } | |
| } | |
| export default { | |
| createConversation, | |
| createCompletion, | |
| createCompletionStream, | |
| getTokenLiveStatus, | |
| tokenSplit, | |
| }; | |